feat(cloud): remove static credentials and add host console
Tests / Test No test results found

This commit is contained in:
2026-07-13 19:45:53 +08:00
parent efeb3eb926
commit c162c2501b
61 changed files with 3118 additions and 1221 deletions
+113 -119
View File
@@ -10,24 +10,29 @@ uv sync --locked --all-packages
## Local SQLite
SQLite is intended for local development and tests with one Cloud API process.
Configure public and host credentials even in local mode because anonymous
development access cannot authorize a host identity.
The Cloud API uses persistent user sessions for human access and a Host-generated
secret for later Host operations; no static bearer credential configuration is
required.
```powershell
$env:CLOUD_ENVIRONMENT = "local"
$env:CLOUD_DATABASE_URL = "sqlite:///cloud/cloud.sqlite3"
$env:CLOUD_PUBLIC_CREDENTIALS_JSON = '[{"principal_id":"local-sdk","token":"replace-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]'
$env:CLOUD_HOST_CREDENTIALS_JSON = '[{"principal_id":"local-host","token":"replace-host-token","scopes":[],"host_id":"host-local"}]'
$env:CLOUD_SESSION_COOKIE_SECURE = "false"
uv run --package device-cloud-api device-cloud-api --host 127.0.0.1 --port 8001
```
In a second terminal, start the Host Agent with the matching host identity and
token:
Create the first administrator interactively, then open the Console and sign
in with that account:
```bash
uv run --package device-cloud-api device-cloud-admin users create --username admin --display-name "Local Administrator" --role admin
```
In a second terminal, configure the local Host Agent to reach this Cloud API:
```powershell
$env:HOST_AGENT_CONTROL_PLANE_URL = "http://127.0.0.1:8001"
$env:HOST_AGENT_HOST_ID = "host-local"
$env:HOST_AGENT_TOKEN = "replace-host-token"
uv run --package device-host-agent device-host-agent setup
uv run --package device-host-agent device-host-agent
```
@@ -41,66 +46,23 @@ defaults to `./tasks`.
The Host Agent only initiates outbound HTTP requests. It does not expose an
inbound port.
## Managed Edge Enrollment
New edge installations do not need a pre-coordinated Host or device ID. The
Cloud API accepts configured one-time enrollment credentials:
```powershell
$env:CLOUD_ENROLLMENT_TOKENS_JSON = '[{"principal_id":"edge-installer","token":"replace-with-a-long-random-one-time-token"}]'
```
On the edge Host, omit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` and provide
the enrollment token only for the first successful enrollment:
```bash
export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
export HOST_AGENT_ENROLLMENT_TOKEN="replace-with-a-long-random-one-time-token"
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
uv run --package device-host-agent device-host-agent
```
Before its first request the Host Agent creates `HOST_AGENT_IDENTITY_PATH` with
an instance identifier and long-lived random Host secret. The cloud consumes
the enrollment token, assigns `host_id`, stores only credential digests, and
returns the assigned ID. The Host Agent then enrolls each record from
`tasks/device_config.sqlite3`, stores its cloud-assigned `device_id` in that
database, connects the resulting devices, and starts heartbeat/claim loops.
Keep the identity file and device configuration database on persistent edge
storage with permissions limited to the service account. The identity file is
a bearer secret: do not put it in an image, repository, log, or general backup.
After successful enrollment, remove `HOST_AGENT_ENROLLMENT_TOKEN` from the edge
environment. An intact identity file is sufficient for restart; if only a
device mapping is lost, device enrollment reconstructs the same cloud ID.
Enrollment tokens are one-time even when they remain in Cloud API environment
configuration: their consumed digest is stored in the database. Reusing a token
for another edge instance returns a conflict. Create a distinct token for every
edge installation.
Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps
the previous legacy behavior, including locally selected device IDs. This is
the rollback and staged-migration path for existing deployments.
## Self-Service Edge Enrollment (Zero-Token)
## Direct Edge Enrollment
The Host Agent's default `HOST_AGENT_CONTROL_PLANE_URL` is
`https://amcp.home.jerryyan.top`. This is a single-operator home deployment
default; override the environment variable for local/dev/test runs pointed at
a different Cloud API.
When `HOST_AGENT_ENROLLMENT_TOKEN` is not set and no cached identity exists,
the Host Agent enrolls with no bearer credential at all. The Cloud API only
accepts that request when `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`
(default `false`); a configured enrollment token, if presented, always takes
priority over self-service. This trades away any approval step: **any caller
that can reach the control-plane URL can register itself as a new Host.**
There is no rate limiting or throttling on this path by design — the intended
mitigation is network-perimeter control (firewall/reverse-proxy access to the
URL), not an in-process limiter. Only enable the flag on a deployment where
that network boundary is enforced.
When no cached identity exists, the Host Agent creates a random instance
identifier and Host secret, registers directly with the Cloud API, persists the
assigned `host_id`, and uses that secret for all later device enrollment,
heartbeat, claim, renewal, and result calls. The Cloud stores only the secret
digest. There is no configured enrollment-token or static Host-credential path.
This trades away any approval step: **any caller that can reach the control
plane can register itself as a new Host.** There is no rate limiting or
throttling on this path by design; restrict access at the firewall or reverse
proxy before exposing the endpoint.
Before the Host Agent's first unattended start, create the one-time local
operator account interactively:
@@ -124,6 +86,54 @@ docker compose run --rm host-agent device-host-agent setup
docker compose up -d
```
Keep `HOST_AGENT_IDENTITY_PATH`, the local-account file, and
`tasks/device_config.sqlite3` on persistent storage with permissions limited to
the service account. The identity file is a bearer secret: do not put it in an
image, repository, log, or general backup.
### Local Web Console
The Host Agent can optionally serve a small local-only web console on the
edge machine: heartbeat/enrollment status, registered local devices, current
assignment progress, local device add/edit/remove, a local account password
change, and recent assignment/heartbeat history. It authenticates with the
same local account created by `device-host-agent setup` above — there is no
separate console credential.
```text
HOST_AGENT_CONSOLE_ENABLED=false
HOST_AGENT_CONSOLE_BIND_HOST=127.0.0.1
HOST_AGENT_CONSOLE_PORT=8765
HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=false
HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS=43200
HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
```
- `HOST_AGENT_CONSOLE_ENABLED` — starts the console when `true`; disabled by
default, so existing deployments see no new listening port.
- `HOST_AGENT_CONSOLE_BIND_HOST` — the address the console binds to; defaults
to loopback-only.
- `HOST_AGENT_CONSOLE_PORT` — the TCP port the console listens on.
- `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` — required opt-in before
`HOST_AGENT_CONSOLE_BIND_HOST` may be a non-loopback address; the Host
Agent refuses to start otherwise.
- `HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS` — sliding idle timeout, in seconds,
for an authenticated console session.
- `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat
entries the console retains before pruning older ones.
Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
operator-accepted risk: the console has no built-in TLS and no rate
limiting, so a non-loopback bind exposes an unencrypted login form to
whatever network can reach that port. To reach the console from another
machine instead, keep it bound to loopback and open an SSH local
port-forward to the edge machine:
```bash
ssh -L 8765:127.0.0.1:8765 user@edge-host
# then open http://127.0.0.1:8765 from the local browser
```
## PostgreSQL Deployment
Start from `.env.example`, replace every `change-me-*` value, and keep the
@@ -151,10 +161,10 @@ the pytest suite (`-m "not integration"`), builds the image from the same
`device-host-agent --help`), and pushes `<REGISTRY>/<IMAGE_NAME>:<BUILD_NUMBER>-<git short sha>`
plus `:latest` to the configured registry.
`compose.deploy.yaml` is the same three-service stack as `compose.yaml`
except `cloud-api` and `host-agent` reference `image:` instead of `build:`.
Set `REGISTRY`, `IMAGE_NAME`, and `IMAGE_TAG` (see `.env.example`) to the tag
Jenkins published, then deploy without a local build step:
`compose.deploy.yaml` contains only PostgreSQL and the Cloud API; Host Agents
run at their edge sites rather than beside the Cloud API. It references the
fixed Jenkins registry image and interpolates only `IMAGE_TAG` (see
`.env.example`). Deploy without a local build step:
```bash
docker compose -f compose.deploy.yaml pull
@@ -172,36 +182,22 @@ uv run --package device-cloud-api device-cloud-api --host 0.0.0.0 --port 8001
```
Production startup requires `CLOUD_ENVIRONMENT=production`, a current schema,
and at least one configured bearer credential.
and HTTPS before browser sessions are exposed. It does not require credential
JSON. Create the first administrator interactively after startup, before
opening the Console to operators.
## Credentials And Scopes
## Authentication And Host Identities
`CLOUD_PUBLIC_CREDENTIALS_JSON` is a JSON array of public API principals. Grant
only the scopes required by each integration:
Cloud users are the human authorization boundary. Their `viewer`, `operator`,
and `admin` roles map to the existing API scopes, and the browser sends an
`HttpOnly` session cookie plus CSRF proof for unsafe operations. The deployment
does not accept `CLOUD_PUBLIC_CREDENTIALS_JSON`,
`CLOUD_HOST_CREDENTIALS_JSON`, or `CLOUD_ENROLLMENT_TOKENS_JSON`.
- `tasks:submit`: submit tasks.
- `tasks:read`: read task status and failure metadata.
- `pool:read`: list hosts and devices.
- `plugins:read`: list installed plugin registrations.
- `plugins:admin`: register installed plugin entry points.
`CLOUD_HOST_CREDENTIALS_JSON` contains Host Agent principals. Every entry must
include exactly one `host_id`; its token is valid only for heartbeat, claim,
renewal, and result operations for that host.
`CLOUD_ENROLLMENT_TOKENS_JSON` contains bootstrap principals with only
`principal_id` and `token`. These credentials cannot submit tasks, read the
pool, or operate as a Host; they can only create one durable Host binding.
Use high-entropy values generated by the deployment secret manager.
`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (default `false`) additionally accepts
Host enrollment requests with no bearer token at all — see
[Self-Service Edge Enrollment](#self-service-edge-enrollment-zero-token).
Do not place bearer tokens in command history, image layers, Compose files, or
logs. Use environment injection or the deployment platform's secret manager.
Rotate a token by deploying the updated Cloud API credential set and Host Agent
configuration together.
A fresh Host sends its generated candidate secret only during direct
registration. The Cloud stores its digest and returns a `host_id`; later Host
operations use that secret and are strictly bound to the returned `host_id`.
Protect the persisted Host identity file as a bearer secret.
Dynamically enrolled Host credentials are stored as digests in the cloud
database. This release exposes repository-level revocation rather than a public
@@ -230,12 +226,10 @@ PY
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
renders task history, devices, hosts, plugins, and the user directory. Human
operators sign in with a username and password; the Cloud API creates an
expiring, revocable `HttpOnly` session cookie and uses a separate CSRF
cookie/header for writes. Existing bearer tokens remain available through the
Console's explicit **Use API token** action and for SDK, Host Agent, and
automation compatibility.
renders task history, devices, hosts, and plugins. Human operators sign in with
a username and password; the Cloud API creates an expiring, revocable
`HttpOnly` session cookie and uses a separate CSRF cookie/header for writes.
The Console has no bearer-token fallback or user-directory view.
### HTTPS and session configuration
@@ -258,7 +252,9 @@ CLOUD_TRUST_PROXY_HEADERS=false
```
Set `CLOUD_TRUST_PROXY_HEADERS=true` only when a trusted proxy overwrites
`X-Forwarded-For` before requests reach the Cloud API.
`X-Forwarded-For` before requests reach the Cloud API. This affects the
client-address bucket used for login throttling; the default is safe when the
application is reached directly.
### Create and recover administrator accounts
@@ -281,11 +277,11 @@ docker compose exec cloud-api device-cloud-admin users revoke-sessions --usernam
```
Roles are fixed: `viewer` can read tasks/pool/plugins; `operator` additionally
submits tasks; `admin` has unrestricted Cloud API access and manages users.
Administrators create users, reset passwords, change roles, disable accounts,
and revoke sessions from the **Users** Console view. New and reset users must
change their temporary password before accessing other resources, and the API
will not disable or demote the last enabled administrator.
submits tasks; `admin` has unrestricted Cloud API access. The administration
CLI creates users, resets passwords, changes roles, enables accounts, and
revokes sessions. New and reset users must change their temporary password
before accessing other resources, and the API will not disable or demote the
last enabled administrator.
### Configure the CORS allow-list
@@ -328,22 +324,20 @@ at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
### Same-origin deployment (baked into the Cloud API image)
The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
and `compose.yaml` / `compose.deploy.yaml` set
`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
this mode the Cloud API itself serves the console at `/console/` (visiting `/`
307-redirects there). Put that origin behind an HTTPS reverse proxy, then open
for example `https://cloud.example.com/` directly — no separate dev server, no
static host, and no CORS allow-list are needed because the SPA and API share one
origin.
The Jenkins-built Docker image already carries the SPA at `/app/console-static`
and sets `CLOUD_CONSOLE_STATIC_DIR` in the image itself. The Cloud API serves
the Console at `/console/` (visiting `/` 307-redirects there). Put that origin
behind an HTTPS reverse proxy, then open for example
`https://cloud.example.com/` directly — no separate dev server, static host, or
CORS allow-list is needed because the SPA and API share one origin.
The SPA shell (`index.html`, JS, CSS) is served without credentials by design
so it can render the login page. All `/v1/*` resource calls remain scope-gated,
and unsafe cookie-authenticated calls require CSRF proof. The browser receives
only the non-secret CSRF value; it never receives the `HttpOnly` session secret.
To opt out (e.g. for local development where you run `npm run dev`), leave
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
Source-based local development leaves `CLOUD_CONSOLE_STATIC_DIR` unset; the
mount remains conditional on that image-provided setting.
Jenkins build args (`NODE_IMAGE`, `NPM_REGISTRY`, `UV_IMAGE`, `APT_MIRROR`,
`UV_INDEX_URL`) default to CN mirrors so builds don't time out pulling from
@@ -406,9 +400,9 @@ For rollback:
1. Stop all Host Agents and the Cloud API.
2. Back up PostgreSQL or the SQLite database file.
3. Before rolling back to a release without enrollment support, provision
temporary static Host credentials for every managed edge that must continue
operating. Stop those Host Agents and set their explicit Host ID/token.
3. Before rolling back to an image that still requires static credentials,
restore that image's matching deployment configuration and provision the
required legacy credentials outside this release's Compose contract.
4. If the previous application version cannot use the current schema, run the
tested downgrade while no application process is connected:
@@ -417,7 +411,7 @@ For rollback:
```
5. Restore the previous application image or checkout and start the Cloud API.
6. Verify `/health/ready`, then restart Host Agents with credentials compatible
6. Verify `/health/ready`, then restart Host Agents with identities compatible
with the restored Cloud API.
Downgrading revision 0002 removes dynamic credential bindings and durable