diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e328992..93c34b6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,21 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_PASSWORD: wanctl-ci
+ POSTGRES_DB: wanctl_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+ env:
+ WANCTL_TEST_POSTGRES: postgres://postgres:wanctl-ci@127.0.0.1:5432/wanctl_test?sslmode=disable
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
diff --git a/README.md b/README.md
index 7210ff6..782e39f 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ relay, while each device keeps final authority through a local approval policy.
- One Go binary for relay, portal, agent, controller, and MCP roles.
- Proxy-agnostic HTTP long-poll transport that works through ordinary reverse proxies; WebSocket remains optional.
- CLI and MCP surfaces designed for scripted and AI-agent-driven control.
+- Optional [WebFetch access](docs/webfetch.md) for URL-reading web AIs, with owner-approved, short-lived device delegation and existing device policy.
## Quick start
@@ -86,6 +87,10 @@ database of its own; it authenticates users and scopes calls to the relay's
Postgres-backed admin API. See [Architecture](docs/architecture.md) for the
trust model, transports, sharing rules, and component map.
+When enabled, the WebFetch component is itself a trusted controller endpoint
+and sees its own commands/results. The broker's other controller-to-device
+sessions remain opaque; see the [adapter trust boundary](docs/webfetch.md).
+
## Build
wanctl requires the Go release named by the `go` directive in `go.mod` (currently 1.26.6); the Go tool downloads it automatically.
diff --git a/docs/architecture.md b/docs/architecture.md
index d2b2e7d..26de936 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -46,6 +46,14 @@ transport + pgstore + admin + dist), `agent`, `client`, `server` (shell+files),
`policy` (rules+approver), `console` (transport-neutral approval queue),
`portal`, `eventlog`, `sessionauth` (relay-issued capability grants).
+The opt-in [WebFetch adapter](webfetch.md) adds URL-only AI clients. Its owner
+approval and short-lived device scopes live in the existing portal/token store;
+the adapter is an ordinary E2E controller with a distinct identity per grant.
+Unlike the opaque broker, this explicitly enabled controller component sees
+the plaintext it sends and receives. Device-local action policy remains the
+authority. Delegated metadata is preserved across admission and both carriers;
+namespace-only legacy paths refuse delegated credentials.
+
## Transports
Two carriers speak the **same** TLS + framed protocol:
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index db0f3ac..1be71ab 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -36,6 +36,11 @@ controller (you/agent) ──┐ ┌── device (wa
`server`(shell + 文件)、`policy`(规则 + 审批者)、`console`(与传输无关的审批队列)、
`portal`、`eventlog`、`sessionauth`(relay 签发的能力授予)。
+可选的 [WebFetch 接入层](webfetch.zh.md) 支持只会读取 URL 的网页 AI。申请、审批、
+设备范围、过期和吊销使用现有门户与 Token 存储;适配器以每份授权独立的控制端身份连接设备。
+这个主动启用的控制端组件能看到自己收发的指令和结果,普通 broker 仍然只转发加密字节。
+操作权限仍由设备决定;委托凭证不会在旧的 namespace-only 接口中被降级为账号全权凭证。
+
## 传输层
两种载体说的是**同一套** TLS + 分帧协议:
diff --git a/docs/environment.md b/docs/environment.md
index bcc3a9b..729b5fc 100644
--- a/docs/environment.md
+++ b/docs/environment.md
@@ -21,6 +21,9 @@ Variables marked "conditional" are required only for the feature described.
| `WANCTL_MCP_LOCAL_ROOT` | MCP stdio | No | process working directory | Only local tree `wanctl_push` and `wanctl_pull` may access. The wanctl config directory is always excluded. |
| `WANCTL_MCP_ALLOWED_ORIGINS` | MCP HTTP | No | none | Comma-separated browser Origin allowlist. Requests with an Origin are denied unless listed; programmatic clients normally send none. |
| `WANCTL_MCP_ALLOW_UNSAFE_TRUST_SERVER` | MCP | No | `0` | Set to `1` only to restore model-callable device TOFU pinning. Default is fail-closed because the model cannot distinguish an independently verified fingerprint from one supplied by a hostile relay. |
+| `WANCTL_WEBFETCH_SEED` | relay/controller adapter | No | disabled | Secret hex seed, at least 32 decoded bytes, enabling `/webfetch`; requires PostgreSQL. See [WebFetch](webfetch.md). |
+| `WANCTL_WEBFETCH_PORTAL_ORIGIN` | WebFetch | Conditional | none | Canonical HTTPS portal origin for authenticated owner approvals. |
+| `WANCTL_WEBFETCH_RELAY_URL` | WebFetch | No | `WANCTL_PUBLIC_ORIGIN` | Internal controller-to-relay origin; HTTPS or loopback HTTP. |
| `RELAY_ADMIN_URL` | portal | Yes | none | Internal relay base URL used for the portal's admin proxy, such as `http://relay:8080`. |
| `WANCTL_GITHUB_CLIENT_ID` | portal | Conditional | none | Enables GitHub OAuth login. Mutually exclusive with `PORTAL_USER_HEADER`. |
| `WANCTL_GITHUB_CLIENT_SECRET` | portal | Conditional | none | OAuth App client secret; required when the client ID is set. |
diff --git a/docs/environment.zh.md b/docs/environment.zh.md
index 922ea58..c2b2def 100644
--- a/docs/environment.zh.md
+++ b/docs/environment.zh.md
@@ -20,6 +20,9 @@
| `WANCTL_MCP_LOCAL_ROOT` | MCP stdio | 否 | 进程工作目录 | `wanctl_push` 和 `wanctl_pull` 唯一可以访问的本地目录树。wanctl 配置目录永远被排除在外。 |
| `WANCTL_MCP_ALLOWED_ORIGINS` | MCP HTTP | 否 | 无 | 逗号分隔的浏览器 Origin 白名单。带 Origin 的请求不在名单里就拒绝;程序化的客户端通常一个都不带。 |
| `WANCTL_MCP_ALLOW_UNSAFE_TRUST_SERVER` | MCP | 否 | `0` | 只有想恢复「模型可调用的设备 TOFU 钉扎」时才设成 `1`。默认是失败即关闭,因为模型分不清一个独立验证过的指纹和一个由敌意 relay 递过来的指纹。 |
+| `WANCTL_WEBFETCH_SEED` | relay 内的控制端适配器 | 否 | 关闭 | 至少 32 字节的十六进制秘密种子,启用 `/webfetch`;必须连接 PostgreSQL。见 [WebFetch](webfetch.zh.md)。 |
+| `WANCTL_WEBFETCH_PORTAL_ORIGIN` | WebFetch | 视情况 | 无 | 设备主人审批所用的门户 HTTPS origin。 |
+| `WANCTL_WEBFETCH_RELAY_URL` | WebFetch | 否 | `WANCTL_PUBLIC_ORIGIN` | 适配器连接 relay 的 origin;仅接受 HTTPS 或回环 HTTP。 |
| `RELAY_ADMIN_URL` | portal | 是 | 无 | 门户的管理代理所用的 relay 内网基址,比如 `http://relay:8080`。 |
| `WANCTL_GITHUB_CLIENT_ID` | portal | 视情况 | 无 | 启用 GitHub OAuth 登录。与 `PORTAL_USER_HEADER` 互斥。 |
| `WANCTL_GITHUB_CLIENT_SECRET` | portal | 视情况 | 无 | OAuth App 的 client secret;设了 client ID 就必需。 |
diff --git a/docs/releases/v0.9.0.md b/docs/releases/v0.9.0.md
new file mode 100644
index 0000000..e728447
--- /dev/null
+++ b/docs/releases/v0.9.0.md
@@ -0,0 +1,15 @@
+# v0.9.0
+
+Adds optional WebFetch access for URL-reading web AI clients, with owner-approved
+device-scoped delegation and existing wanctl device policy. See [WebFetch](../webfetch.md)
+for configuration, limits and compatibility, and [acceptance](../webfetch-acceptance.md)
+for the Qwen browser validation.
+
+Deploy relay and portal with database migration 009, then upgrade the controlled
+agents before using delegated sessions. Self-host Compose forwards the optional
+WebFetch seed and uses the configured portal origin and local relay endpoint.
+Existing CLI/MCP credentials retain their behavior.
+
+Rollback requires revoking every delegated token before starting a relay version
+that lacks scope-aware token resolution. The migration is additive; restore a
+database backup only as part of an explicitly planned data rollback.
diff --git a/docs/webfetch-acceptance.md b/docs/webfetch-acceptance.md
new file mode 100644
index 0000000..7b6a05d
--- /dev/null
+++ b/docs/webfetch-acceptance.md
@@ -0,0 +1,48 @@
+# WebFetch acceptance — 2026-09-16
+
+The implementation was exercised through Qwen3.8-Max in its normal web chat UI,
+using a public HTTPS WebFetch endpoint and an isolated wanctl deployment. The
+owner portal was loopback-only with the test fixture's fixed identity; production
+GitHub OAuth was not reconfigured or used for this run. The relay used real
+PostgreSQL, and the device used wanctl mutual TLS and normal device policy.
+
+Observed sequence:
+
+1. Qwen read the entry page, followed its start URL, received `pending` and
+ returned the owner approval link. No device operation ran.
+2. The owner portal selected the single isolated device and approved 15 minutes.
+ The displayed device fingerprint matched the locally generated device identity;
+ the controller fingerprint was independently derived from the local adapter seed.
+3. Qwen's first command was refused because the controller was not paired.
+ The existing wanctl pairing page then trusted the verified controller.
+4. Qwen retried with a new request ID, and `printf wanctl-webfetch-ok` returned
+ the expected stdout and exit code 0.
+5. Qwen wrote and read a text file containing Chinese, newlines, and `& + % # ?`.
+ Both task results and the actual device file were **62 UTF-8 bytes**, SHA-256
+ `61476ad9436a5bc24c4060a5f7ae79177d700829ca2cdcf6900c757417c7acb6`.
+6. The owner revoked the delegation through the existing Access tokens page.
+ Qwen's attempt to read the previous result and submit a new command both
+ returned HTTP 403 with status `revoked`. Independent HTTP checks agreed.
+ The ledger retained four jobs (one unpaired refusal and three successes),
+ with no post-revocation job. The device log recorded only the three authorized
+ operations, each linked to its authenticated grant, credential and session.
+
+The device was never put into bypass mode. Its existing rules allowed only the
+test directory and the exact harmless command. No credentials or live browser
+tickets are included in this record.
+
+An initial browser attempt encountered a disconnected development SSH tunnel
+and correctly reported 502. After restoring the test ingress and adding reconnect
+supervision, the complete workflow above passed. That tunnel is a development
+fixture, not part of the WebFetch protocol.
+
+Automated validation includes real-PostgreSQL lifecycle/concurrency/retention
+tests, both transports and all four carrier combinations, device-scope and
+management rejection, credential-bound sessions, active revocation and expiry,
+late human approval after revocation, old-agent and upstream compatibility,
+bounded client cancellation, immutable request deduplication, and scoped audit.
+
+This establishes the observed Qwen workflow and tested enforcement paths. It is
+not a claim that every web AI can fetch arbitrary URLs or that arbitrary
+background side effects can be rolled back. See [WebFetch](webfetch.md) for
+limits, compatibility, deployment and rollback requirements.
diff --git a/docs/webfetch.md b/docs/webfetch.md
new file mode 100644
index 0000000..47a602b
--- /dev/null
+++ b/docs/webfetch.md
@@ -0,0 +1,151 @@
+# WebFetch access
+
+WebFetch lets a web AI that can read URLs use wanctl without an MCP connector.
+It is an optional controller adapter hosted alongside the relay. The owner
+approves a short-lived delegation in the existing wanctl portal; device trust,
+rules, mode and per-request approvals still decide what runs.
+
+## Owner workflow
+
+1. Ask the AI to open `https://RELAY/webfetch`, then open its `start_url`.
+2. The AI returns an `approval_url`. Open it yourself, sign in to wanctl,
+ verify the controller and selected device identities, choose your devices
+ and a duration, and approve. Fetching this URL cannot approve a request.
+3. Ask the AI to reread its `status_url`. The approved response contains the
+ exact allowed device targets and tool parameters.
+4. On first use, the selected device may require ordinary controller pairing.
+ The AI must show that owner link, not approve it. Pairing does not change
+ the device's operation rules or enable bypass.
+5. Revoke the delegation from **Settings → Access tokens** when finished.
+
+Only owned devices with persistent IDs and recorded fingerprints can be selected
+in this initial version. Ordinary cross-account sharing is unchanged. Device
+renames do not change grants; device removal or certificate rotation invalidates
+them. A grant has device-use rights only, never console/management rights.
+
+The device's mode is authoritative: granting use of a bypass-mode device gives
+the client broad use of that device. WebFetch does not pretend that an exec
+permission can be separated from what an arbitrary shell command can do.
+
+## Operator setup
+
+Upgrade **both the relay and the controlled agents**. Older agents do not
+advertise delegated-session enforcement, so delegated dials fail closed.
+The existing portal must also be upgraded for the approval page.
+
+WebFetch is disabled unless `WANCTL_WEBFETCH_SEED` is set. Configure:
+
+| Variable | Meaning |
+| --- | --- |
+| `DATABASE_URL` | Existing wanctl PostgreSQL database; required for durable grants and request deduplication |
+| `WANCTL_WEBFETCH_SEED` | Secret hex seed, at least 32 decoded bytes; keep it in the operator's secret store |
+| `WANCTL_PUBLIC_ORIGIN` | Canonical public HTTPS relay origin used in AI-facing links |
+| `WANCTL_WEBFETCH_PORTAL_ORIGIN` | Canonical public HTTPS portal origin used in owner approval links |
+| `WANCTL_WEBFETCH_RELAY_URL` | Optional adapter-to-relay origin; defaults to the public relay origin. HTTPS or loopback HTTP only |
+
+The self-host Compose file forwards these settings: provide the seed through a
+protected environment file, and it reuses `PORTAL_PUBLIC_ORIGIN` plus the relay
+container's loopback endpoint. For example, run Compose with both
+`--env-file .env --env-file /secure/webfetch.env`. Keep using the same protected
+file on subsequent deployments so an omitted seed does not disable the adapter.
+
+Keep the seed stable while delegations are active. Domain-separated derivation
+produces a controller identity and a relay credential for each request. Browser
+pages receive a temporary browser ticket, never the reusable seed, private key,
+owner token, portal token or raw delegated relay credential. PostgreSQL stores
+credential hashes.
+
+The public `/webfetch` endpoint must be reachable by the web AI's fetch service.
+Its calls cannot depend on the owner's browser cookies. Owner approval remains
+on the authenticated portal and uses its existing CSRF protection.
+
+Exclude **both access logs and request-bearing error logs** for `/webfetch/` at
+your ingress: paths contain bearer tickets and query strings contain tool
+arguments. For example, use an ingress-specific redacted log format, or scoped
+`access_log off` and `error_log /var/log/nginx/webfetch.error.log crit` in nginx.
+Do not disable diagnostic logs globally. Application logs never record tickets
+or relay credentials. Responses use `no-store`, `no-referrer` and `noindex`;
+third-party fetch-provider retention is outside wanctl's control.
+
+The adapter is a trusted controller endpoint: it sees commands and returned
+data. Controller-to-device traffic retains wanctl's mutual TLS; this is not
+end-to-end encryption from the web model through an unreadable adapter.
+
+## GET tool protocol
+
+Default responses are static HTML with visible structured data. Add
+`format=json` for JSON. There is no JavaScript or streaming requirement.
+
+The approved manifest returns a `call_endpoint`; construct:
+
+```text
+GET CALL_ENDPOINT?rid=UNIQUE_REQUEST&tool=TOOL&target=CANONICAL_TARGET&...
+```
+
+URL-encode every parameter. The available tools are:
+
+| Tool | Parameters | Result |
+| --- | --- | --- |
+| `exec` | `command`, optional `cwd`, optional `timeout_seconds` | One-shot execution, exit code, bounded stdout/stderr |
+| `write_text` | `path`, `content` | wanctl file upload, byte count and SHA-256 |
+| `read_text` | `path` | wanctl file download, UTF-8 contents, byte count and SHA-256 |
+
+The response contains a `job_id` and `result_url`. Running jobs additionally
+return a fresh `next_url`; read that URL until `done`, `failed` or `unknown`.
+Execution is asynchronous in the adapter but uses normal synchronous, one-shot
+wanctl operations; it does not expose device-side persistent shells or detached
+async jobs to delegated clients.
+
+`rid` is scoped to the grant. Reusing it with identical parameters returns the
+same job; changing parameters returns 409. The durable ledger records the job
+before dispatch, so repeated fetches and an adapter restart never automatically
+repeat an operation. An interrupted call may have produced a side effect even
+without a result: `unknown` means the owner must inspect the device before
+deciding whether to try a new request. This is not a claim of exactly-once
+execution of arbitrary external effects.
+
+Limits: pending requests expire after 10 minutes; approved grants last 1–60
+minutes on up to 16 devices; each grant allows 64 jobs; calls allow 1–60 seconds including queue
+time (default 30); four operations run concurrently; URLs are capped at 8 KiB;
+writes at 2 KiB UTF-8; reads at 32 KiB; exec captures at most 16 KiB each of
+stdout and stderr and cancels on overflow. `HEAD` cannot create or execute tasks.
+
+Browser tickets have an immutable 70-minute envelope. Inactive grants and their
+task contents are removed after at least 24 hours; old browser URLs cannot
+recreate deleted grants. Existing account/device audit is retained separately.
+
+## Authorization and cancellation boundaries
+
+Delegated tokens are checked by the relay on discovery, canonical target
+resolution and both HTTP/WebSocket session paths. They cannot enroll devices,
+impersonate the agent side, touch another credential's session, change device
+management state, mint credentials or call ordinary account-management routes.
+
+Active connections have an exact expiry deadline and revalidate authorization
+every second (revocation propagation also includes store/network latency).
+The device rechecks the grant after a human operation approval, before executing
+or remembering a rule. A late approval cannot revive an expired/revoked grant.
+Result reads require a live grant as well.
+
+Closing a session cancels connected one-shot execution on upgraded agents. It
+does not undo completed writes or guarantee control of a deliberately detached
+background process created by an otherwise authorized command.
+
+**Before rolling a relay back to a version without this feature, revoke all
+`kind='delegated'` tokens.** Older namespace-only token resolvers do not understand
+the new constraints. New relays preserve delegated metadata through the upstream
+inspection API and refuse to downgrade the reserved `wfd_` token prefix through
+a legacy resolver.
+
+## Development acceptance
+
+Set `WANCTL_TEST_POSTGRES` to a disposable PostgreSQL instance to run the real
+grant lifecycle, migration, encrypted controller/agent and file-operation tests.
+CI provisions PostgreSQL and enables those tests by default.
+
+`go run ./tools/webfetch-demo` provides an isolated manual/browser fixture. It
+requires a private `--state-dir`, `--public-origin` and disposable PostgreSQL.
+Only expose its relay `/webfetch` routes. Its owner portal is **loopback-only**
+and intentionally supplies a fixed test identity; it is not a production login
+configuration and must never be proxied to the public Internet. The fixture's
+device uses normal policy with one test directory and one harmless command.
diff --git a/docs/webfetch.zh.md b/docs/webfetch.zh.md
new file mode 100644
index 0000000..f837662
--- /dev/null
+++ b/docs/webfetch.zh.md
@@ -0,0 +1,58 @@
+# WebFetch 接入层
+
+让不支持 MCP、但可以抓取网页的 AI 通过 GET 调用 wanctl。临时授权由 wanctl 签发,
+设备继续使用自己的信任、运行模式、规则和审批;适配器不维护另一套操作权限。
+
+## 使用流程
+
+1. 让 AI 打开中继的 `/webfetch`,再访问返回的 `start_url` 发起申请。
+2. 你在自己的浏览器打开 AI 返回的 `approval_url`,登录 wanctl,核对身份,
+ 选择设备和 1–60 分钟的有效期。读取审批链接本身不会批准申请。
+3. 告诉 AI 已批准,让它重新读取 `status_url`,获取设备范围和工具说明。
+4. 首次连接仍需通过原有的控制端配对流程。AI 只能转交链接,不能自己信任或审批。
+5. 用完后,在「设置 → 访问令牌」吊销这份授权。
+
+首版只允许选择有固定 ID 和已登记指纹的自有设备。设备改名不扩大授权,移除或换证书会使
+授权失效。只授予使用权,不授予设备管理权。设备如果处于 bypass 模式,其使用者也会获得
+相应的广泛操作能力;授权页面会说明这一点。
+
+## 工具与结果
+
+| 工具 | 参数 | 行为 |
+| --- | --- | --- |
+| `exec` | `command`、可选 `cwd`、`timeout_seconds` | 一次性命令,返回退出码和有限长度输出 |
+| `write_text` | `path`、`content` | 使用原生文件上传协议,受设备写入规则限制 |
+| `read_text` | `path` | 使用原生文件读取协议,返回 UTF-8 内容、字节数和 SHA-256 |
+
+模型按照 manifest 中的 `call_endpoint` 自行拼接 URL,并正确编码参数。每次调用需要
+唯一的 `rid`。相同 `rid` 和参数返回原任务;参数不同返回 409。任务结果通过返回的
+`result_url` 或 `next_url` 查询,终态为 `done`、`failed` 或 `unknown`。
+
+`unknown` 表示中断后无法确定操作结果,不能擅自换一个 `rid` 重做,应先查看设备记录。
+任务在调用前写入持久账本,重复抓取和适配器重启不会自动重放操作。这不等于对任意外部
+副作用提供「恰好执行一次」保证。
+
+当前限制:申请 10 分钟过期,授权最长 60 分钟;每份授权 64 个任务;任务连同排队时间
+最长 60 秒,默认 30 秒;同时执行 4 个任务;URL 最长 8 KiB;写入最长 2 KiB;
+读取最长 32 KiB;标准输出和错误输出各最多 16 KiB。`HEAD` 不会触发任务。
+
+## 部署与安全边界
+
+这是主动开启的功能。升级 relay、portal 和 agent;旧 agent 不支持委托校验时会拒绝拨号。
+需要 PostgreSQL,以及 `WANCTL_WEBFETCH_SEED`、`WANCTL_PUBLIC_ORIGIN` 和
+`WANCTL_WEBFETCH_PORTAL_ORIGIN`。种子至少为 32 字节,保存在秘密存储中,不进入聊天或仓库。
+完整部署参数见[环境变量](environment.zh.md)和[英文操作说明](webfetch.md)。
+
+网页收到短期票据,收不到账号 Token、门户 Token、控制端私钥或原始中继委托凭证。
+入口代理必须对 `/webfetch/` 的访问日志和带请求地址的错误日志做脱敏;路径含票据,参数
+含指令。网页 AI 平台和适配器能看到收发内容,不要经此发送秘密或不应披露的原文。
+
+中继限定设备范围、凭证与会话角色;设备在人工批准后再次检查授权,防止吊销后的晚到审批
+仍然执行。活动连接按期限关闭,并每秒复核授权,吊销传播还包括数据库或上游解析延迟。
+关闭连接可取消一次性执行,不会撤销已完成的副作用,也不能保证收回主动脱离的后台进程。
+
+浏览器票据有固定的 70 分钟最长寿命,失效授权及任务内容在至少 24 小时后清理。审计另行保留。
+**回滚到不支持此功能的旧 relay 前,必须先吊销所有 `kind='delegated'` 令牌。**
+
+`tools/webfetch-demo` 是浏览器验收工装:使用独立数据库 schema、回环地址门户和固定测试身份,
+设备只允许测试目录和一条无害命令。它的门户不能发布到公网,也不能作为正式登录配置。
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 1c82ddd..9d87e64 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -312,10 +312,10 @@ const capabilityReadEventLog dataCapability = "read-event-log"
// gateDataCapability keeps data-session capabilities distinct from exec and
// file requests. A later identity/capability layer can deny here before the
// existing interactive policy gate without changing the wire handlers.
-func (a *Agent) gateDataCapability(cap dataCapability, peerFP string) (bool, string) {
+func (a *Agent) gateDataCapability(cap dataCapability, peerFP string, checks ...func() bool) (bool, string) {
switch cap {
case capabilityReadEventLog:
- return a.gate(policy.Request{Kind: policy.KindLogs, Peer: peerFP})
+ return a.gate(policy.Request{Kind: policy.KindLogs, Peer: peerFP}, checks...)
default:
return false, "unsupported-capability"
}
@@ -324,7 +324,7 @@ func (a *Agent) gateDataCapability(cap dataCapability, peerFP string) (bool, str
// gate authorizes a request: bypass/pre-approved pass; otherwise ask the
// approver and optionally remember a rule. Returns whether the op may proceed
// and a short decision string for the audit log.
-func (a *Agent) gate(req policy.Request) (bool, string) {
+func (a *Agent) gate(req policy.Request, checks ...func() bool) (bool, string) {
// Bypasses, not Mode()==bypass: elevated commands are excluded from the
// blanket allow on purpose (policy.KindExecElevated).
if a.engine.Bypasses(req.Kind) {
@@ -337,6 +337,9 @@ func (a *Agent) gate(req policy.Request) (bool, string) {
appr := a.appr
a.apprMu.Unlock()
d := appr.Ask(req)
+ if !checksPass(checks) {
+ return false, "delegation inactive"
+ }
if !d.Allow {
return false, "denied"
}
@@ -350,7 +353,7 @@ func (a *Agent) gate(req policy.Request) (bool, string) {
// gateFile returns the policy root that must constrain the actual filesystem
// open. A one-shot approval is restricted to the requested file's parent;
// global and bypass decisions use an empty root, meaning the filesystem volume.
-func (a *Agent) gateFile(req policy.Request) (bool, string, string) {
+func (a *Agent) gateFile(req policy.Request, checks ...func() bool) (bool, string, string) {
if a.engine.Mode() == policy.ModeBypass {
return true, "bypass", ""
}
@@ -361,6 +364,9 @@ func (a *Agent) gateFile(req policy.Request) (bool, string, string) {
appr := a.appr
a.apprMu.Unlock()
d := appr.Ask(req)
+ if !checksPass(checks) {
+ return false, "delegation inactive", ""
+ }
if !d.Allow {
return false, "denied", ""
}
@@ -401,7 +407,7 @@ func (a *Agent) Run(ctx context.Context) error {
// cancellation (SIGTERM, `wanctl stop`) is never observed.
defer wsconn.CloseOnCancel(ctx, nc)()
enc := json.NewEncoder(nc)
- if err := enc.Encode(map[string]string{"op": "register", "device": a.DeviceID(), "device_id": a.DeviceID(), "name": a.opts.Name, "fingerprint": a.id.Fingerprint, "inst": a.inst}); err != nil {
+ if err := enc.Encode(map[string]string{"op": "register", "device": a.DeviceID(), "device_id": a.DeviceID(), "name": a.opts.Name, "fingerprint": a.id.Fingerprint, "inst": a.inst, "delegation": "1"}); err != nil {
return err
}
fmt.Printf("wanctl agent %q online via %s\n fingerprint: %s\n", a.opts.Name, a.opts.RelayURL, a.id.Fingerprint)
@@ -475,14 +481,21 @@ func rejectHandshake(conn net.Conn, msg protocol.Message) {
// non-admin asking for the console, an anonymous pairing attempt — left the
// device with no trace at all. Denials are the half worth keeping: they are what
// you read when something cannot connect, and what would show someone probing.
-func (a *Agent) refuse(conn net.Conn, fp, name, decision string, msg protocol.Message) {
+func (a *Agent) refuse(conn net.Conn, fp, name, decision string, msg protocol.Message, scopes ...sessionAudit) {
if a.log != nil {
- a.log.Append(eventlog.Event{Type: "connect", PeerFP: fp, PeerName: name, Decision: decision, Detail: msg.Reason})
+ a.logSessionEvent(firstAudit(scopes), eventlog.Event{Type: "connect", PeerFP: fp, PeerName: name, Decision: decision, Detail: msg.Reason})
}
rejectHandshake(conn, msg)
}
func (a *Agent) handleSession(ctx context.Context, nc net.Conn, auth sessionauth.Open) {
+ audit := auditSession(auth)
+ if auth.GrantID != "" {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithDeadline(ctx, auth.ExpiresAt)
+ defer cancel()
+ defer wsconn.CloseOnCancel(ctx, nc)()
+ }
conn, fp, err := transport.ServerHandshake(ctx, nc, a.id)
if err != nil {
return
@@ -501,7 +514,11 @@ func (a *Agent) handleSession(ctx context.Context, nc net.Conn, auth sessionauth
return
}
if !auth.ValidFor(a.DeviceID()) {
- a.refuse(conn, fp, hello.Name, "rejected:session", protocol.Message{Kind: protocol.KindReject, Reason: "invalid relay session capabilities"})
+ a.refuse(conn, fp, hello.Name, "rejected:session", protocol.Message{Kind: protocol.KindReject, Reason: "invalid relay session capabilities"}, audit)
+ return
+ }
+ if auth.GrantID != "" && (auth.ControllerFingerprint != fp || !a.delegationActive(ctx, auth, fp)) {
+ a.refuse(conn, fp, hello.Name, "rejected:delegation", protocol.Message{Kind: protocol.KindReject, Reason: "delegation is inactive or controller fingerprint does not match"}, audit)
return
}
// Pairing grants a controller permission to submit device operations; it
@@ -511,25 +528,25 @@ func (a *Agent) handleSession(ctx context.Context, nc net.Conn, auth sessionauth
// the console capability. An empty administrator set therefore fails closed.
if hello.Kind == protocol.KindConsoleHello {
if !auth.Capabilities.Has(sessionauth.Console) {
- a.refuse(conn, fp, hello.Name, "rejected:capability", protocol.Message{Kind: protocol.KindReject, Reason: "session capability denied: console"})
+ a.refuse(conn, fp, hello.Name, "rejected:capability", protocol.Message{Kind: protocol.KindReject, Reason: "session capability denied: console"}, audit)
return
}
if a.portalAdmins == nil || !a.portalAdmins.Contains(fp) {
a.refuse(conn, fp, hello.Name, "rejected:not-console-admin", protocol.Message{
Kind: protocol.KindReject,
Reason: "controller is not authorized as this device's console administrator",
- })
+ }, audit)
return
}
}
if a.mustIdentify(hello.Kind, fp, hello.Label) {
- a.refuse(conn, fp, hello.Name, "rejected:unlabeled", protocol.Message{Kind: protocol.KindReject, Reason: unlabeledReason})
+ a.refuse(conn, fp, hello.Name, "rejected:unlabeled", protocol.Message{Kind: protocol.KindReject, Reason: unlabeledReason}, audit)
return
}
// Authorize (TOFU / pre-trusted portal key) and reply OK for BOTH exec and
// console sessions BEFORE serving — the controller/portal blocks on this OK,
// and a console session must be gated by the same trust check as an exec one.
- if !a.authorize(fp, hello.Name, hello.Label) {
+ if !a.authorize(fp, hello.Name, hello.Label, audit) {
pairingURL := a.pairingURL(fp, hello.Name, hello.Label)
reason := "device has not paired this controller — ask the user to approve"
if pairingURL == "" {
@@ -539,16 +556,20 @@ func (a *Agent) handleSession(ctx context.Context, nc net.Conn, auth sessionauth
Kind: protocol.KindReject,
Reason: reason,
PairingURL: pairingURL,
- })
+ }, audit)
return
}
protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindOK, Name: a.opts.Name})
- a.log.Append(eventlog.Event{Type: "connect", PeerFP: fp, PeerName: hello.Name, Decision: "accepted"})
+ a.logSessionEvent(audit, eventlog.Event{Type: "connect", PeerFP: fp, PeerName: hello.Name, Decision: "accepted"})
if hello.Kind == protocol.KindConsoleHello {
a.serveConsole(ctx, conn)
return
}
- a.serve(conn, fp, hello.Name, auth.Capabilities)
+ var check func() bool
+ if auth.GrantID != "" {
+ check = func() bool { return a.delegationActive(ctx, auth, fp) }
+ }
+ a.serveAuthorized(conn, fp, hello.Name, auth.Capabilities, check, audit)
}
// unlabeledReason tells the controller how to become answerable, because the
@@ -577,7 +598,7 @@ func (a *Agent) mustIdentify(helloKind, fp, label string) bool {
return a.unlabeledPairing(fp, label)
}
-func (a *Agent) authorize(fp, name, label string) bool {
+func (a *Agent) authorize(fp, name, label string, scopes ...sessionAudit) bool {
if a.known.Has(fp) {
a.known.Touch(fp)
return true
@@ -589,7 +610,7 @@ func (a *Agent) authorize(fp, name, label string) bool {
// (known_clients.json) and the owner who opted in still gets to ask
// "who has been admitted, and when" through `wanctl logs --type trust`
// instead of a stdout line nobody reads.
- a.log.Append(eventlog.Event{Type: "trust", PeerFP: fp, PeerName: name, Detail: label, Decision: "auto-trust"})
+ a.logSessionEvent(firstAudit(scopes), eventlog.Event{Type: "trust", PeerFP: fp, PeerName: name, Detail: label, Decision: "auto-trust"})
a.notifyTrustChanged(fp, name, "granted")
return true
}
@@ -599,7 +620,7 @@ func (a *Agent) authorize(fp, name, label string) bool {
if a.console.AskPair(fp, name, label) {
a.known.AddLabeled(fp, name, label)
fmt.Printf("[paired] controller %q trusted via console: %s\n", name, fp)
- a.log.Append(eventlog.Event{Type: "trust", PeerFP: fp, PeerName: name, Detail: label, Decision: "console"})
+ a.logSessionEvent(firstAudit(scopes), eventlog.Event{Type: "trust", PeerFP: fp, PeerName: name, Detail: label, Decision: "console"})
a.notifyTrustChanged(fp, name, "granted")
return true
}
@@ -667,6 +688,11 @@ func watchPeer(conn io.Reader, cancel context.CancelFunc) <-chan peerRead {
}
func (a *Agent) serve(conn *tls.Conn, fp, peerName string, caps sessionauth.Capabilities) {
+ a.serveAuthorized(conn, fp, peerName, caps, nil)
+}
+
+func (a *Agent) serveAuthorized(conn *tls.Conn, fp, peerName string, caps sessionauth.Capabilities, check func() bool, scopes ...sessionAudit) {
+ audit := firstAudit(scopes)
// Set while a read started by doExec is still in flight; the next request
// comes from it (see watchPeer).
var pending <-chan peerRead
@@ -682,13 +708,24 @@ func (a *Agent) serve(conn *tls.Conn, fp, peerName string, caps sessionauth.Capa
if err != nil {
return
}
+ if check != nil && !check() {
+ a.logSessionEvent(audit, rejectedRequestEvent(fp, peerName, m, "delegation inactive"))
+ protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "delegation inactive"})
+ return
+ }
+ if check != nil && (m.Kind == protocol.KindExecAsync || m.Kind == protocol.KindExecPoll || (m.Kind == protocol.KindExec && !m.OneShot)) {
+ a.logSessionEvent(audit, rejectedRequestEvent(fp, peerName, m, "delegated execution requires a synchronous one-shot command"))
+ protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "delegated execution requires a synchronous one-shot command"})
+ continue
+ }
if required := requiredCapability(m.Kind); required != 0 && !caps.Has(required) {
+ a.logSessionEvent(audit, rejectedRequestEvent(fp, peerName, m, "session capability denied: "+required.String()))
protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "session capability denied: " + required.String()})
continue
}
switch m.Kind {
case protocol.KindExec:
- pending = a.doExec(conn, fp, peerName, m)
+ pending = a.doExecAuthorized(conn, fp, peerName, m, audit, check)
case protocol.KindCancel:
// Nothing is running on this stream: a cancel that lost the race
// with its own command finishing is not a protocol error.
@@ -697,8 +734,11 @@ func (a *Agent) serve(conn *tls.Conn, fp, peerName string, caps sessionauth.Capa
case protocol.KindExecPoll:
a.doExecPoll(conn, m)
case protocol.KindLogs:
- ok, decision := a.gateDataCapability(capabilityReadEventLog, fp)
- a.log.Append(eventlog.Event{
+ ok, decision := a.gateDataCapability(capabilityReadEventLog, fp, check)
+ if ok && check != nil && !check() {
+ ok, decision = false, "delegation inactive"
+ }
+ a.logSessionEvent(audit, eventlog.Event{
Type: "logs", PeerFP: fp, PeerName: peerName,
Detail: "read event log", Decision: decision,
})
@@ -710,18 +750,27 @@ func (a *Agent) serve(conn *tls.Conn, fp, peerName string, caps sessionauth.Capa
}
a.doLogs(conn, m)
case protocol.KindStatus:
+ if audit.grantID != "" {
+ a.logSessionEvent(audit, eventlog.Event{Type: "status", PeerFP: fp, PeerName: peerName, Decision: "accepted"})
+ }
protocol.WriteMessage(conn, a.status())
case protocol.KindFilePut:
- ok, decision, root := a.gateFile(policy.Request{Kind: policy.KindWrite, Path: m.Path, Peer: fp})
- a.log.Append(eventlog.Event{Type: "file", PeerFP: fp, PeerName: peerName, Detail: "PUT " + m.Path, Decision: decision})
+ ok, decision, root := a.gateFile(policy.Request{Kind: policy.KindWrite, Path: m.Path, Peer: fp}, check)
+ if ok && check != nil && !check() {
+ ok, decision = false, "delegation inactive"
+ }
+ a.logSessionEvent(audit, eventlog.Event{Type: "file", PeerFP: fp, PeerName: peerName, Detail: "PUT " + m.Path, Decision: decision})
if !ok {
protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "write denied by device policy: " + m.Path})
continue
}
server.HandleFilePut(conn, m, root)
case protocol.KindFileGet:
- ok, decision, root := a.gateFile(policy.Request{Kind: policy.KindRead, Path: m.Path, Peer: fp})
- a.log.Append(eventlog.Event{Type: "file", PeerFP: fp, PeerName: peerName, Detail: "GET " + m.Path, Decision: decision})
+ ok, decision, root := a.gateFile(policy.Request{Kind: policy.KindRead, Path: m.Path, Peer: fp}, check)
+ if ok && check != nil && !check() {
+ ok, decision = false, "delegation inactive"
+ }
+ a.logSessionEvent(audit, eventlog.Event{Type: "file", PeerFP: fp, PeerName: peerName, Detail: "GET " + m.Path, Decision: decision})
if !ok {
protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindReject, Reason: "read denied by device policy: " + m.Path})
continue
@@ -758,7 +807,11 @@ func requiredCapability(kind string) sessionauth.Capabilities {
// doExec runs one command for a controller. It returns the in-flight read that
// watched for the controller leaving, so the request loop can take its next
// message from there; nil means the loop owns the connection again.
-func (a *Agent) doExec(conn *tls.Conn, fp, peerName string, m protocol.Message) <-chan peerRead {
+func (a *Agent) doExec(conn *tls.Conn, fp, peerName string, m protocol.Message, checks ...func() bool) <-chan peerRead {
+ return a.doExecAuthorized(conn, fp, peerName, m, sessionAudit{}, checks...)
+}
+
+func (a *Agent) doExecAuthorized(conn *tls.Conn, fp, peerName string, m protocol.Message, audit sessionAudit, checks ...func() bool) <-chan peerRead {
kind := policy.KindExec
if m.Elevate {
kind = policy.KindExecElevated
@@ -775,9 +828,12 @@ func (a *Agent) doExec(conn *tls.Conn, fp, peerName string, m protocol.Message)
via = parsed
}
- ok, decision := a.gate(policy.Request{Kind: kind, Cmd: m.Command, Cwd: m.Cwd, Peer: fp, Via: string(via)})
+ ok, decision := a.gate(policy.Request{Kind: kind, Cmd: m.Command, Cwd: m.Cwd, Peer: fp, Via: string(via)}, checks...)
+ if ok && !checksPass(checks) {
+ ok, decision = false, "delegation inactive"
+ }
if !ok {
- a.log.Append(eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Via: string(via)})
+ a.logSessionEvent(audit, eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Via: string(via)})
reason := "command denied by device policy: " + m.Command
if m.Elevate {
reason = "elevated command denied by device policy: " + m.Command +
@@ -814,7 +870,7 @@ func (a *Agent) doExec(conn *tls.Conn, fp, peerName string, m protocol.Message)
// A channel that could not be selected has not run anything, so
// this is a refusal to act rather than a failed command. Say which
// it is: the caller must not read it as "ran, and failed".
- a.log.Append(eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Via: string(via)})
+ a.logSessionEvent(audit, eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Via: string(via)})
protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindError, Reason: err.Error()})
return pending
}
@@ -842,7 +898,7 @@ func (a *Agent) doExec(conn *tls.Conn, fp, peerName string, m protocol.Message)
// listening must not read this as the command itself failing.
err = fmt.Errorf("command cancelled by the controller")
}
- a.log.Append(eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Via: string(ranVia)})
+ a.logSessionEvent(audit, eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Via: string(ranVia)})
if code == 0 {
code = -1
}
@@ -850,7 +906,7 @@ func (a *Agent) doExec(conn *tls.Conn, fp, peerName string, m protocol.Message)
protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindError, Reason: err.Error()})
return pending
}
- a.log.Append(eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Exit: &code, Via: string(ranVia)})
+ a.logSessionEvent(audit, eventlog.Event{Type: "exec", PeerFP: fp, PeerName: peerName, Detail: m.Command, Cwd: m.Cwd, Decision: decision, Exit: &code, Via: string(ranVia)})
a.notifyExecFinished(m.Command, m.Cwd, peerName, code)
protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindExit, Code: code, ElevatedVia: string(ranVia)})
return pending
@@ -956,7 +1012,7 @@ func (a *Agent) runHTTP(ctx context.Context) error {
base := httpBase(a.opts.RelayURL)
fmt.Printf("wanctl agent %q online via %s (http transport)\n fingerprint: %s\n", a.opts.Name, base, a.id.Fingerprint)
hc := &http.Client{Timeout: 35 * time.Second}
- q := url.Values{"device": {a.DeviceID()}, "device_id": {a.DeviceID()}, "name": {a.opts.Name}, "fp": {a.id.Fingerprint}, "inst": {a.inst}}.Encode()
+ q := url.Values{"device": {a.DeviceID()}, "device_id": {a.DeviceID()}, "name": {a.opts.Name}, "fp": {a.id.Fingerprint}, "inst": {a.inst}, "delegation": {"1"}}.Encode()
pollURL := base + "/h/poll?" + q
// Registration lives or dies by this loop: the relay keeps a device listed
// only while its polls keep arriving. Until 2026-08-07 every failure here
diff --git a/internal/agent/delegation.go b/internal/agent/delegation.go
new file mode 100644
index 0000000..ca575f6
--- /dev/null
+++ b/internal/agent/delegation.go
@@ -0,0 +1,90 @@
+package agent
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "time"
+
+ "wanctl/internal/admission"
+ "wanctl/internal/eventlog"
+ "wanctl/internal/protocol"
+ "wanctl/internal/sessionauth"
+)
+
+type sessionAudit struct {
+ grantID, credentialID, sessionID string
+}
+
+func auditSession(auth sessionauth.Open) sessionAudit {
+ if auth.GrantID == "" {
+ return sessionAudit{}
+ }
+ return sessionAudit{grantID: auth.GrantID, credentialID: auth.CredentialID, sessionID: auth.Session}
+}
+
+func (a *Agent) logSessionEvent(scope sessionAudit, e eventlog.Event) {
+ if a.log == nil {
+ return
+ }
+ e.GrantID, e.CredentialID, e.SessionID = scope.grantID, scope.credentialID, scope.sessionID
+ a.log.Append(e)
+}
+
+func firstAudit(scopes []sessionAudit) sessionAudit {
+ if len(scopes) != 0 {
+ return scopes[0]
+ }
+ return sessionAudit{}
+}
+
+func rejectedRequestEvent(fp, name string, m protocol.Message, reason string) eventlog.Event {
+ e := eventlog.Event{Type: "request", PeerFP: fp, PeerName: name, Detail: m.Kind, Decision: "denied: " + reason}
+ switch m.Kind {
+ case protocol.KindExec, protocol.KindExecAsync, protocol.KindExecPoll:
+ e.Type, e.Detail, e.Cwd = "exec", m.Command, m.Cwd
+ case protocol.KindFilePut:
+ e.Type, e.Detail = "file", "PUT "+m.Path
+ case protocol.KindFileGet:
+ e.Type, e.Detail = "file", "GET "+m.Path
+ case protocol.KindLogs:
+ e.Type, e.Detail = "logs", "read event log"
+ }
+ return e
+}
+
+// Check a grant through the authenticated device channel, never through a
+// controller-supplied statement of its own permissions. The relay resolves its
+// session credential again, so a late approval cannot revive a revoked grant.
+func (a *Agent) delegationActive(ctx context.Context, auth sessionauth.Open, fp string) bool {
+ if auth.GrantID == "" {
+ return true
+ }
+ if ctx.Err() != nil || !auth.ValidFor(a.DeviceID()) || auth.ControllerFingerprint != fp {
+ return false
+ }
+ query := url.Values{
+ "device": {a.DeviceID()}, "inst": {a.inst}, "session": {auth.Session},
+ "grant": {auth.GrantID}, "controller_fp": {fp},
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, httpBase(a.opts.RelayURL)+"/agent/delegation-check?"+query.Encode(), nil)
+ if err != nil {
+ return false
+ }
+ admission.SetBearer(req, a.opts.Token)
+ resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+ return resp.StatusCode == http.StatusNoContent && ctx.Err() == nil && time.Now().Before(auth.ExpiresAt)
+}
+
+func checksPass(checks []func() bool) bool {
+ for _, check := range checks {
+ if check != nil && !check() {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/agent/delegation_test.go b/internal/agent/delegation_test.go
new file mode 100644
index 0000000..96d85fe
--- /dev/null
+++ b/internal/agent/delegation_test.go
@@ -0,0 +1,309 @@
+package agent
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+
+ "wanctl/internal/client"
+ "wanctl/internal/delegation"
+ "wanctl/internal/eventlog"
+ "wanctl/internal/policy"
+ "wanctl/internal/relay"
+ "wanctl/internal/transport"
+)
+
+type agentGrantTokens struct {
+ mu sync.Mutex
+ access delegation.Access
+ revoked bool
+ other map[string]delegation.Access
+}
+
+func (s *agentGrantTokens) Resolve(token string) (string, bool) { return "alice", token == "owner" }
+func (s *agentGrantTokens) ResolveAccess(token string) (delegation.Access, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if token == "owner" {
+ return delegation.Access{Namespace: "alice", CredentialID: "owner"}, true
+ }
+ if access, ok := s.other[token]; ok {
+ return access, !s.revoked && time.Now().Before(access.ExpiresAt)
+ }
+ return s.access, token == "delegate" && !s.revoked && time.Now().Before(s.access.ExpiresAt)
+}
+
+func (s *agentGrantTokens) issue(token string, access delegation.Access) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.other == nil {
+ s.other = map[string]delegation.Access{}
+ }
+ s.other[token] = access
+}
+func (s *agentGrantTokens) revoke() { s.mu.Lock(); s.revoked = true; s.mu.Unlock() }
+
+type delegationFixture struct {
+ a *Agent
+ c *client.Client
+ tokens *agentGrantTokens
+ ctx context.Context
+ target string
+ relayURL string
+ ctlTransport string
+}
+
+func startDelegationFixture(t *testing.T, agentTransport, controllerTransport string, mode policy.Mode, autoTrust bool, ttl time.Duration) delegationFixture {
+ t.Helper()
+ t.Setenv("WANCTL_CONFIG_DIR", t.TempDir())
+ cid, err := transport.IdentityFromSeed(bytes.Repeat([]byte{19}, 32), "delegated-test-controller")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ts := &agentGrantTokens{}
+ r := relay.New(ts)
+ srv := httptest.NewServer(r.Handler())
+ t.Cleanup(srv.Close)
+ base := "ws" + strings.TrimPrefix(srv.URL, "http")
+ a, err := New(Options{RelayURL: base, Token: "owner", Name: "allowed", Mode: mode, AutoYes: autoTrust, Transport: agentTransport})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(a.Close)
+ target := "alice/" + a.DeviceID()
+ ts.access = delegation.Access{Namespace: "alice", CredentialID: "delegated-credential", Delegated: true, GrantID: "delegated-grant", ExpiresAt: time.Now().Add(ttl), ControllerFingerprint: cid.Fingerprint, Devices: []delegation.Device{{Namespace: "alice", ID: a.DeviceID(), Fingerprint: a.id.Fingerprint}}}
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ t.Cleanup(cancel)
+ go a.Run(ctx)
+ known := transport.NewMemStore()
+ if err := known.Pin(target, a.id.Fingerprint, false); err != nil {
+ t.Fatal(err)
+ }
+ c := client.NewWith(cid, known, base, "delegate", controllerTransport)
+ c.SetLabel("delegated test")
+ for deadline := time.Now().Add(3 * time.Second); ; {
+ peers, err := c.Peers(ctx)
+ if err == nil && len(peers) > 0 {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("agent never registered: %v", err)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return delegationFixture{a: a, c: c, tokens: ts, ctx: ctx, target: target, relayURL: base, ctlTransport: controllerTransport}
+}
+
+func TestDelegatedDevicesStillEnforcePolicyAndTrust(t *testing.T) {
+ for _, trust := range []bool{false, true} {
+ t.Run(map[bool]string{false: "unpaired", true: "paired-but-denied"}[trust], func(t *testing.T) {
+ f := startDelegationFixture(t, "ws", "http", policy.ModeNormal, trust, time.Minute)
+ f.a.setApprover(policy.DenyApprover{})
+ var out bytes.Buffer
+ _, err := f.c.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: "echo unauthorized", OneShot: true}, &out, io.Discard)
+ var rejected *client.RejectError
+ if !errors.As(err, &rejected) || out.Len() != 0 {
+ t.Fatalf("device boundary bypassed: err=%v out=%q", err, out.String())
+ }
+ want := "approve"
+ if trust {
+ want = "policy"
+ }
+ if !strings.Contains(rejected.Reason, want) {
+ t.Fatalf("wrong denial: %s", rejected.Reason)
+ }
+ })
+ }
+}
+
+func TestDelegatedUseOverAllCarriersAndNoManagement(t *testing.T) {
+ for _, agentTransport := range []string{"ws", "http"} {
+ for _, controllerTransport := range []string{"ws", "http"} {
+ t.Run(agentTransport+"-agent/"+controllerTransport+"-controller", func(t *testing.T) {
+ f := startDelegationFixture(t, agentTransport, controllerTransport, policy.ModeBypass, true, time.Minute)
+ var stdout bytes.Buffer
+ code, err := f.c.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: "printf 'delegated-ok'", OneShot: true}, &stdout, io.Discard)
+ if err != nil || code != 0 || stdout.String() != "delegated-ok" {
+ t.Fatalf("exec: %d %v %q", code, err, stdout.String())
+ }
+ path := filepath.Join(t.TempDir(), "created.txt")
+ if err := f.c.PushBytes(f.ctx, f.target, path, []byte("from delegation"), 0600); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ local := filepath.Join(t.TempDir(), "read.txt")
+ if err := f.c.Pull(f.ctx, f.target, path, local); err != nil {
+ t.Fatalf("pull: %v", err)
+ }
+ got, _ := os.ReadFile(local)
+ if string(got) != "from delegation" {
+ t.Fatalf("file mismatch: %q", got)
+ }
+ if err := f.c.LogsTo(f.ctx, f.target, "", "", "", 1, io.Discard); err != nil {
+ t.Fatalf("logs: %v", err)
+ }
+ if _, err := f.c.OpenConsole(f.ctx, f.target); err == nil {
+ t.Fatal("delegation opened console")
+ }
+ if _, err := f.c.ExecAsync(f.ctx, f.target, "echo forbidden", ""); err == nil {
+ t.Fatal("delegation started detached job")
+ }
+ if _, err := f.c.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: "echo forbidden"}, io.Discard, io.Discard); err == nil {
+ t.Fatal("delegation used persistent shell")
+ }
+ otherID, _ := transport.IdentityFromSeed(bytes.Repeat([]byte{23}, 32), "other-controller")
+ pins := transport.NewMemStore()
+ pins.Pin(f.target, f.a.id.Fingerprint, false)
+ other := client.NewWith(otherID, pins, f.relayURL, "delegate", controllerTransport)
+ other.SetLabel("wrong controller")
+ if _, err := other.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: "echo forbidden", OneShot: true}, io.Discard, io.Discard); err == nil {
+ t.Fatal("credential used by wrong fingerprint")
+ }
+ // A reused fingerprint and arbitrary label cannot merge grants in
+ // the audit log: each operation retains its authenticated session.
+ second := f.tokens.access
+ second.GrantID, second.CredentialID = "second-grant", "second-credential"
+ f.tokens.issue("delegate-two", second)
+ reused := client.NewWith(f.c.Identity(), pins, f.relayURL, "delegate-two", controllerTransport)
+ reused.SetLabel("claimed grant: fabricated-grant")
+ if _, err := reused.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: "printf second-grant-operation", OneShot: true}, io.Discard, io.Discard); err != nil {
+ t.Fatalf("second grant: %v", err)
+ }
+ assertDelegationAudit(t, f.a, map[string]string{"delegated-grant": "delegated-credential", "second-grant": "second-credential"})
+ })
+ }
+ }
+}
+
+func assertDelegationAudit(t *testing.T, a *Agent, grants map[string]string) {
+ t.Helper()
+ events, err := a.log.Read(eventlog.Filter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ connections := map[string]eventlog.Event{}
+ for _, e := range events {
+ if e.Type != "connect" {
+ continue
+ }
+ if e.SessionID == "" || grants[e.GrantID] != e.CredentialID || e.CredentialID == "" {
+ t.Fatalf("connect attribution missing: %+v", e)
+ }
+ if e.Decision == "accepted" {
+ connections[e.SessionID] = e
+ }
+ }
+ counts := map[string]int{}
+ seenGrants := map[string]bool{}
+ for _, e := range events {
+ switch e.Type {
+ case "exec", "file", "logs":
+ default:
+ continue
+ }
+ connected, ok := connections[e.SessionID]
+ if !ok || e.GrantID != connected.GrantID || e.CredentialID != connected.CredentialID || e.PeerFP != connected.PeerFP {
+ t.Fatalf("operation cannot be joined to authenticated connection: %+v", e)
+ }
+ counts[e.Type]++
+ seenGrants[e.GrantID] = true
+ }
+ if counts["exec"] < 2 || counts["file"] < 2 || counts["logs"] < 1 || len(seenGrants) != len(grants) {
+ t.Fatalf("missing delegated operation audit: counts=%v grants=%v", counts, seenGrants)
+ }
+}
+
+type delayedDelegationApproval struct {
+ entered, allow chan struct{}
+ once sync.Once
+}
+
+func (a *delayedDelegationApproval) Ask(policy.Request) policy.Decision {
+ a.once.Do(func() { close(a.entered) })
+ <-a.allow
+ return policy.Decision{Allow: true, Remember: true, Scope: policy.ScopeGlobal}
+}
+
+func TestDelegatedApprovalCannotRunAfterRevoke(t *testing.T) {
+ for _, agentTransport := range []string{"ws", "http"} {
+ t.Run(agentTransport, func(t *testing.T) {
+ f := startDelegationFixture(t, agentTransport, "http", policy.ModeNormal, true, time.Minute)
+ approval := &delayedDelegationApproval{entered: make(chan struct{}), allow: make(chan struct{})}
+ f.a.setApprover(approval)
+ path := filepath.Join(t.TempDir(), "must-not-exist")
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.c.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: "echo bad > " + path, OneShot: true}, io.Discard, io.Discard)
+ done <- err
+ }()
+ select {
+ case <-approval.entered:
+ case <-time.After(3 * time.Second):
+ t.Fatal("approval not reached")
+ }
+ f.tokens.revoke()
+ close(approval.allow) // deliberately before the one-second relay sweeper
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("late approval succeeded")
+ }
+ case <-time.After(4 * time.Second):
+ t.Fatal("late approval hung")
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("revoked pending command created file: %v", err)
+ }
+ if f.a.engine.Allowed(policy.Request{Kind: policy.KindExec, Cmd: "another command", Peer: f.c.Identity().Fingerprint}) {
+ t.Fatal("late approval persisted a policy rule")
+ }
+ })
+ }
+}
+
+func TestDelegatedActiveCommandsStopOnRevokeAndExpiry(t *testing.T) {
+ for _, agentTransport := range []string{"ws", "http"} {
+ for _, controllerTransport := range []string{"ws", "http"} {
+ for _, revoke := range []bool{false, true} {
+ name := agentTransport + "-" + controllerTransport + "-" + map[bool]string{false: "expiry", true: "revoke"}[revoke]
+ t.Run(name, func(t *testing.T) {
+ ttl := 3 * time.Second
+ if revoke {
+ ttl = time.Minute
+ }
+ f := startDelegationFixture(t, agentTransport, controllerTransport, policy.ModeBypass, true, ttl)
+ command, pidFile := remoteProbe(t)
+ done := make(chan error, 1)
+ go func() {
+ _, err := f.c.ExecTo(f.ctx, client.ExecRequest{Target: f.target, Command: command, OneShot: true}, io.Discard, io.Discard)
+ done <- err
+ }()
+ pid := waitForRemotePID(t, pidFile)
+ t.Cleanup(func() { syscall.Kill(pid, syscall.SIGKILL) })
+ if revoke {
+ f.tokens.revoke()
+ }
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("revoked/expired command reported success")
+ }
+ case <-time.After(6 * time.Second):
+ t.Fatal("command still connected")
+ }
+ if !remoteGone(pid, 3*time.Second) {
+ t.Fatal("revoked/expired command process survived")
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/internal/client/cancel_handshake_test.go b/internal/client/cancel_handshake_test.go
new file mode 100644
index 0000000..aac2ea0
--- /dev/null
+++ b/internal/client/cancel_handshake_test.go
@@ -0,0 +1,129 @@
+package client
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "wanctl/internal/protocol"
+ "wanctl/internal/transport"
+ "wanctl/internal/wsconn"
+
+ "github.com/coder/websocket"
+)
+
+// stalledDevice serves real WebSocket sockets carrying mutual TLS. It consumes
+// either the hello or file-put request and deliberately never acknowledges it.
+func stalledDevice(t *testing.T, upload bool) (*Client, <-chan error) {
+ t.Helper()
+ serverIdentity, err := transport.IdentityFromSeed(bytes.Repeat([]byte{1}, 32), "stalled-device")
+ if err != nil {
+ t.Fatal(err)
+ }
+ clientIdentity, err := transport.IdentityFromSeed(bytes.Repeat([]byte{2}, 32), "cancel-controller")
+ if err != nil {
+ t.Fatal(err)
+ }
+ const target = "alice/stalled-device"
+ ready := make(chan error, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if req.URL.Path == "/resolve" {
+ _ = json.NewEncoder(w).Encode(map[string]string{"target": target})
+ return
+ }
+ if req.URL.Path != "/dial" {
+ http.NotFound(w, req)
+ return
+ }
+ ws, err := websocket.Accept(w, req, nil)
+ if err != nil {
+ ready <- err
+ return
+ }
+ defer ws.CloseNow()
+ // httptest.Server cannot close hijacked sockets, so explicitly release
+ // even a broken implementation when the regression test times out.
+ t.Cleanup(func() { _ = ws.CloseNow() })
+ conn, _, err := transport.ServerHandshake(req.Context(), wsconn.FromAccepted(req.Context(), ws), serverIdentity)
+ if err != nil {
+ ready <- err
+ return
+ }
+ hello, err := protocol.ReadMessage(conn)
+ if err != nil || hello.Kind != protocol.KindHello {
+ ready <- fmt.Errorf("hello = %q: %v", hello.Kind, err)
+ return
+ }
+ if upload {
+ if err := protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindOK}); err != nil {
+ ready <- err
+ return
+ }
+ put, err := protocol.ReadMessage(conn)
+ if err != nil || put.Kind != protocol.KindFilePut {
+ ready <- fmt.Errorf("file request = %q: %v", put.Kind, err)
+ return
+ }
+ }
+ ready <- nil
+ // Block until cancellation closes the real socket; no synthetic timeout
+ // or server response is allowed to make the controller return.
+ _, _ = protocol.ReadMessage(conn)
+ }))
+ t.Cleanup(srv.Close)
+ known := transport.NewMemStore()
+ if err := known.Pin(target, serverIdentity.Fingerprint, false); err != nil {
+ t.Fatal(err)
+ }
+ return NewWith(clientIdentity, known, "ws"+strings.TrimPrefix(srv.URL, "http"), "test-token", "ws"), ready
+}
+
+func TestCancelledContextClosesPostTLSHello(t *testing.T) {
+ c, ready := stalledDevice(t, false)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ _, _, err := c.Pair(ctx, "alice/stalled-device")
+ done <- err
+ }()
+ assertStalledOperationCancels(t, ready, done, cancel)
+}
+
+func TestCancelledContextClosesUploadAcknowledgementWait(t *testing.T) {
+ c, ready := stalledDevice(t, true)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ done <- c.PushBytes(ctx, "alice/stalled-device", "note.txt", []byte("test"), 0o600)
+ }()
+ assertStalledOperationCancels(t, ready, done, cancel)
+}
+
+func assertStalledOperationCancels(t *testing.T, ready, done <-chan error, cancel context.CancelFunc) {
+ t.Helper()
+ select {
+ case err := <-ready:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("device did not receive the request")
+ }
+ cancel()
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("cancelled operation reported success")
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("controller remained blocked after cancellation")
+ }
+}
diff --git a/internal/client/client.go b/internal/client/client.go
index 2531c8f..f223a2b 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -387,6 +387,9 @@ func (c *Client) connectKind(ctx context.Context, target, helloKind string) (*tl
if err != nil {
return nil, err
}
+ // TLS handshaking observes ctx itself, but the following application hello
+ // reads from a connection that intentionally outlives its dial context.
+ defer wsconn.CloseOnCancel(ctx, nc)()
return c.finishHandshake(ctx, nc, target, helloKind)
}
diff --git a/internal/client/files.go b/internal/client/files.go
index ac74802..47014d0 100644
--- a/internal/client/files.go
+++ b/internal/client/files.go
@@ -8,6 +8,7 @@ import (
"os"
"wanctl/internal/protocol"
+ "wanctl/internal/wsconn"
)
const fileChunk = 64 << 10
@@ -52,6 +53,7 @@ func (c *Client) pushReader(ctx context.Context, target, remotePath string, r io
return err
}
defer conn.Close()
+ defer wsconn.CloseOnCancel(ctx, conn)()
if err := protocol.WriteMessage(conn, protocol.Message{
Kind: protocol.KindFilePut,
diff --git a/internal/client/readbytes.go b/internal/client/readbytes.go
new file mode 100644
index 0000000..59d67b2
--- /dev/null
+++ b/internal/client/readbytes.go
@@ -0,0 +1,71 @@
+package client
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+
+ "wanctl/internal/protocol"
+ "wanctl/internal/wsconn"
+)
+
+// PullBytes reads a bounded remote file without opening any local server file.
+// It uses the same device-side file-get policy as Pull.
+func (c *Client) PullBytes(ctx context.Context, target, path string, maxBytes int64) ([]byte, error) {
+ if maxBytes < 0 || maxBytes > protocol.MaxFileSize {
+ return nil, fmt.Errorf("invalid read limit")
+ }
+ conn, err := c.connect(ctx, target)
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+ defer wsconn.CloseOnCancel(ctx, conn)()
+ if err = protocol.WriteMessage(conn, protocol.Message{Kind: protocol.KindFileGet, Path: path}); err != nil {
+ return nil, err
+ }
+ meta, err := protocol.ReadMessage(conn)
+ if err != nil {
+ return nil, err
+ }
+ if meta.Kind == protocol.KindReject {
+ return nil, rejectError(meta)
+ }
+ if meta.Kind == protocol.KindError {
+ return nil, fmt.Errorf("remote refused download: %s", meta.Reason)
+ }
+ if meta.Kind != protocol.KindFileMeta || meta.Size < 0 || meta.Size > maxBytes {
+ return nil, fmt.Errorf("remote file exceeds read limit or returned invalid metadata")
+ }
+ var out bytes.Buffer
+ for {
+ kind, payload, err := protocol.ReadFrame(conn)
+ if err != nil {
+ return nil, err
+ }
+ switch kind {
+ case protocol.FrameData:
+ if int64(out.Len())+int64(len(payload)) > maxBytes || int64(out.Len())+int64(len(payload)) > meta.Size {
+ return nil, fmt.Errorf("remote file exceeds announced size or read limit")
+ }
+ out.Write(payload)
+ case protocol.FrameJSON:
+ message, err := protocol.DecodeMessage(payload)
+ if err != nil {
+ return nil, err
+ }
+ if message.Kind == protocol.KindEOF {
+ if int64(out.Len()) != meta.Size {
+ return nil, fmt.Errorf("remote file size mismatch")
+ }
+ return out.Bytes(), nil
+ }
+ if message.Kind == protocol.KindError {
+ return nil, fmt.Errorf("remote read failed: %s", message.Reason)
+ }
+ return nil, fmt.Errorf("unexpected file response")
+ default:
+ return nil, fmt.Errorf("unexpected file frame")
+ }
+ }
+}
diff --git a/internal/delegation/delegation.go b/internal/delegation/delegation.go
new file mode 100644
index 0000000..8db692c
--- /dev/null
+++ b/internal/delegation/delegation.go
@@ -0,0 +1,116 @@
+// Package delegation describes temporary device-use grants. Device policy is
+// still authoritative for individual operations; grants add no action matrix.
+package delegation
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "time"
+)
+
+var (
+ ErrNotFound = errors.New("delegation not found")
+ ErrForbidden = errors.New("delegation forbidden")
+ ErrConflict = errors.New("delegation conflict")
+ ErrExpired = errors.New("delegation expired")
+ ErrInvalid = errors.New("invalid delegation")
+ ErrLimit = errors.New("delegation limit exceeded")
+)
+
+type Device struct {
+ Namespace string `json:"namespace"`
+ ID string `json:"id"`
+ Fingerprint string `json:"fingerprint"`
+}
+
+func (d Device) Target() string { return d.Namespace + "/" + d.ID }
+
+// Access is admission metadata, never the bearer credential itself.
+type Access struct {
+ Namespace string `json:"namespace"`
+ CredentialID string `json:"credential_id"`
+ Delegated bool `json:"delegated"`
+ GrantID string `json:"grant_id,omitempty"`
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
+ Devices []Device `json:"devices,omitempty"`
+ ControllerFingerprint string `json:"controller_fingerprint,omitempty"`
+}
+
+func (a Access) Allows(target string) bool {
+ if !a.Delegated {
+ return true
+ }
+ if a.ExpiresAt.IsZero() || !time.Now().Before(a.ExpiresAt) {
+ return false
+ }
+ for _, d := range a.Devices {
+ if d.Target() == target {
+ return true
+ }
+ }
+ return false
+}
+
+type Request struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ ControllerFingerprint string `json:"controller_fingerprint"`
+ Status string `json:"status"`
+ Namespace string `json:"namespace,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ RequestExpiresAt time.Time `json:"request_expires_at"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ Devices []Device `json:"devices,omitempty"`
+ TokenID int `json:"token_id,omitempty"`
+}
+
+// NewRequest contains hashes only. The adapter derives the relay credential
+// from its seed and the browser ticket; neither plaintext is stored in Postgres.
+type NewRequest struct {
+ ID string
+ TicketHash string
+ TokenHash string
+ Label string
+ ControllerFingerprint string
+ RequestExpiresAt time.Time
+}
+
+type Approval struct {
+ RequestID string `json:"request_id"`
+ Namespace string `json:"namespace"`
+ Devices []string `json:"devices"`
+ Minutes int `json:"minutes"`
+ ControllerFingerprint string `json:"controller_fingerprint"`
+ DeviceFingerprints map[string]string `json:"device_fingerprints"`
+}
+
+type Store interface {
+ CreateDelegation(context.Context, NewRequest) (Request, error)
+ GetDelegation(context.Context, string) (Request, error)
+ GetDelegationByTicket(context.Context, string, string) (Request, error)
+ ApproveDelegation(context.Context, Approval) (Request, error)
+ RejectDelegation(context.Context, string, string) error
+ ResolveAccess(string) (Access, bool)
+}
+
+// Jobs are an execution ledger, not an authorization source. The adapter must
+// resolve the live grant before dispatch or returning any result.
+type Job struct {
+ ID string `json:"id"`
+ GrantID string `json:"grant_id"`
+ RequestID string `json:"request_id"`
+ PayloadHash string `json:"-"`
+ Payload json.RawMessage `json:"-"`
+ State string `json:"status"`
+ Result json.RawMessage `json:"result,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type JobStore interface {
+ // BeginJob atomically records an operation before execution. Existing IDs
+ // return new=false and cannot be executed again, including after restart.
+ BeginJob(context.Context, string, string, string, json.RawMessage) (Job, bool, error)
+ FinishJob(context.Context, string, string, string, json.RawMessage) error
+ GetJob(context.Context, string, string) (Job, error)
+}
diff --git a/internal/eventlog/eventlog.go b/internal/eventlog/eventlog.go
index 26fd47a..198f913 100644
--- a/internal/eventlog/eventlog.go
+++ b/internal/eventlog/eventlog.go
@@ -26,11 +26,17 @@ type Event struct {
Type string `json:"type"` // connect | trust | exec | file | logs
PeerFP string `json:"peer_fp,omitempty"` // peer fingerprint
PeerName string `json:"peer_name,omitempty"`
- Detail string `json:"detail,omitempty"` // command line or file path
- Cwd string `json:"cwd,omitempty"`
- Decision string `json:"decision,omitempty"` // bypass | pre-approved | approved | remembered:* | denied; for trust: auto-trust | console
- Exit *int `json:"exit,omitempty"` // exec exit code
- Bytes int64 `json:"bytes,omitempty"`
+ // Delegation attribution comes from relay-authenticated session metadata,
+ // never the controller's self-reported name or label. These are record IDs,
+ // not bearer credentials; SessionID correlates individual device operations.
+ GrantID string `json:"grant_id,omitempty"`
+ CredentialID string `json:"credential_id,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ Detail string `json:"detail,omitempty"` // command line or file path
+ Cwd string `json:"cwd,omitempty"`
+ Decision string `json:"decision,omitempty"` // bypass | pre-approved | approved | remembered:* | denied; for trust: auto-trust | console
+ Exit *int `json:"exit,omitempty"` // exec exit code
+ Bytes int64 `json:"bytes,omitempty"`
// Via names the elevation channel that ran an elevated command (su,
// adb). Present only on elevated execs, which is what makes
// "what has run as root on this phone" a greppable question.
diff --git a/internal/portal/changelog/v0.9.0.md b/internal/portal/changelog/v0.9.0.md
new file mode 100644
index 0000000..35036d4
--- /dev/null
+++ b/internal/portal/changelog/v0.9.0.md
@@ -0,0 +1,11 @@
+# v0.9.0 — 网页 AI 可以通过 WebFetch 使用设备
+
+- **新增可选的 WebFetch 接入层。** 不支持 MCP、但能够抓取指定 URL 的网页 AI,可以申请临时使用 wanctl 设备,执行一次性命令、写入和读取文本文件。
+- **授权仍在 wanctl 内完成。** 设备主人登录门户,选择自有设备并批准 1–60 分钟的访问;首次连接继续使用原有配对流程。设备自己的规则和审批决定操作是否允许,临时使用者不能修改信任、规则或模式。
+- **支持随时吊销。** 访问令牌页面显示设备范围和到期状态。吊销或到期后,新操作和任务结果读取都会被拒绝;设备在等待人工审批后还会再次检查授权。
+- **重复抓取不会自动重复执行。** 任务在调用前写入持久账本。中断后无法确定结果的任务会标为 unknown,需要人工核对,不会自动重放。
+- **审计保留授权归属。** 设备日志记录授权、凭证与连接编号,便于确认是哪次网页 AI 会话执行了操作。
+
+默认关闭,需要管理员配置 WebFetch 种子并升级 relay、portal 和受控端。旧受控端会拒绝委托连接,原有 CLI/MCP 使用不受影响。网页入口为中继域名下的 `/webfetch`。
+
+本版本包含数据库迁移 009。若回滚至旧 relay,必须先吊销所有 delegated 令牌。关闭连接不能撤回已经完成的操作;在 bypass 模式的设备上授权使用,仍意味着授予该设备现有的广泛操作能力。
diff --git a/internal/portal/pages.go b/internal/portal/pages.go
index 5e2b76f..e122d9e 100644
--- a/internal/portal/pages.go
+++ b/internal/portal/pages.go
@@ -21,7 +21,7 @@ import (
//
// All three share /assets/app.css and /assets/auth.js with the app. That is
// what handleAsset's deliberate lack of authentication is for.
-var pages = template.Must(template.ParseFS(assets, "web/login.html", "web/pending.html", "web/enroll.html"))
+var pages = template.Must(template.ParseFS(assets, "web/login.html", "web/pending.html", "web/enroll.html", "web/delegation.html"))
// render writes one of those pages. It buffers first: a template that fails
// halfway would otherwise have already sent 200 plus half a page, which reads
diff --git a/internal/portal/pages_test.go b/internal/portal/pages_test.go
index a3ff03f..0af38b6 100644
--- a/internal/portal/pages_test.go
+++ b/internal/portal/pages_test.go
@@ -138,7 +138,7 @@ func TestAuthPagesAbsentInHeaderMode(t *testing.T) {
// which still carry their placeholders.
func TestAssetsRefuseHTML(t *testing.T) {
s := New(Config{})
- for _, name := range []string{"index.html", "login.html", "pending.html", "enroll.html"} {
+ for _, name := range []string{"index.html", "login.html", "pending.html", "enroll.html", "delegation.html"} {
rec := httptest.NewRecorder()
s.handleAsset(rec, httptest.NewRequest("GET", "/assets/"+name, nil))
if rec.Code != http.StatusNotFound {
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index bee12ea..fe98064 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -165,6 +165,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/assets/", s.handleAsset)
mux.HandleFunc("/whoami", s.handleWhoami)
mux.HandleFunc("/enroll", s.handleEnroll)
+ mux.HandleFunc("/webfetch/approve", s.handleDelegationPage)
+ mux.HandleFunc("/api/delegations/request", s.handleDelegationRequest)
+ mux.HandleFunc("/api/delegations/approve", s.handleDelegationApprove)
+ mux.HandleFunc("/api/delegations/reject", s.handleDelegationReject)
mux.HandleFunc("/auth/login", s.handleAuthLogin)
mux.HandleFunc("/auth/github", s.handleAuthStart)
mux.HandleFunc("/auth/callback", s.handleAuthCallback)
@@ -260,6 +264,8 @@ func (s *Server) handleAdminLogs(w http.ResponseWriter, r *http.Request) {
}
var mutationPaths = map[string]bool{
+ "/api/delegations/approve": true,
+ "/api/delegations/reject": true,
"/api/tokens": true,
"/api/tokens/revoke": true,
"/api/acl": true,
diff --git a/internal/portal/web/app.css b/internal/portal/web/app.css
index d8f1ba1..79adc5f 100644
--- a/internal/portal/web/app.css
+++ b/internal/portal/web/app.css
@@ -662,6 +662,27 @@ pre code{background:none;padding:0;font-size:1em}
.auth form .go{margin-top:0}
.auth .err{margin:10px 0 0;font-size:13px;color:var(--red);min-height:1.3em}
+/* Temporary web-client grants reuse the auth page's single-column rhythm. */
+.delegation main{max-width:38rem;justify-content:flex-start;padding-top:52px}
+.delegate-details{margin:24px 0 0;display:grid;gap:5px;min-width:0}
+.delegate-details dt{margin-top:9px;font-size:12px;color:var(--ink-2)}
+.delegate-details dd{margin:0;overflow-wrap:anywhere}
+.delegate-details dd.fp{margin-top:0}
+.delegate-devices{min-width:0;margin:0;padding:0;border:0}
+.delegate-devices legend{font-weight:600;margin-bottom:8px}
+.delegate-device{display:flex;align-items:flex-start;gap:12px;padding:14px 0;border-bottom:1px solid var(--hairline)}
+.delegate-device>span{min-width:0}
+.delegate-device .fp,.delegate-id{display:block}
+.delegate-id{margin-top:4px;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere}
+.delegate-device small{font-size:12px;color:var(--ink-2)}
+.auth .delegate-device input,.auth .delegate-confirm input{width:17px;height:17px;flex:none;margin-top:3px;padding:0;accent-color:var(--blue)}
+.delegate-duration{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:12px 0}
+.delegate-duration select{max-width:13rem}
+.delegate-confirm{display:flex;align-items:flex-start;gap:12px;font-size:13px;line-height:1.5}
+.delegate-actions{display:flex;gap:10px;margin-top:8px}
+.delegation form .fine{margin:2px 0}
+.token-scope{display:block;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere;max-width:26rem}
+
/* 申请访问的四种状态。页面把四段都发下来,选哪一段由
决定 —— 纯 CSS,脚本没跑起来的时候看到的也是对的那一段。
状态是服务端算的:不能让人看见一张交上去会被拒的表单。 */
diff --git a/internal/portal/web/app.js b/internal/portal/web/app.js
index b474937..85c8442 100644
--- a/internal/portal/web/app.js
+++ b/internal/portal/web/app.js
@@ -89,7 +89,8 @@
friendYes: 'friend', friendIn: 'wants to add you', friendOut: 'waiting for them',
accept: 'Accept', decline: 'Decline', withdraw: 'Withdraw',
shareDevice: 'Share a device', shareGrantee: 'Grant to', sharePerms: 'Permissions', share: 'Share',
- never2: 'never', revoked: 'revoked', active: 'active',
+ never2: 'never', revoked: 'revoked', active: 'active', expired: 'expired',
+ delegated: 'Temporary device use', allDevices: 'Account access',
copied: 'Copied', copy: 'Copy',
saved: 'Saved', cleared: 'Cleared', sent: 'Test delivered',
aliasNone: 'no alias',
@@ -204,7 +205,8 @@
friendYes: '好友', friendIn: '想加你为好友', friendOut: '等待对方接受',
accept: '接受', decline: '拒绝', withdraw: '撤回',
shareDevice: '共享一台设备', shareGrantee: '授权给', sharePerms: '权限', share: '授权',
- never2: '永不', revoked: '已吊销', active: '有效',
+ never2: '永不', revoked: '已吊销', active: '有效', expired: '已过期',
+ delegated: '临时设备使用权', allDevices: '账号访问权限',
copied: '已复制', copy: '复制',
saved: '已保存', cleared: '已清除', sent: '测试通知已送达',
aliasNone: '未设置',
@@ -1251,11 +1253,18 @@
var xs = d.tokens || [];
$('#tokens').innerHTML = xs.length ? xs.map(function (x) {
var rev = !!x.revoked_at;
- return '' + esc(x.label || '—') + ' ' +
+ var expired = !!x.expires_at && new Date(x.expires_at).getTime() <= Date.now();
+ var scope = (x.devices || []).map(function (device) {
+ return typeof device === 'string' ? device : ((device.namespace ? device.namespace + '/' : '') + device.id);
+ }).join(', ');
+ var delegated = x.kind === 'delegated' || !!x.grant_id;
+ var scopeLine = delegated ? t().delegated + (scope ? ' · ' + scope : '') : t().allDevices;
+ return '' + esc(x.label || '—') +
+ '' + esc(scopeLine) + ' ' +
'' + esc(fmt(x.created_at)) + ' ' +
'' + (x.expires_at ? esc(fmt(x.expires_at)) : esc(t().never2)) + ' ' +
- '' + esc(rev ? t().revoked : t().active) + ' ' +
- '' + (rev ? '' : '' + esc(t().revoke) + ' ') + ' ';
+ '' + esc(rev ? t().revoked : expired ? t().expired : t().active) + ' ' +
+ '' + (rev || expired ? '' : '' + esc(t().revoke) + ' ') + ' ';
}).join('') : '' + esc(t().noTokens) + ' ';
relabel();
$$('#tokens .act').forEach(function (b) {
diff --git a/internal/portal/web/auth.js b/internal/portal/web/auth.js
index cb45c85..af37ba1 100644
--- a/internal/portal/web/auth.js
+++ b/internal/portal/web/auth.js
@@ -60,6 +60,46 @@
});
}
+ /* WebFetch uses the same authenticated approval surface as other clients.
+ GET displays the request; only this CSRF-protected POST grants access. */
+ var delegate = $('#delegate');
+ if (delegate) {
+ var approve = $('#delegateApprove'), reject = $('#delegateReject');
+ var delegateError = $('#delegateError');
+ var selectedDevices = function () { return $$('#delegate input[name="device"]:checked'); };
+ var updateApprove = function () {
+ approve.disabled = !$('#delegateConfirm').checked || !selectedDevices().length;
+ };
+ delegate.addEventListener('change', function (event) {
+ if (event.target.name === 'device' || event.target.name === 'minutes') $('#delegateConfirm').checked = false;
+ updateApprove();
+ });
+ var decide = function (path, body) {
+ approve.disabled = reject.disabled = true;
+ delegateError.textContent = '';
+ post(path, body).then(function (r) {
+ if (r.ok) { location.reload(); return; }
+ return r.text().then(function (msg) { throw new Error(msg.trim() || ('HTTP ' + r.status)); });
+ }).catch(function (err) {
+ delegateError.textContent = err.message || (lang === 'en' ? 'Network error — try again.' : '网络错误,请重试。');
+ reject.disabled = false;
+ updateApprove();
+ });
+ };
+ delegate.onsubmit = function (event) {
+ event.preventDefault();
+ var selected = selectedDevices(), fingerprints = {};
+ if (!selected.length || !$('#delegateConfirm').checked) return;
+ selected.forEach(function (input) { fingerprints[input.value] = input.dataset.fingerprint; });
+ decide('/api/delegations/approve', {
+ request_id: delegate.dataset.request, devices: selected.map(function (input) { return input.value; }),
+ minutes: Number($('#delegateMinutes').value), confirmed: true,
+ controller_fingerprint: delegate.dataset.controllerFingerprint, device_fingerprints: fingerprints
+ });
+ };
+ reject.onclick = function () { decide('/api/delegations/reject', { request_id: delegate.dataset.request }); };
+ }
+
/* ── 设备授权页:复制授权码 ────────────────────────────────────────
复制成功后「点一下复制」换成「已复制」,有效期那半句留着 —— 它在
复制之后依然是这一行里唯一还会变的信息。 */
diff --git a/internal/portal/web/delegation.html b/internal/portal/web/delegation.html
new file mode 100644
index 0000000..8928854
--- /dev/null
+++ b/internal/portal/web/delegation.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+Web AI access · wanctl
+
+
+
+
+
+
+ Web AI access
+ Let this client use selected devices for a short time. Your devices keep deciding which operations to allow.
+
+ Your account {{.NS}}
+ Client label (self-reported) {{.Request.Label}}
+ Controller fingerprint {{.Request.ControllerFingerprint}}
+ Request {{.Request.ID}}
+
+ {{if .Pending}}
+
+ {{else}}
+
+ {{if eq .Status "approved"}}Access approved. Return to your AI chat to continue. You can revoke it in Access tokens.
+ {{else if eq .Status "rejected"}}This request was rejected.
+ {{else}}This request is no longer active. Ask your AI to start again. {{end}}
+
+ {{if .Request.ExpiresAt}}Expires: {{.Request.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}
{{end}}
+ {{end}}
+ Manage access tokens
+
+
+
+
diff --git a/internal/portal/web_test.go b/internal/portal/web_test.go
index d03fb70..d28022c 100644
--- a/internal/portal/web_test.go
+++ b/internal/portal/web_test.go
@@ -96,7 +96,7 @@ func TestDownloadTableUsesFilesWithExtensions(t *testing.T) {
// before the language switch is wired, on the very first page of the product.
func TestAuthScriptMatchesItsPages(t *testing.T) {
js := readWeb(t, "web/auth.js")
- pageNames := []string{"web/login.html", "web/pending.html", "web/enroll.html"}
+ pageNames := []string{"web/login.html", "web/pending.html", "web/enroll.html", "web/delegation.html"}
id := regexp.MustCompile(`id="([A-Za-z0-9_-]+)"`)
anywhere := map[string]bool{}
diff --git a/internal/portal/webfetch.go b/internal/portal/webfetch.go
new file mode 100644
index 0000000..6234b14
--- /dev/null
+++ b/internal/portal/webfetch.go
@@ -0,0 +1,227 @@
+package portal
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "time"
+
+ "wanctl/internal/delegation"
+ "wanctl/internal/transport"
+)
+
+type delegationDevice struct {
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+ Shared bool `json:"shared"`
+ Alias string `json:"alias"`
+ DisplayName string `json:"display_name"`
+ Fingerprint string `json:"fingerprint"`
+ Online bool `json:"online"`
+}
+
+func (d delegationDevice) Label() string {
+ if d.Alias != "" {
+ return d.Alias
+ }
+ if d.DisplayName != "" {
+ return d.DisplayName
+ }
+ return d.Name
+}
+
+func validDelegationRequestID(id string) bool {
+ if len(id) < 8 || len(id) > 128 {
+ return false
+ }
+ for _, c := range id {
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
+ return false
+ }
+ }
+ return true
+}
+
+func (s *Server) delegationRequest(w http.ResponseWriter, ns, id string) (delegation.Request, bool) {
+ var out delegation.Request
+ if !validDelegationRequestID(id) {
+ http.Error(w, "invalid request id", http.StatusBadRequest)
+ return out, false
+ }
+ resp, err := s.adminReq(http.MethodGet, "/admin/delegations/request", url.Values{"namespace": {ns}, "id": {id}}, nil)
+ if err != nil {
+ http.Error(w, "relay unreachable", http.StatusBadGateway)
+ return out, false
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ copyResp(w, resp)
+ return out, false
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ http.Error(w, "invalid relay response", http.StatusBadGateway)
+ return out, false
+ }
+ // Pending requests have not selected an owner. Once decided, only that
+ // owner can read the resulting grant through the human portal.
+ if out.Namespace != "" && out.Namespace != ns {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return delegation.Request{}, false
+ }
+ return out, true
+}
+
+func (s *Server) delegationDevices(w http.ResponseWriter, ns string) ([]delegationDevice, bool) {
+ resp, err := s.adminReq(http.MethodGet, "/admin/devices", url.Values{"namespace": {ns}}, nil)
+ if err != nil {
+ http.Error(w, "relay unreachable", http.StatusBadGateway)
+ return nil, false
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ copyResp(w, resp)
+ return nil, false
+ }
+ var out struct {
+ Devices []delegationDevice `json:"devices"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ http.Error(w, "invalid relay response", http.StatusBadGateway)
+ return nil, false
+ }
+ owned := []delegationDevice{}
+ for _, d := range out.Devices {
+ if !d.Shared && (d.Owner == "" || d.Owner == ns) && transport.ValidFingerprint(d.Fingerprint) {
+ owned = append(owned, d)
+ }
+ }
+ return owned, true
+}
+
+func (s *Server) handleDelegationPage(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("request")
+ if !validDelegationRequestID(id) {
+ http.Error(w, "invalid request id", http.StatusBadRequest)
+ return
+ }
+ next := "/webfetch/approve?" + url.Values{"request": {id}}.Encode()
+ ns, ok := s.pageAuth(w, r, next)
+ if !ok {
+ return
+ }
+ req, ok := s.delegationRequest(w, ns, id)
+ if !ok {
+ return
+ }
+ pending := req.Status == "pending" && time.Now().Before(req.RequestExpiresAt)
+ var devices []delegationDevice
+ if pending {
+ devices, ok = s.delegationDevices(w, ns)
+ if !ok {
+ return
+ }
+ }
+ status := req.Status
+ if req.Status == "pending" && !pending {
+ status = "expired"
+ }
+ if req.Status == "approved" && req.ExpiresAt != nil && !time.Now().Before(*req.ExpiresAt) {
+ status = "expired"
+ }
+ s.render(w, "delegation.html", map[string]any{"NS": ns, "Request": req, "Devices": devices, "Pending": pending, "Status": status})
+}
+
+func (s *Server) handleDelegationRequest(w http.ResponseWriter, r *http.Request) {
+ ns, ok := s.requireNS(w, r)
+ if !ok {
+ return
+ }
+ req, ok := s.delegationRequest(w, ns, r.URL.Query().Get("id"))
+ if !ok {
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(req)
+}
+
+func (s *Server) handleDelegationApprove(w http.ResponseWriter, r *http.Request) {
+ ns, ok := s.requireNS(w, r)
+ if !ok {
+ return
+ }
+ var body struct {
+ RequestID string `json:"request_id"`
+ Devices []string `json:"devices"`
+ Minutes int `json:"minutes"`
+ Confirmed bool `json:"confirmed"`
+ ControllerFingerprint string `json:"controller_fingerprint"`
+ DeviceFingerprints map[string]string `json:"device_fingerprints"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || !body.Confirmed || len(body.Devices) == 0 || len(body.Devices) > 16 || body.Minutes < 1 || body.Minutes > 60 {
+ http.Error(w, "select 1 to 16 devices, confirm identities and choose 1 to 60 minutes", http.StatusBadRequest)
+ return
+ }
+ req, ok := s.delegationRequest(w, ns, body.RequestID)
+ if !ok {
+ return
+ }
+ if req.Status != "pending" || !time.Now().Before(req.RequestExpiresAt) {
+ http.Error(w, "request is no longer pending", http.StatusConflict)
+ return
+ }
+ if req.ControllerFingerprint != body.ControllerFingerprint {
+ http.Error(w, "controller identity changed; reload before confirming", http.StatusConflict)
+ return
+ }
+ owned, ok := s.delegationDevices(w, ns)
+ if !ok {
+ return
+ }
+ fingerprints := map[string]string{}
+ for _, d := range owned {
+ fingerprints[d.Name] = d.Fingerprint
+ }
+ seen := map[string]bool{}
+ for _, id := range body.Devices {
+ if fingerprints[id] == "" || seen[id] {
+ http.Error(w, "device not owned by you or selected twice", http.StatusForbidden)
+ return
+ }
+ if body.DeviceFingerprints[id] != fingerprints[id] {
+ http.Error(w, "device identity changed; reload before confirming", http.StatusConflict)
+ return
+ }
+ seen[id] = true
+ }
+ resp, err := s.adminReq(http.MethodPost, "/admin/delegations/approve", nil, map[string]any{
+ "namespace": ns, "request_id": body.RequestID, "devices": body.Devices, "minutes": body.Minutes,
+ "controller_fingerprint": body.ControllerFingerprint, "device_fingerprints": body.DeviceFingerprints,
+ })
+ if err != nil {
+ http.Error(w, "relay unreachable", http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+ copyResp(w, resp)
+}
+
+func (s *Server) handleDelegationReject(w http.ResponseWriter, r *http.Request) {
+ ns, ok := s.requireNS(w, r)
+ if !ok {
+ return
+ }
+ var body struct {
+ RequestID string `json:"request_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || !validDelegationRequestID(body.RequestID) {
+ http.Error(w, "invalid request id", http.StatusBadRequest)
+ return
+ }
+ resp, err := s.adminReq(http.MethodPost, "/admin/delegations/reject", nil, map[string]string{"namespace": ns, "request_id": body.RequestID})
+ if err != nil {
+ http.Error(w, "relay unreachable", http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+ copyResp(w, resp)
+}
diff --git a/internal/portal/webfetch_test.go b/internal/portal/webfetch_test.go
new file mode 100644
index 0000000..d22a4ff
--- /dev/null
+++ b/internal/portal/webfetch_test.go
@@ -0,0 +1,261 @@
+package portal
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "wanctl/internal/delegation"
+ "wanctl/internal/transport"
+)
+
+const testDelegationID = "request_test_1234"
+
+func delegationFixture() delegation.Request {
+ return delegation.Request{ID: testDelegationID, Label: "Qwen experiment", Status: "pending",
+ ControllerFingerprint: transport.Fingerprint([]byte("temporary controller")),
+ CreatedAt: time.Now(), RequestExpiresAt: time.Now().Add(10 * time.Minute)}
+}
+
+func delegationPortal(t *testing.T, request delegation.Request, devices []delegationDevice, onApprove func(map[string]any)) *Server {
+ t.Helper()
+ return newTestPortal(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/admin/resolve-user":
+ json.NewEncoder(w).Encode(map[string]string{"namespace": "alice", "role": "user"})
+ case "/admin/delegations/request":
+ if r.URL.Query().Get("namespace") != "alice" || r.URL.Query().Get("id") != testDelegationID {
+ t.Errorf("request not owner scoped: %s", r.URL)
+ }
+ json.NewEncoder(w).Encode(request)
+ case "/admin/devices":
+ if r.URL.Query().Get("namespace") != "alice" {
+ t.Errorf("devices not owner scoped: %s", r.URL)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"devices": devices})
+ case "/admin/delegations/approve", "/admin/delegations/reject":
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+ if onApprove != nil {
+ onApprove(body)
+ } else {
+ t.Error("unexpected mutation")
+ }
+ json.NewEncoder(w).Encode(request)
+ default:
+ t.Errorf("unexpected relay path: %s", r.URL)
+ w.WriteHeader(http.StatusNotFound)
+ }
+ })
+}
+
+func delegationPOST(t *testing.T, s *Server, path string, body map[string]any) *httptest.ResponseRecorder {
+ t.Helper()
+ data, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := newCSRFToken()
+ req := httptest.NewRequest(http.MethodPost, "https://portal.test"+path, bytes.NewReader(data))
+ req.Header.Set("X-User", "alice@example.com")
+ req.Header.Set("Origin", "https://portal.test")
+ req.Header.Set(csrfHeaderName, token)
+ req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token})
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ return rec
+}
+
+func approvalBody() map[string]any {
+ return map[string]any{"request_id": testDelegationID, "namespace": "attacker", "minutes": 15,
+ "devices": []string{"mac-id"}, "confirmed": true,
+ "controller_fingerprint": delegationFixture().ControllerFingerprint,
+ "device_fingerprints": map[string]string{"mac-id": transport.Fingerprint([]byte("mac"))}}
+}
+
+func TestDelegationPagePreservesRequestThroughLogin(t *testing.T) {
+ s := newOAuthPortal(t, resolveOKAs("alice", "user"))
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/webfetch/approve?request="+testDelegationID, nil))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ u, err := url.Parse(rec.Header().Get("Location"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := u.Query().Get("next"); got != "/webfetch/approve?request="+testDelegationID {
+ t.Fatalf("login lost request: %q", got)
+ }
+}
+
+func TestDelegationPageShowsOwnedDevicesAndEscapesUntrustedLabels(t *testing.T) {
+ req := delegationFixture()
+ req.Label = ` `
+ s := delegationPortal(t, req, []delegationDevice{
+ {Name: "owned-id", Owner: "alice", Alias: "My Mac", Fingerprint: transport.Fingerprint([]byte("owned"))},
+ {Name: "shared-id", Owner: "bob", Shared: true, Fingerprint: transport.Fingerprint([]byte("shared"))},
+ {Name: "no-identity", Owner: "alice"},
+ }, nil)
+ r := httptest.NewRequest(http.MethodGet, "/webfetch/approve?request="+testDelegationID, nil)
+ r.Header.Set("X-User", "alice@example.com")
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, r)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
+ }
+ page := rec.Body.String()
+ for _, wanted := range []string{"owned-id", "My Mac", "<img", `value="15" selected`, req.ControllerFingerprint, `id="delegateApprove" disabled`} {
+ if !strings.Contains(page, wanted) {
+ t.Errorf("page missing %q", wanted)
+ }
+ }
+ for _, forbidden := range []string{"shared-id", "no-identity", " httpAgentTTL {
@@ -107,10 +124,15 @@ func (r *Relay) handleWSDialToHTTP(w http.ResponseWriter, req *http.Request, tar
http.Error(w, "device offline", http.StatusNotFound)
return
}
+ if access.Delegated && !a.delegation {
+ r.hmu.Unlock()
+ http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict)
+ return
+ }
sid := newID()
auth.Session = sid
r.hmu.Unlock()
- s := r.newHTTPSession(sid, auth)
+ s := r.newHTTPSession(sid, auth, access, token)
select {
case a.open <- auth:
@@ -129,10 +151,11 @@ func (r *Relay) handleWSDialToHTTP(w http.ResponseWriter, req *http.Request, tar
}
limits.ClearHijackedDeadline(req.Context())
clientNC := wsconn.FromAccepted(req.Context(), c)
+ s.lease.addCloser(func() { clientNC.Close() })
pipe(clientNC, r.httpSessionConn(sid, s, "client"))
}
-func (r *Relay) handleHDialToWS(w http.ResponseWriter, targetKey string, auth sessionauth.Open) {
+func (r *Relay) handleHDialToWS(w http.ResponseWriter, targetKey string, auth sessionauth.Open, access delegation.Access, token string) {
r.mu.Lock()
ac := r.agents[targetKey]
r.mu.Unlock()
@@ -141,12 +164,16 @@ func (r *Relay) handleHDialToWS(w http.ResponseWriter, targetKey string, auth se
return
}
+ if access.Delegated && !ac.delegation {
+ http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict)
+ return
+ }
sid := newID()
auth.Op = "open"
auth.Session = sid
auth.URL = "/session/" + sid
- s := r.newHTTPSession(sid, auth)
- ps := &pendingSession{agentSide: make(chan io.ReadWriteCloser, 1), done: make(chan struct{})}
+ s := r.newHTTPSession(sid, auth, access, token)
+ ps := &pendingSession{agentSide: make(chan io.ReadWriteCloser, 1), done: make(chan struct{}), ownerNS: auth.OwnerNamespace}
r.mu.Lock()
r.pending[sid] = ps
r.mu.Unlock()
@@ -169,6 +196,7 @@ func (r *Relay) bridgeHTTPControllerToWS(sid string, s *httpSession, ps *pending
defer timer.Stop()
select {
case agentNC := <-ps.agentSide:
+ s.lease.addCloser(func() { agentNC.Close() })
pipe(r.httpSessionConn(sid, s, "agent"), agentNC)
case <-timer.C:
r.closeHTTPSession(sid, s)
diff --git a/internal/relay/delegation_admin.go b/internal/relay/delegation_admin.go
new file mode 100644
index 0000000..e2b5a66
--- /dev/null
+++ b/internal/relay/delegation_admin.go
@@ -0,0 +1,147 @@
+package relay
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "wanctl/internal/delegation"
+)
+
+func (r *Relay) registerDelegationAdmin(mux *http.ServeMux) {
+ mux.HandleFunc("/admin/delegations/request", r.adminDelegationRequest)
+ mux.HandleFunc("/admin/delegations/approve", r.adminDelegationApprove)
+ mux.HandleFunc("/admin/delegations/reject", r.adminDelegationReject)
+ mux.HandleFunc("/admin/tokens/inspect", r.adminTokenInspect)
+}
+
+func (r *Relay) delegationAdminStore(w http.ResponseWriter, req *http.Request) (delegation.Store, bool) {
+ if !r.requireAdminStore(w, req) {
+ return nil, false
+ }
+ store, ok := r.admin.(delegation.Store)
+ if !ok {
+ http.Error(w, "delegation store is not configured", http.StatusServiceUnavailable)
+ }
+ return store, ok
+}
+
+func writeDelegationError(w http.ResponseWriter, err error) {
+ status := http.StatusInternalServerError
+ switch {
+ case errors.Is(err, delegation.ErrNotFound):
+ status = http.StatusNotFound
+ case errors.Is(err, delegation.ErrForbidden):
+ status = http.StatusForbidden
+ case errors.Is(err, delegation.ErrInvalid):
+ status = http.StatusBadRequest
+ case errors.Is(err, delegation.ErrExpired):
+ status = http.StatusGone
+ case errors.Is(err, delegation.ErrConflict):
+ status = http.StatusConflict
+ case errors.Is(err, delegation.ErrLimit):
+ status = http.StatusTooManyRequests
+ }
+ // DB failures may contain SQL details; expose only stable contract errors.
+ if status == http.StatusInternalServerError {
+ http.Error(w, "delegation store unavailable", status)
+ return
+ }
+ http.Error(w, err.Error(), status)
+}
+
+func (r *Relay) adminDelegationRequest(w http.ResponseWriter, req *http.Request) {
+ store, ok := r.delegationAdminStore(w, req)
+ if !ok || !requireMethod(w, req, http.MethodGet) {
+ return
+ }
+ ns := req.URL.Query().Get("namespace")
+ if guardNamespace(ns, r.portalNS) != nil {
+ writeDelegationError(w, delegation.ErrForbidden)
+ return
+ }
+ out, err := store.GetDelegation(req.Context(), req.URL.Query().Get("id"))
+ if err != nil {
+ writeDelegationError(w, err)
+ return
+ }
+ if out.Namespace != "" && out.Namespace != ns {
+ writeDelegationError(w, delegation.ErrForbidden)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ writeJSON(w, out)
+}
+
+func (r *Relay) adminDelegationApprove(w http.ResponseWriter, req *http.Request) {
+ store, ok := r.delegationAdminStore(w, req)
+ if !ok || !requireMethod(w, req, http.MethodPost) {
+ return
+ }
+ var in delegation.Approval
+ if json.NewDecoder(http.MaxBytesReader(w, req.Body, 16<<10)).Decode(&in) != nil {
+ writeDelegationError(w, delegation.ErrInvalid)
+ return
+ }
+ if guardNamespace(in.Namespace, r.portalNS) != nil {
+ writeDelegationError(w, delegation.ErrForbidden)
+ return
+ }
+ out, err := store.ApproveDelegation(req.Context(), in)
+ if err != nil {
+ writeDelegationError(w, err)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ writeJSON(w, out)
+}
+
+func (r *Relay) adminDelegationReject(w http.ResponseWriter, req *http.Request) {
+ store, ok := r.delegationAdminStore(w, req)
+ if !ok || !requireMethod(w, req, http.MethodPost) {
+ return
+ }
+ var in struct {
+ RequestID string `json:"request_id"`
+ Namespace string `json:"namespace"`
+ }
+ if json.NewDecoder(http.MaxBytesReader(w, req.Body, 4<<10)).Decode(&in) != nil {
+ writeDelegationError(w, delegation.ErrInvalid)
+ return
+ }
+ if guardNamespace(in.Namespace, r.portalNS) != nil {
+ writeDelegationError(w, delegation.ErrForbidden)
+ return
+ }
+ if err := store.RejectDelegation(req.Context(), in.RequestID, in.Namespace); err != nil {
+ writeDelegationError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// Satellite relays must retain admission metadata, never downgrade delegated
+// credentials through the older namespace-only resolve endpoint.
+func (r *Relay) adminTokenInspect(w http.ResponseWriter, req *http.Request) {
+ if !r.secretOK(req) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ if !requireMethod(w, req, http.MethodPost) {
+ return
+ }
+ var in struct {
+ Token string `json:"token"`
+ }
+ if json.NewDecoder(http.MaxBytesReader(w, req.Body, 4<<10)).Decode(&in) != nil || in.Token == "" {
+ writeDelegationError(w, delegation.ErrInvalid)
+ return
+ }
+ out, ok := ResolveAccess(r.ts, in.Token)
+ if !ok {
+ http.Error(w, "unknown token", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ writeJSON(w, out)
+}
diff --git a/internal/relay/delegation_auth.go b/internal/relay/delegation_auth.go
new file mode 100644
index 0000000..8b3dd1d
--- /dev/null
+++ b/internal/relay/delegation_auth.go
@@ -0,0 +1,204 @@
+package relay
+
+import (
+ "net/http"
+ "sync"
+ "time"
+
+ "wanctl/internal/admission"
+ "wanctl/internal/delegation"
+ "wanctl/internal/sessionauth"
+)
+
+const delegationRecheckInterval = time.Second
+
+func (r *Relay) authAccess(w http.ResponseWriter, req *http.Request) (delegation.Access, string, bool) {
+ token, legacy, ok := admission.Token(req)
+ if !ok {
+ return delegation.Access{}, "", false
+ }
+ if legacy {
+ admission.MarkLegacy(w)
+ }
+ a, ok := ResolveAccess(r.ts, token)
+ if a.Delegated && a.Namespace == r.portalNS {
+ return delegation.Access{}, "", false
+ }
+ return a, token, ok
+}
+
+func (r *Relay) dialAccessAllowed(a delegation.Access, target string) (string, sessionauth.Open, string, bool) {
+ key, auth, reason, ok := r.dialAllowedReason(a.Namespace, target)
+ if !ok {
+ // Account-level resolution diagnostics can enumerate shared devices
+ // outside this grant. A delegated caller receives only a generic denial.
+ if a.Delegated {
+ return "", sessionauth.Open{}, "", false
+ }
+ return key, auth, reason, false
+ }
+ if a.Delegated {
+ if !a.Allows(key) {
+ return "", sessionauth.Open{}, "", false
+ }
+ auth.Capabilities = sessionauth.UseCapabilities
+ auth.GrantID = a.GrantID
+ auth.CredentialID = a.CredentialID
+ auth.ControllerFingerprint = a.ControllerFingerprint
+ auth.ExpiresAt = a.ExpiresAt
+ }
+ return key, auth, "", true
+}
+
+func (r *Relay) accessPeers(a delegation.Access) map[string]any {
+ devices, aliases := r.livePeers(a.Namespace)
+ shared := r.sharedPeers(a.Namespace)
+ if a.Delegated {
+ filtered := make([]string, 0, len(devices))
+ for _, d := range devices {
+ if a.Allows(a.Namespace + "/" + d) {
+ filtered = append(filtered, d)
+ }
+ }
+ devices = filtered
+ for d := range aliases {
+ if !a.Allows(a.Namespace + "/" + d) {
+ delete(aliases, d)
+ }
+ }
+ filteredShared := make([]SharedPeer, 0, len(shared))
+ for _, d := range shared {
+ if a.Allows(d.Target) {
+ filteredShared = append(filteredShared, d)
+ }
+ }
+ shared = filteredShared
+ }
+ return peersBody(a.Namespace, devices, aliases, shared)
+}
+
+// accessLease ties all carrier legs to the exact controller credential. Raw
+// credentials live only in relay memory and are never forwarded to the agent.
+type accessLease struct {
+ r *Relay
+ sid, target, token string
+ access delegation.Access
+ done chan struct{}
+ once sync.Once
+ mu sync.Mutex
+ closers []func()
+}
+
+func (r *Relay) beginAccessLease(sid, target string, access delegation.Access, token string) *accessLease {
+ l := &accessLease{r: r, sid: sid, target: target, token: token, access: access, done: make(chan struct{})}
+ if !access.Delegated {
+ return l
+ }
+ r.leaseMu.Lock()
+ if r.leases == nil {
+ r.leases = make(map[string]*accessLease)
+ }
+ r.leases[sid] = l
+ r.leaseMu.Unlock()
+ // Expiry must not wait for a slow database or upstream revalidation.
+ deadline := time.AfterFunc(time.Until(access.ExpiresAt), l.close)
+ go func() {
+ defer deadline.Stop()
+ ticker := time.NewTicker(delegationRecheckInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-l.done:
+ return
+ case <-ticker.C:
+ if !l.valid() {
+ l.close()
+ return
+ }
+ }
+ }
+ }()
+ return l
+}
+
+func (l *accessLease) valid() bool {
+ select {
+ case <-l.done:
+ return false
+ default:
+ }
+ if !l.credentialValid() {
+ return false
+ }
+ select {
+ case <-l.done:
+ return false
+ default:
+ return true
+ }
+}
+
+// A normal EOF may still leave final output in the HTTP queue. Revalidate its
+// credential without discarding those bytes merely because the peer closed.
+func (l *accessLease) credentialValid() bool {
+ if !l.access.Delegated {
+ return true
+ }
+ a, ok := ResolveAccess(l.r.ts, l.token)
+ return ok && a.Delegated && a.GrantID == l.access.GrantID &&
+ a.CredentialID == l.access.CredentialID && a.ControllerFingerprint == l.access.ControllerFingerprint && a.Allows(l.target)
+}
+
+func (l *accessLease) addCloser(f func()) {
+ l.mu.Lock()
+ select {
+ case <-l.done:
+ l.mu.Unlock()
+ f()
+ default:
+ l.closers = append(l.closers, f)
+ l.mu.Unlock()
+ }
+}
+
+func (l *accessLease) close() {
+ l.once.Do(func() {
+ l.mu.Lock()
+ close(l.done)
+ closers := l.closers
+ l.closers = nil
+ l.mu.Unlock()
+ if l.access.Delegated {
+ l.r.leaseMu.Lock()
+ if l.r.leases[l.sid] == l {
+ delete(l.r.leases, l.sid)
+ }
+ l.r.leaseMu.Unlock()
+ }
+ for _, f := range closers {
+ f()
+ }
+ })
+}
+
+// An agent revalidates after a potentially long policy approval. Merely closing
+// a socket is insufficient: approval UIs can resolve after the caller left.
+func (r *Relay) handleAgentDelegationCheck(w http.ResponseWriter, req *http.Request) {
+ if !requireMethod(w, req, http.MethodGet) {
+ return
+ }
+ ns, device, ok := r.authAgentInstance(w, req)
+ if !ok {
+ return
+ }
+ r.leaseMu.Lock()
+ l := r.leases[req.URL.Query().Get("session")]
+ r.leaseMu.Unlock()
+ if l == nil || l.target != ns+"/"+device || l.access.GrantID != req.URL.Query().Get("grant") ||
+ l.access.ControllerFingerprint != req.URL.Query().Get("controller_fp") || !l.valid() {
+ http.Error(w, "delegation inactive", http.StatusForbidden)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/relay/delegation_auth_test.go b/internal/relay/delegation_auth_test.go
new file mode 100644
index 0000000..b865a4f
--- /dev/null
+++ b/internal/relay/delegation_auth_test.go
@@ -0,0 +1,240 @@
+package relay
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "wanctl/internal/delegation"
+ "wanctl/internal/sessionauth"
+)
+
+type transportGrantStore struct {
+ mu sync.Mutex
+ grants map[string]delegation.Access
+}
+
+func (s *transportGrantStore) Resolve(token string) (string, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ a, ok := s.grants[token]
+ return a.Namespace, ok && !a.Delegated
+}
+func (s *transportGrantStore) ResolveAccess(token string) (delegation.Access, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ a, ok := s.grants[token]
+ return a, ok
+}
+func (s *transportGrantStore) revoke(token string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.grants, token)
+}
+func testTransportGrant() delegation.Access {
+ return delegation.Access{Namespace: "alice", CredentialID: "credential-one", Delegated: true, GrantID: "grant-one", ControllerFingerprint: "controller-fp", ExpiresAt: time.Now().Add(time.Minute), Devices: []delegation.Device{{Namespace: "alice", ID: "allowed", Fingerprint: "device-fp"}}}
+}
+func grantRequest(h http.Handler, method, path, token string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, strings.NewReader(`{}`))
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestDelegatedAdmissionRejectsEveryManagementAndAgentSurface(t *testing.T) {
+ s := &transportGrantStore{grants: map[string]delegation.Access{"delegate": testTransportGrant()}}
+ r := New(s)
+ h := r.Handler()
+ for _, route := range []struct{ method, path string }{
+ {"GET", "/agent"}, {"GET", "/h/poll?device=allowed"}, {"POST", "/h/deregister?device=allowed"},
+ {"GET", "/session/leaked"}, {"GET", "/agent/notify-policy?device=allowed&inst=x"}, {"POST", "/agent/events?device=allowed&inst=x"},
+ {"GET", "/u/friends"}, {"POST", "/u/friends/request"}, {"POST", "/u/friends/accept"}, {"POST", "/u/friends/decline"}, {"POST", "/u/friends/remove"},
+ {"GET", "/u/users/lookup"}, {"GET", "/u/shares"}, {"POST", "/u/shares/grant"}, {"POST", "/u/shares/manage"}, {"POST", "/u/shares/revoke"},
+ {"GET", "/u/notify"}, {"POST", "/u/notify"}, {"POST", "/u/notify/test"}, {"GET", "/u/devices/notify"},
+ {"POST", "/docs/groups"}, {"POST", "/docs/groups/delete"}, {"POST", "/docs/articles"}, {"POST", "/docs/articles/delete"},
+ {"POST", "/admin/tokens/issue"}, {"POST", "/admin/enroll/mint"}, {"POST", "/admin/delegations/approve"},
+ } {
+ t.Run(route.path+route.method, func(t *testing.T) {
+ rec := grantRequest(h, route.method, route.path, "delegate")
+ if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ })
+ }
+}
+
+func TestDelegatedScopeUsesCanonicalDeviceAndFiltersDiscovery(t *testing.T) {
+ a := testTransportGrant()
+ r := New(&transportGrantStore{grants: map[string]delegation.Access{"delegate": a}})
+ r.agents["alice/allowed"] = &agentConn{ns: "alice", device: "allowed", name: "alias", delegation: true}
+ r.agents["alice/hidden"] = &agentConn{ns: "alice", device: "hidden", name: "private-label"}
+ key, auth, _, ok := r.dialAccessAllowed(a, "alias")
+ if !ok || key != "alice/allowed" || auth.Capabilities != sessionauth.UseCapabilities || auth.GrantID != a.GrantID || auth.ControllerFingerprint != a.ControllerFingerprint {
+ t.Fatalf("resolved scope: %s %+v %v", key, auth, ok)
+ }
+ for _, target := range []string{"hidden", "alice/hidden", "other/allowed"} {
+ if _, _, _, ok := r.dialAccessAllowed(a, target); ok {
+ t.Fatalf("allowed target %s", target)
+ }
+ }
+ for _, route := range []string{"/peers", "/h/peers"} {
+ rec := grantRequest(r.Handler(), "GET", route, "delegate")
+ if rec.Code != 200 || strings.Contains(rec.Body.String(), "hidden") || strings.Contains(rec.Body.String(), "private-label") || !strings.Contains(rec.Body.String(), "allowed") {
+ t.Fatalf("discovery leak: %d %s", rec.Code, rec.Body.String())
+ }
+ }
+ // A device does not inherit access from a name after that name moves.
+ r.agents["alice/allowed"].name = "renamed"
+ r.agents["alice/hidden"].name = "alias"
+ if _, _, _, ok := r.dialAccessAllowed(a, "alias"); ok {
+ t.Fatal("scope moved with alias")
+ }
+}
+
+func TestDelegatedHTTPSessionBindsCredentialAndClientRole(t *testing.T) {
+ a, b := testTransportGrant(), testTransportGrant()
+ b.CredentialID = "credential-two"
+ b.GrantID = "grant-two"
+ r := New(&transportGrantStore{grants: map[string]delegation.Access{"a": a, "b": b}})
+ r.hsess["mine"] = &httpSession{callerNS: "alice", ownerNS: "alice", credentialID: a.CredentialID, toAgent: newSideQueue(), toClient: newSideQueue()}
+ r.hsess["owner"] = &httpSession{callerNS: "alice", ownerNS: "alice", toAgent: newSideQueue(), toClient: newSideQueue()}
+ h := r.Handler()
+ for _, route := range []string{"/h/up", "/h/down", "/h/close"} {
+ for _, tc := range []struct{ token, suffix string }{{"b", "?session=mine&role=client"}, {"a", "?session=mine&role=agent"}, {"a", "?session=owner&role=client"}} {
+ method := "POST"
+ if route == "/h/down" {
+ method = "GET"
+ }
+ if rec := grantRequest(h, method, route+tc.suffix, tc.token); rec.Code != 404 {
+ t.Fatalf("%s %s status=%d", route, tc.suffix, rec.Code)
+ }
+ }
+ }
+ if rec := grantRequest(h, "POST", "/h/up?session=mine&role=client", "a"); rec.Code != 200 {
+ t.Fatalf("own upload: %d", rec.Code)
+ }
+ if rec := grantRequest(h, "POST", "/h/close?session=mine&role=client", "a"); rec.Code != 200 {
+ t.Fatalf("own close: %d", rec.Code)
+ }
+}
+
+func TestDelegatedDialRejectsOldAgent(t *testing.T) {
+ a := testTransportGrant()
+ r := New(&transportGrantStore{grants: map[string]delegation.Access{"a": a}})
+ r.agents["alice/allowed"] = &agentConn{ns: "alice", device: "allowed"}
+ for _, path := range []string{"/dial?target=allowed", "/h/dial?target=allowed"} {
+ if rec := grantRequest(r.Handler(), "GET", path, "a"); rec.Code != 409 {
+ t.Fatalf("old agent status=%d", rec.Code)
+ }
+ }
+}
+
+func TestDelegatedLeaseExpiresAndRevokes(t *testing.T) {
+ for _, revoke := range []bool{false, true} {
+ t.Run(map[bool]string{false: "expire", true: "revoke"}[revoke], func(t *testing.T) {
+ a := testTransportGrant()
+ if !revoke {
+ a.ExpiresAt = time.Now().Add(100 * time.Millisecond)
+ }
+ s := &transportGrantStore{grants: map[string]delegation.Access{"a": a}}
+ r := New(s)
+ l := r.beginAccessLease("s", "alice/allowed", a, "a")
+ defer l.close()
+ closed := make(chan struct{})
+ l.addCloser(func() { close(closed) })
+ if revoke {
+ s.revoke("a")
+ }
+ select {
+ case <-closed:
+ case <-time.After(3 * time.Second):
+ t.Fatal("live lease not closed")
+ }
+ if l.valid() {
+ t.Fatal("closed lease valid")
+ }
+ r.leaseMu.Lock()
+ n := len(r.leases)
+ r.leaseMu.Unlock()
+ if n != 0 {
+ t.Fatal("lease retained")
+ }
+ })
+ }
+}
+
+func TestUpstreamPreservesDelegationAndDoesNotCacheIt(t *testing.T) {
+ a := testTransportGrant()
+ s := &transportGrantStore{grants: map[string]delegation.Access{"delegate": a}}
+ r := New(s)
+ r.SetAdminSecret("admin")
+ srv := httptest.NewServer(r.Handler())
+ defer srv.Close()
+ up := NewUpstreamTokenStore(srv.URL, "admin")
+ chain := ChainTokenStore{EnvTokenStore("full:bob"), up}
+ got, ok := ResolveAccess(chain, "delegate")
+ want, _ := json.Marshal(a)
+ actual, _ := json.Marshal(got)
+ if !ok || string(actual) != string(want) {
+ t.Fatalf("metadata lost: %+v %v", got, ok)
+ }
+ if _, ok := chain.Resolve("delegate"); ok {
+ t.Fatal("legacy upstream widened delegation")
+ }
+ s.revoke("delegate")
+ if _, ok := ResolveAccess(chain, "delegate"); ok {
+ t.Fatal("cached revoked grant")
+ }
+}
+
+func TestLegacyUpstreamCannotWidenWebFetchToken(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if req.URL.Path == "/admin/tokens/inspect" {
+ http.NotFound(w, req)
+ return
+ }
+ writeJSON(w, map[string]string{"namespace": "alice"})
+ }))
+ defer upstream.Close()
+ up := NewUpstreamTokenStore(upstream.URL, "secret")
+ if _, ok := up.Resolve("wfd_legacy-downgrade"); ok {
+ t.Fatal("legacy resolution accepted delegated bearer")
+ }
+ if _, ok := ResolveAccess(up, "wfd_legacy-downgrade"); ok {
+ t.Fatal("fallback erased delegation scope")
+ }
+ if _, ok := ResolveAccess(EnvTokenStore("wfd_static:alice"), "wfd_static"); ok {
+ t.Fatal("static tokens accepted reserved delegation prefix")
+ }
+}
+
+type slowDelegationTokens struct {
+ release chan struct{}
+ access delegation.Access
+}
+
+func (s slowDelegationTokens) Resolve(string) (string, bool) { return "", false }
+func (s slowDelegationTokens) ResolveAccess(string) (delegation.Access, bool) {
+ <-s.release
+ return s.access, true
+}
+
+func TestDelegatedExpiryDoesNotWaitForSlowRevalidation(t *testing.T) {
+ a := testTransportGrant()
+ a.ExpiresAt = time.Now().Add(1200 * time.Millisecond)
+ store := slowDelegationTokens{release: make(chan struct{}), access: a}
+ defer close(store.release)
+ r := New(store)
+ l := r.beginAccessLease("session", "alice/allowed", a, "token")
+ defer l.close()
+ select {
+ case <-l.done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("expiry blocked behind token store")
+ }
+}
diff --git a/internal/relay/delegation_cleanup.go b/internal/relay/delegation_cleanup.go
new file mode 100644
index 0000000..de1180e
--- /dev/null
+++ b/internal/relay/delegation_cleanup.go
@@ -0,0 +1,93 @@
+package relay
+
+import (
+ "context"
+ "database/sql"
+ "time"
+
+ "wanctl/internal/delegation"
+)
+
+// CleanupDelegations removes inactive delegation records after at least a day.
+// It may only be enabled with time-bounded browser tickets: the adapter must
+// reject an old ticket before looking up or creating its request, so deletion
+// cannot revive an old request ID. Owner tokens and audit records are retained.
+func (p *PGStore) CleanupDelegations(ctx context.Context, retention time.Duration) error {
+ if retention < 24*time.Hour {
+ return delegation.ErrInvalid
+ }
+ cutoff := time.Now().Add(-retention)
+ for {
+ deleted, err := p.cleanupDelegationBatch(ctx, cutoff)
+ if err != nil || deleted == 0 {
+ return err
+ }
+ }
+}
+
+func (p *PGStore) cleanupDelegationBatch(ctx context.Context, cutoff time.Time) (int, error) {
+ tx, err := p.db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+ // Lock only requests, not the outer-joined tokens. Approval takes the same
+ // request lock; independent cleanup workers skip each other's current batch.
+ rows, err := tx.QueryContext(ctx, `SELECT r.id,t.id
+ FROM delegation_requests r LEFT JOIN tokens t ON t.id=r.token_id
+ WHERE r.created_at<$1 AND (
+ r.status='rejected' OR
+ (r.status='pending' AND r.request_expires_at<=now()) OR
+ (r.status='approved' AND (t.id IS NULL OR t.revoked_at IS NOT NULL OR
+ t.expires_at IS NULL OR t.expires_at<=now() OR
+ EXISTS (SELECT 1 FROM delegation_devices g LEFT JOIN devices d
+ ON d.owner_namespace=g.namespace AND d.device_id=g.device_id
+ AND d.uses_device_id AND d.fingerprint=g.fingerprint
+ WHERE g.grant_id=r.id AND d.id IS NULL)))
+ ) ORDER BY r.created_at,r.id LIMIT 256 FOR UPDATE OF r SKIP LOCKED`, cutoff)
+ if err != nil {
+ return 0, err
+ }
+ var ids []string
+ var tokenIDs []int64
+ for rows.Next() {
+ var id string
+ var tokenID sql.NullInt64
+ if err := rows.Scan(&id, &tokenID); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ ids = append(ids, id)
+ if tokenID.Valid {
+ tokenIDs = append(tokenIDs, tokenID.Int64)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ if err := rows.Close(); err != nil {
+ return 0, err
+ }
+ if len(ids) == 0 {
+ return 0, nil
+ }
+ for _, statement := range []string{
+ `DELETE FROM delegation_jobs WHERE grant_id=ANY($1::text[])`,
+ `DELETE FROM delegation_devices WHERE grant_id=ANY($1::text[])`,
+ `DELETE FROM delegation_requests WHERE id=ANY($1::text[])`,
+ } {
+ if _, err := tx.ExecContext(ctx, statement, ids); err != nil {
+ return 0, err
+ }
+ }
+ if len(tokenIDs) > 0 {
+ if _, err := tx.ExecContext(ctx, `DELETE FROM tokens WHERE id=ANY($1::integer[]) AND kind='delegated'`, tokenIDs); err != nil {
+ return 0, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, err
+ }
+ return len(ids), nil
+}
diff --git a/internal/relay/delegation_cleanup_test.go b/internal/relay/delegation_cleanup_test.go
new file mode 100644
index 0000000..17539b3
--- /dev/null
+++ b/internal/relay/delegation_cleanup_test.go
@@ -0,0 +1,139 @@
+package relay
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "wanctl/internal/delegation"
+)
+
+func TestDelegationPostgresCleanupPreservesActiveGrantsAndOwnerData(t *testing.T) {
+ p, db, exec := pgDeviceIDStore(t)
+ ctx := context.Background()
+ ownerToken, err := p.IssueToken("alice", "owner credential", 30)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var removed, retained []string
+ var removedTokenIDs []int
+ var removedJobIDs []string
+ for _, status := range []string{"expired", "revoked", "rotated", "rejected", "pending-expired", "active", "pending-active", "recent-expired"} {
+ in, approval, _ := delegationFixture(t, p)
+ if _, err := p.CreateDelegation(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ isApproved := status != "rejected" && status != "pending-expired" && status != "pending-active"
+ var grant delegation.Request
+ var job delegation.Job
+ if isApproved {
+ grant, err = p.ApproveDelegation(ctx, approval)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload := json.RawMessage(`{"content":"private job data"}`)
+ job, _, err = p.BeginJob(ctx, in.ID, "retention", HashToken(string(payload)), payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := p.FinishJob(ctx, in.ID, job.ID, "done", json.RawMessage(`{"content":"private result"}`)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if status != "recent-expired" {
+ exec(`UPDATE delegation_requests SET created_at=now()-interval '2 days' WHERE id=$1`, in.ID)
+ }
+ switch status {
+ case "expired", "recent-expired":
+ exec(`UPDATE tokens SET expires_at=now()-interval '1 second' WHERE id=$1`, grant.TokenID)
+ case "revoked":
+ if err := p.RevokeToken("alice", grant.TokenID); err != nil {
+ t.Fatal(err)
+ }
+ case "rotated":
+ exec(`UPDATE devices SET fingerprint='changed' WHERE owner_namespace='alice' AND device_id=$1`, approval.Devices[0])
+ case "rejected":
+ if err := p.RejectDelegation(ctx, in.ID, "alice"); err != nil {
+ t.Fatal(err)
+ }
+ case "pending-expired":
+ exec(`UPDATE delegation_requests SET request_expires_at=now()-interval '1 second' WHERE id=$1`, in.ID)
+ }
+ if status == "active" || status == "pending-active" || status == "recent-expired" {
+ retained = append(retained, in.ID)
+ } else {
+ removed = append(removed, in.ID)
+ if isApproved {
+ removedTokenIDs = append(removedTokenIDs, grant.TokenID)
+ removedJobIDs = append(removedJobIDs, job.ID)
+ }
+ }
+ }
+ var beforeAudit int
+ if err := db.QueryRow(`SELECT count(*) FROM audit`).Scan(&beforeAudit); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.CleanupDelegations(ctx, time.Hour); !errors.Is(err, delegation.ErrInvalid) {
+ t.Fatalf("unsafe retention accepted: %v", err)
+ }
+ if err := p.CleanupDelegations(ctx, 24*time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range removed {
+ if _, err := p.GetDelegation(ctx, id); !errors.Is(err, delegation.ErrNotFound) {
+ t.Fatalf("inactive grant %s survived: %v", id, err)
+ }
+ }
+ for _, id := range retained {
+ if _, err := p.GetDelegation(ctx, id); err != nil {
+ t.Fatalf("active/recent grant %s removed: %v", id, err)
+ }
+ }
+ for _, tableAndQuery := range []string{
+ `SELECT count(*) FROM delegation_devices WHERE grant_id=ANY($1::text[])`,
+ `SELECT count(*) FROM delegation_jobs WHERE grant_id=ANY($1::text[])`,
+ } {
+ var count int
+ if err := db.QueryRow(tableAndQuery, removed).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("inactive data survived: count=%d err=%v", count, err)
+ }
+ }
+ var count int
+ if err := db.QueryRow(`SELECT count(*) FROM tokens WHERE id=ANY($1::integer[])`, removedTokenIDs).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("inactive credentials survived: count=%d err=%v", count, err)
+ }
+ if err := db.QueryRow(`SELECT count(*) FROM delegation_jobs WHERE id=ANY($1::text[])`, removedJobIDs).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("private job results survived: count=%d err=%v", count, err)
+ }
+ if ns, ok := p.Resolve(ownerToken); !ok || ns != "alice" {
+ t.Fatal("cleanup removed owner credential")
+ }
+ if err := db.QueryRow(`SELECT count(*) FROM audit`).Scan(&count); err != nil || count != beforeAudit {
+ t.Fatalf("audit changed: count=%d want=%d err=%v", count, beforeAudit, err)
+ }
+ if err := p.CleanupDelegations(ctx, 24*time.Hour); err != nil {
+ t.Fatalf("repeat cleanup: %v", err)
+ }
+}
+
+func TestDelegationPostgresCleanupBatchesAcrossConcurrentWorkers(t *testing.T) {
+ p, db, exec := pgDeviceIDStore(t)
+ exec(`INSERT INTO delegation_requests(id,ticket_hash,token_hash,label,controller_fingerprint,created_at,request_expires_at)
+ SELECT 'old-'||n,'ticket-'||n,'token-'||n,'expired','fp',now()-interval '2 days',now()-interval '1 day'
+ FROM generate_series(1,600) n`)
+ results := make(chan error, 2)
+ for i := 0; i < 2; i++ {
+ go func() { results <- p.CleanupDelegations(context.Background(), 24*time.Hour) }()
+ }
+ for i := 0; i < 2; i++ {
+ if err := <-results; err != nil {
+ t.Fatal(err)
+ }
+ }
+ var count int
+ if err := db.QueryRow(`SELECT count(*) FROM delegation_requests`).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("cleanup stopped before clearing the backlog: count=%d err=%v", count, err)
+ }
+}
diff --git a/internal/relay/delegation_resolution_test.go b/internal/relay/delegation_resolution_test.go
new file mode 100644
index 0000000..6e51b9e
--- /dev/null
+++ b/internal/relay/delegation_resolution_test.go
@@ -0,0 +1,48 @@
+package relay
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "wanctl/internal/delegation"
+)
+
+func TestDelegatedResolutionDoesNotDiscloseOtherSharedDevices(t *testing.T) {
+ grant := testTransportGrant()
+ r := New(&transportGrantStore{grants: map[string]delegation.Access{
+ "delegate": grant,
+ "owner": {Namespace: "alice", CredentialID: "owner"},
+ }})
+ store := &memSharedAdmin{devices: []sharedTestDevice{
+ {owner: "alice", id: "allowed", label: "My Mac"},
+ {owner: "bob", id: "bob-private-device-id", label: "build", grantee: "alice", perms: "exec"},
+ {owner: "carol", id: "carol-private-device-id", label: "build", grantee: "alice", perms: "exec"},
+ }}
+ r.SetAdmin(store)
+ r.SetACL(store)
+
+ for _, endpoint := range []string{"/resolve", "/dial", "/h/dial"} {
+ t.Run(endpoint, func(t *testing.T) {
+ got := grantRequest(r.Handler(), http.MethodGet, endpoint+"?target=build", "delegate")
+ wantStatus, wantBody := http.StatusForbidden, "forbidden"
+ if endpoint == "/resolve" {
+ wantStatus, wantBody = http.StatusConflict, "device unavailable or ambiguous; use its device ID"
+ }
+ if got.Code != wantStatus || strings.TrimSpace(got.Body.String()) != wantBody {
+ t.Fatalf("delegated diagnostic = %d %q; want %d %q", got.Code, got.Body.String(), wantStatus, wantBody)
+ }
+
+ // Full account credentials retain actionable ambiguity diagnostics.
+ full := grantRequest(r.Handler(), http.MethodGet, endpoint+"?target=build", "owner")
+ if full.Code != wantStatus {
+ t.Fatalf("owner status = %d want %d", full.Code, wantStatus)
+ }
+ for _, target := range []string{"bob/bob-private-device-id", "carol/carol-private-device-id"} {
+ if !strings.Contains(full.Body.String(), target) {
+ t.Fatalf("owner diagnostic lost %q: %s", target, full.Body.String())
+ }
+ }
+ })
+ }
+}
diff --git a/internal/relay/delegation_store.go b/internal/relay/delegation_store.go
new file mode 100644
index 0000000..22aa0cf
--- /dev/null
+++ b/internal/relay/delegation_store.go
@@ -0,0 +1,379 @@
+package relay
+
+import (
+ "context"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "wanctl/internal/delegation"
+ "wanctl/internal/transport"
+)
+
+const (
+ maxPendingDelegations = 1000
+ maxDelegationDevices = 16
+ maxDelegationJobs = 64
+ maxDelegationPayload = 32 << 10
+ maxDelegationResult = 256 << 10
+)
+
+var _ delegation.Store = (*PGStore)(nil)
+var _ delegation.JobStore = (*PGStore)(nil)
+
+func validDelegationID(s string) bool {
+ if len(s) < 1 || len(s) > 128 {
+ return false
+ }
+ for _, c := range s {
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+func validDelegationHash(s string) bool {
+ _, err := hex.DecodeString(s)
+ return len(s) == 64 && err == nil && s == strings.ToLower(s)
+}
+
+type delegationQuerier interface {
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+ QueryContext(context.Context, string, ...any) (*sql.Rows, error)
+}
+
+// readDelegation derives live status from the existing token and current device
+// identities. Revocation and certificate rotation take effect without a second ACL.
+func readDelegation(ctx context.Context, q delegationQuerier, id, ticketHash string) (delegation.Request, error) {
+ var out delegation.Request
+ var expires sql.NullTime
+ err := q.QueryRowContext(ctx, `SELECT r.id,r.label,r.controller_fingerprint,
+ CASE WHEN r.status='approved' AND (t.id IS NULL OR t.revoked_at IS NOT NULL OR
+ EXISTS (SELECT 1 FROM delegation_devices g LEFT JOIN devices d
+ ON d.owner_namespace=g.namespace AND d.device_id=g.device_id AND d.uses_device_id
+ AND d.fingerprint=g.fingerprint WHERE g.grant_id=r.id AND d.id IS NULL)) THEN 'revoked'
+ WHEN r.status='approved' AND (t.expires_at IS NULL OR t.expires_at<=now()) THEN 'expired'
+ WHEN r.status='pending' AND r.request_expires_at<=now() THEN 'expired'
+ ELSE r.status END,
+ r.namespace,r.created_at,r.request_expires_at,t.expires_at,COALESCE(r.token_id,0)
+ FROM delegation_requests r LEFT JOIN tokens t ON t.id=r.token_id
+ WHERE r.id=$1 AND ($2='' OR r.ticket_hash=$2)`, id, ticketHash).Scan(
+ &out.ID, &out.Label, &out.ControllerFingerprint, &out.Status, &out.Namespace,
+ &out.CreatedAt, &out.RequestExpiresAt, &expires, &out.TokenID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return out, delegation.ErrNotFound
+ }
+ if err != nil {
+ return out, err
+ }
+ if expires.Valid {
+ out.ExpiresAt = &expires.Time
+ }
+ rows, err := q.QueryContext(ctx, `SELECT namespace,device_id,fingerprint FROM delegation_devices WHERE grant_id=$1 ORDER BY device_id`, id)
+ if err != nil {
+ return out, err
+ }
+ defer rows.Close()
+ out.Devices = []delegation.Device{}
+ for rows.Next() {
+ var d delegation.Device
+ if err := rows.Scan(&d.Namespace, &d.ID, &d.Fingerprint); err != nil {
+ return out, err
+ }
+ out.Devices = append(out.Devices, d)
+ }
+ return out, rows.Err()
+}
+
+func (p *PGStore) CreateDelegation(ctx context.Context, in delegation.NewRequest) (delegation.Request, error) {
+ if !validDelegationID(in.ID) || !validDelegationHash(in.TicketHash) || !validDelegationHash(in.TokenHash) ||
+ len(in.Label) == 0 || len(in.Label) > 200 || !transport.ValidFingerprint(in.ControllerFingerprint) {
+ return delegation.Request{}, delegation.ErrInvalid
+ }
+ tx, err := p.db.BeginTx(ctx, nil)
+ if err != nil {
+ return delegation.Request{}, err
+ }
+ defer tx.Rollback()
+ // Serialize both the pending quota and idempotent creates across instances.
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('wanctl_delegation_requests'))`); err != nil {
+ return delegation.Request{}, err
+ }
+ var ticketHash, tokenHash, fp string
+ err = tx.QueryRowContext(ctx, `SELECT ticket_hash,token_hash,controller_fingerprint FROM delegation_requests WHERE id=$1`, in.ID).Scan(&ticketHash, &tokenHash, &fp)
+ if err == nil {
+ if ticketHash != in.TicketHash || tokenHash != in.TokenHash || fp != in.ControllerFingerprint {
+ return delegation.Request{}, delegation.ErrConflict
+ }
+ return readDelegation(ctx, tx, in.ID, in.TicketHash)
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return delegation.Request{}, err
+ }
+ now := time.Now()
+ if !in.RequestExpiresAt.After(now) {
+ return delegation.Request{}, delegation.ErrInvalid
+ }
+ if in.RequestExpiresAt.After(now.Add(10 * time.Minute)) {
+ in.RequestExpiresAt = now.Add(10 * time.Minute)
+ }
+ var pending int
+ if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM delegation_requests WHERE status='pending' AND request_expires_at>now()`).Scan(&pending); err != nil {
+ return delegation.Request{}, err
+ }
+ if pending >= maxPendingDelegations {
+ return delegation.Request{}, delegation.ErrLimit
+ }
+ result, err := tx.ExecContext(ctx, `INSERT INTO delegation_requests(id,ticket_hash,token_hash,label,controller_fingerprint,request_expires_at)
+ VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, in.ID, in.TicketHash, in.TokenHash, in.Label, in.ControllerFingerprint, in.RequestExpiresAt)
+ if err != nil {
+ return delegation.Request{}, err
+ }
+ if n, _ := result.RowsAffected(); n != 1 {
+ return delegation.Request{}, delegation.ErrConflict
+ }
+ out, err := readDelegation(ctx, tx, in.ID, in.TicketHash)
+ if err != nil {
+ return out, err
+ }
+ return out, tx.Commit()
+}
+
+func (p *PGStore) GetDelegation(ctx context.Context, id string) (delegation.Request, error) {
+ return readDelegation(ctx, p.db, id, "")
+}
+
+func (p *PGStore) GetDelegationByTicket(ctx context.Context, id, ticketHash string) (delegation.Request, error) {
+ if !validDelegationHash(ticketHash) {
+ return delegation.Request{}, delegation.ErrNotFound
+ }
+ return readDelegation(ctx, p.db, id, ticketHash)
+}
+
+func (p *PGStore) ApproveDelegation(ctx context.Context, in delegation.Approval) (delegation.Request, error) {
+ if guardNamespace(in.Namespace, "") != nil || len(in.Namespace) > 128 || in.Minutes < 1 || in.Minutes > 60 ||
+ len(in.Devices) < 1 || len(in.Devices) > maxDelegationDevices || !transport.ValidFingerprint(in.ControllerFingerprint) ||
+ len(in.DeviceFingerprints) != len(in.Devices) {
+ return delegation.Request{}, delegation.ErrInvalid
+ }
+ ids := append([]string(nil), in.Devices...)
+ sort.Strings(ids)
+ for i, id := range ids {
+ if !transport.ValidDeviceID(id) || !transport.ValidFingerprint(in.DeviceFingerprints[id]) || (i > 0 && id == ids[i-1]) {
+ return delegation.Request{}, delegation.ErrInvalid
+ }
+ }
+ tx, err := p.db.BeginTx(ctx, nil)
+ if err != nil {
+ return delegation.Request{}, err
+ }
+ defer tx.Rollback()
+ var status, owner, fp, tokenHash string
+ var expiry time.Time
+ err = tx.QueryRowContext(ctx, `SELECT status,namespace,controller_fingerprint,token_hash,request_expires_at
+ FROM delegation_requests WHERE id=$1 FOR UPDATE`, in.RequestID).Scan(&status, &owner, &fp, &tokenHash, &expiry)
+ if errors.Is(err, sql.ErrNoRows) {
+ return delegation.Request{}, delegation.ErrNotFound
+ }
+ if err != nil {
+ return delegation.Request{}, err
+ }
+ if owner != "" && owner != in.Namespace {
+ return delegation.Request{}, delegation.ErrForbidden
+ }
+ if status != "pending" {
+ return delegation.Request{}, delegation.ErrConflict
+ }
+ if !time.Now().Before(expiry) {
+ return delegation.Request{}, delegation.ErrExpired
+ }
+ if fp != in.ControllerFingerprint {
+ return delegation.Request{}, delegation.ErrConflict
+ }
+ for _, id := range ids {
+ var actual string
+ err = tx.QueryRowContext(ctx, `SELECT fingerprint FROM devices WHERE owner_namespace=$1 AND device_id=$2 AND uses_device_id FOR SHARE`, in.Namespace, id).Scan(&actual)
+ if errors.Is(err, sql.ErrNoRows) {
+ return delegation.Request{}, delegation.ErrForbidden
+ }
+ if err != nil {
+ return delegation.Request{}, err
+ }
+ if actual != in.DeviceFingerprints[id] {
+ return delegation.Request{}, delegation.ErrConflict
+ }
+ if _, err = tx.ExecContext(ctx, `INSERT INTO delegation_devices(grant_id,namespace,device_id,fingerprint) VALUES ($1,$2,$3,$4)`, in.RequestID, in.Namespace, id, actual); err != nil {
+ return delegation.Request{}, err
+ }
+ }
+ var tokenID int
+ err = tx.QueryRowContext(ctx, `INSERT INTO tokens(namespace,kind,hash,label,expires_at)
+ SELECT $2,'delegated',token_hash,label,now()+($3 * interval '1 minute') FROM delegation_requests WHERE id=$1 RETURNING id`, in.RequestID, in.Namespace, in.Minutes).Scan(&tokenID)
+ if err != nil {
+ return delegation.Request{}, err
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE delegation_requests SET status='approved',namespace=$2,token_id=$3,decided_at=now() WHERE id=$1`, in.RequestID, in.Namespace, tokenID); err != nil {
+ return delegation.Request{}, err
+ }
+ if _, err = tx.ExecContext(ctx, `INSERT INTO audit(namespace,token_id,event) VALUES ($1,$2,$3)`, in.Namespace, tokenID, "delegation-approved:"+in.RequestID); err != nil {
+ return delegation.Request{}, err
+ }
+ out, err := readDelegation(ctx, tx, in.RequestID, "")
+ if err != nil {
+ return out, err
+ }
+ return out, tx.Commit()
+}
+
+func (p *PGStore) RejectDelegation(ctx context.Context, id, ns string) error {
+ if guardNamespace(ns, "") != nil {
+ return delegation.ErrInvalid
+ }
+ result, err := p.db.ExecContext(ctx, `UPDATE delegation_requests SET status='rejected',namespace=$2,decided_at=now()
+ WHERE id=$1 AND status='pending' AND request_expires_at>now() AND namespace=''`, id, ns)
+ if err != nil {
+ return err
+ }
+ if n, _ := result.RowsAffected(); n == 1 {
+ return nil
+ }
+ out, err := p.GetDelegation(ctx, id)
+ if err != nil {
+ return err
+ }
+ if out.Namespace != "" && out.Namespace != ns {
+ return delegation.ErrForbidden
+ }
+ if out.Status == "rejected" {
+ return nil
+ }
+ if out.Status == "expired" {
+ return delegation.ErrExpired
+ }
+ return delegation.ErrConflict
+}
+
+// ResolveAccess preserves the scope on delegated tokens. Legacy Resolve must
+// reject them because a namespace alone would turn a grant into owner rights.
+func (p *PGStore) ResolveAccess(raw string) (delegation.Access, bool) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ var out delegation.Access
+ var id int
+ var kind, grantID string
+ var expires sql.NullTime
+ err := p.db.QueryRowContext(ctx, `SELECT t.id,t.namespace,t.kind,t.expires_at,COALESCE(r.id,'')
+ FROM tokens t LEFT JOIN delegation_requests r ON r.token_id=t.id
+ WHERE t.hash=$1 AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at>now())`, HashToken(raw)).Scan(&id, &out.Namespace, &kind, &expires, &grantID)
+ if err != nil {
+ return delegation.Access{}, false
+ }
+ out.CredentialID = strconv.Itoa(id)
+ if expires.Valid {
+ out.ExpiresAt = expires.Time
+ }
+ if kind != "delegated" {
+ return out, true
+ }
+ grant, err := p.GetDelegation(ctx, grantID)
+ if err != nil || grant.Status != "approved" || grant.Namespace != out.Namespace || len(grant.Devices) == 0 || grant.ExpiresAt == nil {
+ return delegation.Access{}, false
+ }
+ out.Delegated = true
+ out.GrantID = grant.ID
+ out.ExpiresAt = *grant.ExpiresAt
+ out.ControllerFingerprint = grant.ControllerFingerprint
+ out.Devices = grant.Devices
+ return out, true
+}
+
+func scanDelegationJob(row interface{ Scan(...any) error }) (delegation.Job, error) {
+ var out delegation.Job
+ var payload, result []byte
+ err := row.Scan(&out.ID, &out.GrantID, &out.RequestID, &out.PayloadHash, &payload, &out.State, &result, &out.CreatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return out, delegation.ErrNotFound
+ }
+ out.Payload = json.RawMessage(payload)
+ out.Result = json.RawMessage(result)
+ return out, err
+}
+
+const delegationJobColumns = `id,grant_id,request_id,payload_hash,payload,state,result,created_at`
+
+func (p *PGStore) BeginJob(ctx context.Context, grant, rid, payloadHash string, payload json.RawMessage) (delegation.Job, bool, error) {
+ if !validDelegationID(grant) || !validDelegationID(rid) || !validDelegationHash(payloadHash) || len(payload) > maxDelegationPayload || !json.Valid(payload) {
+ return delegation.Job{}, false, delegation.ErrInvalid
+ }
+ tx, err := p.db.BeginTx(ctx, nil)
+ if err != nil {
+ return delegation.Job{}, false, err
+ }
+ defer tx.Rollback()
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "delegation-job:"+grant); err != nil {
+ return delegation.Job{}, false, err
+ }
+ old, err := scanDelegationJob(tx.QueryRowContext(ctx, `SELECT `+delegationJobColumns+` FROM delegation_jobs WHERE grant_id=$1 AND request_id=$2`, grant, rid))
+ if err == nil {
+ if old.PayloadHash != payloadHash {
+ return delegation.Job{}, false, delegation.ErrConflict
+ }
+ return old, false, nil
+ }
+ if !errors.Is(err, delegation.ErrNotFound) {
+ return delegation.Job{}, false, err
+ }
+ var count int
+ if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM delegation_jobs WHERE grant_id=$1`, grant).Scan(&count); err != nil {
+ return delegation.Job{}, false, err
+ }
+ if count >= maxDelegationJobs {
+ return delegation.Job{}, false, delegation.ErrLimit
+ }
+ // The ledger also fails closed on inactive grants. The adapter must still
+ // recheck immediately before dispatch to cover revocation after this write.
+ access, err := readDelegation(ctx, tx, grant, "")
+ if err != nil {
+ return delegation.Job{}, false, err
+ }
+ if access.Status != "approved" {
+ return delegation.Job{}, false, delegation.ErrForbidden
+ }
+ id := "j_" + randHex(16)
+ job, err := scanDelegationJob(tx.QueryRowContext(ctx, `INSERT INTO delegation_jobs(id,grant_id,request_id,payload_hash,payload) VALUES ($1,$2,$3,$4,$5) RETURNING `+delegationJobColumns, id, grant, rid, payloadHash, []byte(payload)))
+ if err != nil {
+ return delegation.Job{}, false, err
+ }
+ if err = tx.Commit(); err != nil {
+ return delegation.Job{}, false, err
+ }
+ return job, true, nil
+}
+
+func (p *PGStore) FinishJob(ctx context.Context, grant, id, state string, result json.RawMessage) error {
+ if state != "done" && state != "failed" && state != "unknown" || len(result) > maxDelegationResult || !json.Valid(result) {
+ return delegation.ErrInvalid
+ }
+ res, err := p.db.ExecContext(ctx, `UPDATE delegation_jobs SET state=$3,result=$4,finished_at=now() WHERE grant_id=$1 AND id=$2 AND state='running'`, grant, id, state, []byte(result))
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 1 {
+ return nil
+ }
+ _, err = p.GetJob(ctx, grant, id)
+ if err != nil {
+ return err
+ }
+ return delegation.ErrConflict
+}
+
+func (p *PGStore) GetJob(ctx context.Context, grant, id string) (delegation.Job, error) {
+ return scanDelegationJob(p.db.QueryRowContext(ctx, `SELECT `+delegationJobColumns+` FROM delegation_jobs WHERE grant_id=$1 AND id=$2`, grant, id))
+}
diff --git a/internal/relay/delegation_store_test.go b/internal/relay/delegation_store_test.go
new file mode 100644
index 0000000..74e5e61
--- /dev/null
+++ b/internal/relay/delegation_store_test.go
@@ -0,0 +1,306 @@
+package relay
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "wanctl/internal/delegation"
+ "wanctl/internal/transport"
+
+ "github.com/google/uuid"
+)
+
+func delegationFixture(t *testing.T, p *PGStore) (delegation.NewRequest, delegation.Approval, string) {
+ t.Helper()
+ id := uuid.NewString()
+ deviceID := uuid.NewString()
+ deviceFP := transport.Fingerprint([]byte("device-" + id))
+ controllerFP := transport.Fingerprint([]byte("controller-" + id))
+ if _, err := p.RegisterDevice("alice", deviceID, "test device", deviceFP); err != nil {
+ t.Fatal(err)
+ }
+ raw := "delegation-test-token-" + id
+ in := delegation.NewRequest{ID: id, TicketHash: HashToken("ticket-" + id), TokenHash: HashToken(raw), Label: "Browser test", ControllerFingerprint: controllerFP, RequestExpiresAt: time.Now().Add(10 * time.Minute)}
+ approval := delegation.Approval{RequestID: id, Namespace: "alice", Devices: []string{deviceID}, Minutes: 5, ControllerFingerprint: controllerFP, DeviceFingerprints: map[string]string{deviceID: deviceFP}}
+ return in, approval, raw
+}
+
+func TestDelegationPostgresGrantLifecycle(t *testing.T) {
+ p, db, exec := pgDeviceIDStore(t)
+ ctx := context.Background()
+ in, approval, raw := delegationFixture(t, p)
+ created, err := p.CreateDelegation(ctx, in)
+ if err != nil || created.Status != "pending" {
+ t.Fatalf("create: %+v %v", created, err)
+ }
+ if _, ok := p.ResolveAccess(raw); ok {
+ t.Fatal("pending request resolved")
+ }
+ if replay, err := p.CreateDelegation(ctx, in); err != nil || replay.ID != created.ID {
+ t.Fatalf("replay: %+v %v", replay, err)
+ }
+ conflict := in
+ conflict.TokenHash = HashToken("changed")
+ if _, err := p.CreateDelegation(ctx, conflict); !errors.Is(err, delegation.ErrConflict) {
+ t.Fatalf("create conflict: %v", err)
+ }
+ if _, err := p.GetDelegationByTicket(ctx, in.ID, HashToken("wrong")); !errors.Is(err, delegation.ErrNotFound) {
+ t.Fatalf("ticket bypass: %v", err)
+ }
+ bad := approval
+ bad.Namespace = "bob"
+ if _, err := p.ApproveDelegation(ctx, bad); !errors.Is(err, delegation.ErrForbidden) {
+ t.Fatalf("non-owner approval: %v", err)
+ }
+ bad = approval
+ bad.ControllerFingerprint = transport.Fingerprint([]byte("other"))
+ if _, err := p.ApproveDelegation(ctx, bad); !errors.Is(err, delegation.ErrConflict) {
+ t.Fatalf("controller substitution: %v", err)
+ }
+ bad = approval
+ bad.DeviceFingerprints = map[string]string{approval.Devices[0]: transport.Fingerprint([]byte("stale"))}
+ if _, err := p.ApproveDelegation(ctx, bad); !errors.Is(err, delegation.ErrConflict) {
+ t.Fatalf("device substitution: %v", err)
+ }
+ approved, err := p.ApproveDelegation(ctx, approval)
+ if err != nil || approved.Status != "approved" || approved.TokenID == 0 {
+ t.Fatalf("approve: %+v %v", approved, err)
+ }
+ if _, ok := p.Resolve(raw); ok {
+ t.Fatal("delegated token downgraded to owner namespace")
+ }
+ access, ok := p.ResolveAccess(raw)
+ if !ok || !access.Delegated || access.GrantID != in.ID || len(access.Devices) != 1 || !access.Allows("alice/"+approval.Devices[0]) || access.Allows("alice/"+uuid.NewString()) {
+ t.Fatalf("scope: %+v %v", access, ok)
+ }
+ if _, err := p.ApproveDelegation(ctx, approval); !errors.Is(err, delegation.ErrConflict) {
+ t.Fatalf("approval changed by replay: %v", err)
+ }
+ listed, err := p.ListTokens("alice")
+ if err != nil || len(listed) != 1 || listed[0]["kind"] != "delegated" || listed[0]["grant_id"] != in.ID {
+ t.Fatalf("list: %+v %v", listed, err)
+ }
+ var stored string
+ if err := db.QueryRow(`SELECT hash FROM tokens WHERE id=$1`, approved.TokenID).Scan(&stored); err != nil || stored != HashToken(raw) || stored == raw {
+ t.Fatalf("credential storage: %q %v", stored, err)
+ }
+ if err := p.RevokeToken("bob", approved.TokenID); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := p.ResolveAccess(raw); !ok {
+ t.Fatal("other namespace revoked token")
+ }
+ if err := p.RevokeToken("alice", approved.TokenID); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := p.ResolveAccess(raw); ok {
+ t.Fatal("revoked token resolved")
+ }
+ out, err := p.GetDelegation(ctx, in.ID)
+ if err != nil || out.Status != "revoked" {
+ t.Fatalf("revocation status: %+v %v", out, err)
+ }
+
+ // Device removal and fingerprint replacement invalidate a grant immediately.
+ for _, mutation := range []string{"rotate", "remove", "expire"} {
+ in, a, raw := delegationFixture(t, p)
+ if _, err := p.CreateDelegation(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ grant, err := p.ApproveDelegation(ctx, a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "revoked"
+ switch mutation {
+ case "rotate":
+ exec(`UPDATE devices SET fingerprint=$2 WHERE device_id=$1`, a.Devices[0], transport.Fingerprint([]byte("replacement")))
+ case "remove":
+ exec(`DELETE FROM devices WHERE device_id=$1`, a.Devices[0])
+ case "expire":
+ exec(`UPDATE tokens SET expires_at=now()-interval '1 second' WHERE id=$1`, grant.TokenID)
+ want = "expired"
+ }
+ if _, ok := p.ResolveAccess(raw); ok {
+ t.Fatalf("%s grant resolved", mutation)
+ }
+ out, err := p.GetDelegation(ctx, in.ID)
+ if err != nil || out.Status != want {
+ t.Fatalf("%s status: %+v %v", mutation, out, err)
+ }
+ }
+}
+
+func TestDelegationPostgresPendingLimitsAndRejection(t *testing.T) {
+ p, _, exec := pgDeviceIDStore(t)
+ ctx := context.Background()
+ in, a, _ := delegationFixture(t, p)
+ in.RequestExpiresAt = time.Now().Add(24 * time.Hour)
+ out, err := p.CreateDelegation(ctx, in)
+ if err != nil || out.RequestExpiresAt.After(time.Now().Add(10*time.Minute)) {
+ t.Fatalf("request TTL bound: %+v %v", out, err)
+ }
+ for _, minutes := range []int{0, 61} {
+ bad := a
+ bad.Minutes = minutes
+ if _, err := p.ApproveDelegation(ctx, bad); !errors.Is(err, delegation.ErrInvalid) {
+ t.Fatalf("duration %d: %v", minutes, err)
+ }
+ }
+ if err := p.RejectDelegation(ctx, in.ID, "alice"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.RejectDelegation(ctx, in.ID, "alice"); err != nil {
+ t.Fatalf("reject replay: %v", err)
+ }
+ if err := p.RejectDelegation(ctx, in.ID, "bob"); !errors.Is(err, delegation.ErrForbidden) {
+ t.Fatalf("reject scope: %v", err)
+ }
+ if _, err := p.ApproveDelegation(ctx, a); !errors.Is(err, delegation.ErrConflict) {
+ t.Fatalf("rejected approval: %v", err)
+ }
+ second, a, _ := delegationFixture(t, p)
+ if _, err := p.CreateDelegation(ctx, second); err != nil {
+ t.Fatal(err)
+ }
+ exec(`UPDATE delegation_requests SET request_expires_at=now()-interval '1 second' WHERE id=$1`, second.ID)
+ if _, err := p.ApproveDelegation(ctx, a); !errors.Is(err, delegation.ErrExpired) {
+ t.Fatalf("expired approval: %v", err)
+ }
+ // The queue is globally bounded even under many independent tickets.
+ exec(`INSERT INTO delegation_requests(id,ticket_hash,token_hash,label,controller_fingerprint,request_expires_at)
+ SELECT 'bulk-'||n,'ticket-'||n,'token-'||n,'pending','fp',now()+interval '10 minutes' FROM generate_series(1,1000) n`)
+ third, _, _ := delegationFixture(t, p)
+ if _, err := p.CreateDelegation(ctx, third); !errors.Is(err, delegation.ErrLimit) {
+ t.Fatalf("pending limit: %v", err)
+ }
+}
+
+func TestDelegationPostgresJobLedgerConcurrentReplay(t *testing.T) {
+ p, _, _ := pgDeviceIDStore(t)
+ ctx := context.Background()
+ in, a, _ := delegationFixture(t, p)
+ if _, err := p.CreateDelegation(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.ApproveDelegation(ctx, a); err != nil {
+ t.Fatal(err)
+ }
+ payload := json.RawMessage(`{"device":"test","command":"printf hello"}`)
+ hash := HashToken(string(payload))
+ var newJobs atomic.Int32
+ var wg sync.WaitGroup
+ ids := make(chan string, 12)
+ for i := 0; i < 12; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ job, fresh, err := p.BeginJob(ctx, in.ID, "same-rid", hash, payload)
+ if err != nil {
+ t.Errorf("begin: %v", err)
+ return
+ }
+ if fresh {
+ newJobs.Add(1)
+ }
+ ids <- job.ID
+ }()
+ }
+ wg.Wait()
+ close(ids)
+ var id string
+ for got := range ids {
+ if id != "" && got != id {
+ t.Fatalf("duplicate IDs: %s %s", id, got)
+ }
+ id = got
+ }
+ if newJobs.Load() != 1 {
+ t.Fatalf("executions: %d", newJobs.Load())
+ }
+ if _, _, err := p.BeginJob(ctx, in.ID, "same-rid", HashToken("changed"), payload); !errors.Is(err, delegation.ErrConflict) {
+ t.Fatalf("payload conflict: %v", err)
+ }
+ // A new store instance sees the persisted running job and never dispatches again.
+ restarted := &PGStore{db: p.db}
+ job, fresh, err := restarted.BeginJob(ctx, in.ID, "same-rid", hash, payload)
+ if err != nil || fresh || job.ID != id || job.State != "running" {
+ t.Fatalf("restart: %+v %v %v", job, fresh, err)
+ }
+ if err := p.FinishJob(ctx, "other-grant", id, "done", json.RawMessage(`{"ok":true}`)); !errors.Is(err, delegation.ErrNotFound) {
+ t.Fatalf("finish scope: %v", err)
+ }
+ if err := p.FinishJob(ctx, in.ID, id, "done", json.RawMessage(`{"ok":true}`)); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.FinishJob(ctx, in.ID, id, "failed", json.RawMessage(`{"ok":false}`)); !errors.Is(err, delegation.ErrConflict) {
+ t.Fatalf("terminal overwrite: %v", err)
+ }
+ got, err := p.GetJob(ctx, in.ID, id)
+ if err != nil || got.State != "done" || !strings.Contains(string(got.Result), "true") {
+ t.Fatalf("result: %+v %v", got, err)
+ }
+ if _, err := p.GetJob(ctx, "other-grant", id); !errors.Is(err, delegation.ErrNotFound) {
+ t.Fatalf("get scope: %v", err)
+ }
+}
+
+func TestDelegationPostgresAdminNamespaceAndInspect(t *testing.T) {
+ p, _, _ := pgDeviceIDStore(t)
+ ctx := context.Background()
+ in, approval, raw := delegationFixture(t, p)
+ if _, err := p.CreateDelegation(ctx, in); err != nil {
+ t.Fatal(err)
+ }
+ r := New(p)
+ r.SetAdmin(p)
+ r.SetAdminSecret("admin-secret")
+ r.SetPortalNS("custom-portal")
+ handler := r.Handler()
+ request := func(method, path, body, secret string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ req.Header.Set("X-Admin-Secret", secret)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+ return w
+ }
+ for _, path := range []string{"/admin/delegations/request?id=" + in.ID + "&namespace=alice", "/admin/delegations/approve", "/admin/delegations/reject", "/admin/tokens/inspect"} {
+ if w := request(http.MethodGet, path, "", ""); w.Code != http.StatusForbidden {
+ t.Fatalf("unguarded %s: %d", path, w.Code)
+ }
+ }
+ if w := request(http.MethodGet, "/admin/delegations/request?id="+in.ID+"&namespace=alice", "", "admin-secret"); w.Code != 200 || strings.Contains(w.Body.String(), in.TicketHash) || strings.Contains(w.Body.String(), in.TokenHash) {
+ t.Fatalf("pending: %d %s", w.Code, w.Body.String())
+ }
+ bad := approval
+ bad.Namespace = "custom-portal"
+ body, _ := json.Marshal(bad)
+ if w := request(http.MethodPost, "/admin/delegations/approve", string(body), "admin-secret"); w.Code != 403 {
+ t.Fatalf("portal grant: %d %s", w.Code, w.Body.String())
+ }
+ body, _ = json.Marshal(approval)
+ if w := request(http.MethodPost, "/admin/delegations/approve", string(body), "admin-secret"); w.Code != 200 {
+ t.Fatalf("approve: %d %s", w.Code, w.Body.String())
+ }
+ if w := request(http.MethodGet, "/admin/delegations/request?id="+in.ID+"&namespace=bob", "", "admin-secret"); w.Code != 403 {
+ t.Fatalf("foreign grant: %d %s", w.Code, w.Body.String())
+ }
+ body, _ = json.Marshal(map[string]string{"token": raw})
+ w := request(http.MethodPost, "/admin/tokens/inspect", string(body), "admin-secret")
+ var access delegation.Access
+ if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &access) != nil || !access.Delegated || access.GrantID != in.ID || strings.Contains(w.Body.String(), raw) {
+ t.Fatalf("inspect: %d %s", w.Code, w.Body.String())
+ }
+ if w := request(http.MethodPost, "/admin/tokens/resolve", string(body), "admin-secret"); w.Code != 404 {
+ t.Fatalf("legacy resolve leak: %d %s", w.Code, w.Body.String())
+ }
+}
diff --git a/internal/relay/device_id.go b/internal/relay/device_id.go
index 3fc4f12..e48718a 100644
--- a/internal/relay/device_id.go
+++ b/internal/relay/device_id.go
@@ -156,12 +156,12 @@ func (r *Relay) resolveLiveLabel(ns, target string) (string, bool) {
// handleResolve gives controllers the exact route before they look up TLS pins.
// Metadata is released only after the same owner/ACL check used for dialing.
func (r *Relay) handleResolve(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, _, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", 401)
return
}
- key, auth, reason, ok := r.dialAllowedReason(ns, req.URL.Query().Get("target"))
+ key, auth, reason, ok := r.dialAccessAllowed(access, req.URL.Query().Get("target"))
if !ok {
if reason == "" {
reason = "device unavailable or ambiguous; use its device ID"
diff --git a/internal/relay/http.go b/internal/relay/http.go
index 9ae3232..33ca9fc 100644
--- a/internal/relay/http.go
+++ b/internal/relay/http.go
@@ -8,6 +8,7 @@ import (
"sync"
"time"
+ "wanctl/internal/delegation"
"wanctl/internal/limits"
"wanctl/internal/sessionauth"
)
@@ -21,6 +22,7 @@ type httpAgent struct {
inst string
retired map[string]struct{}
changed chan struct{}
+ delegation bool
}
// sideQueue is one direction of a session's byte flow. The relay never inspects
@@ -94,9 +96,11 @@ type httpSession struct {
// inject into or tear down a session in another (audit 2026-08-28,
// SEC-A-02). lastActive is bumped by every /h/up and /h/down so the sweeper
// can reap sessions that both parties have abandoned (SEC-A-03).
- callerNS string
- ownerNS string
- lastActive time.Time
+ callerNS string
+ credentialID string
+ lease *accessLease
+ ownerNS string
+ lastActive time.Time
}
func (s *httpSession) close() {
@@ -189,6 +193,7 @@ func (r *Relay) handleHPoll(w http.ResponseWriter, req *http.Request) {
a.inst = inst
}
a.name = req.URL.Query().Get("name")
+ a.delegation = req.URL.Query().Get("delegation") == "1"
a.lastSeen = time.Now()
r.hagents[key] = a
changed := a.changed
@@ -244,7 +249,7 @@ func (r *Relay) requeueHTTPJob(key string, open sessionauth.Open) {
}
func (r *Relay) handleHDial(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, token, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -253,7 +258,7 @@ func (r *Relay) handleHDial(w http.ResponseWriter, req *http.Request) {
// polling. Start the session reaper on this path too, or abandoned hybrid
// sessions live forever despite the idle deadline.
r.startHTTPReaper()
- targetKey, auth, reason, ok := r.dialAllowedReason(ns, req.URL.Query().Get("target"))
+ targetKey, auth, reason, ok := r.dialAccessAllowed(access, req.URL.Query().Get("target"))
if !ok {
http.Error(w, dialRefusal(reason), http.StatusForbidden)
return
@@ -262,13 +267,18 @@ func (r *Relay) handleHDial(w http.ResponseWriter, req *http.Request) {
a := r.hagents[targetKey]
if a == nil || time.Since(a.lastSeen) > httpAgentTTL {
r.hmu.Unlock()
- r.handleHDialToWS(w, targetKey, auth)
+ r.handleHDialToWS(w, targetKey, auth, access, token)
+ return
+ }
+ if access.Delegated && !a.delegation {
+ r.hmu.Unlock()
+ http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict)
return
}
sid := newID()
auth.Session = sid
r.hmu.Unlock()
- r.newHTTPSession(sid, auth)
+ r.newHTTPSession(sid, auth, access, token)
select {
case a.open <- auth:
@@ -339,22 +349,21 @@ func (r *Relay) wsDeviceLive(key string) bool {
}
func (r *Relay) handleHPeers(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, _, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- devices, aliases := r.livePeers(ns)
- writeJSON(w, peersBody(ns, devices, aliases, r.sharedPeers(ns)))
+ writeJSON(w, r.accessPeers(access))
}
func (r *Relay) handleHUp(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, _, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- s := r.sessionForParty(req.URL.Query().Get("session"), ns)
+ s := r.sessionForAccess(req.URL.Query().Get("session"), access, req.URL.Query().Get("role"))
if s == nil {
http.Error(w, "no such session", http.StatusNotFound)
return
@@ -370,6 +379,11 @@ func (r *Relay) handleHUp(w http.ResponseWriter, req *http.Request) {
http.Error(w, "read body", http.StatusBadRequest)
return
}
+ if s.lease != nil && !s.lease.credentialValid() {
+ r.closeHTTPSession(req.URL.Query().Get("session"), s)
+ http.Error(w, "session closed", http.StatusGone)
+ return
+ }
dst := s.toAgent // role=client writes toward the agent
if req.URL.Query().Get("role") == "agent" {
dst = s.toClient
@@ -382,12 +396,12 @@ func (r *Relay) handleHUp(w http.ResponseWriter, req *http.Request) {
}
func (r *Relay) handleHDown(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, _, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- s := r.sessionForParty(req.URL.Query().Get("session"), ns)
+ s := r.sessionForAccess(req.URL.Query().Get("session"), access, req.URL.Query().Get("role"))
if s == nil {
http.Error(w, "no such session", http.StatusNotFound)
return
@@ -397,6 +411,11 @@ func (r *Relay) handleHDown(w http.ResponseWriter, req *http.Request) {
src = s.toAgent
}
data, closed := src.drain(downPollWait)
+ if s.lease != nil && !s.lease.credentialValid() {
+ r.closeHTTPSession(req.URL.Query().Get("session"), s)
+ http.Error(w, "session closed", http.StatusGone)
+ return
+ }
if closed && len(data) == 0 {
http.Error(w, "session closed", http.StatusGone)
return
@@ -411,7 +430,7 @@ func (r *Relay) handleHDown(w http.ResponseWriter, req *http.Request) {
}
func (r *Relay) handleHClose(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, _, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -419,7 +438,7 @@ func (r *Relay) handleHClose(w http.ResponseWriter, req *http.Request) {
sid := req.URL.Query().Get("session")
r.hmu.Lock()
s := r.hsess[sid]
- if s == nil || (ns != s.callerNS && ns != s.ownerNS) {
+ if s == nil || !s.allowsAccess(access, req.URL.Query().Get("role")) {
r.hmu.Unlock()
http.Error(w, "no such session", http.StatusNotFound)
return
@@ -427,6 +446,9 @@ func (r *Relay) handleHClose(w http.ResponseWriter, req *http.Request) {
delete(r.hsess, sid)
r.hmu.Unlock()
s.close()
+ if s.lease != nil {
+ s.lease.close()
+ }
w.WriteHeader(http.StatusOK)
}
@@ -436,14 +458,20 @@ func (r *Relay) session(sid string) *httpSession {
return r.hsess[sid]
}
-// sessionForParty returns the session only if ns is one of its two parties
-// (the dialing controller or the device owner), and marks it active. A caller
-// that is neither gets nil — indistinguishable from an unknown id.
-func (r *Relay) sessionForParty(sid, ns string) *httpSession {
+// Delegated controllers can touch only their own session's client role. Full
+// credentials preserve the existing owner/controller namespace behavior.
+func (s *httpSession) allowsAccess(a delegation.Access, role string) bool {
+ if a.Delegated {
+ return (role == "" || role == "client") && s.credentialID != "" && a.CredentialID == s.credentialID && a.Namespace == s.callerNS
+ }
+ return a.Namespace == s.callerNS || a.Namespace == s.ownerNS
+}
+
+func (r *Relay) sessionForAccess(sid string, a delegation.Access, role string) *httpSession {
r.hmu.Lock()
defer r.hmu.Unlock()
s := r.hsess[sid]
- if s == nil || (ns != s.callerNS && ns != s.ownerNS) {
+ if s == nil || !s.allowsAccess(a, role) {
return nil
}
s.lastActive = time.Now()
@@ -505,6 +533,9 @@ func (r *Relay) reapHTTP(now time.Time) {
}
for _, s := range dead {
s.close()
+ if s.lease != nil {
+ s.lease.close()
+ }
}
}
diff --git a/internal/relay/migrate_test.go b/internal/relay/migrate_test.go
index ae735ab..b337c94 100644
--- a/internal/relay/migrate_test.go
+++ b/internal/relay/migrate_test.go
@@ -191,6 +191,7 @@ func TestEmbeddedMigrationsApplyEveryVersion(t *testing.T) {
{6, "CREATE TABLE IF NOT EXISTS access_requests"},
{7, "RENAME COLUMN name TO device_id"},
{8, "ALTER TABLE acl ADD COLUMN IF NOT EXISTS manage"},
+ {9, "CREATE TABLE delegation_requests"},
}
if len(state.committedBodies) != len(want) {
t.Fatalf("applied %d migrations, want %d: %#v",
diff --git a/internal/relay/migrations/009_delegations.sql b/internal/relay/migrations/009_delegations.sql
new file mode 100644
index 0000000..28db0b4
--- /dev/null
+++ b/internal/relay/migrations/009_delegations.sql
@@ -0,0 +1,41 @@
+-- Temporary controllers use the same token lifecycle as ordinary controllers.
+-- Only hashes are stored; the browser ticket and controller bearer stay out of DB.
+CREATE TABLE delegation_requests (
+ id text PRIMARY KEY,
+ ticket_hash text NOT NULL UNIQUE,
+ token_hash text NOT NULL UNIQUE,
+ label text NOT NULL,
+ controller_fingerprint text NOT NULL,
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
+ namespace text NOT NULL DEFAULT '',
+ token_id integer UNIQUE REFERENCES tokens(id),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ request_expires_at timestamptz NOT NULL,
+ decided_at timestamptz
+);
+CREATE INDEX delegation_pending_idx ON delegation_requests (request_expires_at) WHERE status = 'pending';
+CREATE INDEX delegation_namespace_idx ON delegation_requests (namespace);
+CREATE INDEX delegation_retention_idx ON delegation_requests (created_at);
+
+-- Retain the approved identity even after removal/rotation so it cannot silently
+-- acquire a replacement device. ResolveAccess checks the current device record.
+CREATE TABLE delegation_devices (
+ grant_id text NOT NULL REFERENCES delegation_requests(id),
+ namespace text NOT NULL,
+ device_id text NOT NULL,
+ fingerprint text NOT NULL,
+ PRIMARY KEY (grant_id, device_id)
+);
+
+CREATE TABLE delegation_jobs (
+ id text PRIMARY KEY,
+ grant_id text NOT NULL REFERENCES delegation_requests(id),
+ request_id text NOT NULL,
+ payload_hash text NOT NULL,
+ payload jsonb NOT NULL,
+ state text NOT NULL DEFAULT 'running' CHECK (state IN ('running', 'done', 'failed', 'unknown')),
+ result jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ finished_at timestamptz,
+ UNIQUE (grant_id, request_id)
+);
diff --git a/internal/relay/pgstore.go b/internal/relay/pgstore.go
index 887e1f6..1c4c798 100644
--- a/internal/relay/pgstore.go
+++ b/internal/relay/pgstore.go
@@ -50,7 +50,7 @@ func (p *PGStore) Resolve(token string) (string, bool) {
var ns string
err := p.db.QueryRow(
`SELECT namespace FROM tokens
- WHERE hash = $1 AND revoked_at IS NULL
+ WHERE hash = $1 AND revoked_at IS NULL AND kind <> 'delegated'
AND (expires_at IS NULL OR expires_at > now())`,
HashToken(token),
).Scan(&ns)
diff --git a/internal/relay/relay.go b/internal/relay/relay.go
index dc886e5..ddd8e28 100644
--- a/internal/relay/relay.go
+++ b/internal/relay/relay.go
@@ -27,12 +27,13 @@ import (
)
type agentConn struct {
- device string
- name string
- ns string
- inst string
- ctrl io.Writer
- mu sync.Mutex // serialize control writes
+ device string
+ name string
+ ns string
+ inst string
+ ctrl io.Writer
+ delegation bool
+ mu sync.Mutex // serialize control writes
}
func (a *agentConn) send(v any) error {
@@ -44,6 +45,7 @@ func (a *agentConn) send(v any) error {
type pendingSession struct {
agentSide chan io.ReadWriteCloser
done chan struct{}
+ ownerNS string
}
// Grant is a live cross-namespace share.
@@ -70,18 +72,19 @@ type webhookSender interface {
// Relay is the broker.
type Relay struct {
- ts TokenStore
- acl ACLChecker
- audit Auditor
- admin AdminStore
- aliases DeviceAliasStore
- notifyStore NotifyStore
- notifySend webhookSender
- docs DocsStore
- mcpHandler http.Handler // optional: HTTP/Streamable MCP at /mcp
- adminSecret string
- portalNS string
- logs *serverlog.Buffer
+ ts TokenStore
+ acl ACLChecker
+ audit Auditor
+ admin AdminStore
+ aliases DeviceAliasStore
+ notifyStore NotifyStore
+ notifySend webhookSender
+ docs DocsStore
+ mcpHandler http.Handler // optional: HTTP/Streamable MCP at /mcp
+ webfetchHandler http.Handler // optional: GET adapter for delegated controllers
+ adminSecret string
+ portalNS string
+ logs *serverlog.Buffer
registrationMu sync.Mutex
mu sync.Mutex
@@ -92,6 +95,8 @@ type Relay struct {
hagents map[string]*httpAgent // key "ns/device" (HTTP transport)
hsess map[string]*httpSession // key session id (HTTP transport)
reaperOnce sync.Once
+ leaseMu sync.Mutex
+ leases map[string]*accessLease
enrollMu sync.Mutex
enrollCodes map[string]*enrollCode // one-time device-enrollment codes
@@ -122,6 +127,7 @@ func (r *Relay) Handler() http.Handler {
mux.HandleFunc("/agent", r.handleAgent)
mux.HandleFunc("/agent/notify-policy", r.handleAgentNotifyPolicy)
mux.HandleFunc("/agent/events", r.handleAgentEvent)
+ mux.HandleFunc("/agent/delegation-check", r.handleAgentDelegationCheck)
mux.HandleFunc("/dial", r.handleDial)
mux.HandleFunc("/session/", r.handleSession)
mux.HandleFunc("/peers", r.handlePeers)
@@ -147,6 +153,10 @@ func (r *Relay) Handler() http.Handler {
mux.Handle("/wanctl-mcp", r.mcpHandler)
mux.Handle("/wanctl-mcp/", r.mcpHandler)
}
+ if r.webfetchHandler != nil {
+ mux.Handle("/webfetch", r.webfetchHandler)
+ mux.Handle("/webfetch/", r.webfetchHandler)
+ }
return limitBodies(mux)
}
@@ -186,6 +196,9 @@ func bodyCapFor(path string) int64 {
// at GET/POST /mcp. Pass nil (or never call) to disable the endpoint.
func (r *Relay) SetMCPHandler(h http.Handler) { r.mcpHandler = h }
+// SetWebFetchHandler installs the optional delegated GET adapter.
+func (r *Relay) SetWebFetchHandler(h http.Handler) { r.webfetchHandler = h }
+
// SetAdmin installs the admin store backing the /admin/* endpoints.
func (r *Relay) SetAdmin(a AdminStore) {
r.admin = a
@@ -212,6 +225,9 @@ func (r *Relay) auth(w http.ResponseWriter, req *http.Request) (ns string, ok bo
if !ok {
return "", false
}
+ if strings.HasPrefix(token, "wfd_") {
+ return "", false
+ }
if legacy {
admission.MarkLegacy(w)
}
@@ -337,6 +353,7 @@ func (r *Relay) handleAgent(w http.ResponseWriter, req *http.Request) {
var reg struct {
Op, Device, Fingerprint, Inst, Name string
DeviceID string `json:"device_id"`
+ Delegation string `json:"delegation"`
}
if err := dec.Decode(®); err != nil || reg.Op != "register" || reg.Device == "" {
c.Close(websocket.StatusPolicyViolation, "expected register")
@@ -367,7 +384,7 @@ func (r *Relay) handleAgent(w http.ResponseWriter, req *http.Request) {
}
key := ns + "/" + reg.Device
wasLive := r.deviceLive(ns, reg.Device)
- ac := &agentConn{device: reg.Device, name: reg.Name, ns: ns, inst: reg.Inst, ctrl: nc}
+ ac := &agentConn{device: reg.Device, name: reg.Name, ns: ns, inst: reg.Inst, ctrl: nc, delegation: reg.Delegation == "1"}
r.mu.Lock()
r.agents[key] = ac
r.mu.Unlock()
@@ -404,12 +421,12 @@ func (r *Relay) handleAgent(w http.ResponseWriter, req *http.Request) {
}
func (r *Relay) handleDial(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, token, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- targetKey, auth, reason, ok := r.dialAllowedReason(ns, req.URL.Query().Get("target"))
+ targetKey, auth, reason, ok := r.dialAccessAllowed(access, req.URL.Query().Get("target"))
if !ok {
http.Error(w, dialRefusal(reason), http.StatusForbidden)
return
@@ -418,14 +435,20 @@ func (r *Relay) handleDial(w http.ResponseWriter, req *http.Request) {
ac := r.agents[targetKey]
r.mu.Unlock()
if ac == nil {
- r.handleWSDialToHTTP(w, req, targetKey, auth)
+ r.handleWSDialToHTTP(w, req, targetKey, auth, access, token)
+ return
+ }
+ if access.Delegated && !ac.delegation {
+ http.Error(w, "device agent must be upgraded for delegated access", http.StatusConflict)
return
}
if r.audit != nil {
r.audit.Audit(auth.OwnerNamespace, auth.Device, "dial")
}
sid := newID()
- ps := &pendingSession{agentSide: make(chan io.ReadWriteCloser, 1), done: make(chan struct{})}
+ ps := &pendingSession{agentSide: make(chan io.ReadWriteCloser, 1), done: make(chan struct{}), ownerNS: auth.OwnerNamespace}
+ lease := r.beginAccessLease(sid, targetKey, access, token)
+ defer lease.close()
r.mu.Lock()
r.pending[sid] = ps
r.mu.Unlock()
@@ -449,17 +472,22 @@ func (r *Relay) handleDial(w http.ResponseWriter, req *http.Request) {
}
limits.ClearHijackedDeadline(req.Context())
clientNC := wsconn.FromAccepted(req.Context(), c)
+ lease.addCloser(func() { clientNC.Close() })
select {
case agentNC := <-ps.agentSide:
+ lease.addCloser(func() { agentNC.Close() })
pipe(clientNC, agentNC)
+ case <-lease.done:
+ return
case <-time.After(agentSessionOpenTimeout):
c.Close(websocket.StatusBadGateway, "agent did not open session")
}
}
func (r *Relay) handleSession(w http.ResponseWriter, req *http.Request) {
- if _, ok := r.auth(w, req); !ok {
+ ns, ok := r.auth(w, req)
+ if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
@@ -467,7 +495,7 @@ func (r *Relay) handleSession(w http.ResponseWriter, req *http.Request) {
r.mu.Lock()
ps := r.pending[sid]
r.mu.Unlock()
- if ps == nil {
+ if ps == nil || (ps.ownerNS != "" && ps.ownerNS != ns) {
http.Error(w, "no such session", http.StatusNotFound)
return
}
@@ -487,14 +515,12 @@ func (r *Relay) handleSession(w http.ResponseWriter, req *http.Request) {
}
func (r *Relay) handlePeers(w http.ResponseWriter, req *http.Request) {
- ns, ok := r.auth(w, req)
+ access, _, ok := r.authAccess(w, req)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
- devices, aliases := r.livePeers(ns)
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(peersBody(ns, devices, aliases, r.sharedPeers(ns)))
+ writeJSON(w, r.accessPeers(access))
}
// pipe copies bytes both directions until either side closes, then tears down.
diff --git a/internal/relay/tokenstore.go b/internal/relay/tokenstore.go
index 82aad74..9b23313 100644
--- a/internal/relay/tokenstore.go
+++ b/internal/relay/tokenstore.go
@@ -1,12 +1,47 @@
package relay
-import "strings"
+import (
+ "strings"
+ "time"
+
+ "wanctl/internal/delegation"
+)
// TokenStore resolves an access/registration token to its namespace.
type TokenStore interface {
Resolve(token string) (namespace string, ok bool)
}
+// ResolveAccess preserves device scope when a store supports delegation. The
+// legacy Resolve method must never resolve delegated credentials: callers of
+// that method are agent and account-management endpoints.
+func ResolveAccess(store TokenStore, token string) (delegation.Access, bool) {
+ if scoped, ok := store.(interface {
+ ResolveAccess(string) (delegation.Access, bool)
+ }); ok {
+ a, valid := scoped.ResolveAccess(token)
+ if !valid || a.Namespace == "" {
+ return delegation.Access{}, false
+ }
+ if strings.HasPrefix(token, "wfd_") && !a.Delegated {
+ return delegation.Access{}, false
+ }
+ if a.CredentialID == "" {
+ a.CredentialID = HashToken(token)
+ }
+ if a.Delegated && (a.GrantID == "" || a.ControllerFingerprint == "" || len(a.Devices) == 0 || a.ExpiresAt.IsZero() || !time.Now().Before(a.ExpiresAt)) {
+ return delegation.Access{}, false
+ }
+ return a, true
+ }
+ // A legacy store cannot express a WebFetch credential's restrictions.
+ if strings.HasPrefix(token, "wfd_") {
+ return delegation.Access{}, false
+ }
+ ns, ok := store.Resolve(token)
+ return delegation.Access{Namespace: ns, CredentialID: HashToken(token)}, ok
+}
+
type envTokenStore map[string]string
// EnvTokenStore builds a static store from "token:ns,token:ns" (used for the
@@ -26,6 +61,9 @@ func EnvTokenStore(spec string) TokenStore {
}
func (m envTokenStore) Resolve(token string) (string, bool) {
+ if strings.HasPrefix(token, "wfd_") {
+ return "", false
+ }
ns, ok := m[token]
return ns, ok
}
diff --git a/internal/relay/upstream.go b/internal/relay/upstream.go
index b362dab..590844f 100644
--- a/internal/relay/upstream.go
+++ b/internal/relay/upstream.go
@@ -4,8 +4,11 @@ import (
"bytes"
"encoding/json"
"net/http"
+ "strings"
"sync"
"time"
+
+ "wanctl/internal/delegation"
)
// UpstreamTokenStore resolves tokens by asking another relay's admin API
@@ -50,6 +53,9 @@ func NewUpstreamTokenStore(upstreamURL, secret string) *UpstreamTokenStore {
// Resolve implements TokenStore.
func (u *UpstreamTokenStore) Resolve(token string) (string, bool) {
+ if strings.HasPrefix(token, "wfd_") {
+ return "", false
+ }
u.mu.Lock()
if e, hit := u.cache[token]; hit && time.Now().Before(e.expiry) {
u.mu.Unlock()
@@ -105,3 +111,60 @@ func (c ChainTokenStore) Resolve(token string) (string, bool) {
}
return "", false
}
+
+func (c ChainTokenStore) ResolveAccess(token string) (delegation.Access, bool) {
+ for _, ts := range c {
+ if access, ok := ResolveAccess(ts, token); ok {
+ return access, true
+ }
+ }
+ return delegation.Access{}, false
+}
+
+// Delegated grants are deliberately not cached: revocation is rechecked by
+// active-session leases, and a minutes-long cache would outlive the grant.
+func (u *UpstreamTokenStore) ResolveAccess(token string) (delegation.Access, bool) {
+ u.mu.Lock()
+ if e, hit := u.cache[token]; hit && e.ok && time.Now().Before(e.expiry) {
+ u.mu.Unlock()
+ return delegation.Access{Namespace: e.ns, CredentialID: HashToken(token)}, true
+ }
+ u.mu.Unlock()
+ body, _ := json.Marshal(map[string]string{"token": token})
+ req, err := http.NewRequest(http.MethodPost, u.url+"/admin/tokens/inspect", bytes.NewReader(body))
+ if err != nil {
+ return delegation.Access{}, false
+ }
+ req.Header.Set("X-Admin-Secret", u.secret)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := u.hc.Do(req)
+ if err != nil {
+ return delegation.Access{}, false
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ if strings.HasPrefix(token, "wfd_") {
+ return delegation.Access{}, false
+ }
+ // A pre-delegation upstream has no inspect route. Its legacy route is
+ // permitted only for complete credentials; modern upstreams enforce it.
+ ns, ok := u.Resolve(token)
+ return delegation.Access{Namespace: ns, CredentialID: HashToken(token)}, ok
+ }
+ if resp.StatusCode != http.StatusOK {
+ return delegation.Access{}, false
+ }
+ var a delegation.Access
+ if json.NewDecoder(resp.Body).Decode(&a) != nil || a.Namespace == "" {
+ return delegation.Access{}, false
+ }
+ if a.CredentialID == "" {
+ a.CredentialID = HashToken(token)
+ }
+ if !a.Delegated {
+ u.mu.Lock()
+ u.cache[token] = upstreamEntry{ns: a.Namespace, ok: true, expiry: time.Now().Add(upstreamHitTTL)}
+ u.mu.Unlock()
+ }
+ return a, true
+}
diff --git a/internal/sessionauth/sessionauth.go b/internal/sessionauth/sessionauth.go
index 6e8ae80..2851efc 100644
--- a/internal/sessionauth/sessionauth.go
+++ b/internal/sessionauth/sessionauth.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"strings"
+ "time"
)
type Capabilities uint8
@@ -103,16 +104,23 @@ func (c *Capabilities) UnmarshalJSON(data []byte) error {
// Open is relay-authenticated metadata for one E2E controller session.
type Open struct {
- Op string `json:"op,omitempty"`
- Session string `json:"session"`
- URL string `json:"url,omitempty"`
- CallerNamespace string `json:"caller_namespace"`
- OwnerNamespace string `json:"owner_namespace"`
- Device string `json:"device"`
- Capabilities Capabilities `json:"capabilities"`
+ Op string `json:"op,omitempty"`
+ Session string `json:"session"`
+ URL string `json:"url,omitempty"`
+ CallerNamespace string `json:"caller_namespace"`
+ OwnerNamespace string `json:"owner_namespace"`
+ Device string `json:"device"`
+ Capabilities Capabilities `json:"capabilities"`
+ GrantID string `json:"grant_id,omitempty"`
+ CredentialID string `json:"credential_id,omitempty"`
+ ControllerFingerprint string `json:"controller_fingerprint,omitempty"`
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
}
func (o Open) ValidFor(device string) bool {
+ if o.GrantID != "" && (o.CredentialID == "" || o.ControllerFingerprint == "" || o.ExpiresAt.IsZero() || !time.Now().Before(o.ExpiresAt) || o.Capabilities != UseCapabilities) {
+ return false
+ }
return o.Session != "" && o.CallerNamespace != "" && o.OwnerNamespace != "" &&
o.Device == device && o.Capabilities.Valid()
}
diff --git a/internal/webfetch/handler.go b/internal/webfetch/handler.go
new file mode 100644
index 0000000..b9754c1
--- /dev/null
+++ b/internal/webfetch/handler.go
@@ -0,0 +1,571 @@
+// Package webfetch adapts finite GET fetches to ordinary wanctl controller
+// operations. All authorization comes from wanctl delegation and device policy.
+package webfetch
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "wanctl/internal/client"
+ "wanctl/internal/delegation"
+ "wanctl/internal/transport"
+)
+
+const (
+ MaxURLBytes = 8192
+ MaxOutputBytes = 32 << 10
+ MaxWriteBytes = 2048
+ MaxRequestTime = 60 * time.Second
+)
+
+var ticketPattern = regexp.MustCompile(`^[0-9]{10}-[a-f0-9]{48}$`)
+var ridPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
+
+type Config struct {
+ Store delegation.Store
+ Jobs delegation.JobStore
+ Seed []byte
+ RelayURL string
+ PublicOrigin string
+ PortalOrigin string
+}
+
+type Handler struct {
+ cfg Config
+ ctx context.Context
+ cancel context.CancelFunc
+ queue chan work
+ wg sync.WaitGroup
+ mu sync.Mutex
+ requests map[string]rateWindow
+}
+
+type rateWindow struct {
+ at time.Time
+ count int
+}
+type work struct {
+ job delegation.Job
+ request delegation.Request
+ token string
+ operation Operation
+}
+
+type Operation struct {
+ Tool string `json:"tool"`
+ Target string `json:"target"`
+ Command string `json:"command,omitempty"`
+ Cwd string `json:"cwd,omitempty"`
+ Path string `json:"path,omitempty"`
+ Content string `json:"content,omitempty"`
+ Timeout int `json:"timeout_seconds"`
+}
+
+func New(cfg Config) (*Handler, error) {
+ if cfg.Store == nil || cfg.Jobs == nil || len(cfg.Seed) < 32 {
+ return nil, fmt.Errorf("webfetch requires delegation/job stores and a seed of at least 32 bytes")
+ }
+ for _, origin := range []string{cfg.PublicOrigin, cfg.PortalOrigin, cfg.RelayURL} {
+ u, err := url.Parse(origin)
+ if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || net.ParseIP(u.Hostname()).IsLoopback()))) {
+ return nil, fmt.Errorf("webfetch origins must use HTTPS (or loopback HTTP)")
+ }
+ if u.Path != "" && u.Path != "/" {
+ return nil, fmt.Errorf("webfetch origins cannot contain a path")
+ }
+ }
+ cfg.Seed = append([]byte(nil), cfg.Seed...)
+ cfg.PublicOrigin, cfg.PortalOrigin, cfg.RelayURL = strings.TrimRight(cfg.PublicOrigin, "/"), strings.TrimRight(cfg.PortalOrigin, "/"), strings.TrimRight(cfg.RelayURL, "/")
+ ctx, cancel := context.WithCancel(context.Background())
+ h := &Handler{cfg: cfg, ctx: ctx, cancel: cancel, queue: make(chan work, 64), requests: map[string]rateWindow{}}
+ for i := 0; i < 4; i++ {
+ h.wg.Add(1)
+ go h.worker()
+ }
+ if cleaner, ok := cfg.Store.(interface {
+ CleanupDelegations(context.Context, time.Duration) error
+ }); ok {
+ h.wg.Add(1)
+ go func() {
+ defer h.wg.Done()
+ ticker := time.NewTicker(15 * time.Minute)
+ defer ticker.Stop()
+ for {
+ ctx, cancel := context.WithTimeout(h.ctx, 10*time.Second)
+ if err := cleaner.CleanupDelegations(ctx, 24*time.Hour); err != nil && h.ctx.Err() == nil {
+ log.Print("webfetch: expired-record cleanup unavailable")
+ }
+ cancel()
+ select {
+ case <-h.ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+ }()
+ }
+ return h, nil
+}
+
+func (h *Handler) Close() { h.cancel(); h.wg.Wait() }
+
+func (h *Handler) rateAllowed(key string, limit int) bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ now := time.Now()
+ if len(h.requests) >= 4096 {
+ for k, v := range h.requests {
+ if now.Sub(v.at) > time.Minute {
+ delete(h.requests, k)
+ }
+ }
+ }
+ v, exists := h.requests[key]
+ if !exists && len(h.requests) >= 4096 {
+ return false
+ }
+ if now.Sub(v.at) > time.Minute {
+ v = rateWindow{at: now}
+ }
+ v.count++
+ h.requests[key] = v
+ return v.count <= limit
+}
+
+func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
+
+func (h *Handler) derive(domain, input string) []byte {
+ m := hmac.New(sha256.New, h.cfg.Seed)
+ m.Write([]byte(domain))
+ m.Write([]byte{0})
+ m.Write([]byte(input))
+ return m.Sum(nil)
+}
+
+func (h *Handler) credentials(ticket string) (string, string, *transport.Identity, error) {
+ id := "d_" + digest([]byte(ticket))[:32]
+ token := "wfd_" + hex.EncodeToString(h.derive("wanctl-webfetch-access-v1", ticket))
+ identity, err := transport.IdentityFromSeed(h.derive("wanctl-webfetch-identity-v1", id)[:ed25519.SeedSize], "wanctl-webfetch:"+id)
+ return id, token, identity, err
+}
+
+func (h *Handler) sessionURL(ticket string) string {
+ return h.cfg.PublicOrigin + "/webfetch/s/" + ticket
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store, private, max-age=0")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'")
+ if r.Method != http.MethodGet {
+ w.Header().Set("Allow", "GET")
+ h.respond(w, r, 405, map[string]any{"error": "GET required; HEAD never creates requests or runs tools"})
+ return
+ }
+ if len(r.URL.RequestURI()) > MaxURLBytes {
+ h.respond(w, r, 414, map[string]any{"error": "URL exceeds 8192 bytes"})
+ return
+ }
+ if r.Header.Get("Origin") != "" {
+ h.respond(w, r, 403, map[string]any{"error": "cross-origin browser requests are not supported"})
+ return
+ }
+ if r.URL.Path == "/webfetch" || r.URL.Path == "/webfetch/" {
+ var random [24]byte
+ if _, err := rand.Read(random[:]); err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ ticket := strconv.FormatInt(time.Now().Unix(), 10) + "-" + hex.EncodeToString(random[:])
+ h.respond(w, r, 200, map[string]any{"title": "wanctl WebFetch", "status": "start", "instruction": "Open start_url to request temporary device access. Give approval_url to the device owner. Only the owner may approve in the wanctl portal. Do not fetch an approval link as a way to approve it.", "start_url": h.sessionURL(ticket), "authorization": "wanctl device scope, expiry, revocation, identity trust and device-local policy apply"})
+ return
+ }
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ if len(parts) < 3 || parts[0] != "webfetch" || parts[1] != "s" || !ticketPattern.MatchString(parts[2]) {
+ h.respond(w, r, 404, map[string]any{"error": "not found"})
+ return
+ }
+ ticket := parts[2]
+ issuedSeconds, _ := strconv.ParseInt(strings.SplitN(ticket, "-", 2)[0], 10, 64)
+ issued := time.Unix(issuedSeconds, 0)
+ if issued.After(time.Now().Add(30*time.Second)) || !time.Now().Before(issued.Add(70*time.Minute)) {
+ h.fail(w, r, delegation.ErrExpired)
+ return
+ }
+ query, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ h.respond(w, r, 400, map[string]any{"error": "invalid URL encoding"})
+ return
+ }
+ for _, values := range query {
+ if len(values) != 1 {
+ h.respond(w, r, 400, map[string]any{"error": "duplicate query parameter"})
+ return
+ }
+ }
+ id, token, identity, err := h.credentials(ticket)
+ if err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ if !h.rateAllowed("grant:"+id, 120) {
+ h.respond(w, r, 429, map[string]any{"error": "request rate exceeded"})
+ return
+ }
+ request, err := h.cfg.Store.GetDelegationByTicket(r.Context(), id, digest([]byte(ticket)))
+ if errors.Is(err, delegation.ErrNotFound) && len(parts) == 3 {
+ if !time.Now().Before(issued.Add(10 * time.Minute)) {
+ h.fail(w, r, delegation.ErrExpired)
+ return
+ }
+ ip, _, _ := net.SplitHostPort(r.RemoteAddr)
+ if !h.rateAllowed("new:"+ip, 10) {
+ h.respond(w, r, 429, map[string]any{"error": "too many access requests"})
+ return
+ }
+ label := query.Get("label")
+ if label == "" {
+ label = "WebFetch AI"
+ }
+ if len(label) > 120 || strings.ContainsAny(label, "\r\n\x00") || !utf8.ValidString(label) {
+ h.respond(w, r, 400, map[string]any{"error": "invalid client label"})
+ return
+ }
+ request, err = h.cfg.Store.CreateDelegation(r.Context(), delegation.NewRequest{ID: id, TicketHash: digest([]byte(ticket)), TokenHash: digest([]byte(token)), Label: label, ControllerFingerprint: identity.Fingerprint, RequestExpiresAt: issued.Add(10 * time.Minute)})
+ }
+ if err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ if len(parts) == 3 && request.Status == "pending" {
+ h.respond(w, r, 200, map[string]any{"title": "Device access requested", "status": "pending", "request_id": id, "request_expires_at": request.RequestExpiresAt, "controller_fingerprint": identity.Fingerprint, "approval_url": h.cfg.PortalOrigin + "/webfetch/approve?request=" + url.QueryEscape(id), "status_url": h.sessionURL(ticket) + "?check=" + strconv.FormatInt(time.Now().UnixNano(), 10), "instruction": "Ask the owner to open approval_url, verify identities, choose devices and approve a duration. Stop and report this link while approval is pending. This GET does not grant access."})
+ return
+ }
+ access, ok := h.cfg.Store.ResolveAccess(token)
+ if !ok || !access.Delegated || access.GrantID != id || request.Status != "approved" || access.ControllerFingerprint != identity.Fingerprint {
+ h.respond(w, r, 403, map[string]any{"error": "delegation is not approved, has expired, or was revoked", "status": request.Status})
+ return
+ }
+ switch {
+ case len(parts) == 3:
+ h.respond(w, r, 200, map[string]any{"title": "wanctl tools", "status": "approved", "request_id": id, "owner": access.Namespace, "expires_at": access.ExpiresAt, "devices": access.Devices, "call_endpoint": h.sessionURL(ticket) + "/call", "instruction": "Construct GET call_endpoint?rid=UNIQUE_ID&tool=TOOL&target=CANONICAL_TARGET plus the tool parameters. URL-encode all values. Reuse exactly the same rid and arguments for retries. Poll result_url/next_url until done; at most 8 polls. Pairing or device-policy approval must be performed by the owner in wanctl, never by the AI.", "tools": []map[string]any{{"name": "exec", "parameters": []string{"command", "cwd (optional)", "timeout_seconds (1..60, default30)"}, "description": "One-shot command through wanctl; device policy applies; bounded stdout/stderr."}, {"name": "write_text", "parameters": []string{"path", "content"}, "description": "Write up to 2048 UTF-8 bytes through wanctl file-put; device write rules apply."}, {"name": "read_text", "parameters": []string{"path"}, "description": "Read a UTF-8 file up to 32768 bytes through wanctl file-get; device read rules apply."}}, "limits": map[string]any{"jobs_per_grant": 64, "url_bytes": MaxURLBytes, "output_bytes": MaxOutputBytes}, "notice": "Commands and results are visible to this adapter and the web chat provider. Do not send secrets. A lost/ambiguous job is never automatically rerun."})
+ case len(parts) == 4 && parts[3] == "call":
+ op, err := parseOperation(query)
+ if err != nil {
+ h.respond(w, r, 400, map[string]any{"error": err.Error()})
+ return
+ }
+ if !access.Allows(op.Target) {
+ h.fail(w, r, delegation.ErrForbidden)
+ return
+ }
+ payload, _ := json.Marshal(op)
+ job, fresh, err := h.cfg.Jobs.BeginJob(r.Context(), id, query.Get("rid"), digest(payload), payload)
+ if err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ if fresh {
+ select {
+ case h.queue <- work{job: job, request: request, token: token, operation: op}:
+ default:
+ result, _ := json.Marshal(map[string]any{"ok": false, "error": "adapter queue is full; operation did not start"})
+ if err := h.cfg.Jobs.FinishJob(r.Context(), id, job.ID, "failed", result); err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ job.State, job.Result = "failed", result
+ }
+ }
+ h.jobResponse(w, r, ticket, job, !fresh)
+ case len(parts) == 5 && parts[3] == "jobs":
+ job, err := h.cfg.Jobs.GetJob(r.Context(), id, parts[4])
+ if err != nil {
+ h.fail(w, r, err)
+ return
+ }
+ var op Operation
+ if json.Unmarshal(job.Payload, &op) != nil || !access.Allows(op.Target) {
+ h.fail(w, r, delegation.ErrForbidden)
+ return
+ }
+ h.jobResponse(w, r, ticket, job, false)
+ default:
+ h.respond(w, r, 404, map[string]any{"error": "not found"})
+ }
+}
+
+func parseOperation(q url.Values) (Operation, error) {
+ op := Operation{Tool: q.Get("tool"), Target: q.Get("target"), Timeout: 30}
+ if !ridPattern.MatchString(q.Get("rid")) {
+ return op, fmt.Errorf("rid must contain 1..64 ASCII letters, digits, hyphens or underscores")
+ }
+ if op.Target == "" || len(op.Target) > 200 || strings.ContainsAny(op.Target, "\r\n\x00") {
+ return op, fmt.Errorf("canonical target required")
+ }
+ allowed := map[string]bool{"rid": true, "tool": true, "target": true, "format": true, "timeout_seconds": true}
+ if value := q.Get("timeout_seconds"); value != "" {
+ n, err := strconv.Atoi(value)
+ if err != nil || n < 1 || n > 60 {
+ return op, fmt.Errorf("timeout_seconds must be 1..60")
+ }
+ op.Timeout = n
+ }
+ switch op.Tool {
+ case "exec":
+ allowed["command"], allowed["cwd"] = true, true
+ op.Command, op.Cwd = q.Get("command"), q.Get("cwd")
+ if op.Command == "" || len(op.Command) > 2048 || len(op.Cwd) > 1024 || strings.ContainsAny(op.Command+op.Cwd, "\x00") {
+ return op, fmt.Errorf("command/cwd exceeds limits or is invalid")
+ }
+ case "write_text", "read_text":
+ allowed["path"] = true
+ op.Path = q.Get("path")
+ if op.Path == "" || len(op.Path) > 1024 || strings.ContainsAny(op.Path, "\x00") {
+ return op, fmt.Errorf("path required and must be at most 1024 bytes")
+ }
+ if op.Tool == "write_text" {
+ allowed["content"] = true
+ op.Content = q.Get("content")
+ if len(op.Content) > MaxWriteBytes || !utf8.ValidString(op.Content) {
+ return op, fmt.Errorf("content must be UTF-8 and at most 2048 bytes")
+ }
+ }
+ default:
+ return op, fmt.Errorf("unknown tool")
+ }
+ for key := range q {
+ if !allowed[key] {
+ return op, fmt.Errorf("unknown parameter %q", key)
+ }
+ }
+ return op, nil
+}
+
+func (h *Handler) jobResponse(w http.ResponseWriter, r *http.Request, ticket string, job delegation.Job, duplicate bool) {
+ resultURL := h.sessionURL(ticket) + "/jobs/" + job.ID
+ state := job.State
+ if (state == "running" || state == "queued") && time.Since(job.CreatedAt) > 90*time.Second {
+ state = "unknown"
+ }
+ data := map[string]any{"title": "wanctl job", "job_id": job.ID, "request_id": job.RequestID, "status": state, "duplicate_request": duplicate, "result_url": resultURL, "result": job.Result}
+ if state == "running" || state == "queued" {
+ data["next_url"] = resultURL + "?check=" + strconv.FormatInt(time.Now().UnixNano(), 10)
+ data["instruction"] = "Read next_url for the result. Do not submit this operation under a new rid."
+ }
+ if state == "unknown" {
+ data["instruction"] = "The operation outcome is unknown after an interruption. Do not automatically repeat it. Ask the owner to inspect device logs."
+ }
+ h.respond(w, r, 200, data)
+}
+
+func (h *Handler) worker() {
+ defer h.wg.Done()
+ for {
+ select {
+ case <-h.ctx.Done():
+ return
+ case task := <-h.queue:
+ h.execute(task)
+ }
+ }
+}
+
+func (h *Handler) execute(task work) {
+ result := map[string]any{"ok": false, "tool": task.operation.Tool, "target": task.operation.Target}
+ state := "failed"
+ defer func() {
+ if recover() != nil {
+ result = map[string]any{"ok": false, "error": "internal adapter failure; outcome unknown"}
+ state = "unknown"
+ }
+ encoded, _ := json.Marshal(result)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := h.cfg.Jobs.FinishJob(ctx, task.job.GrantID, task.job.ID, state, encoded); err != nil {
+ log.Printf("webfetch: could not persist outcome for grant=%s job=%s; do not retry automatically", task.job.GrantID, task.job.ID)
+ }
+ }()
+ access, ok := h.cfg.Store.ResolveAccess(task.token)
+ if !ok || !access.Delegated || access.GrantID != task.job.GrantID || !access.Allows(task.operation.Target) {
+ result["error"] = "delegation is no longer valid; operation did not start"
+ return
+ }
+ deadline := task.job.CreatedAt.Add(time.Duration(task.operation.Timeout) * time.Second)
+ if access.ExpiresAt.Before(deadline) {
+ deadline = access.ExpiresAt
+ }
+ if !time.Now().Before(deadline) {
+ result["error"] = "job expired before dispatch; operation did not start"
+ return
+ }
+ ctx, cancel := context.WithDeadline(h.ctx, deadline)
+ defer cancel()
+ identity, err := transport.IdentityFromSeed(h.derive("wanctl-webfetch-identity-v1", task.job.GrantID)[:ed25519.SeedSize], "wanctl-webfetch:"+task.job.GrantID)
+ if err != nil {
+ result["error"] = "controller identity unavailable"
+ return
+ }
+ if identity.Fingerprint != access.ControllerFingerprint {
+ result["error"] = "controller identity changed; request a new delegation"
+ return
+ }
+ known := transport.NewMemStore()
+ for _, device := range access.Devices {
+ if err := known.Pin(device.Target(), device.Fingerprint, false); err != nil {
+ result["error"] = "invalid approved device identity"
+ return
+ }
+ }
+ c := client.NewWith(identity, known, h.cfg.RelayURL, task.token, "http")
+ c.SetLabel("WebFetch · " + task.request.Label + " · " + task.job.GrantID)
+ switch task.operation.Tool {
+ case "exec":
+ stdout, stderr := &boundedOutput{limit: MaxOutputBytes / 2, cancel: cancel}, &boundedOutput{limit: MaxOutputBytes / 2, cancel: cancel}
+ var code int
+ code, err = c.ExecTo(ctx, client.ExecRequest{Target: task.operation.Target, Command: task.operation.Command, Cwd: task.operation.Cwd, OneShot: true}, stdout, stderr)
+ result["stdout"], result["stderr"], result["exit_code"] = stdout.String(), stderr.String(), code
+ result["truncated"] = stdout.truncated || stderr.truncated
+ case "write_text":
+ data := []byte(task.operation.Content)
+ err = c.PushBytes(ctx, task.operation.Target, task.operation.Path, data, 0o600)
+ result["byte_count"], result["sha256"] = len(data), digest(data)
+ case "read_text":
+ var data []byte
+ data, err = c.PullBytes(ctx, task.operation.Target, task.operation.Path, MaxOutputBytes)
+ if err == nil && !utf8.Valid(data) {
+ err = fmt.Errorf("file is not UTF-8 text")
+ }
+ if err == nil {
+ result["content"], result["byte_count"], result["sha256"] = string(data), len(data), digest(data)
+ }
+ }
+ if err != nil {
+ result["error"] = boundedText(err.Error(), 4096)
+ var rejected *client.RejectError
+ var untrusted *client.TrustRequiredError
+ if task.operation.Tool != "read_text" && !errors.As(err, &rejected) && !errors.As(err, &untrusted) {
+ state = "unknown"
+ result["instruction"] = "The operation may have started. Do not retry under a new rid; ask the owner to inspect the device."
+ }
+ if errors.As(err, &rejected) {
+ result["error"] = boundedText(rejected.Reason, 4096)
+ if len(rejected.PairingURL) <= 2048 && h.ownerLink(rejected.PairingURL) {
+ result["pairing_url"] = rejected.PairingURL
+ result["instruction"] = "The device owner must approve pairing in wanctl. Do not approve it by fetching the link."
+ }
+ }
+ return
+ }
+ result["ok"] = true
+ state = "done"
+}
+
+func boundedText(text string, limit int) string {
+ if len(text) <= limit {
+ return text
+ }
+ text = text[:limit]
+ for !utf8.ValidString(text) && len(text) > 0 {
+ text = text[:len(text)-1]
+ }
+ return text + " [truncated]"
+}
+
+func (h *Handler) ownerLink(raw string) bool {
+ u, err := url.Parse(raw)
+ if err != nil || u.User != nil {
+ return false
+ }
+ origin, err := url.Parse(h.cfg.PortalOrigin)
+ return err == nil && u.Scheme == origin.Scheme && u.Host == origin.Host
+}
+
+type boundedOutput struct {
+ bytes.Buffer
+ limit int
+ truncated bool
+ cancel context.CancelFunc
+}
+
+func (b *boundedOutput) Write(p []byte) (int, error) {
+ n := len(p)
+ remaining := b.limit - b.Len()
+ if len(p) > remaining {
+ b.truncated = true
+ if remaining > 0 {
+ b.Buffer.Write(p[:remaining])
+ }
+ b.cancel()
+ return n, nil
+ }
+ return b.Buffer.Write(p)
+}
+
+func (h *Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
+ status, message := 500, "delegation service unavailable"
+ switch {
+ case errors.Is(err, delegation.ErrNotFound):
+ status, message = 404, "not found"
+ case errors.Is(err, delegation.ErrForbidden), errors.Is(err, delegation.ErrExpired):
+ status, message = 403, "delegation denied or expired"
+ case errors.Is(err, delegation.ErrConflict):
+ status, message = 409, "request ID already exists with different arguments"
+ case errors.Is(err, delegation.ErrInvalid):
+ status, message = 400, "invalid request"
+ case errors.Is(err, delegation.ErrLimit):
+ status, message = 429, "request or job limit exceeded"
+ }
+ h.respond(w, r, status, map[string]any{"error": message})
+}
+
+var responseTemplate = template.Must(template.New("response").Parse(`wanctl WebFetch wanctl WebFetch {{range .Links}}{{.Name}} {{.URL}}
{{end}}{{.JSON}} `))
+
+func (h *Handler) respond(w http.ResponseWriter, r *http.Request, status int, data map[string]any) {
+ encoded, _ := json.MarshalIndent(data, "", " ")
+ if r.URL.Query().Get("format") == "json" {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write(encoded)
+ return
+ }
+ var links []struct{ Name, URL string }
+ for _, key := range []string{"start_url", "approval_url", "status_url", "next_url", "result_url"} {
+ if value, ok := data[key].(string); ok {
+ links = append(links, struct{ Name, URL string }{key, value})
+ }
+ }
+ var body bytes.Buffer
+ if responseTemplate.Execute(&body, struct {
+ Links []struct{ Name, URL string }
+ JSON string
+ }{links, string(encoded)}) != nil {
+ http.Error(w, "render failed", 500)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = io.Copy(w, &body)
+}
diff --git a/internal/webfetch/handler_e2e_test.go b/internal/webfetch/handler_e2e_test.go
new file mode 100644
index 0000000..cf2ee1b
--- /dev/null
+++ b/internal/webfetch/handler_e2e_test.go
@@ -0,0 +1,316 @@
+package webfetch
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "wanctl/internal/agent"
+ "wanctl/internal/delegation"
+ "wanctl/internal/policy"
+ "wanctl/internal/relay"
+ "wanctl/internal/transport"
+
+ "github.com/google/uuid"
+)
+
+type liveFixture struct {
+ h *Handler
+ pg *relay.PGStore
+ db *sql.DB
+ web *httptest.Server
+ root string
+ agentDir string
+ device delegation.Device
+}
+
+func testTicket(letter string) string {
+ return strconv.FormatInt(time.Now().Unix(), 10) + "-" + strings.Repeat(letter, 48)
+}
+
+func liveWebFetch(t *testing.T) *liveFixture {
+ t.Helper()
+ dsn := os.Getenv("WANCTL_TEST_POSTGRES")
+ if dsn == "" {
+ t.Skip("set WANCTL_TEST_POSTGRES for real delegation/controller/file E2E")
+ }
+ base, err := sql.Open("pgx", dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ schema := "webfetch_" + strings.ReplaceAll(uuid.NewString(), "-", "")
+ if _, err = base.Exec("CREATE SCHEMA " + schema); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { base.Exec("DROP SCHEMA " + schema + " CASCADE"); base.Close() })
+ u, err := url.Parse(dsn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ q := u.Query()
+ q.Set("search_path", schema)
+ u.RawQuery = q.Encode()
+ pg, err := relay.OpenPG(u.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { pg.Close() })
+ db, err := sql.Open("pgx", u.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { db.Close() })
+ ownerToken, err := pg.IssueToken("owner", "test-device", 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := relay.New(pg)
+ r.SetAdmin(pg)
+ r.SetACL(pg)
+ r.SetAuditor(pg)
+ r.SetAdminSecret(strings.Repeat("a", 32))
+ relayServer := httptest.NewServer(r.Handler())
+ t.Cleanup(relayServer.Close)
+ webServer := httptest.NewUnstartedServer(nil)
+ webOrigin := "http://" + webServer.Listener.Addr().String()
+ h, err := New(Config{Store: pg, Jobs: pg, Seed: bytes.Repeat([]byte{9}, 32), RelayURL: relayServer.URL, PublicOrigin: webOrigin, PortalOrigin: "http://127.0.0.1:9999"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(h.Close)
+ webServer.Config.Handler = h
+ webServer.Start()
+ t.Cleanup(webServer.Close)
+ agentDir, root := t.TempDir(), t.TempDir()
+ t.Setenv("WANCTL_CONFIG_DIR", agentDir)
+ identity, err := transport.LoadOrCreateIdentity()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rules, err := policy.Open("rules.json", policy.ModeNormal)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, kind := range []policy.Kind{policy.KindRead, policy.KindWrite} {
+ if err := rules.Add(policy.Rule{Kind: kind, Pattern: root, Scope: policy.ScopeDir}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := rules.Add(policy.Rule{Kind: policy.KindExec, Pattern: "printf webfetch-ok", Scope: policy.ScopeGlobal}); err != nil {
+ t.Fatal(err)
+ }
+ ag, err := agent.New(agent.Options{RelayURL: relayServer.URL, Token: ownerToken, Name: "webfetch-e2e", Transport: "http", Mode: policy.ModeNormal})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ go ag.Run(ctx)
+ t.Cleanup(func() { cancel(); ag.Close() })
+ device := delegation.Device{Namespace: "owner", ID: ag.DeviceID(), Fingerprint: identity.Fingerprint}
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ var exists bool
+ err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM devices WHERE device_id=$1 AND fingerprint=$2)", device.ID, device.Fingerprint).Scan(&exists)
+ if err == nil && exists {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("device never registered: %v", err)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ return &liveFixture{h: h, pg: pg, db: db, web: webServer, root: root, agentDir: agentDir, device: device}
+}
+
+func fetchDoc(t *testing.T, raw string) (int, map[string]any) {
+ t.Helper()
+ sep := "?"
+ if strings.Contains(raw, "?") {
+ sep = "&"
+ }
+ resp, err := http.Get(raw + sep + "format=json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var data map[string]any
+ if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ t.Fatalf("decode %d: %v", resp.StatusCode, err)
+ }
+ if !strings.Contains(resp.Header.Get("Cache-Control"), "no-store") {
+ t.Fatal("missing no-store")
+ }
+ return resp.StatusCode, data
+}
+
+func (f *liveFixture) approve(t *testing.T, ticket string, pair bool) (string, delegation.Request) {
+ t.Helper()
+ session := f.web.URL + "/webfetch/s/" + ticket
+ status, pending := fetchDoc(t, session)
+ if status != 200 || pending["status"] != "pending" {
+ t.Fatalf("pending=%v %v", status, pending)
+ }
+ id, _, identity, err := f.h.credentials(ticket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ approval := delegation.Approval{RequestID: id, Namespace: "owner", Devices: []string{f.device.ID}, Minutes: 1, ControllerFingerprint: identity.Fingerprint, DeviceFingerprints: map[string]string{f.device.ID: f.device.Fingerprint}}
+ request, err := f.pg.ApproveDelegation(context.Background(), approval)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pair {
+ t.Setenv("WANCTL_CONFIG_DIR", f.agentDir)
+ known, err := transport.OpenStore("known_clients.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = known.Add(identity.Fingerprint, "explicit-test-pairing"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return session, request
+}
+
+func awaitJob(t *testing.T, job map[string]any) map[string]any {
+ t.Helper()
+ deadline := time.Now().Add(8 * time.Second)
+ for job["status"] == "running" || job["status"] == "queued" {
+ if time.Now().After(deadline) {
+ t.Fatalf("job did not finish: %v", job)
+ }
+ time.Sleep(20 * time.Millisecond)
+ status, latest := fetchDoc(t, job["result_url"].(string))
+ if status != 200 {
+ t.Fatalf("poll=%v %v", status, latest)
+ }
+ job = latest
+ }
+ return job
+}
+
+func TestWebFetchRealFileExecPolicyAndRevocation(t *testing.T) {
+ f := liveWebFetch(t)
+ ticket := testTicket("b")
+ session, request := f.approve(t, ticket, true)
+ text := "真实 wanctl 文件\n特殊字符 & + % # ?\nend"
+ remote := filepath.Join(f.root, "qwen.txt")
+ values := url.Values{"rid": {"write-1"}, "tool": {"write_text"}, "target": {f.device.Target()}, "path": {remote}, "content": {text}}
+ call := session + "/call?" + values.Encode()
+ status, job := fetchDoc(t, call)
+ if status != 200 {
+ t.Fatalf("submit=%v %v", status, job)
+ }
+ job = awaitJob(t, job)
+ if job["status"] != "done" {
+ t.Fatalf("write failed: %v", job)
+ }
+ data, err := os.ReadFile(remote)
+ if err != nil || string(data) != text {
+ t.Fatalf("actual file=%q %v", data, err)
+ }
+ before, _ := os.Stat(remote)
+ _, duplicate := fetchDoc(t, call)
+ if duplicate["job_id"] != job["job_id"] || duplicate["duplicate_request"] != true {
+ t.Fatalf("duplicate=%v", duplicate)
+ }
+ after, _ := os.Stat(remote)
+ if !before.ModTime().Equal(after.ModTime()) {
+ t.Fatal("duplicate rewrote file")
+ }
+ values = url.Values{"rid": {"read-1"}, "tool": {"read_text"}, "target": {f.device.Target()}, "path": {remote}}
+ _, read := fetchDoc(t, session+"/call?"+values.Encode())
+ read = awaitJob(t, read)
+ if read["status"] != "done" || read["result"].(map[string]any)["content"] != text {
+ t.Fatalf("read=%v", read)
+ }
+ values = url.Values{"rid": {"exec-1"}, "tool": {"exec"}, "target": {f.device.Target()}, "command": {"printf webfetch-ok"}}
+ _, exec := fetchDoc(t, session+"/call?"+values.Encode())
+ exec = awaitJob(t, exec)
+ if exec["status"] != "done" || exec["result"].(map[string]any)["stdout"] != "webfetch-ok" {
+ t.Fatalf("exec=%v", exec)
+ }
+ values.Set("rid", "denied")
+ values.Set("command", "echo not-authorized")
+ _, denied := fetchDoc(t, session+"/call?"+values.Encode())
+ denied = awaitJob(t, denied)
+ if denied["status"] != "failed" || denied["result"].(map[string]any)["ok"] != false {
+ t.Fatalf("device policy not preserved: %v", denied)
+ }
+ values.Set("rid", "outside")
+ values.Set("target", "owner/"+uuid.NewString())
+ if status, _ := fetchDoc(t, session+"/call?"+values.Encode()); status != 403 {
+ t.Fatalf("outside scope=%d", status)
+ }
+ if err := f.pg.RevokeToken("owner", request.TokenID); err != nil {
+ t.Fatal(err)
+ }
+ if status, _ := fetchDoc(t, read["result_url"].(string)); status != 403 {
+ t.Fatalf("revoked result visible: %d", status)
+ }
+ if status, _ := fetchDoc(t, call); status != 403 {
+ t.Fatalf("revoked repeat callable: %d", status)
+ }
+}
+
+func TestWebFetchPairingNotBypassedAndInputBoundaries(t *testing.T) {
+ f := liveWebFetch(t)
+ ticket := testTicket("c")
+ session := f.web.URL + "/webfetch/s/" + ticket
+ if status, _ := fetchDoc(t, session+"/call?rid=a&tool=exec&target=x&command=echo"); status != 404 {
+ t.Fatalf("missing grant=%d", status)
+ }
+ session, _ = f.approve(t, ticket, false)
+ values := url.Values{"rid": {"unpaired"}, "tool": {"exec"}, "target": {f.device.Target()}, "command": {"printf webfetch-ok"}}
+ _, job := fetchDoc(t, session+"/call?"+values.Encode())
+ job = awaitJob(t, job)
+ if job["status"] != "failed" {
+ t.Fatalf("unpaired execution=%v", job)
+ }
+ if !strings.Contains(fmt.Sprint(job["result"]), "approve") && !strings.Contains(fmt.Sprint(job["result"]), "paired") {
+ t.Fatalf("missing pairing refusal: %v", job)
+ }
+ if status, _ := fetchDoc(t, session+"/call?rid=1&rid=2"); status != 400 {
+ t.Fatalf("duplicate query=%d", status)
+ }
+ resp, err := http.Head(session + "/call?" + values.Encode())
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != 405 {
+ t.Fatalf("HEAD=%d", resp.StatusCode)
+ }
+ req, _ := http.NewRequest(http.MethodGet, session, nil)
+ req.Header.Set("Origin", "https://untrusted.example")
+ resp, err = http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != 403 {
+ t.Fatalf("Origin=%d", resp.StatusCode)
+ }
+ oldTicket := strconv.FormatInt(time.Now().Add(-71*time.Minute).Unix(), 10) + "-" + strings.Repeat("d", 48)
+ if status, _ := fetchDoc(t, f.web.URL+"/webfetch/s/"+oldTicket); status != 403 {
+ t.Fatalf("expired browser ticket resurrected=%d", status)
+ }
+ var count int
+ id, _, _, _ := f.h.credentials(oldTicket)
+ if err := f.db.QueryRow("SELECT count(*) FROM delegation_requests WHERE id=$1", id).Scan(&count); err != nil || count != 0 {
+ t.Fatalf("expired ticket created record: %d %v", count, err)
+ }
+}
diff --git a/internal/webfetch/rate_test.go b/internal/webfetch/rate_test.go
new file mode 100644
index 0000000..39eb79d
--- /dev/null
+++ b/internal/webfetch/rate_test.go
@@ -0,0 +1,42 @@
+package webfetch
+
+import (
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestRateWindowCapacityRecoversAfterExpiry(t *testing.T) {
+ h := &Handler{requests: make(map[string]rateWindow)}
+ for i := 0; i < 4096; i++ {
+ if !h.rateAllowed(fmt.Sprintf("grant:%d", i), 120) {
+ t.Fatalf("window %d denied before capacity", i)
+ }
+ }
+ if h.rateAllowed("new:live-overflow", 10) {
+ t.Fatal("new window exceeded the live window cap")
+ }
+ if len(h.requests) != 4096 {
+ t.Fatalf("window count = %d, want 4096", len(h.requests))
+ }
+ // Request IDs are attacker controlled. Filling the map must only consume
+ // this time window, never permanently prevent new sessions until restart.
+ for key, window := range h.requests {
+ window.at = time.Now().Add(-2 * time.Minute)
+ h.requests[key] = window
+ }
+ if !h.rateAllowed("new:after-expiry", 10) {
+ t.Fatal("expired windows permanently exhausted rate-limiter capacity")
+ }
+ if len(h.requests) != 1 {
+ t.Fatalf("window count after pruning = %d, want 1", len(h.requests))
+ }
+ for i := 1; i < 10; i++ {
+ if !h.rateAllowed("new:after-expiry", 10) {
+ t.Fatalf("request %d unexpectedly denied", i+1)
+ }
+ }
+ if h.rateAllowed("new:after-expiry", 10) {
+ t.Fatal("per-window rate limit lost after capacity recovery")
+ }
+}
diff --git a/main.go b/main.go
index 0357339..f978880 100644
--- a/main.go
+++ b/main.go
@@ -39,6 +39,7 @@ import (
"wanctl/internal/script"
"wanctl/internal/serverlog"
"wanctl/internal/transport"
+ "wanctl/internal/webfetch"
)
var usage = `wanctl — control a device across the internet over an encrypted, relayed channel
@@ -298,12 +299,14 @@ func cmdRelay(args []string) error {
return err
}
var r *relay.Relay
+ var pgStore *relay.PGStore
if dsn := os.Getenv("DATABASE_URL"); dsn != "" {
pg, err := relay.OpenPG(dsn)
if err != nil {
return fmt.Errorf("postgres: %w", err)
}
r = relay.New(pg)
+ pgStore = pg
r.SetACL(pg)
r.SetAuditor(pg)
r.SetAdmin(pg)
@@ -357,6 +360,31 @@ func cmdRelay(args []string) error {
r.SetMCPHandler(h)
log.Print("wanctl relay: MCP server enabled at /wanctl-mcp (Streamable HTTP)")
}
+ if seedHex := os.Getenv("WANCTL_WEBFETCH_SEED"); seedHex != "" {
+ if pgStore == nil {
+ return fmt.Errorf("WebFetch requires DATABASE_URL for durable delegation and job records")
+ }
+ seed, err := hex.DecodeString(seedHex)
+ if err != nil {
+ return fmt.Errorf("WANCTL_WEBFETCH_SEED must be hex-encoded")
+ }
+ publicOrigin := os.Getenv("WANCTL_PUBLIC_ORIGIN")
+ relayURL := os.Getenv("WANCTL_WEBFETCH_RELAY_URL")
+ if relayURL == "" {
+ relayURL = publicOrigin
+ }
+ h, err := webfetch.New(webfetch.Config{
+ Store: pgStore, Jobs: pgStore, Seed: seed,
+ PublicOrigin: publicOrigin, RelayURL: relayURL,
+ PortalOrigin: os.Getenv("WANCTL_WEBFETCH_PORTAL_ORIGIN"),
+ })
+ if err != nil {
+ return fmt.Errorf("webfetch: %w", err)
+ }
+ defer h.Close()
+ r.SetWebFetchHandler(h)
+ log.Print("wanctl relay: WebFetch enabled at /webfetch (owner-approved device delegation)")
+ }
log.Printf("wanctl relay listening on %s", *addr)
return limits.HTTPServer(*addr, r.Handler()).ListenAndServe()
}
diff --git a/selfhost/.env.example b/selfhost/.env.example
index ddd8d0a..4ae5c11 100644
--- a/selfhost/.env.example
+++ b/selfhost/.env.example
@@ -38,6 +38,12 @@ WANCTL_SESSION_SECRET=replace-with-a-random-value
# Optional privileged token for the portal's remote device console. Leave empty initially; docs/self-hosting.md explains how to issue it.
WANCTL_PORTAL_TOKEN=
+# Optional WebFetch adapter; empty keeps it disabled. Use a stable random hex
+# seed of at least 32 bytes. Supply its value from a protected secret env file:
+# docker compose --env-file .env --env-file /secure/webfetch.env up -d
+# Compose reuses PORTAL_PUBLIC_ORIGIN and the relay container's loopback URL.
+WANCTL_WEBFETCH_SEED=
+
# Go module proxy used while building the image. Only change this when the host
# cannot reach proxy.golang.org (e.g. GOPROXY=https://goproxy.cn,direct).
#GOPROXY=https://proxy.golang.org,direct
diff --git a/selfhost/docker-compose.yml b/selfhost/docker-compose.yml
index a48b52d..f725fce 100644
--- a/selfhost/docker-compose.yml
+++ b/selfhost/docker-compose.yml
@@ -43,6 +43,9 @@ services:
WANCTL_ADMIN_SECRET: ${WANCTL_ADMIN_SECRET:?set WANCTL_ADMIN_SECRET in selfhost/.env}
WANCTL_PORTAL_NS: portal
WANCTL_PUBLIC_ORIGIN: ${RELAY_PUBLIC_ORIGIN:?set RELAY_PUBLIC_ORIGIN in selfhost/.env}
+ WANCTL_WEBFETCH_SEED: ${WANCTL_WEBFETCH_SEED:-}
+ WANCTL_WEBFETCH_PORTAL_ORIGIN: ${PORTAL_PUBLIC_ORIGIN:?set PORTAL_PUBLIC_ORIGIN in selfhost/.env}
+ WANCTL_WEBFETCH_RELAY_URL: http://127.0.0.1:8080
ports:
- "127.0.0.1:${RELAY_PORT:-8080}:8080"
volumes:
diff --git a/tools/webfetch-demo/main.go b/tools/webfetch-demo/main.go
new file mode 100644
index 0000000..610a3a2
--- /dev/null
+++ b/tools/webfetch-demo/main.go
@@ -0,0 +1,194 @@
+// webfetch-demo is an isolated browser acceptance fixture, never a production
+// authentication configuration. The owner portal is loopback-only and supplies
+// a fixed test identity; expose only /webfetch on the relay to the web chat.
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "wanctl/internal/agent"
+ "wanctl/internal/config"
+ "wanctl/internal/limits"
+ "wanctl/internal/policy"
+ "wanctl/internal/portal"
+ "wanctl/internal/relay"
+ "wanctl/internal/transport"
+ "wanctl/internal/webfetch"
+)
+
+func secret(path string) []byte {
+ if data, err := os.ReadFile(path); err == nil {
+ return data
+ }
+ data := make([]byte, 32)
+ if _, err := rand.Read(data); err != nil {
+ log.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ log.Fatal(err)
+ }
+ return data
+}
+
+func main() {
+ state := flag.String("state-dir", "", "private directory outside the source tree")
+ public := flag.String("public-origin", "", "HTTPS origin exposing only relay /webfetch")
+ relayAddr := flag.String("relay-addr", "127.0.0.1:18995", "loopback relay")
+ portalAddr := flag.String("portal-addr", "127.0.0.1:18996", "loopback owner portal")
+ flag.Parse()
+ if *state == "" || *public == "" || !strings.HasPrefix(*relayAddr, "127.0.0.1:") || !strings.HasPrefix(*portalAddr, "127.0.0.1:") {
+ log.Fatal("state-dir, public-origin and loopback listen addresses required")
+ }
+ if err := os.MkdirAll(*state, 0o700); err != nil {
+ log.Fatal(err)
+ }
+ dsn := os.Getenv("WANCTL_TEST_POSTGRES")
+ if dsn == "" {
+ log.Fatal("WANCTL_TEST_POSTGRES must point to disposable PostgreSQL")
+ }
+ base, err := sql.Open("pgx", dsn)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if _, err = base.Exec("CREATE SCHEMA IF NOT EXISTS webfetch_browser_demo"); err != nil {
+ log.Fatal(err)
+ }
+ base.Close()
+ u, err := url.Parse(dsn)
+ if err != nil {
+ log.Fatal(err)
+ }
+ query := u.Query()
+ query.Set("search_path", "webfetch_browser_demo")
+ u.RawQuery = query.Encode()
+ pg, err := relay.OpenPG(u.String())
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer pg.Close()
+ owner, err := pg.ResolveUser("webfetch-demo@example.invalid")
+ if err != nil {
+ log.Fatal(err)
+ }
+ deviceToken, err := pg.IssueToken(owner, "isolated-test-agent", 1)
+ if err != nil {
+ log.Fatal(err)
+ }
+ portalToken, err := pg.IssueToken("portal", "isolated-test-portal", 1)
+ if err != nil {
+ log.Fatal(err)
+ }
+ adminSecret := hex.EncodeToString(secret(filepath.Join(*state, "admin.key")))
+ portalID, err := transport.IdentityFromSeed(secret(filepath.Join(*state, "portal.key")), "webfetch-test-portal")
+ if err != nil {
+ log.Fatal(err)
+ }
+ localRelay := "http://" + *relayAddr
+ localPortal := "http://" + *portalAddr
+ r := relay.New(pg)
+ r.SetAdmin(pg)
+ r.SetACL(pg)
+ r.SetAuditor(pg)
+ r.SetDocs(pg)
+ r.SetAdminSecret(adminSecret)
+ r.SetPortalNS("portal")
+ h, err := webfetch.New(webfetch.Config{Store: pg, Jobs: pg, Seed: secret(filepath.Join(*state, "webfetch.key")), RelayURL: localRelay, PublicOrigin: *public, PortalOrigin: localPortal})
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer h.Close()
+ r.SetWebFetchHandler(h)
+ deviceDir := filepath.Join(*state, "device")
+ if err = os.MkdirAll(deviceDir, 0o700); err != nil {
+ log.Fatal(err)
+ }
+ os.Setenv("WANCTL_CONFIG_DIR", deviceDir)
+ if err = config.SaveSetting("portal", localPortal); err != nil {
+ log.Fatal(err)
+ }
+ identity, err := transport.LoadOrCreateIdentity()
+ if err != nil {
+ log.Fatal(err)
+ }
+ sandbox := filepath.Join(*state, "sandbox")
+ if err = os.MkdirAll(sandbox, 0o700); err != nil {
+ log.Fatal(err)
+ }
+ rules, err := policy.Open("rules.json", policy.ModeNormal)
+ if err != nil {
+ log.Fatal(err)
+ }
+ // This fixture uses the real device policy with only one test directory
+ // and one harmless command. It never grants bypass mode.
+ if len(rules.List()) == 0 {
+ for _, kind := range []policy.Kind{policy.KindRead, policy.KindWrite} {
+ if err = rules.Add(policy.Rule{Kind: kind, Pattern: sandbox, Scope: policy.ScopeDir}); err != nil {
+ log.Fatal(err)
+ }
+ }
+ if err = rules.Add(policy.Rule{Kind: policy.KindExec, Pattern: "printf wanctl-webfetch-ok", Scope: policy.ScopeGlobal}); err != nil {
+ log.Fatal(err)
+ }
+ }
+ ag, err := agent.New(agent.Options{RelayURL: localRelay, Token: deviceToken, Name: "WebFetch isolated test device", Mode: policy.ModeNormal, Transport: "http", PortalFP: portalID.Fingerprint, Version: "webfetch-dev"})
+ if err != nil {
+ log.Fatal(err)
+ }
+ known := transport.NewMemStore()
+ if err = known.Pin(owner+"/"+ag.DeviceID(), identity.Fingerprint, false); err != nil {
+ log.Fatal(err)
+ }
+ p := portal.New(portal.Config{RelayAdminURL: localRelay, AdminSecret: adminSecret, UserHeader: "X-Demo-User", RelayDialURL: localRelay, PortalToken: portalToken, Transport: "http", Identity: portalID, Known: known, PublicOrigin: localPortal})
+ ph := p.Handler()
+ portalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Host != *portalAddr {
+ http.Error(w, "loopback fixture only", 403)
+ return
+ }
+ r.Header.Del("X-Demo-User")
+ r.Header.Set("X-Demo-User", "webfetch-demo@example.invalid")
+ ph.ServeHTTP(w, r)
+ })
+ relayServer := limits.HTTPServer(*relayAddr, r.Handler())
+ portalServer := limits.HTTPServer(*portalAddr, portalHandler)
+ go func() {
+ if err := relayServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatal(err)
+ }
+ }()
+ go func() {
+ if err := portalServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatal(err)
+ }
+ }()
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer cancel()
+ go ag.Run(ctx)
+ info := map[string]any{"relay": localRelay, "portal": localPortal, "public_webfetch": *public + "/webfetch", "owner": owner, "device": ag.DeviceID(), "device_fingerprint": identity.Fingerprint, "portal_fingerprint": portalID.Fingerprint, "sandbox": sandbox, "policy": "normal; test-directory read/write and printf wanctl-webfetch-ok only"}
+ data, _ := json.MarshalIndent(info, "", " ")
+ if err = os.WriteFile(filepath.Join(*state, "info.json"), append(data, '\n'), 0o600); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println(string(data))
+ <-ctx.Done()
+ ag.Close()
+ shutdown, stop := context.WithTimeout(context.Background(), 5*time.Second)
+ defer stop()
+ relayServer.Shutdown(shutdown)
+ portalServer.Shutdown(shutdown)
+}