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(rev ? t().revoked : expired ? t().expired : t().active) + '' + + '' + (rev || expired ? '' : '') + ''; }).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 + + + + +
+ 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}} +
+
+ Choose your devices + {{range .Devices}} + + {{else}} +

No owned devices with a registered identity. Enroll a device first.

+ {{end}} +
+ + +

This grants device use only. It cannot change trust, rules or mode, or approve operations. First connections still use the existing pairing flow. A device in bypass mode allows operations without asking.

+
+ + +
+ +
+ {{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:

{{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) +}