Skip to content

📡 Daemon API: gRPC (ConnectRPC) + REST ​

📌 Description ​

The daemon's API server: the same axum router — gRPC (tonic; compatible with the platform's ConnectRPC clients) and REST (JSON over HTTP) — served on two listeners: a TCP port for the platform (bearer-token auth) and a local unix socket for the CLI (peer-credential auth, DMN-042). All transports call one service layer; every call carries the caller's user context, which scopes app visibility (a user sees their own apps, root everyone's). The contracts are protobuf files in proto/, which are also the source of truth for the platform's clients (a buf dependency).

🎯 Scenarios ​

  • The asc CLI manages apps through the unix socket without sudo and without the docker group: asc install, asc ls, asc app start work for a regular user and touch only that user's apps.
  • The AdminService.Cloud platform manages the node over gRPC/ConnectRPC (through the nodeservice tunnel).
  • Scripts and third-party integrations use REST: curl -H "Authorization: Bearer <token>" http://127.0.0.1:8420/v1/apps, or the socket: curl --unix-socket /run/asc/asc.sock http://localhost/v1/apps (no token — identity comes from the kernel).
  • Before opening a WebSocket console the client requests a temporary console token (IssueConsoleToken) — the platform does this automatically after checking permissions.

🏗️ Technical design ​

Server ​

  • TCP listener on 127.0.0.1:8420 (the [api] listen setting in config.toml). The port is not exposed externally — remote access goes through the platform tunnel (DMN-005 → tunnel).
  • Unix-socket listener on /run/asc/asc.sock (the [api] socket setting; the directory comes from the systemd unit's RuntimeDirectory=asc). Best-effort: a host where the socket cannot be bound keeps the TCP API and logs a warning.
  • One router: axum REST + tonic routes for gRPC (h2c), dispatched by path/Content-Type.
  • All blocking operations (docker/systemctl/git) go to spawn_blocking — the event loop is never blocked.

🔐 Authentication and the user context ​

Every authenticated request gets a UserContext (uid, user name, root flag) stamped by the transport middleware; the service layer enforces app ownership from it — the same rule as the CLI's in-process mode (DMN-002).

TCP (platform): bearer token. Two kinds of them (DMN-065) — the full model is in 🔐 security-tokens.

  • The primary token is generated by the daemon on first start (32 bytes from a CSPRNG) and stored in api.token next to config.toml (root-only, 0600). It is the credential the platform saves when the node is added, and it is effectively a refresh token: it mints access tokens and it is what a rotation replaces.
  • An access token is minted from the primary, lives 10 minutes by default and only in the daemon's memory. It carries the same authority for every service here; the one thing it may not do is manage tokens (/v1/token/* answers 403 with a token_denied payload, gRPC PERMISSION_DENIED).
  • Required for both transports: REST — the Authorization: Bearer <token> header, gRPC — the authorization metadata. The primary is compared in constant time; access tokens are looked up by their SHA-256 digest.
  • Without a recognised token: REST → 401 {"error": ...}, gRPC → UNAUTHENTICATED.
  • Token calls act with full visibility (the platform performs its own per-user permission checks); per-user API tokens are a post-MVP task.

Unix socket (CLI): SO_PEERCRED. (DMN-042)

  • No token. The daemon asks the kernel for the connecting process's uid (SO_PEERCRED) and builds the context from it — nothing inside the request can escalate privileges.
  • The socket file is world-connectable (0666) on purpose: reaching it grants nothing, authorization is the peer uid, per request. A regular user sees and manages only their own apps; a root peer sees everyone's.
  • sudo asc ... attribution: the CLI forwards SUDO_UID/SUDO_USER as the X-Asc-Sudo-Uid/X-Asc-Sudo-User headers; the daemon honors them only when the peer itself is root, mirroring the in-process behavior — new apps are attributed to the invoking user while sudo keeps full visibility.
  • CLI routing: the lifecycle commands (ls/status/install/app start|stop|restart|logs|remove|info), app attach and app settings (DMN-043), and the reporting and upgrade commands (disk/ports/stats/stacks/upgrade, DMN-053) go through the socket whenever it exists. No socket file — the CLI works in-process as before (DMN-041: root on the system paths, a user on their private ~/.asc tree). A socket that exists but does not answer is an error for a regular user and a warned in-process fallback for root (recovery must not depend on a healthy daemon).
  • Known limits (DMN-043): docker containers still run with the daemon's (root) privileges — a malicious manifest can request dangerous mounts, so a container policy for non-root owners is the follow-up; private-repo installs through the daemon use the daemon's git credentials, not the caller's.

🧭 Capability discovery (DMN-076) ​

GetStatus carries a capabilities list (empty array on a daemon that predates this field — that's a valid, distinguishable state, not a parsing gap): feature strings such as app.upgrade, app.settings, app.ports, app.stats, console.exec, sources, credentials, added to the response in the same change that ships the matching RPC. A caller that needs to know whether a node's daemon supports, say, an interactive shell session checks this list before offering the button, instead of opening a socket that immediately fails or catching an UNIMPLEMENTED after the fact. Values are only ever appended, never removed or renamed — an older platform build checking for a name it recognizes keeps working against a newer daemon.

🎫 Temporary console tokens ​

  • AppService.IssueConsoleToken(app_id, session) / POST /v1/apps/{id}/console-token {"session": "logs"|"attach"}.
  • The token is single-use, TTL 30 seconds, bound to an application and a session type; kept in the daemon's memory.
  • The WebSocket console (DMN-007) accepts connections only with such a token.

🗺️ REST routes ↔ gRPC methods ​

RESTgRPCDescription
GET /v1/statusDaemonService.GetStatusVersion, application counters, capability list (DMN-076)
GET /v1/appsAppService.ListAppsApplication list (scoped by the caller's context)
POST /v1/apps {"spec": ..., "source"?, "name"?, "branch"?, "tag"?, "license_ack"?}AppService.InstallAppInstall from a registry or directly from a git URL (DMN-040); without license_ack a repository shipping a LICENSE fails with 409 + license_required payload, an ambiguous package with 409 + ambiguous (candidate list) — the CLI renders its consent prompt / source pick from these
GET /v1/apps/{id}AppService.GetAppA single application
GET /v1/apps/{id}/diskAppService.GetAppDiskDisk usage: image, repository, data, custom volumes
GET /v1/apps/{id}/imageAppService.GetAppImage (DMN-120)Image reference, version, local vs registry digest (?checkRemote=true) and freshness state of a Docker app; {} for other runtimes
POST /v1/apps/{id}/repullAppService.RepullApp (DMN-120)Pull the latest image afresh and restart a running app if it changed; 409 for a pinned tag, a digest or a locally built image
GET /v1/apps/{id}/ports— (REST only for now)The ports the app publishes (DMN-049), resolved from its settings — a stopped app reports what it will bind next start
POST /v1/apps/{id}/upgrade {"version"?}— (REST only for now)Upgrade the app (DMN-003); without version — to the repository's newest tag, or the branch it tracks for a direct repository install (DMN-053). The app must be stopped. Answers {"id", "up_to_date", "from", "to"}
POST /v1/apps/{id}/clone {"name"?}AppService.CloneApp/CloneAppStream (DMN-113)Full copy of the app under a new <id>-N, always stopped; the stream sibling reports the directory copy as progress lines. REST exposes only the unary form — the platform's clone dialog uses the gRPC stream, same split as install/upgrade. Answers {"id", "name", "copied_bytes"}
GET /v1/disk— (REST only for now)Every visible app's footprint, largest first, plus the capacity of the filesystem holding the app store (DMN-053)
GET /v1/ports— (REST only for now)Every visible app and the ports it publishes
GET /v1/stats?ids=a,bAppService.GetAppStats (DMN-080)Resource consumption per app (CPU %, memory, disk, network); ids filters the set before sampling, not after — empty means every visible app. Costs the ~500 ms sampling interval per call
— (gRPC only)AppService.StreamAppStats (DMN-081)The same samples as a push stream; min_interval_secs is the minimum gap between frames (the server floors it at ≥1s)
POST /v1/apps/{id}/start|stop|restartAppService.Start/Stop/RestartAppLifecycle
GET /v1/apps/{id}/logs?tail=N&timestamps=boolAppService.GetAppLogsLog tail, optionally with an ISO timestamp on each line (DMN-088)
GET /v1/apps/{id}/settings— (REST only for now)The app's settings schema (asc.settings.yaml, null when the package defines none) and the values chosen so far, defaults merged in
PUT /v1/apps/{id}/settings {"values": {...}}— (REST only for now)Replace the chosen values; keys the app's own schema does not define are rejected. A gRPC counterpart waits on modelling the settings schema in the proto contracts
DELETE /v1/apps/{id}AppService.RemoveAppRemoval including data
POST /v1/apps/{id}/console-tokenAppService.IssueConsoleTokenTemporary console token
GET /v1/docker/containers?all=&size=DockerService.ListContainers (DMN-102)Every container on the host, ASC-managed or not, with the owning application resolved where there is one. size=1 makes the Engine walk each container's writable layer, so it is off by default. Root context only
GET /v1/docker/stats?ids=a,bDockerService.ListContainerStats (DMN-112)Live CPU/memory/network/block usage of containers; ids filters before the sampling window, empty means every running container. Costs one ~500 ms window for the whole set. Root context only
GET /v1/docker/imagesDockerService.ListImages (DMN-104)Every image on the host, ASC-managed or not, with ascProtected/protectedReason set when an installed app still runs it. Root context only
GET /v1/docker/volumesDockerService.ListVolumes (DMN-104)Every named volume, with ascProtected/protectedReason set when an installed app's settings declare it. Root context only
GET /v1/docker/networksDockerService.ListNetworks (DMN-104)Every network, inventory-only — see PruneDocker below for why there is no delete route. Root context only
GET /v1/docker/disk-usageDockerService.GetDockerDiskUsage (DMN-104)docker system df's four categories (images/containers/volumes/build cache): counts and bytes. Expensive — call on demand, never on a poll. Root context only
POST /v1/docker/pruneDockerService.PruneDocker (DMN-105)Body {target: "images"|"volumes"|"build_cache", dryRun, danglingOnly}. Removes unused items one at a time; anything an installed app still needs comes back in skipped with why, dryRun computes the same plan without deleting. Root context only
POST /v1/docker/controlDockerService.ControlContainer (DMN-111)Body {container | composeProject, action: "start"|"stop"|"restart"|"pause"|"unpause"|"remove"}. Lifecycle of one container (id, unambiguous id prefix or name) or of every container of a compose project; a stack skips members the action does not apply to and goes oldest-first to start, newest-first to stop. A container of an installed ASC app (or of its compose project) is refused with 409 owned_by_app / FAILED_PRECONDITION — control it through the app. Capability docker.control. Root context only
GET /v1/backups?app_id=&storage=BackupService.ListBackups (DMN-115)Archives of one app or all apps the caller sees, on one storage or all: name, storage, size, creation time; an unreachable storage is reported in errors. Capability backups
GET /v1/backups/storagesBackupService.ListBackupStoragesStorages (local first) without credentials
PUT /v1/backups/storages/{name}BackupService.UpsertBackupStorageBody {managed_by?, s3: {endpoint, region, bucket, access_key, secret_key, prefix}} or {local: {dir}}; an entry with a different managed_by is not replaced
DELETE /v1/backups/storages/{name}?managed_by=BackupService.RemoveBackupStorageRemove a storage (only one carrying that marker, when given)
POST /v1/apps/{id}/backupsBackupService.CreateBackupBody {storages?, keep?} — one archive pushed to every storage, one result each. Long-running
POST /v1/apps/{id}/backups/restoreBackupService.RestoreBackupBody {storage, name, stop_app} — stop_app stops a running app, restores and starts it again
DELETE /v1/apps/{id}/backups/{storage}/{name}BackupService.DeleteBackupDelete one archive
GET /v1/schedules?managed_by=ScheduleService.ListSchedules (DMN-114)Scheduled jobs with next_run_unix and the last run. Capability schedules
PUT /v1/schedules/{id}ScheduleService.UpsertScheduleBody — a job (trigger, utc, enabled, comment, action: {type, …})
DELETE /v1/schedules/{id}ScheduleService.RemoveScheduleRemove a job and its run history
PUT /v1/schedules/managed/{managed_by}ScheduleService.ReplaceManagedSchedulesBody {schedules} — full replace of that owner's jobs; operator jobs are never touched
POST /v1/schedules/{id}/runScheduleService.RunScheduleStart a job now; answers {started, run} without waiting
GET /v1/schedules/{id}/runs?limit=ScheduleService.ListScheduleRunsThe job's runs, newest first (at most 50 kept)
GET /v1/metricsMonitorService.GetSystemMetricsCurrent system metrics (503 until the first sample)
GET /v1/metrics/history?limit=NMonitorService.GetMetricsHistoryMetrics history from the ring buffer, oldest → newest
GET /v1/ports/listeningMonitorService.ListListeningPorts (DMN-103)Real host listening ports parsed from /proc/net/*, merged with Docker/app attribution — distinct from GET /v1/ports above, which reports what apps declare
GET /v1/processesProcessService.ListProcesses (DMN-119)Host processes (ps/top): CPU, RSS, user, state, start time, Docker container and owning app; ?kernelThreads=true includes kernel threads
POST /v1/processes/{pid}/signalProcessService.SignalProcess (DMN-119)Body {"signal": "term"|"kill"|"hup"|"int"|"stop"|"cont"|"usr1"|"usr2", "expectedStartTicks"?: n}; root only; 403 for pid 1/the daemon/kernel threads, 404 for a gone pid, 409 when the pid was reused
GET /v1/tokenTokenService.GetTokenStatusToken state: kind, live access tokens, rotation window, the primary's truncated digest — never token material
POST /v1/system/rebootSystemService.RebootSystemRequests a full host reboot after acknowledging the caller; primary token only
POST /v1/token/access {"ttl_secs"?, "label"?}TokenService.IssueAccessTokenMint a short-lived access token (primary only)
DELETE /v1/token/accessTokenService.RevokeAccessTokensKill every live access token, {"revoked": n} (primary only)
POST /v1/token/rotate {"grace_secs"?}TokenService.RotatePrimaryTokenReplace the primary, revoking all access tokens; returns the new token (primary only)
POST /v1/token/rotate/commitTokenService.CommitPrimaryTokenRotationConfirm the new primary is stored and close the grace window; refused when presented with the token it replaced
GET /v1/files?path=&hidden=FileService.ListDirectoryList a directory from /; details — 📁 files
GET /v1/files/stat?path=FileService.StatPathMetadata for one path
POST /v1/files/directoryFileService.CreateDirectoryCreate a directory
POST /v1/files/move · /copy · /delete · /archiveFileService.MovePath / CopyPath / DeletePaths / CreateArchiveMove, copy, delete, archive
GET /v1/files/content?path=&offset=FileService.ReadFile (stream)Download a file
PUT /v1/files/content?path=&name=&overwrite=FileService.WriteFile (stream)Upload a file
POST /v1/files/attributesFileService.SetPathAttributesMode and/or owner/group by name
GET /v1/files/identitiesFileService.ListSystemIdentitiesThe machine's local users and groups

📜 Code generation ​

  • Rust code is generated from proto/ in build.rs via protox (a pure-Rust protobuf compiler) + tonic-build — no system protoc needed, the build is hermetic.
  • Contract changes are backward-compatible only (new fields are optional, field numbers are never reused).

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

Released under the MIT License.