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
+40 -12
View File
@@ -376,10 +376,8 @@ uv run --package device-host-agent device-host-agent setup
`HOST_AGENT_CONTROL_PLANE_URL` 默认已固定为 `https://amcp.home.jerryyan.top`
仅在连接其他云端环境(如本地/测试用的 `cloud-api`)时才需要覆盖该变量。未设置
`HOST_AGENT_ENROLLMENT_TOKEN` 时,Host Agent 会直接向云端发起零 token 自助注册
(需要云端 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`);若仍持有云端管理员
签发的一次性 enrollment token,也可以继续设置 `HOST_AGENT_ENROLLMENT_TOKEN`
走原有的 token 注册路径:
缓存身份不存在时,Host Agent 会直接向云端注册,由云端返回 `host_id`;后续运行
使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
```bash
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
@@ -394,17 +392,47 @@ uv run --package device-host-agent device-host-agent
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 不监听入站端口。若使用了一次性 enrollment token,成功后可以将其从
边缘环境移除;但必须保留并保护 `tasks/host_identity.json`
Host Agent 不监听入站端口。必须保留并保护 `tasks/host_identity.json`
`tasks/device_config.sqlite3`;前者等同于 Host bearer credential。
零 token 自助注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有
审批环节,也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层
限制,仅应在受控网络中开启 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED`
直接注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有审批环节,
也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层限制。
需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host
credential,然后显式设置 `HOST_AGENT_HOST_ID``HOST_AGENT_TOKEN`。这两个变量
同时存在时优先于 enrollment state。
### 启用本地 Web Console(可选)
Host Agent 内置一个默认关闭的本地 Web Console,用于在这台 Mac 上直接查看和管理
正在运行的 Host,无需 SSH 进去读原始状态文件。启用时追加环境变量后再启动:
```bash
export HOST_AGENT_CONSOLE_ENABLED="true"
uv run --package device-host-agent device-host-agent
```
不要修改 `HOST_AGENT_CONSOLE_BIND_HOST`,保持默认回环地址 `127.0.0.1`;Console
启动后在同一台 Mac 上打开:
```
http://127.0.0.1:8765
```
`8765``HOST_AGENT_CONSOLE_PORT` 的默认值(见
`apps/device-host-agent/host_agent/config.py`)。登录使用与
`uv run --package device-host-agent device-host-agent setup` 创建的同一个本地账号,
没有单独的 Console 账号体系。
登录后可以看到:
- 状态仪表盘:最近一次 heartbeat 结果与时间、enrollment/identity 状态、本地登记
设备及其连接状态、当前 assignment 执行进度。
- 本地设备管理:新增、编辑、删除登记在这台 Host 上的设备,改动会立即在运行中的
`DeviceManager` 上生效,无需重启 Host Agent。
- 修改密码:更新本地操作账号密码,需要先输入当前密码。
- 最近历史:近期 assignment 与 heartbeat 的执行记录。
完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
## 10. 多设备与端口