Skip to content

📡 API демона: gRPC (ConnectRPC) + REST ​

📌 Описание ​

API-сервер демона: один и тот же axum-роутер — gRPC (tonic; совместим с ConnectRPC-клиентами платформы) и REST (JSON поверх HTTP) — обслуживается на двух листенерах: TCP-порт для платформы (bearer-токен) и локальный unix-сокет для CLI (аутентификация по peer credentials, DMN-042). Все транспорты вызывают один сервисный слой; каждый вызов несёт контекст пользователя, который ограничивает видимость приложений (пользователь видит свои, root — все). Контракты — protobuf-файлы в proto/, они же источник правды для клиентов платформы (buf-зависимость).

🎯 Сценарии использования ​

  • CLI asc управляет приложениями через unix-сокет без sudo и без группы docker: asc install, asc ls, asc app start работают у обычного пользователя и затрагивают только его приложения.
  • Платформа AdminService.Cloud управляет нодой через gRPC/ConnectRPC (через туннель nodeservice).
  • Скрипты и сторонние интеграции используют REST: curl -H "Authorization: Bearer <token>" http://127.0.0.1:8420/v1/apps, либо сокет: curl --unix-socket /run/asc/asc.sock http://localhost/v1/apps (без токена — личность определяет ядро).
  • Перед открытием WebSocket-консоли клиент запрашивает временный консольный токен (IssueConsoleToken) — платформа делает это автоматически после проверки прав.

🏗️ Техническое решение ​

Сервер ​

  • TCP-листенер на 127.0.0.1:8420 (настройка [api] listen в config.toml). Наружу порт не открывается — удалённый доступ идёт через туннель платформы (DMN-005 → tunnel).
  • Unix-сокет /run/asc/asc.sock (настройка [api] socket; каталог создаёт systemd-юнит через RuntimeDirectory=asc). Best-effort: если сокет не удаётся открыть, демон продолжает работать с одним TCP API и пишет предупреждение в лог.
  • Один роутер: axum REST + tonic-маршруты gRPC (h2c), диспетчеризация по пути/Content-Type.
  • Все блокирующие операции (docker/systemctl/git) уходят в spawn_blocking — event loop не блокируется.

🔐 Аутентификация и контекст пользователя ​

Каждый аутентифицированный запрос получает UserContext (uid, имя пользователя, признак root), который проставляет middleware транспорта; сервисный слой применяет из него правило владения приложениями — то же, что в in-process-режиме CLI (DMN-002).

TCP (платформа): bearer-токен. Их два вида (DMN-065) — полная модель в 🔐 security-tokens.

  • Основной токен демон генерирует при первом старте (32 байта из CSPRNG) и хранит в api.token рядом с config.toml (root-only, 0600). Именно его платформа сохраняет при добавлении ноды, и по сути он refresh-токен: выпускает временные токены и заменяется при ротации.
  • Временный токен выпускается по основному, живёт 10 минут по умолчанию и только в памяти демона. Права по всем сервисам здесь у него те же; единственное, чего он не может, — управлять токенами (/v1/token/* отвечает 403 с полем token_denied, gRPC — PERMISSION_DENIED).
  • Обязателен для обоих транспортов: REST — заголовок Authorization: Bearer <token>, gRPC — metadata authorization. Основной сравнивается constant-time, временные ищутся по SHA-256-дайджесту.
  • Без распознанного токена: REST → 401 {"error": ...}, gRPC → UNAUTHENTICATED.
  • Токен-вызовы действуют с полной видимостью (права пользователей платформа проверяет сама); пер-пользовательские API-токены — задача после MVP.

Unix-сокет (CLI): SO_PEERCRED. (DMN-042)

  • Без токена. Демон запрашивает у ядра uid подключившегося процесса (SO_PEERCRED) и строит контекст из него — ничто внутри запроса не может повысить привилегии.
  • Файл сокета нарочно доступен всем (0666): доступ к сокету сам по себе ничего не даёт, авторизация — peer uid, на каждый запрос. Обычный пользователь видит и управляет только своими приложениями; root-пир — всеми.
  • Атрибуция sudo asc ...: CLI передаёт SUDO_UID/SUDO_USER заголовками X-Asc-Sudo-Uid/X-Asc-Sudo-User; демон учитывает их только если сам пир — root, зеркаля in-process-поведение: новые приложения приписываются вызвавшему пользователю, sudo сохраняет полную видимость.
  • Маршрутизация CLI: команды жизненного цикла (ls/status/install/app start|stop|restart|logs|remove|info), app attach и app settings (DMN-043), а также отчётные команды и обновление (disk/ports/stats/stacks/upgrade, DMN-053) идут через сокет, когда он существует. Нет файла сокета — CLI работает in-process, как раньше (DMN-041: root по системным путям, пользователь в своём дереве ~/.asc). Сокет есть, но не отвечает — ошибка для обычного пользователя и in-process-fallback с предупреждением для root (восстановление не должно зависеть от здоровья демона).
  • Известные ограничения (DMN-043): docker-контейнеры по-прежнему выполняются с привилегиями демона (root) — злонамеренный манифест может запросить опасные маунты, поэтому политика контейнеров для не-root владельцев — следующая задача; установка приватных репозиториев через демон использует git-креды демона, а не вызывающего.

🧭 Обнаружение возможностей (DMN-076) ​

GetStatus несёт список capabilities (пустой массив у демона, выпущенного до появления этого поля, — валидное и отличимое состояние, а не пробел в парсинге): строки вроде app.upgrade, app.settings, app.ports, app.stats, console.exec, sources, credentials, добавляемые в ответ тем же изменением, что и сам RPC. Вызывающий, которому нужно узнать, поддерживает ли демон конкретной ноды, скажем, интерактивную сессию shell, проверяет этот список до того, как предложить кнопку, — вместо открытия сокета, который тут же падает, или перехвата UNIMPLEMENTED постфактум. Значения только добавляются, никогда не удаляются и не переименовываются — старая сборка платформы, ищущая знакомое имя, продолжает работать против нового демона.

🎫 Временные консольные токены ​

  • AppService.IssueConsoleToken(app_id, session) / POST /v1/apps/{id}/console-token {"session": "logs"|"attach"}.
  • Токен одноразовый, TTL 30 секунд, привязан к приложению и типу сессии; хранится в памяти демона.
  • WebSocket-консоль (DMN-007) принимает подключение только по такому токену.

🗺️ Маршруты REST ↔ методы gRPC ​

RESTgRPCОписание
GET /v1/statusDaemonService.GetStatusВерсия, счётчики приложений, список возможностей (DMN-076)
GET /v1/appsAppService.ListAppsСписок приложений (в объёме контекста вызывающего)
POST /v1/apps {"spec": ..., "source"?, "name"?, "branch"?, "tag"?, "license_ack"?}AppService.InstallAppУстановка из реестра или напрямую из git-URL (DMN-040); без license_ack репозиторий с LICENSE отвечает 409 + license_required, неоднозначный пакет — 409 + ambiguous (список кандидатов) — из них CLI строит диалог согласия / выбор источника
GET /v1/apps/{id}AppService.GetAppОдно приложение
GET /v1/apps/{id}/diskAppService.GetAppDiskДисковое пространство: образ, репозиторий, данные, кастомные тома
GET /v1/apps/{id}/imageAppService.GetAppImage (DMN-120)Ссылка на образ, версия, локальный и удалённый дайджест (?checkRemote=true) и свежесть образа Docker-приложения; {} для других типов
POST /v1/apps/{id}/repullAppService.RepullApp (DMN-120)Заново скачать образ latest и перезапустить запущенное приложение, если он изменился; 409 для закреплённого тега, дайджеста или локальной сборки
GET /v1/apps/{id}/ports— (пока только REST)Порты, которые публикует приложение (DMN-049), из его настроек — остановленное приложение показывает, что займёт при следующем старте
POST /v1/apps/{id}/upgrade {"version"?}— (пока только REST)Обновление приложения (DMN-003); без version — до самого свежего тега репозитория или до ветки, которую отслеживает приложение, установленное по ссылке (DMN-053). Приложение должно быть остановлено. Ответ — {"id", "up_to_date", "from", "to"}
POST /v1/apps/{id}/clone {"name"?}AppService.CloneApp/CloneAppStream (DMN-113)Полная копия приложения под новым <id>-N, всегда остановленная; потоковый вариант отдаёт копирование каталога строками прогресса. REST отдаёт только нестриминговую форму — диалог клонирования на платформе использует gRPC-поток, тот же приём, что у install/upgrade. Ответ — {"id", "name", "copied_bytes"}
GET /v1/disk— (пока только REST)Сколько занимает каждое видимое приложение, по убыванию, и ёмкость файловой системы, где лежит хранилище приложений (DMN-053)
GET /v1/ports— (пока только REST)Все видимые приложения и порты, которые они публикуют
GET /v1/stats?ids=a,bAppService.GetAppStats (DMN-080)Потребление ресурсов по приложениям (CPU %, память, диск, сеть); ids фильтрует список до сэмплирования, а не после; пусто — все видимые приложения. Каждый вызов стоит ~500 мс интервала сэмплирования
— (только gRPC)AppService.StreamAppStats (DMN-081)Те же сэмплы push-стримом; min_interval_secs — минимальный интервал между кадрами (сервер поднимает до ≥1с)
POST /v1/apps/{id}/start|stop|restartAppService.Start/Stop/RestartAppЖизненный цикл
GET /v1/apps/{id}/logs?tail=N&timestamps=boolAppService.GetAppLogsХвост логов, опционально с ISO-меткой на каждой строке (DMN-088)
GET /v1/apps/{id}/settings— (пока только REST)Схема настроек приложения (asc.settings.yaml, null если пакет их не объявляет) и выбранные значения с подмешанными значениями по умолчанию
PUT /v1/apps/{id}/settings {"values": {...}}— (пока только REST)Заменить выбранные значения; ключи, которых нет в схеме самого приложения, отклоняются. gRPC-аналог ждёт моделирования схемы настроек в proto-контрактах
DELETE /v1/apps/{id}AppService.RemoveAppУдаление с данными
POST /v1/apps/{id}/console-tokenAppService.IssueConsoleTokenВременный токен консоли
GET /v1/docker/containers?all=&size=DockerService.ListContainers (DMN-102)Все контейнеры хоста — и ASC-овские, и чужие, — с определённым приложением-владельцем там, где оно есть. size=1 заставляет Engine обойти writable-слой каждого контейнера, поэтому по умолчанию выключено. Только root-контекст
GET /v1/docker/stats?ids=a,bDockerService.ListContainerStats (DMN-112)Живые CPU/память/сеть/диск контейнеров; ids фильтрует до окна сэмплирования, пусто — все запущенные. Стоит одного окна ~500 мс на весь набор. Только root-контекст
GET /v1/docker/imagesDockerService.ListImages (DMN-104)Все образы хоста — и ASC-овские, и чужие, — с ascProtected/protectedReason, когда образ всё ещё запускает установленное приложение. Только root-контекст
GET /v1/docker/volumesDockerService.ListVolumes (DMN-104)Все именованные тома, с ascProtected/protectedReason, когда том объявлен настройками установленного приложения. Только root-контекст
GET /v1/docker/networksDockerService.ListNetworks (DMN-104)Все сети, только инвентарь — почему нет маршрута удаления, см. PruneDocker ниже. Только root-контекст
GET /v1/docker/disk-usageDockerService.GetDockerDiskUsage (DMN-104)Четыре категории docker system df (образы/контейнеры/тома/build cache): счётчики и байты. Дорогой вызов — только по требованию, никогда по таймеру. Только root-контекст
POST /v1/docker/pruneDockerService.PruneDocker (DMN-105)Тело {target: "images"|"volumes"|"build_cache", dryRun, danglingOnly}. Удаляет неиспользуемое поштучно; то, что всё ещё нужно установленному приложению, возвращается в skipped с причиной, dryRun считает тот же план без удаления. Только root-контекст
POST /v1/docker/controlDockerService.ControlContainer (DMN-111)Тело {container | composeProject, action: "start"|"stop"|"restart"|"pause"|"unpause"|"remove"}. Жизненный цикл одного контейнера (id, однозначный префикс id или имя) или всех контейнеров compose-проекта; у стека неприменимые к члену действия пропускаются, старт идёт от старых к новым, стоп — от новых к старым. Контейнер установленного ASC-приложения (или его compose-проекта) получает отказ 409 owned_by_app / FAILED_PRECONDITION — им управляют через приложение. Capability docker.control. Только root-контекст
GET /v1/backups?app_id=&storage=BackupService.ListBackups (DMN-115)Архивы одного или всех видимых приложений на одном или всех хранилищах: имя, хранилище, размер, время создания; недоступное хранилище — в errors. Capability backups
GET /v1/backups/storagesBackupService.ListBackupStoragesХранилища (local первым) без реквизитов
PUT /v1/backups/storages/{name}BackupService.UpsertBackupStorageТело {managed_by?, s3: {endpoint, region, bucket, access_key, secret_key, prefix}} или {local: {dir}}; запись с другим managed_by не заменяется
DELETE /v1/backups/storages/{name}?managed_by=BackupService.RemoveBackupStorageУдалить хранилище (только с этой меткой, если она передана)
POST /v1/apps/{id}/backupsBackupService.CreateBackupТело {storages?, keep?} — один архив во все хранилища, результат по каждому. Долгий вызов
POST /v1/apps/{id}/backups/restoreBackupService.RestoreBackupТело {storage, name, stop_app} — stop_app останавливает запущенное приложение, восстанавливает и запускает снова
DELETE /v1/apps/{id}/backups/{storage}/{name}BackupService.DeleteBackupУдалить один архив
GET /v1/schedules?managed_by=ScheduleService.ListSchedules (DMN-114)Задачи планировщика с next_run_unix и последним запуском. Capability schedules
PUT /v1/schedules/{id}ScheduleService.UpsertScheduleТело — задача (trigger, utc, enabled, comment, action: {type, …})
DELETE /v1/schedules/{id}ScheduleService.RemoveScheduleУдалить задачу и её журнал
PUT /v1/schedules/managed/{managed_by}ScheduleService.ReplaceManagedSchedulesТело {schedules} — полная замена задач этого владельца; задачи оператора не трогаются
POST /v1/schedules/{id}/runScheduleService.RunScheduleЗапустить задачу сейчас; ответ {started, run} без ожидания
GET /v1/schedules/{id}/runs?limit=ScheduleService.ListScheduleRunsЗапуски задачи, новые первыми (хранится до 50)
GET /v1/metricsMonitorService.GetSystemMetricsТекущие системные метрики (503, пока нет первого сэмпла)
GET /v1/metrics/history?limit=NMonitorService.GetMetricsHistoryИстория метрик из кольцевого буфера, старые → новые
GET /v1/ports/listeningMonitorService.ListListeningPorts (DMN-103)Реально занятые порты хоста из /proc/net/*, слитые с атрибуцией по Docker/приложениям — в отличие от GET /v1/ports выше, который отдаёт то, что приложения объявляют
GET /v1/processesProcessService.ListProcesses (DMN-119)Процессы хоста (ps/top): CPU, RSS, пользователь, состояние, время старта, контейнер Docker и приложение-владелец; ?kernelThreads=true — вместе с потоками ядра
POST /v1/processes/{pid}/signalProcessService.SignalProcess (DMN-119)Тело {"signal": "term"|"kill"|"hup"|"int"|"stop"|"cont"|"usr1"|"usr2", "expectedStartTicks"?: n}; только root; 403 для pid 1/демона/потоков ядра, 404 — процесса уже нет, 409 — pid переиспользован
GET /v1/tokenTokenService.GetTokenStatusСостояние токенов: вид, число живых временных, окно ротации, усечённый дайджест основного — никогда сам токен
POST /v1/system/rebootSystemService.RebootSystemЗапрашивает полную перезагрузку хоста после подтверждения вызова; только основной токен
POST /v1/token/access {"ttl_secs"?, "label"?}TokenService.IssueAccessTokenВыпуск временного токена (только по основному)
DELETE /v1/token/accessTokenService.RevokeAccessTokensГасит все живые временные токены, {"revoked": n} (только по основному)
POST /v1/token/rotate {"grace_secs"?}TokenService.RotatePrimaryTokenЗамена основного токена с отзывом всех временных; возвращает новый токен (только по основному)
POST /v1/token/rotate/commitTokenService.CommitPrimaryTokenRotationПодтверждение, что новый основной токен сохранён, и закрытие окна; отклоняется, если предъявлен заменённый токен
GET /v1/files?path=&hidden=FileService.ListDirectoryСписок каталога от /; подробности — 📁 files
GET /v1/files/stat?path=FileService.StatPathМетаданные одного пути
POST /v1/files/directoryFileService.CreateDirectoryСоздание каталога
POST /v1/files/move · /copy · /delete · /archiveFileService.MovePath / CopyPath / DeletePaths / CreateArchiveПеремещение, копирование, удаление, архивирование
GET /v1/files/content?path=&offset=FileService.ReadFile (стрим)Скачивание файла
PUT /v1/files/content?path=&name=&overwrite=FileService.WriteFile (стрим)Загрузка файла
POST /v1/files/attributesFileService.SetPathAttributesРежим и/или владелец/группа по имени
GET /v1/files/identitiesFileService.ListSystemIdentitiesЛокальные пользователи и группы

📜 Кодогенерация ​

  • Rust-код генерируется из proto/ в build.rs через protox (pure-Rust компилятор protobuf) + tonic-build — системный protoc не нужен, сборка герметична.
  • Изменения контрактов — только обратно совместимые (новые поля — optional, номера не переиспользовать).

🔗 Связанные задачи ​

DMN-005, DMN-007, DMN-042, DMN-043, DMN-053, DMN-065, DMN-066, DMN-070, DMN-111 в ROADMAP.md.

Распространяется по лицензии MIT.