📡 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
ascCLI manages apps through the unix socket without sudo and without the docker group:asc install,asc ls,asc app startwork 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] listensetting 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] socketsetting; the directory comes from the systemd unit'sRuntimeDirectory=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.tokennext 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/*answers403with atoken_deniedpayload, gRPCPERMISSION_DENIED). - Required for both transports: REST — the
Authorization: Bearer <token>header, gRPC — theauthorizationmetadata. 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 forwardsSUDO_UID/SUDO_USERas theX-Asc-Sudo-Uid/X-Asc-Sudo-Userheaders; 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 attachandapp 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~/.asctree). 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
| REST | gRPC | Description |
|---|---|---|
GET /v1/status | DaemonService.GetStatus | Version, application counters, capability list (DMN-076) |
GET /v1/apps | AppService.ListApps | Application list (scoped by the caller's context) |
POST /v1/apps {"spec": ..., "source"?, "name"?, "branch"?, "tag"?, "license_ack"?} | AppService.InstallApp | Install 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.GetApp | A single application |
GET /v1/apps/{id}/disk | AppService.GetAppDisk | Disk usage: image, repository, data, custom volumes |
GET /v1/apps/{id}/image | AppService.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}/repull | AppService.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,b | AppService.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|restart | AppService.Start/Stop/RestartApp | Lifecycle |
GET /v1/apps/{id}/logs?tail=N×tamps=bool | AppService.GetAppLogs | Log 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.RemoveApp | Removal including data |
POST /v1/apps/{id}/console-token | AppService.IssueConsoleToken | Temporary 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,b | DockerService.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/images | DockerService.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/volumes | DockerService.ListVolumes (DMN-104) | Every named volume, with ascProtected/protectedReason set when an installed app's settings declare it. Root context only |
GET /v1/docker/networks | DockerService.ListNetworks (DMN-104) | Every network, inventory-only — see PruneDocker below for why there is no delete route. Root context only |
GET /v1/docker/disk-usage | DockerService.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/prune | DockerService.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/control | DockerService.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/storages | BackupService.ListBackupStorages | Storages (local first) without credentials |
PUT /v1/backups/storages/{name} | BackupService.UpsertBackupStorage | Body {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.RemoveBackupStorage | Remove a storage (only one carrying that marker, when given) |
POST /v1/apps/{id}/backups | BackupService.CreateBackup | Body {storages?, keep?} — one archive pushed to every storage, one result each. Long-running |
POST /v1/apps/{id}/backups/restore | BackupService.RestoreBackup | Body {storage, name, stop_app} — stop_app stops a running app, restores and starts it again |
DELETE /v1/apps/{id}/backups/{storage}/{name} | BackupService.DeleteBackup | Delete 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.UpsertSchedule | Body — a job (trigger, utc, enabled, comment, action: {type, …}) |
DELETE /v1/schedules/{id} | ScheduleService.RemoveSchedule | Remove a job and its run history |
PUT /v1/schedules/managed/{managed_by} | ScheduleService.ReplaceManagedSchedules | Body {schedules} — full replace of that owner's jobs; operator jobs are never touched |
POST /v1/schedules/{id}/run | ScheduleService.RunSchedule | Start a job now; answers {started, run} without waiting |
GET /v1/schedules/{id}/runs?limit= | ScheduleService.ListScheduleRuns | The job's runs, newest first (at most 50 kept) |
GET /v1/metrics | MonitorService.GetSystemMetrics | Current system metrics (503 until the first sample) |
GET /v1/metrics/history?limit=N | MonitorService.GetMetricsHistory | Metrics history from the ring buffer, oldest → newest |
GET /v1/ports/listening | MonitorService.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/processes | ProcessService.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}/signal | ProcessService.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/token | TokenService.GetTokenStatus | Token state: kind, live access tokens, rotation window, the primary's truncated digest — never token material |
POST /v1/system/reboot | SystemService.RebootSystem | Requests a full host reboot after acknowledging the caller; primary token only |
POST /v1/token/access {"ttl_secs"?, "label"?} | TokenService.IssueAccessToken | Mint a short-lived access token (primary only) |
DELETE /v1/token/access | TokenService.RevokeAccessTokens | Kill every live access token, {"revoked": n} (primary only) |
POST /v1/token/rotate {"grace_secs"?} | TokenService.RotatePrimaryToken | Replace the primary, revoking all access tokens; returns the new token (primary only) |
POST /v1/token/rotate/commit | TokenService.CommitPrimaryTokenRotation | Confirm the new primary is stored and close the grace window; refused when presented with the token it replaced |
GET /v1/files?path=&hidden= | FileService.ListDirectory | List a directory from /; details — 📁 files |
GET /v1/files/stat?path= | FileService.StatPath | Metadata for one path |
POST /v1/files/directory | FileService.CreateDirectory | Create a directory |
POST /v1/files/move · /copy · /delete · /archive | FileService.MovePath / CopyPath / DeletePaths / CreateArchive | Move, 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/attributes | FileService.SetPathAttributes | Mode and/or owner/group by name |
GET /v1/files/identities | FileService.ListSystemIdentities | The 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 systemprotocneeded, the build is hermetic. - Contract changes are backward-compatible only (new fields are optional, field numbers are never reused).
🔗 Related tasks
DMN-005, DMN-007, DMN-042, DMN-043, DMN-053, DMN-065, DMN-066, DMN-070, DMN-111 in ROADMAP.md.