From 481e2bcd90450f5fda37a3f0f5ab88b35b9b2f88 Mon Sep 17 00:00:00 2001 From: dcanic Date: Thu, 24 Sep 2026 11:35:57 +0200 Subject: [PATCH 1/9] feat(db): add `--product wordpress` to `db tunnel` for Airo-managed sites Managed WordPress sites are owned through an Airo subscription, so the Node.js Hosting mint does not know them. `--product wordpress` mints the tunnel token from the Airo API instead; the default stays `nodejs`. Adds a proposal doc for the WordPress flow and its open dependencies. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 250 +++++++++++++++++++++++++++++++ rust/src/db/tunnel.rs | 51 ++++++- rust/src/hosting/client.rs | 20 ++- rust/src/hosting/client_tests.rs | 26 ++++ 4 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 docs/proposals/db-tunnel-mwp.md diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md new file mode 100644 index 00000000..02bec185 --- /dev/null +++ b/docs/proposals/db-tunnel-mwp.md @@ -0,0 +1,250 @@ +# Proposal: `gddy db tunnel --product wordpress` — MySQL access for Managed WordPress + +Status: draft. The CLI, airo-go, and hosting mint changes are implemented but not +merged. The end-to-end flow is **blocked**: Managed WordPress apps do not run an +agent today (see **Blocker: no agent on Managed WordPress**). + +This document covers only what differs for Managed WordPress (product `mwp`, +product app type `mhwp`). The WebSocket relay, bind safety, events, feature +gating, and the MySQL security model are the same as for Node.js Hosting; see +[db-tunnel.md](./db-tunnel.md). + +## Motivation + +`gddy db tunnel` gives a developer a local MySQL port that relays to an app's +database through the app's per-app **agent**. For Node.js Hosting apps, the +CLI gets the agent URL and a short-lived token from the Node.js Hosting API. +Managed WordPress sites are Airo-managed apps: they are owned through an Airo +subscription, and the Node.js Hosting API does not know about them. They need +their own mint path, and the rest of the tunnel stays the same. + +## How it works + +The two planes are the same as for Node.js: + +- **Control plane.** One HTTPS call when the command starts. For WordPress it + goes to the **Airo API** (airo-go) and not to the Node.js Hosting API. + airo-go checks the caller and asks hosting to mint. +- **Data plane.** One WebSocket per local TCP connection, straight from the CLI + to the app's agent, carrying raw MySQL bytes. It never touches airo-go or + hosting. + +```mermaid +sequenceDiagram + autonumber + participant CLI as gddy db tunnel
--product wordpress + participant Edge as api.godaddy.com gateway
+ frontdoor (pioneermgmt) + participant Airo as airo-go
(Airo API) + participant Host as hosting API + participant SSO as SSO (cert2s) + participant AAB as airo-builder
/api/auth/agent-token + participant Agent as App agent + participant DB as App MySQL + + CLI->>Edge: POST /v1/airo/apps/{appId}/database-tunnel/agent-token
Authorization: Bearer + Edge->>Airo: POST /api/airo/v1/apps/{appId}/database-tunnel/agent-token + Airo->>Airo: validate OAuth JWT (JWKS, iss, aud, typ, exp)
require scope hosting.database.tunnel:execute + Airo->>Airo: rate limit per app + customer (10/min, burst 5) + Airo->>Airo: GetCustomerForApp(appId)
caller must be the owner → owner shopperId + Airo->>Host: POST hosting/v1/apps/{appId}/database-tunnel/agent-token
service JWT, body {shopperId}, X-OAuth-Token + Host->>Host: resolve agent URL from the composition URL patterns
(https only; none → 404, no mint) + Host->>SSO: cert2s delegation for shopperId + Host->>AAB: POST /api/auth/agent-token
Authorization: sso-jwt , X-OAuth-Token
{siteId, owningProduct: AiroAppBuilder, requestDatabaseTunnel: true, ttl 1h} + AAB-->>Host: {token} (agent JWT with canTunnelDatabase) + Host->>Host: check token exp (≤ 1h + 1m skew) + Host-->>Airo: {agentUrl, token, expires} + Airo-->>CLI: {agentUrl, token, expires} (passed through) + CLI->>Agent: per TCP conn: WSS /apps/{appId}/database/tunnel
Authorization: Bearer + Agent->>DB: connect(host, port), then relay raw bytes +``` + +### Step by step + +1. **CLI.** `--product wordpress` selects + `HostingClient::get_airo_database_tunnel_token`. It sends + `POST {api base}/v1/airo/apps/{appId}/database-tunnel/agent-token` with the + CLI's OAuth token. The CLI gets that token with the same scopes as for + Node.js: deploy-execute and `hosting.database.tunnel:execute`. The response + body is not logged, because it contains the agent token. The response shape + `{agentUrl, token}` is the same as for Node.js, so everything after the mint + is shared code. +2. **Edge.** The public gateway maps `/v1/airo/...` to the mgmt host at + `/api/airo/...`. Then frontdoor must let an OAuth Bearer token through on + that one path. **Neither route exists today** (see **Cross-team + dependencies**). +3. **airo-go: authenticate.** `RequireOAuthBearer` validates the token the same + way the PaaS public API (HWA) does: + - It gets the signing keys from `https://api./v2/oauth2/jwks`. Keys + are cached for 1 hour. After a failed fetch, the cache waits 30 seconds + before it tries again. + - The token must use RS256. The issuer must be `https://oauth.api.`, + the audience `godaddy.com`, and `typ` either `at+jwt` or + `application/at+jwt`. `exp` is required, with 30 seconds of leeway. + - The scope `hosting.database.tunnel:execute` is required. + - `sub` must be `customer:`. + + An `sso-jwt` header, a missing token, or an invalid token gets `401`. If the + keys cannot be fetched, the answer is `502`. +4. **airo-go: rate limit.** At most 10 requests per minute with a burst of 5, + counted per app and customer (`ExecutionRateLimitKey`). Beyond that the + answer is `429`. +5. **airo-go: ownership.** The CLI sends only the app ID. airo-go looks up the + app's subscription and the customer who owns it (`GetCustomerForApp`), and + the caller must be that customer. An unknown app, a failed lookup, another + customer's app, or an owner without a shopper ID all get the same + `404 app not found`, so the answer does not show whether a foreign app + exists. Only the owner can open a tunnel. Collaborator grants are not + resolved on this path. +6. **airo-go → hosting.** airo-go calls the internal hosting mint with its + service credential. It sends the owner's `shopperId` in the body and the + caller's OAuth token in `X-OAuth-Token`. +7. **hosting: agent URL first.** hosting resolves the app and its cell and + builds the app's URLs from its composition. It takes the `agent` URL, from + the `preview` variant first and then the other variants in alphabetical + order. If no variant has one, it answers `404 App does not have an agent + URL` and **does not mint**. If the agent URL is not `https`, it also does + not mint. +8. **hosting: mint.** hosting gets a cert2s delegation for the shopper. It + then calls airo-builder's `/api/auth/agent-token` with + `owningProduct: AiroAppBuilder` and `requestDatabaseTunnel: true`, and asks + for a token that lives one hour. The token is never cached. hosting reads + the token's `exp` and refuses to return a token that has already expired or + that lives longer than 1 hour plus 1 minute. +9. **Relay.** The CLI opens one WebSocket per MySQL connection to + `wss://{agent host}/apps/{appId}/database/tunnel`. The agent checks the + token, including the `canTunnelDatabase` claim and that the token's app + matches the app in the path. It then dials the app's database and relays + bytes. Everything from here on is described in [db-tunnel.md](./db-tunnel.md). + +## Command surface + +This adds one flag to the flags in [db-tunnel.md](./db-tunnel.md#command-surface): + +| Flag | Required | Default | Purpose | +| --- | --- | --- | --- | +| `--product ` | no | `nodejs` | Selects the service that mints the tunnel token: `nodejs` uses the Node.js Hosting API, `wordpress` uses the Airo API. Any other value is rejected by the argument parser. | + +```console +$ gddy db tunnel --app-id --product wordpress +$ mysql --ssl-mode=REQUIRED -h 127.0.0.1 -P 3306 -u -p +``` + +The product is an explicit flag. The CLI does not detect it, because the only +signal it could use is the agent URL. airo-go makes up an agent URL for apps +that have no agent (see below), so that signal is not reliable. + +## Authentication and authorization + +| Layer | Check | Failure | +| --- | --- | --- | +| Frontdoor | The route allows an OAuth Bearer token on this one path (still to be added) | `401` from the edge, with an empty body | +| airo-go | The OAuth JWT is valid and has the scope `hosting.database.tunnel:execute` | `401` (`502` if the signing keys are unavailable) | +| airo-go | Rate limit per app and customer | `429` | +| airo-go | The caller is the app's owner and the owner has a shopper ID | `404 app not found` | +| hosting | The service credential is allowed (`JWTOrCert`: Airo console or CTK cert) | airo-go answers `502` | +| hosting | The app exists and its composition defines an `https` agent URL | `404` | +| airo-builder | Grants `canTunnelDatabase` for the cert2s delegation and the `AiroAppBuilder` product | hosting `422`, which airo-go answers as `403` | +| hosting | The token's `exp` is within 1 hour | `502` | +| agent | The token signature, the `canTunnelDatabase` claim, and a token app that matches the path app | WebSocket `401`/`403` | +| MySQL | The database user's own password, `GRANT`s, and TLS, end to end | a MySQL error | + +airo-go uses fixed error texts for hosting's `401` and `403`, because those +mean airo-go's own service credential failed. Hosting's other error bodies are +fixed texts and are passed through, so the CLI sees messages such as "App does +not have an agent URL". + +Compared with Node.js Hosting: + +- **Who validates OAuth.** For Node.js, HWA validates the OAuth token. For + WordPress, airo-go validates it with the same rules. airo-builder receives the + OAuth token only as `X-OAuth-Token`, next to the cert2s delegation that it + bases its grant on. +- **Ownership.** For Node.js, the app is looked up for the authenticated + customer. For WordPress, airo-go resolves the app's owner through its + subscription and compares that owner with the caller. Collaborators are + refused. +- **Required scope.** airo-go checks only the tunnel scope. The CLI still + requests deploy-execute as well, so both products use the same credential. + +## Blocker: no agent on Managed WordPress + +The flow above depends on the app having an agent. Managed WordPress apps do +not have one: + +- The `managed-wordpress` composition + (`backends/hosting/templates/compositions/managed-wordpress/v1`) defines only + the `publish` and `staging` variants. Its only URL patterns are + `{appId}..myftpupload.com` and `{appId}-staging..myftpupload.com`. + `requiredApps` is empty, and the only on-demand jobs are `ssh` and `pma`. No + job template contains an agent task. +- Only the `ai-builder` and `paas-nodejs` compositions define an agent task and + an `agent` URL pattern. +- airo-go's `EnhanceApp` makes up agent and preview URLs whenever hosting + returns none, so the Airo app API shows an agent URL for a WordPress app + anyway. For example, on test, `sd4prorehl` shows an agent URL that has no DNS + record. Do not use that URL as proof that an agent exists. + +Because hosting builds the agent URL from the composition and not from +airo-go's made-up URL, the mint **fails closed** today. The CLI gets +`404 App does not have an agent URL`, and no token is minted. + +To remove the blocker, the Managed WordPress job must run something that can +reach the site's database and accept the tunnel WebSocket. The options are: + +1. **An agent task or sidecar in the `managed-wordpress` job**, with an `agent` + URL pattern and routing for it. This runs for the full life of the site. +2. **A tunnel-only on-demand job**, like the existing `ssh` and `pma` jobs. It + starts when a tunnel is requested and gets its own URL. It uses resources + only when a tunnel is in use, but the mint must then wait for the job to be + ready. + +Either option is a hosting template change, and it decides which URL hosting +returns in step 7. + +## Cross-team dependencies + +| Owner | Change | State | +| --- | --- | --- | +| CLI | `--product wordpress` and `get_airo_database_tunnel_token` | Implemented. The checks, clippy, 951 tests, and the module-size check pass. | +| airo-go | `POST /v1/apps/:appId/database-tunnel/agent-token`: OAuth validator, `RequireOAuthBearer`, rate limit, owner check, and a proxy to hosting | Implemented. The route is registered only when the OAuth endpoints can be derived from `GodaddySSOHost`. The build and the handler and middleware tests must run with the Artifactory `GOPROXY`. | +| hosting | `POST /hosting/v1/apps/:id/database-tunnel/agent-token`: resolves the agent URL, cert2s delegation, airo-builder mint, and a token lifetime check | Implemented. The service is wired only when the airo-builder URL and the cert client are configured. | +| hosting | An agent, or an on-demand tunnel job, for `managed-wordpress` | **Not started. This is the blocker.** | +| frontdoor (`frontdoor-config`, `hosting-api-ext/*/pioneermgmt`) | A POST route for `^/api/airo/v1/apps/[^/]+/database-tunnel/agent-token/?$` with `authz-oauth`, placed before the `/api/airo/` catch-all in test and before the `/api/` catch-all in production | Not started. Today the catch-alls allow only `pagely,gdjwt`, so an OAuth token gets `401` at the edge. The frontdoor owners must confirm that the edge passes the `Authorization` header through unchanged. | +| API gateway | Map `api.godaddy.com/v1/airo/apps/...` to the mgmt host at `/api/airo/v1/apps/...` | Not started. The route is not in `frontdoor-config`, and its owner is unknown. | +| airo-builder (AAB) | `resolveDatabaseTunnelGrant` grants for cert2s with `AiroAppBuilder`, and the agent's `PaaSNodeJS` product gate is widened only together with the `canTunnelDatabase` check | For the owner of the AAB OAuth/JWT work. It is not part of this change. | + +## Deferred work + +- **Collaborators.** Only the owner can use this path. Supporting collaborators + would need the OAuth customer mapped to collaborator capabilities. +- **Re-mint before expiry.** This is the same as for Node.js: the token lives + one hour and the CLI does not renew it. +- **Detecting the product.** Once only apps with a real agent return an agent + URL, the CLI could choose the mint itself and `--product` could become + optional. + +## Testing + +- **CLI.** + - `rust/src/db/tunnel.rs`: `product_defaults_to_nodejs_and_accepts_wordpress` + checks that the default is `nodejs`, that `wordpress` is accepted, and that + unknown values are rejected. + - `rust/src/hosting/client_tests.rs`: `get_airo_database_tunnel_token_posts_to_airo_path` + checks the request path, the Bearer header, and the parsed response. +- **airo-go.** + - `oauth/access_token_validator_test.go` covers the signature, issuer, + audience, `typ`, expiry, scope, and `sub` checks, and the key cache. + - `middleware/oauth_bearer_test.go` covers the header handling and maps + errors to `401`, `502`, and `503`. + - `handlers/database_tunnel_proxy_handler_test.go` covers the owner check, + that every miss returns the same 404, and that hosting's `422` becomes + `403`. Router-level tests check that the route is registered only when a + validator is present, that `sso-jwt` and invalid Bearer tokens are refused + before the owner lookup, and that the sixth request in a burst gets `429`. +- **hosting.** `database_tunnel_service_test.go`, `database_tunnel_handler_test.go`, + and `sharetokenapi/agent_token_test.go` cover agent URL selection, the refusal + of non-https agent URLs, the error mapping, and the token lifetime checks. +- **End to end.** Not possible until the blocker is removed and the frontdoor + and gateway routes exist. A probe on test confirmed the current edge + behavior: `401` at the edge for an OAuth token on `mgmt.nstk.net`, and `404` + for `api.test-godaddy.com/v1/airo/...`. diff --git a/rust/src/db/tunnel.rs b/rust/src/db/tunnel.rs index 972a83ad..67cd3fe3 100644 --- a/rust/src/db/tunnel.rs +++ b/rust/src/db/tunnel.rs @@ -9,7 +9,8 @@ //! matching `platform app deploy`. //! //! Auth: the CLI mints a short-lived agent token from the hosting API -//! (`POST /v1/hosting/nodejs/apps/:id/agent-token`) using your GoDaddy OAuth +//! (`POST /v1/hosting/nodejs/apps/:id/agent-token`, or for `--product wordpress` +//! `POST /v1/airo/apps/:id/database-tunnel/agent-token`) using your GoDaddy OAuth //! credential, stepped up to the dedicated `hosting.database.tunnel:execute` //! scope alongside deploy-execute — the tunnel scope is a separate grant, so //! authority to publish a deployment does not by itself grant raw database @@ -80,6 +81,17 @@ struct TunnelArgs { /// the network. Off by default; the default loopback bind needs no flag. #[arg(long = "allow-non-loopback")] allow_non_loopback: bool, + + /// Product the app belongs to. Selects which service mints the tunnel token: + /// Node.js Hosting, or the Airo API for agent-enabled WordPress sites. + #[arg(long, value_enum, value_name = "PRODUCT", default_value_t = TunnelProduct::Nodejs)] + product: TunnelProduct, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +enum TunnelProduct { + Nodejs, + Wordpress, } pub(super) fn command() -> RuntimeCommandSpec { @@ -102,7 +114,9 @@ pub(super) fn command() -> RuntimeCommandSpec { `--ssl-mode=REQUIRED`) so the session is encrypted across the local \ hop as well; the database may require it. The CLI authorizes with \ your GoDaddy credentials and connects to the app's assigned agent \ - automatically. Runs until interrupted (Ctrl-C).", + automatically. Pass `--product wordpress` for an agent-enabled \ + WordPress site; the default is a Node.js Hosting app. Runs until \ + interrupted (Ctrl-C).", ) .with_system("database") .with_tier(Tier::Mutate) @@ -176,7 +190,7 @@ async fn run_tunnel( sender .send(json!({ "type": "step", "name": "authorize", "status": "started" })) .await; - let (agent_url, token) = match mint_agent_token(ctx, &args.app_id).await { + let (agent_url, token) = match mint_agent_token(ctx, &args.app_id, args.product).await { Ok(pair) => pair, Err(e) => return Err(fail(sender, e).await), }; @@ -301,18 +315,21 @@ async fn run_tunnel( /// deploy-execute and the dedicated `hosting.database.tunnel:execute` scope: /// authority to publish a deployment does not by itself grant database access, /// so opening a tunnel requires the separate database-tunnel grant as well. +/// `product` picks the mint; both return the same `{ agentUrl, token }` shape. async fn mint_agent_token( ctx: &CommandContext, app_id: &str, + product: TunnelProduct, ) -> cli_engine::Result<(String, String)> { let required = vec![DEPLOY_EXECUTE.to_owned(), DATABASE_TUNNEL.to_owned()]; let token = ctx.credential_with_scopes(&required).await?.token; let base_url = api_url_for_env(&ctx.middleware.env)?; let client = HostingClient::new(base_url, token); - let resp = client - .get_agent_token(app_id) - .await - .map_err(|e| GddyError::from(e).into_cli_error())?; + let minted = match product { + TunnelProduct::Nodejs => client.get_agent_token(app_id).await, + TunnelProduct::Wordpress => client.get_airo_database_tunnel_token(app_id).await, + }; + let resp = minted.map_err(|e| GddyError::from(e).into_cli_error())?; let agent_url = field_str(&resp, "agentUrl")?; let token = field_str(&resp, "token")?; Ok((agent_url, token)) @@ -689,6 +706,26 @@ mod tests { } } + #[test] + fn product_defaults_to_nodejs_and_accepts_wordpress() { + use clap::Parser; + + #[derive(clap::Parser)] + struct Cli { + #[command(flatten)] + args: super::TunnelArgs, + } + + let default = Cli::try_parse_from(["t", "--app-id", "abc123"]).expect("parse default"); + assert_eq!(default.args.product, super::TunnelProduct::Nodejs); + + let wp = Cli::try_parse_from(["t", "--app-id", "abc123", "--product", "wordpress"]) + .expect("parse wordpress"); + assert_eq!(wp.args.product, super::TunnelProduct::Wordpress); + + assert!(Cli::try_parse_from(["t", "--app-id", "abc123", "--product", "php"]).is_err()); + } + #[test] fn tunnel_error_event_carries_code_and_fix() { let err = crate::error::GddyError::network("boom") diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index 29df9644..e5765df5 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -147,7 +147,7 @@ impl HostingClient { // Spec has no request body; Akamai still 411s a POST with no Content-Length. async fn post_empty_json(&self, path: &str) -> Result { - self.post_empty_json_inner(path, true).await + self.post_empty_json_inner(self.url(path), true).await } /// Like [`post_empty_json`](Self::post_empty_json), but the response body is @@ -157,17 +157,17 @@ impl HostingClient { /// verbatim and offers no body-redaction hook, so the suppression happens /// here, at the one call site that needs it. async fn post_empty_json_secret_response(&self, path: &str) -> Result { - self.post_empty_json_inner(path, false).await + self.post_empty_json_inner(self.url(path), false).await } async fn post_empty_json_inner( &self, - path: &str, + url: String, log_response_body: bool, ) -> Result { let request = self .http - .request(Method::POST, self.url(path)) + .request(Method::POST, url) .bearer_auth(&self.token) .header("x-request-id", Self::new_request_id()) .json(&json!({})) @@ -300,6 +300,18 @@ impl HostingClient { .await } + /// Mint the database-tunnel agent token for an Airo-managed app (agent-enabled + /// WordPress). Same response shape and scopes as + /// [`get_agent_token`](Self::get_agent_token), but served by the Airo API at + /// `/v1/airo/apps/:id/database-tunnel/agent-token`, outside the `/v1/hosting` base. + pub async fn get_airo_database_tunnel_token(&self, app_id: &str) -> Result { + let url = format!( + "{}/v1/airo/apps/{app_id}/database-tunnel/agent-token", + self.base_url + ); + self.post_empty_json_inner(url, false).await + } + pub async fn list_deployments( &self, app_id: &str, diff --git a/rust/src/hosting/client_tests.rs b/rust/src/hosting/client_tests.rs index 138d5ace..1a6c450e 100644 --- a/rust/src/hosting/client_tests.rs +++ b/rust/src/hosting/client_tests.rs @@ -557,3 +557,29 @@ async fn get_agent_token_posts_empty_body_and_returns_url_and_token() { assert_eq!(body["agentUrl"], "https://app-1.agent.example"); assert_eq!(body["token"], "minted-agent-jwt"); } + +#[tokio::test] +async fn get_airo_database_tunnel_token_posts_to_airo_path() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/airo/apps/app-1/database-tunnel/agent-token") + .header("authorization", "Bearer test-token") + .json_body(json!({})); + then.status(200).json_body(json!({ + "agentUrl": "https://app-1.agent.example", + "token": "minted-agent-jwt" + })); + }) + .await; + + let body = client(&server.base_url()) + .get_airo_database_tunnel_token("app-1") + .await + .expect("get airo database tunnel token"); + + mock.assert_async().await; + assert_eq!(body["agentUrl"], "https://app-1.agent.example"); + assert_eq!(body["token"], "minted-agent-jwt"); +} From 9932114c2710bd1c5080999df0f0aff73b2c9aaa Mon Sep 17 00:00:00 2001 From: dcanic Date: Thu, 24 Sep 2026 12:49:07 +0200 Subject: [PATCH 2/9] fix(db): mint WordPress tunnel tokens from the Airo /v1/hosting route airo-go now serves the tunnel mint on its shared /v1/hosting OAuth chain, so the CLI calls /v1/airo/hosting/apps/:id/database-tunnel/agent-token. The MWP proposal doc describes the new chain, status codes and edge routes. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 86 +++++++++++++++++++------------- rust/src/db/tunnel.rs | 2 +- rust/src/hosting/client.rs | 4 +- rust/src/hosting/client_tests.rs | 2 +- 4 files changed, 54 insertions(+), 40 deletions(-) diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index 02bec185..351d1c49 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -41,9 +41,10 @@ sequenceDiagram participant Agent as App agent participant DB as App MySQL - CLI->>Edge: POST /v1/airo/apps/{appId}/database-tunnel/agent-token
Authorization: Bearer - Edge->>Airo: POST /api/airo/v1/apps/{appId}/database-tunnel/agent-token - Airo->>Airo: validate OAuth JWT (JWKS, iss, aud, typ, exp)
require scope hosting.database.tunnel:execute + CLI->>Edge: POST /v1/airo/hosting/apps/{appId}/database-tunnel/agent-token
Authorization: Bearer + Edge->>Airo: POST /api/airo/v1/hosting/apps/{appId}/database-tunnel/agent-token + Airo->>Airo: /v1/hosting OAuth chain: validate OAuth JWT (JWKS, iss, aud, typ, exp),
resolve the caller's shopper, TLA gate + Airo->>Airo: require scope hosting.database.tunnel:execute Airo->>Airo: rate limit per app + customer (10/min, burst 5) Airo->>Airo: GetCustomerForApp(appId)
caller must be the owner → owner shopperId Airo->>Host: POST hosting/v1/apps/{appId}/database-tunnel/agent-token
service JWT, body {shopperId}, X-OAuth-Token @@ -62,32 +63,43 @@ sequenceDiagram 1. **CLI.** `--product wordpress` selects `HostingClient::get_airo_database_tunnel_token`. It sends - `POST {api base}/v1/airo/apps/{appId}/database-tunnel/agent-token` with the + `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel/agent-token` with the CLI's OAuth token. The CLI gets that token with the same scopes as for Node.js: deploy-execute and `hosting.database.tunnel:execute`. The response body is not logged, because it contains the agent token. The response shape `{agentUrl, token}` is the same as for Node.js, so everything after the mint is shared code. 2. **Edge.** The public gateway maps `/v1/airo/...` to the mgmt host at - `/api/airo/...`. Then frontdoor must let an OAuth Bearer token through on + `/api/airo/v1/...`. Then frontdoor must let an OAuth Bearer token through on that one path. **Neither route exists today** (see **Cross-team - dependencies**). -3. **airo-go: authenticate.** `RequireOAuthBearer` validates the token the same - way the PaaS public API (HWA) does: - - It gets the signing keys from `https://api./v2/oauth2/jwks`. Keys - are cached for 1 hour. After a failed fetch, the cache waits 30 seconds - before it tries again. - - The token must use RS256. The issuer must be `https://oauth.api.`, - the audience `godaddy.com`, and `typ` either `at+jwt` or - `application/at+jwt`. `exp` is required, with 30 seconds of leeway. - - The scope `hosting.database.tunnel:execute` is required. - - `sub` must be `customer:`. - - An `sso-jwt` header, a missing token, or an invalid token gets `401`. If the - keys cannot be fetched, the answer is `502`. -4. **airo-go: rate limit.** At most 10 requests per minute with a burst of 5, - counted per app and customer (`ExecutionRateLimitKey`). Beyond that the - answer is `429`. + dependencies**). The public path assumes that mapping; the gateway owner has + to confirm it. +3. **airo-go: authenticate.** The route is on airo-go's public `/v1/hosting` + group, which accepts only an Authorization Platform OAuth access token + (`OAuthAuthMiddleware`, BACK-3991). It never accepts an `sso-jwt`, an admin + token, or a service certificate. It validates the token the same way the + PaaS public API (HWA) does: + - The issuer and its key set are configured (`oauthIssuer`, `oauthJwksUrl`: + `https://oauth.api.` and `https://api./v2/oauth2/jwks`). If + they are not configured, the group, and so this route, is not mounted. + - The token must use RS256 with a key of at least 2048 bits. The audience + must be `godaddy.com`, and `typ` either `at+jwt` or `application/at+jwt`. + `exp` is required, with 30 seconds of leeway. Delegated tokens (an `act` + claim) are refused. + - `sub` must be `customer:`. The middleware maps that customer to a + shopper ID (`airo_customers`, then the Shopper API) and applies the TLA + shopper gate, which is enforced in production. + + A missing, malformed, or invalid token, or an `sso-jwt` header, gets `401`. + A valid token for a customer with no shopper, or a shopper outside the TLA + gate, gets `403`. If the signing keys cannot be fetched, the answer is + `503`. +4. **airo-go: scope and rate limit.** Every `/v1/hosting` route names its + scope. This one requires `hosting.database.tunnel:execute`; a token without + it gets `403 Insufficient scope`, so deploy authority alone is not enough. + Then at most 10 requests per minute with a burst of 5 are allowed, counted + per app and customer (`ExecutionRateLimitKey`). Beyond that the answer is + `429`. 5. **airo-go: ownership.** The CLI sends only the app ID. airo-go looks up the app's subscription and the customer who owns it (`GetCustomerForApp`), and the caller must be that customer. An unknown app, a failed lookup, another @@ -138,7 +150,9 @@ that have no agent (see below), so that signal is not reliable. | Layer | Check | Failure | | --- | --- | --- | | Frontdoor | The route allows an OAuth Bearer token on this one path (still to be added) | `401` from the edge, with an empty body | -| airo-go | The OAuth JWT is valid and has the scope `hosting.database.tunnel:execute` | `401` (`502` if the signing keys are unavailable) | +| airo-go | The OAuth JWT is valid (`/v1/hosting` chain) | `401` (`503` if the signing keys are unavailable) | +| airo-go | The customer has a shopper ID and passes the TLA gate | `403` | +| airo-go | The token has the scope `hosting.database.tunnel:execute` | `403 Insufficient scope` | | airo-go | Rate limit per app and customer | `429` | | airo-go | The caller is the app's owner and the owner has a shopper ID | `404 app not found` | | hosting | The service credential is allowed (`JWTOrCert`: Airo console or CTK cert) | airo-go answers `502` | @@ -156,9 +170,9 @@ not have an agent URL". Compared with Node.js Hosting: - **Who validates OAuth.** For Node.js, HWA validates the OAuth token. For - WordPress, airo-go validates it with the same rules. airo-builder receives the - OAuth token only as `X-OAuth-Token`, next to the cert2s delegation that it - bases its grant on. + WordPress, airo-go's shared `/v1/hosting` OAuth chain validates it with the + same rules. airo-builder receives the OAuth token only as `X-OAuth-Token`, + next to the cert2s delegation that it bases its grant on. - **Ownership.** For Node.js, the app is looked up for the authenticated customer. For WordPress, airo-go resolves the app's owner through its subscription and compares that owner with the caller. Collaborators are @@ -206,11 +220,11 @@ returns in step 7. | Owner | Change | State | | --- | --- | --- | | CLI | `--product wordpress` and `get_airo_database_tunnel_token` | Implemented. The checks, clippy, 951 tests, and the module-size check pass. | -| airo-go | `POST /v1/apps/:appId/database-tunnel/agent-token`: OAuth validator, `RequireOAuthBearer`, rate limit, owner check, and a proxy to hosting | Implemented. The route is registered only when the OAuth endpoints can be derived from `GodaddySSOHost`. The build and the handler and middleware tests must run with the Artifactory `GOPROXY`. | +| airo-go | `POST /v1/hosting/apps/:appId/database-tunnel/agent-token` on the shared `/v1/hosting` OAuth chain (BACK-3991): scope check, rate limit, owner check, and a proxy to hosting | Implemented. The route is registered only when `oauthIssuer` and `oauthJwksUrl` are configured. The build and the handler tests must run with the Artifactory `GOPROXY`. | | hosting | `POST /hosting/v1/apps/:id/database-tunnel/agent-token`: resolves the agent URL, cert2s delegation, airo-builder mint, and a token lifetime check | Implemented. The service is wired only when the airo-builder URL and the cert client are configured. | | hosting | An agent, or an on-demand tunnel job, for `managed-wordpress` | **Not started. This is the blocker.** | -| frontdoor (`frontdoor-config`, `hosting-api-ext/*/pioneermgmt`) | A POST route for `^/api/airo/v1/apps/[^/]+/database-tunnel/agent-token/?$` with `authz-oauth`, placed before the `/api/airo/` catch-all in test and before the `/api/` catch-all in production | Not started. Today the catch-alls allow only `pagely,gdjwt`, so an OAuth token gets `401` at the edge. The frontdoor owners must confirm that the edge passes the `Authorization` header through unchanged. | -| API gateway | Map `api.godaddy.com/v1/airo/apps/...` to the mgmt host at `/api/airo/v1/apps/...` | Not started. The route is not in `frontdoor-config`, and its owner is unknown. | +| frontdoor (`frontdoor-config`, `hosting-api-ext/*/pioneermgmt`) | A POST route for `^/api/airo/v1/hosting/apps/[^/]+/database-tunnel/agent-token/?$` with `authz-oauth`, placed before the `/api/airo/` catch-all in test and before the `/api/` catch-all in production | Not started. Today the catch-alls allow only `pagely,gdjwt`, so an OAuth token gets `401` at the edge. The frontdoor owners must confirm that the edge passes the `Authorization` header through unchanged. | +| API gateway | Map `api.godaddy.com/v1/airo/hosting/apps/...` to the mgmt host at `/api/airo/v1/hosting/apps/...` | Not started. The route is not in `frontdoor-config`, and its owner is unknown. | | airo-builder (AAB) | `resolveDatabaseTunnelGrant` grants for cert2s with `AiroAppBuilder`, and the agent's `PaaSNodeJS` product gate is widened only together with the `canTunnelDatabase` check | For the owner of the AAB OAuth/JWT work. It is not part of this change. | ## Deferred work @@ -232,15 +246,15 @@ returns in step 7. - `rust/src/hosting/client_tests.rs`: `get_airo_database_tunnel_token_posts_to_airo_path` checks the request path, the Bearer header, and the parsed response. - **airo-go.** - - `oauth/access_token_validator_test.go` covers the signature, issuer, - audience, `typ`, expiry, scope, and `sub` checks, and the key cache. - - `middleware/oauth_bearer_test.go` covers the header handling and maps - errors to `401`, `502`, and `503`. + - The shared OAuth chain has its own tests (`auth/oauth_access_token_validator_test.go`, + `middleware/oauth_auth_test.go`, `handlers/hosting_router_test.go`). - `handlers/database_tunnel_proxy_handler_test.go` covers the owner check, that every miss returns the same 404, and that hosting's `422` becomes - `403`. Router-level tests check that the route is registered only when a - validator is present, that `sso-jwt` and invalid Bearer tokens are refused - before the owner lookup, and that the sixth request in a burst gets `429`. + `403`. Router-level tests run through the real `/v1/hosting` chain. They + check that the route is registered only when that chain is configured, + that `sso-jwt` and invalid Bearer tokens get `401` before the owner lookup, + that a token without the tunnel scope gets `403`, that the validated token + is forwarded to hosting, and that the sixth request in a burst gets `429`. - **hosting.** `database_tunnel_service_test.go`, `database_tunnel_handler_test.go`, and `sharetokenapi/agent_token_test.go` cover agent URL selection, the refusal of non-https agent URLs, the error mapping, and the token lifetime checks. diff --git a/rust/src/db/tunnel.rs b/rust/src/db/tunnel.rs index 67cd3fe3..86ae1197 100644 --- a/rust/src/db/tunnel.rs +++ b/rust/src/db/tunnel.rs @@ -10,7 +10,7 @@ //! //! Auth: the CLI mints a short-lived agent token from the hosting API //! (`POST /v1/hosting/nodejs/apps/:id/agent-token`, or for `--product wordpress` -//! `POST /v1/airo/apps/:id/database-tunnel/agent-token`) using your GoDaddy OAuth +//! `POST /v1/airo/hosting/apps/:id/database-tunnel/agent-token`) using your GoDaddy OAuth //! credential, stepped up to the dedicated `hosting.database.tunnel:execute` //! scope alongside deploy-execute — the tunnel scope is a separate grant, so //! authority to publish a deployment does not by itself grant raw database diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index e5765df5..67b198ee 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -303,10 +303,10 @@ impl HostingClient { /// Mint the database-tunnel agent token for an Airo-managed app (agent-enabled /// WordPress). Same response shape and scopes as /// [`get_agent_token`](Self::get_agent_token), but served by the Airo API at - /// `/v1/airo/apps/:id/database-tunnel/agent-token`, outside the `/v1/hosting` base. + /// `/v1/airo/hosting/apps/:id/database-tunnel/agent-token`, outside the `/v1/hosting` base. pub async fn get_airo_database_tunnel_token(&self, app_id: &str) -> Result { let url = format!( - "{}/v1/airo/apps/{app_id}/database-tunnel/agent-token", + "{}/v1/airo/hosting/apps/{app_id}/database-tunnel/agent-token", self.base_url ); self.post_empty_json_inner(url, false).await diff --git a/rust/src/hosting/client_tests.rs b/rust/src/hosting/client_tests.rs index 1a6c450e..d7877e09 100644 --- a/rust/src/hosting/client_tests.rs +++ b/rust/src/hosting/client_tests.rs @@ -564,7 +564,7 @@ async fn get_airo_database_tunnel_token_posts_to_airo_path() { let mock = server .mock_async(|when, then| { when.method(POST) - .path("/v1/airo/apps/app-1/database-tunnel/agent-token") + .path("/v1/airo/hosting/apps/app-1/database-tunnel/agent-token") .header("authorization", "Bearer test-token") .json_body(json!({})); then.status(200).json_body(json!({ From 7e5af95d74b888c387483cbbe8fa68bd9702a60f Mon Sep 17 00:00:00 2001 From: dcanic Date: Thu, 24 Sep 2026 13:07:58 +0200 Subject: [PATCH 3/9] docs(db): drop the unconfirmed frontdoor routing from the MWP tunnel proposal The public route from the CLI to airo-go is not confirmed, so the proposal no longer assumes a proxy in front of it and lists the routing as an open item. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index 351d1c49..b3ff5886 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -33,7 +33,7 @@ The two planes are the same as for Node.js: sequenceDiagram autonumber participant CLI as gddy db tunnel
--product wordpress - participant Edge as api.godaddy.com gateway
+ frontdoor (pioneermgmt) + participant Route as Public route to airo-go
(not confirmed) participant Airo as airo-go
(Airo API) participant Host as hosting API participant SSO as SSO (cert2s) @@ -41,8 +41,8 @@ sequenceDiagram participant Agent as App agent participant DB as App MySQL - CLI->>Edge: POST /v1/airo/hosting/apps/{appId}/database-tunnel/agent-token
Authorization: Bearer - Edge->>Airo: POST /api/airo/v1/hosting/apps/{appId}/database-tunnel/agent-token + CLI->>Route: POST /v1/airo/hosting/apps/{appId}/database-tunnel/agent-token
Authorization: Bearer + Route->>Airo: POST /v1/hosting/apps/{appId}/database-tunnel/agent-token Airo->>Airo: /v1/hosting OAuth chain: validate OAuth JWT (JWKS, iss, aud, typ, exp),
resolve the caller's shopper, TLA gate Airo->>Airo: require scope hosting.database.tunnel:execute Airo->>Airo: rate limit per app + customer (10/min, burst 5) @@ -69,11 +69,12 @@ sequenceDiagram body is not logged, because it contains the agent token. The response shape `{agentUrl, token}` is the same as for Node.js, so everything after the mint is shared code. -2. **Edge.** The public gateway maps `/v1/airo/...` to the mgmt host at - `/api/airo/v1/...`. Then frontdoor must let an OAuth Bearer token through on - that one path. **Neither route exists today** (see **Cross-team - dependencies**). The public path assumes that mapping; the gateway owner has - to confirm it. +2. **Public route.** The request must reach airo-go's + `/v1/hosting/apps/{appId}/database-tunnel/agent-token` with the + `Authorization` header unchanged. **How the public URL maps to airo-go is not + confirmed.** The CLI path above assumes that `/v1/airo/...` on the API host + maps to airo-go's `/v1/...`. Nothing in this proposal depends on a particular + proxy in between (see **Cross-team dependencies**). 3. **airo-go: authenticate.** The route is on airo-go's public `/v1/hosting` group, which accepts only an Authorization Platform OAuth access token (`OAuthAuthMiddleware`, BACK-3991). It never accepts an `sso-jwt`, an admin @@ -149,7 +150,6 @@ that have no agent (see below), so that signal is not reliable. | Layer | Check | Failure | | --- | --- | --- | -| Frontdoor | The route allows an OAuth Bearer token on this one path (still to be added) | `401` from the edge, with an empty body | | airo-go | The OAuth JWT is valid (`/v1/hosting` chain) | `401` (`503` if the signing keys are unavailable) | | airo-go | The customer has a shopper ID and passes the TLA gate | `403` | | airo-go | The token has the scope `hosting.database.tunnel:execute` | `403 Insufficient scope` | @@ -223,8 +223,7 @@ returns in step 7. | airo-go | `POST /v1/hosting/apps/:appId/database-tunnel/agent-token` on the shared `/v1/hosting` OAuth chain (BACK-3991): scope check, rate limit, owner check, and a proxy to hosting | Implemented. The route is registered only when `oauthIssuer` and `oauthJwksUrl` are configured. The build and the handler tests must run with the Artifactory `GOPROXY`. | | hosting | `POST /hosting/v1/apps/:id/database-tunnel/agent-token`: resolves the agent URL, cert2s delegation, airo-builder mint, and a token lifetime check | Implemented. The service is wired only when the airo-builder URL and the cert client are configured. | | hosting | An agent, or an on-demand tunnel job, for `managed-wordpress` | **Not started. This is the blocker.** | -| frontdoor (`frontdoor-config`, `hosting-api-ext/*/pioneermgmt`) | A POST route for `^/api/airo/v1/hosting/apps/[^/]+/database-tunnel/agent-token/?$` with `authz-oauth`, placed before the `/api/airo/` catch-all in test and before the `/api/` catch-all in production | Not started. Today the catch-alls allow only `pagely,gdjwt`, so an OAuth token gets `401` at the edge. The frontdoor owners must confirm that the edge passes the `Authorization` header through unchanged. | -| API gateway | Map `api.godaddy.com/v1/airo/hosting/apps/...` to the mgmt host at `/api/airo/v1/hosting/apps/...` | Not started. The route is not in `frontdoor-config`, and its owner is unknown. | +| Public routing (owner to be identified) | Confirm the public URL for airo-go's `/v1/hosting/apps/:appId/database-tunnel/agent-token` and that it forwards an OAuth `Authorization: Bearer` header unchanged | Not confirmed. The CLI path `/v1/airo/hosting/...` is an assumption and changes if the confirmed URL differs. | | airo-builder (AAB) | `resolveDatabaseTunnelGrant` grants for cert2s with `AiroAppBuilder`, and the agent's `PaaSNodeJS` product gate is widened only together with the `canTunnelDatabase` check | For the owner of the AAB OAuth/JWT work. It is not part of this change. | ## Deferred work @@ -258,7 +257,5 @@ returns in step 7. - **hosting.** `database_tunnel_service_test.go`, `database_tunnel_handler_test.go`, and `sharetokenapi/agent_token_test.go` cover agent URL selection, the refusal of non-https agent URLs, the error mapping, and the token lifetime checks. -- **End to end.** Not possible until the blocker is removed and the frontdoor - and gateway routes exist. A probe on test confirmed the current edge - behavior: `401` at the edge for an OAuth token on `mgmt.nstk.net`, and `404` - for `api.test-godaddy.com/v1/airo/...`. +- **End to end.** Not possible until the blocker is removed and the public + route to airo-go is confirmed. From 973a401cd108e01242ea04702149f387530b92a1 Mon Sep 17 00:00:00 2001 From: dcanic Date: Tue, 29 Sep 2026 18:00:19 +0200 Subject: [PATCH 4/9] feat(db): open WordPress tunnels through on-demand relay sessions Managed WordPress has no per-app agent, so `--product wordpress` now asks the Airo API for an on-demand relay session and waits for its readiness probe before listening. The proposal is updated to the on-demand relay design. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 387 +++++++++++++++---------------- rust/src/db/tunnel.rs | 222 +++++++++++++++--- rust/src/hosting/client.rs | 17 +- rust/src/hosting/client_tests.rs | 19 +- 4 files changed, 393 insertions(+), 252 deletions(-) diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index b3ff5886..c774253f 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -1,133 +1,127 @@ # Proposal: `gddy db tunnel --product wordpress` — MySQL access for Managed WordPress -Status: draft. The CLI, airo-go, and hosting mint changes are implemented but not -merged. The end-to-end flow is **blocked**: Managed WordPress apps do not run an -agent today (see **Blocker: no agent on Managed WordPress**). +Status: draft. The CLI, airo-go, and hosting changes are implemented but not +merged. Open work, blockers, and their current state are tracked in +`mhp-core/docs/db-tunnel-on-demand-followups.md`. This document describes the +design only. This document covers only what differs for Managed WordPress (product `mwp`, -product app type `mhwp`). The WebSocket relay, bind safety, events, feature -gating, and the MySQL security model are the same as for Node.js Hosting; see +product app type `mhwp`). The WebSocket protocol, bind safety, events, and the +MySQL security model are the same as for Node.js Hosting; see [db-tunnel.md](./db-tunnel.md). ## Motivation `gddy db tunnel` gives a developer a local MySQL port that relays to an app's -database through the app's per-app **agent**. For Node.js Hosting apps, the -CLI gets the agent URL and a short-lived token from the Node.js Hosting API. -Managed WordPress sites are Airo-managed apps: they are owned through an Airo -subscription, and the Node.js Hosting API does not know about them. They need -their own mint path, and the rest of the tunnel stays the same. +database over a WebSocket. For Node.js Hosting apps, the far end is the app's +long-running **agent**. Managed WordPress apps have no agent: the +`managed-wordpress` composition runs no agent task and defines no `agent` URL. +Running one for the full life of every site only to serve an occasional tunnel +is wasteful. -## How it works +Instead, hosting starts a small **on-demand relay job** when a tunnel is +requested, in the same way it starts phpMyAdmin (`pma`) sessions. The relay +speaks the same WebSocket protocol as the agent, so the CLI relay code is +shared. + +## Design decisions + +| Decision | Chosen | Rejected, and why | +| --- | --- | --- | +| What serves the WebSocket | An on-demand `db-tunnel` Nomad job per session, deployed through hosting's existing on-demand job framework (`onDemandJobs` in the composition) | An agent task or sidecar in the main `managed-wordpress` job: it would run for the full life of every site. | +| How the CLI gets a token | hosting mints a relay session token. airo-go only authorizes and proxies. | A hosting mint of an airo-builder agent token (cert2s delegation plus `X-OAuth-Token`): there is no agent to accept it. This design was built and reverted. | +| How MySQL is reached | The relay dials the database host and port that hosting returns. MySQL credentials never leave the database. | Going through the phpMyAdmin proxy: that gives a web console, not a MySQL port. | +| Session sharing | One live session per app, cell, and variant is reused, and every mint gets a fresh token | A new job per CLI run: it starts slowly and wastes resources on reconnects. | +| Token storage | Only a sha256 verifier is stored. The token is bound to one session. | Plaintext tokens, or tokens valid for any session: a leaked row, or a token from one relay, could open another. | +| Infrastructure | Phase 1 reuses the phpMyAdmin DNS zone, Nomad namespace, and hosting API base URL (see **Phase 1 shortcuts**) | New ones now: that needs DNS, Nomad, and config work before the first tunnel. | +| Image ownership | Pioneer images builds the relay image, alongside the phpMyAdmin image. It is registered per app with the same mechanism. | — | -The two planes are the same as for Node.js: +## How it works -- **Control plane.** One HTTPS call when the command starts. For WordPress it - goes to the **Airo API** (airo-go) and not to the Node.js Hosting API. - airo-go checks the caller and asks hosting to mint. +- **Control plane.** One HTTPS call when the command starts, to the **Airo API** + (airo-go). airo-go checks the caller and asks hosting for a relay session. + Hosting creates the session and schedules the relay job, or reuses a live + session. +- **Readiness.** A new relay needs time to be scheduled and routed. The CLI polls + the session's `pollUrl` until it answers, for at most 3 minutes, before it opens + the local port. - **Data plane.** One WebSocket per local TCP connection, straight from the CLI - to the app's agent, carrying raw MySQL bytes. It never touches airo-go or - hosting. + to the relay, carrying raw MySQL bytes. It never touches airo-go or hosting. ```mermaid sequenceDiagram autonumber participant CLI as gddy db tunnel
--product wordpress - participant Route as Public route to airo-go
(not confirmed) participant Airo as airo-go
(Airo API) participant Host as hosting API - participant SSO as SSO (cert2s) - participant AAB as airo-builder
/api/auth/agent-token - participant Agent as App agent + participant Nomad as Nomad (cell) + participant Relay as db-tunnel relay job participant DB as App MySQL - CLI->>Route: POST /v1/airo/hosting/apps/{appId}/database-tunnel/agent-token
Authorization: Bearer - Route->>Airo: POST /v1/hosting/apps/{appId}/database-tunnel/agent-token - Airo->>Airo: /v1/hosting OAuth chain: validate OAuth JWT (JWKS, iss, aud, typ, exp),
resolve the caller's shopper, TLA gate - Airo->>Airo: require scope hosting.database.tunnel:execute - Airo->>Airo: rate limit per app + customer (10/min, burst 5) - Airo->>Airo: GetCustomerForApp(appId)
caller must be the owner → owner shopperId - Airo->>Host: POST hosting/v1/apps/{appId}/database-tunnel/agent-token
service JWT, body {shopperId}, X-OAuth-Token - Host->>Host: resolve agent URL from the composition URL patterns
(https only; none → 404, no mint) - Host->>SSO: cert2s delegation for shopperId - Host->>AAB: POST /api/auth/agent-token
Authorization: sso-jwt , X-OAuth-Token
{siteId, owningProduct: AiroAppBuilder, requestDatabaseTunnel: true, ttl 1h} - AAB-->>Host: {token} (agent JWT with canTunnelDatabase) - Host->>Host: check token exp (≤ 1h + 1m skew) - Host-->>Airo: {agentUrl, token, expires} - Airo-->>CLI: {agentUrl, token, expires} (passed through) - CLI->>Agent: per TCP conn: WSS /apps/{appId}/database/tunnel
Authorization: Bearer - Agent->>DB: connect(host, port), then relay raw bytes + CLI->>Airo: POST /v1/airo/hosting/apps/{appId}/database-tunnel
Authorization: Bearer + Airo->>Airo: /v1/hosting OAuth chain, TLA gate,
scope hosting.database.tunnel:execute, rate limit + Airo->>Airo: GetCustomerForApp(appId): caller must be the owner + Airo->>Host: POST hosting/v1/apps/{appId}/db-tunnel
service credential, {createdBy, variant?} + Host->>Host: reuse a live session, or create one
(row + sha256 token verifier) + Host->>Nomad: deploy on-demand job "db-tunnel"
host dbt-{sessionId}..pma. + Host-->>Airo: {sessionId, url, pollUrl, token, variant, expiresAt, reused} + Airo-->>CLI: passed through + loop until 2xx or 3 minutes + CLI->>Relay: GET pollUrl (/healthz) + end + CLI->>Relay: per TCP conn: WSS /apps/{appId}/database/tunnel
Authorization: Bearer + Relay->>Host: POST /v1/db-tunnel/redeem {token, sessionId} + Host-->>Relay: {host, port} (no credentials) + Relay->>DB: connect(host, port), then relay raw bytes + Relay->>Host: POST /v1/db-tunnel/heartbeat every 60s (410 → exit) ``` ### Step by step -1. **CLI.** `--product wordpress` selects - `HostingClient::get_airo_database_tunnel_token`. It sends - `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel/agent-token` with the - CLI's OAuth token. The CLI gets that token with the same scopes as for - Node.js: deploy-execute and `hosting.database.tunnel:execute`. The response - body is not logged, because it contains the agent token. The response shape - `{agentUrl, token}` is the same as for Node.js, so everything after the mint - is shared code. -2. **Public route.** The request must reach airo-go's - `/v1/hosting/apps/{appId}/database-tunnel/agent-token` with the - `Authorization` header unchanged. **How the public URL maps to airo-go is not - confirmed.** The CLI path above assumes that `/v1/airo/...` on the API host - maps to airo-go's `/v1/...`. Nothing in this proposal depends on a particular - proxy in between (see **Cross-team dependencies**). -3. **airo-go: authenticate.** The route is on airo-go's public `/v1/hosting` - group, which accepts only an Authorization Platform OAuth access token - (`OAuthAuthMiddleware`, BACK-3991). It never accepts an `sso-jwt`, an admin - token, or a service certificate. It validates the token the same way the - PaaS public API (HWA) does: - - The issuer and its key set are configured (`oauthIssuer`, `oauthJwksUrl`: - `https://oauth.api.` and `https://api./v2/oauth2/jwks`). If - they are not configured, the group, and so this route, is not mounted. - - The token must use RS256 with a key of at least 2048 bits. The audience - must be `godaddy.com`, and `typ` either `at+jwt` or `application/at+jwt`. - `exp` is required, with 30 seconds of leeway. Delegated tokens (an `act` - claim) are refused. - - `sub` must be `customer:`. The middleware maps that customer to a - shopper ID (`airo_customers`, then the Shopper API) and applies the TLA - shopper gate, which is enforced in production. - - A missing, malformed, or invalid token, or an `sso-jwt` header, gets `401`. - A valid token for a customer with no shopper, or a shopper outside the TLA - gate, gets `403`. If the signing keys cannot be fetched, the answer is - `503`. -4. **airo-go: scope and rate limit.** Every `/v1/hosting` route names its - scope. This one requires `hosting.database.tunnel:execute`; a token without - it gets `403 Insufficient scope`, so deploy authority alone is not enough. - Then at most 10 requests per minute with a burst of 5 are allowed, counted - per app and customer (`ExecutionRateLimitKey`). Beyond that the answer is - `429`. -5. **airo-go: ownership.** The CLI sends only the app ID. airo-go looks up the - app's subscription and the customer who owns it (`GetCustomerForApp`), and - the caller must be that customer. An unknown app, a failed lookup, another - customer's app, or an owner without a shopper ID all get the same - `404 app not found`, so the answer does not show whether a foreign app - exists. Only the owner can open a tunnel. Collaborator grants are not - resolved on this path. -6. **airo-go → hosting.** airo-go calls the internal hosting mint with its - service credential. It sends the owner's `shopperId` in the body and the - caller's OAuth token in `X-OAuth-Token`. -7. **hosting: agent URL first.** hosting resolves the app and its cell and - builds the app's URLs from its composition. It takes the `agent` URL, from - the `preview` variant first and then the other variants in alphabetical - order. If no variant has one, it answers `404 App does not have an agent - URL` and **does not mint**. If the agent URL is not `https`, it also does - not mint. -8. **hosting: mint.** hosting gets a cert2s delegation for the shopper. It - then calls airo-builder's `/api/auth/agent-token` with - `owningProduct: AiroAppBuilder` and `requestDatabaseTunnel: true`, and asks - for a token that lives one hour. The token is never cached. hosting reads - the token's `exp` and refuses to return a token that has already expired or - that lives longer than 1 hour plus 1 minute. -9. **Relay.** The CLI opens one WebSocket per MySQL connection to - `wss://{agent host}/apps/{appId}/database/tunnel`. The agent checks the - token, including the `canTunnelDatabase` claim and that the token's app - matches the app in the path. It then dials the app's database and relays - bytes. Everything from here on is described in [db-tunnel.md](./db-tunnel.md). +1. **CLI.** `--product wordpress` calls + `HostingClient::ensure_airo_database_tunnel_session`, which sends + `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel` with the CLI's + OAuth token. The token has the same scopes as for Node.js: deploy-execute and + `hosting.database.tunnel:execute`. The response body is not logged, because + it contains the relay token. +2. **airo-go: authenticate, scope, rate limit.** The route is on airo-go's + public `/v1/hosting` group, which accepts only an Authorization Platform + OAuth access token. The token must carry `hosting.database.tunnel:execute`, + so deploy authority alone is not enough. At most 10 requests per minute + with a burst of 5 are allowed per app and customer. +3. **airo-go: ownership.** airo-go looks up the customer who owns the app, and + the caller must be that customer. An unknown app, a failed lookup, or + another customer's app all get the same `404 app not found`. Collaborators + are not resolved on this path. +4. **airo-go → hosting.** airo-go calls hosting's + `POST hosting/v1/apps/{appId}/db-tunnel` with its service credential and no + `X-On-Behalf-Of`. The body carries only attribution (`createdBy`) and an + optional `variant` (`publish` or `staging`). `force` is never forwarded. +5. **hosting: session.** hosting refuses an app with no database. It reuses a + live session in the same cell and variant when at least 15 minutes of its + 1-hour lifetime remain, and mints a fresh token for it. Otherwise it creates + a session and deploys the `db-tunnel` on-demand job. Only a sha256 verifier + of the token is stored. +6. **Relay.** The relay redeems the token for its own session only, and gets + back the database host and port, never credentials. MySQL authentication and + TLS stay end to end between the client and the database. The relay + heartbeats while it runs. If a heartbeat returns `410`, the session is gone + and the relay exits. +7. **Teardown.** Sessions end when they expire, when the relay stops + heartbeating, or when the app is torn down, archived, or moved. hosting + revokes the tokens, marks the session stopped, and stops the Nomad job. A + sweep command (`db-tunnel-session-sweep`) cleans up leftovers. + +### Session lifecycle + +| Setting | Value | Effect | +| --- | --- | --- | +| Session lifetime | 1 hour | The token and the session expire together. The CLI does not renew them. | +| Reuse window | at least 15 minutes left | A mint reuses a live session only if it has at least this long left. Otherwise it starts a new one. | +| Heartbeat interval | 60 seconds | How often the relay reports that it is alive. | +| Liveness timeout | 5 minutes | With no heartbeat for this long, the session counts as dead: redeem answers `410` and it is not reused. | +| Startup grace | 3 minutes | A new session counts as alive before its first heartbeat for this long. The CLI's readiness wait uses the same limit. | +| Sweep grace | 15 minutes past expiry | The sweep stops sessions older than this, up to 200 per run. | ## Command surface @@ -142,9 +136,8 @@ $ gddy db tunnel --app-id --product wordpress $ mysql --ssl-mode=REQUIRED -h 127.0.0.1 -P 3306 -u -p ``` -The product is an explicit flag. The CLI does not detect it, because the only -signal it could use is the agent URL. airo-go makes up an agent URL for apps -that have no agent (see below), so that signal is not reliable. +For WordPress, the event stream has an extra `provision` step between +`authorize` and `listening` while the CLI waits for the relay. ## Authentication and authorization @@ -154,108 +147,90 @@ that have no agent (see below), so that signal is not reliable. | airo-go | The customer has a shopper ID and passes the TLA gate | `403` | | airo-go | The token has the scope `hosting.database.tunnel:execute` | `403 Insufficient scope` | | airo-go | Rate limit per app and customer | `429` | -| airo-go | The caller is the app's owner and the owner has a shopper ID | `404 app not found` | +| airo-go | The caller is the app's owner | `404 app not found` | | hosting | The service credential is allowed (`JWTOrCert`: Airo console or CTK cert) | airo-go answers `502` | -| hosting | The app exists and its composition defines an `https` agent URL | `404` | -| airo-builder | Grants `canTunnelDatabase` for the cert2s delegation and the `AiroAppBuilder` product | hosting `422`, which airo-go answers as `403` | -| hosting | The token's `exp` is within 1 hour | `502` | -| agent | The token signature, the `canTunnelDatabase` claim, and a token app that matches the path app | WebSocket `401`/`403` | +| hosting | The app exists and owns a database | `409` | +| relay → hosting | The token is live and belongs to the relay's own session | `401`, or `410` when the session is gone | | MySQL | The database user's own password, `GRANT`s, and TLS, end to end | a MySQL error | -airo-go uses fixed error texts for hosting's `401` and `403`, because those -mean airo-go's own service credential failed. Hosting's other error bodies are -fixed texts and are passed through, so the CLI sees messages such as "App does -not have an agent URL". - -Compared with Node.js Hosting: - -- **Who validates OAuth.** For Node.js, HWA validates the OAuth token. For - WordPress, airo-go's shared `/v1/hosting` OAuth chain validates it with the - same rules. airo-builder receives the OAuth token only as `X-OAuth-Token`, - next to the cert2s delegation that it bases its grant on. -- **Ownership.** For Node.js, the app is looked up for the authenticated - customer. For WordPress, airo-go resolves the app's owner through its - subscription and compares that owner with the caller. Collaborators are - refused. -- **Required scope.** airo-go checks only the tunnel scope. The CLI still - requests deploy-execute as well, so both products use the same credential. - -## Blocker: no agent on Managed WordPress - -The flow above depends on the app having an agent. Managed WordPress apps do -not have one: - -- The `managed-wordpress` composition - (`backends/hosting/templates/compositions/managed-wordpress/v1`) defines only - the `publish` and `staging` variants. Its only URL patterns are - `{appId}..myftpupload.com` and `{appId}-staging..myftpupload.com`. - `requiredApps` is empty, and the only on-demand jobs are `ssh` and `pma`. No - job template contains an agent task. -- Only the `ai-builder` and `paas-nodejs` compositions define an agent task and - an `agent` URL pattern. -- airo-go's `EnhanceApp` makes up agent and preview URLs whenever hosting - returns none, so the Airo app API shows an agent URL for a WordPress app - anyway. For example, on test, `sd4prorehl` shows an agent URL that has no DNS - record. Do not use that URL as proof that an agent exists. - -Because hosting builds the agent URL from the composition and not from -airo-go's made-up URL, the mint **fails closed** today. The CLI gets -`404 App does not have an agent URL`, and no token is minted. - -To remove the blocker, the Managed WordPress job must run something that can -reach the site's database and accept the tunnel WebSocket. The options are: - -1. **An agent task or sidecar in the `managed-wordpress` job**, with an `agent` - URL pattern and routing for it. This runs for the full life of the site. -2. **A tunnel-only on-demand job**, like the existing `ssh` and `pma` jobs. It - starts when a tunnel is requested and gets its own URL. It uses resources - only when a tunnel is in use, but the mint must then wait for the job to be - ready. - -Either option is a hosting template change, and it decides which URL hosting -returns in step 7. - -## Cross-team dependencies - -| Owner | Change | State | -| --- | --- | --- | -| CLI | `--product wordpress` and `get_airo_database_tunnel_token` | Implemented. The checks, clippy, 951 tests, and the module-size check pass. | -| airo-go | `POST /v1/hosting/apps/:appId/database-tunnel/agent-token` on the shared `/v1/hosting` OAuth chain (BACK-3991): scope check, rate limit, owner check, and a proxy to hosting | Implemented. The route is registered only when `oauthIssuer` and `oauthJwksUrl` are configured. The build and the handler tests must run with the Artifactory `GOPROXY`. | -| hosting | `POST /hosting/v1/apps/:id/database-tunnel/agent-token`: resolves the agent URL, cert2s delegation, airo-builder mint, and a token lifetime check | Implemented. The service is wired only when the airo-builder URL and the cert client are configured. | -| hosting | An agent, or an on-demand tunnel job, for `managed-wordpress` | **Not started. This is the blocker.** | -| Public routing (owner to be identified) | Confirm the public URL for airo-go's `/v1/hosting/apps/:appId/database-tunnel/agent-token` and that it forwards an OAuth `Authorization: Bearer` header unchanged | Not confirmed. The CLI path `/v1/airo/hosting/...` is an assumption and changes if the confirmed URL differs. | -| airo-builder (AAB) | `resolveDatabaseTunnelGrant` grants for cert2s with `AiroAppBuilder`, and the agent's `PaaSNodeJS` product gate is widened only together with the `canTunnelDatabase` check | For the owner of the AAB OAuth/JWT work. It is not part of this change. | - -## Deferred work - -- **Collaborators.** Only the owner can use this path. Supporting collaborators - would need the OAuth customer mapped to collaborator capabilities. -- **Re-mint before expiry.** This is the same as for Node.js: the token lives - one hour and the CLI does not renew it. -- **Detecting the product.** Once only apps with a real agent return an agent - URL, the CLI could choose the mint itself and `--product` could become - optional. +The CLI also refuses a `pollUrl` that is not `https` or not on the relay's own +host. + +## Phase 1 shortcuts + +These reuse phpMyAdmin infrastructure and are marked with TODOs in the hosting +code: + +- **DNS.** The relay's hostname is under the phpMyAdmin wildcard zone + (`dbt-{sessionId}..pma.`). It should move to its own zone. +- **Nomad namespace.** The relay job runs in the `phpmyadmin` namespace. It + should get its own. +- **API base URL.** The relay reaches hosting through the same base URL that + phpMyAdmin uses. + +## Ownership + +| Owner | Part | +| --- | --- | +| CLI | `--product wordpress`, the session mint call, and the readiness poll | +| airo-go | `POST /v1/hosting/apps/:appId/database-tunnel` on the `/v1/hosting` OAuth chain: scope, rate limit, owner check, and a proxy to hosting. Also wiring that chain in production. | +| hosting | Session tables, the session service, the mint, redeem, heartbeat, and stop routes, the `db-tunnel` job template, teardown, and the sweep command | +| hosting operations | Registering the relay image per environment and scheduling the sweep | +| pioneer images | The relay image, built to the **Relay contract** | + +The state of each part is in `mhp-core/docs/db-tunnel-on-demand-followups.md`. + +## Hosting API + +| Route | Caller | Auth | Answer | +| --- | --- | --- | --- | +| `POST /hosting/v1/apps/:id/db-tunnel` | airo-go, support tools | `JWTOrCert` (Airo console or CTK cert) | `200 {sessionId, domain, url, pollUrl, token, variant, expiresAt, reused}`. `400` for a bad request, `409` for no database, `503` when not configured or the cell is unreachable. | +| `POST /hosting/v1/apps/:id/db-tunnel/stop` | support tools | `JWTOrCert` | `200 {stopped, failed}`, or `204` if nothing was live | +| `POST /hosting/v1/db-tunnel/redeem` | relay | the session token in the body | `200 {sessionId, appId, variant, host, port, expiresAt}`. `401` for a bad token, `410` when the session is gone. | +| `POST /hosting/v1/db-tunnel/heartbeat` | relay | the session token in the body | `200 {ok: true}`. `401` for a bad token, `410` when the session is gone. | + +## Relay contract + +The relay image must: + +- Listen on port `8080`. Answer `GET /healthz` with `200` once it can take + tunnels. +- Accept `GET /apps/{APP_ID}/database/tunnel` as a WebSocket upgrade with + `Authorization: Bearer `. Relay binary frames to and from MySQL + exactly like the Node.js agent, with frames of up to 32 MiB. +- Redeem each presented token with `POST {HOSTING_DB_TUNNEL_API}/v1/db-tunnel/redeem` + and the body `{"token": "...", "sessionId": ""}`. Dial + the `host:port` it returns. Refuse the WebSocket on `401` or `410`. +- Send `POST {HOSTING_DB_TUNNEL_API}/v1/db-tunnel/heartbeat` with the same body + every 60 seconds. Exit on `410`. +- Never log tokens, and never receive or log database credentials. +- Run with a read-only root filesystem, all capabilities dropped, + `no-new-privileges`, and a 16 MB `/tmp`, within 64 MHz of CPU and 128 MB of + memory. + +The job sets `APP_ID`, `DOM_ID`, `DB_TUNNEL_DOMAIN`, `DB_TUNNEL_SESSION_ID`, +`DB_TUNNEL_VARIANT`, `HOSTING_DB_TUNNEL_API`, `PORT`, `REGION`, `SERVER_ENV`, +and `IMAGE`. + +## Limitations + +- **Owner only.** Collaborator grants are not resolved on the OAuth path. +- **No renewal.** New connections fail once the 1-hour session ends. Run the + command again to get a new session. +- **Publish only from the CLI.** airo-go and hosting accept `staging`, but the + CLI has no `--variant` flag yet. +- **Idle relays.** A relay stays up until its session expires, even after the + CLI exits, because concurrent tunnels can share a session. ## Testing -- **CLI.** - - `rust/src/db/tunnel.rs`: `product_defaults_to_nodejs_and_accepts_wordpress` - checks that the default is `nodejs`, that `wordpress` is accepted, and that - unknown values are rejected. - - `rust/src/hosting/client_tests.rs`: `get_airo_database_tunnel_token_posts_to_airo_path` - checks the request path, the Bearer header, and the parsed response. -- **airo-go.** - - The shared OAuth chain has its own tests (`auth/oauth_access_token_validator_test.go`, - `middleware/oauth_auth_test.go`, `handlers/hosting_router_test.go`). - - `handlers/database_tunnel_proxy_handler_test.go` covers the owner check, - that every miss returns the same 404, and that hosting's `422` becomes - `403`. Router-level tests run through the real `/v1/hosting` chain. They - check that the route is registered only when that chain is configured, - that `sso-jwt` and invalid Bearer tokens get `401` before the owner lookup, - that a token without the tunnel scope gets `403`, that the validated token - is forwarded to hosting, and that the sixth request in a burst gets `429`. -- **hosting.** `database_tunnel_service_test.go`, `database_tunnel_handler_test.go`, - and `sharetokenapi/agent_token_test.go` cover agent URL selection, the refusal - of non-https agent URLs, the error mapping, and the token lifetime checks. -- **End to end.** Not possible until the blocker is removed and the public - route to airo-go is confirmed. +- **CLI.** `rust/src/db/tunnel.rs` covers the product flag, parsing both mint + response shapes, and the `pollUrl` checks. `rust/src/hosting/client_tests.rs` + covers the request path, the Bearer header, and the parsed response. +- **airo-go.** `handlers/database_tunnel_proxy_handler_test.go` covers the owner + check, the same 404 for every miss, variant forwarding and validation, status + mapping, and, through the real `/v1/hosting` chain, registration, `401`, + scope `403`, and the rate limit. +- **hosting.** The service, sweep, handler, router, and template render tests + (`db_tunnel_*_test.go`, `managed_wordpress_db_tunnel_job_test.go`). +- **End to end.** Not run yet. See the follow-up doc for what it is waiting on. diff --git a/rust/src/db/tunnel.rs b/rust/src/db/tunnel.rs index 86ae1197..8dcae952 100644 --- a/rust/src/db/tunnel.rs +++ b/rust/src/db/tunnel.rs @@ -8,16 +8,21 @@ //! never injects or inspects credentials. Progress is streamed as JSON events, //! matching `platform app deploy`. //! -//! Auth: the CLI mints a short-lived agent token from the hosting API -//! (`POST /v1/hosting/nodejs/apps/:id/agent-token`, or for `--product wordpress` -//! `POST /v1/airo/hosting/apps/:id/database-tunnel/agent-token`) using your GoDaddy OAuth -//! credential, stepped up to the dedicated `hosting.database.tunnel:execute` -//! scope alongside deploy-execute — the tunnel scope is a separate grant, so -//! authority to publish a deployment does not by itself grant raw database -//! read/write. It then connects to the agent URL -//! that call returns, sending the minted token as `Authorization: Bearer`. The -//! agent URL and token both come from the service — neither is a user-supplied -//! flag. +//! Auth: the CLI mints a short-lived token using your GoDaddy OAuth credential, +//! stepped up to the dedicated `hosting.database.tunnel:execute` scope alongside +//! deploy-execute — the tunnel scope is a separate grant, so authority to +//! publish a deployment does not by itself grant raw database read/write. +//! +//! - Node.js Hosting: `POST /v1/hosting/nodejs/apps/:id/agent-token` returns the +//! app's agent URL and an agent token. +//! - `--product wordpress`: Managed WordPress has no per-app agent, so +//! `POST /v1/airo/hosting/apps/:id/database-tunnel` starts (or reuses) an +//! on-demand relay speaking the same WebSocket protocol, and returns its URL, +//! a readiness `pollUrl` and a relay token. The CLI waits for `pollUrl` to +//! answer before listening. +//! +//! The token goes out as `Authorization: Bearer`. The URL and token both come +//! from the service — neither is a user-supplied flag. use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -60,6 +65,15 @@ const MAX_AGENT_FRAME_BYTES: usize = 32 * 1024 * 1024; /// if a pong misses the next tick). Comfortably inside that window. const FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); +/// How long to wait for a freshly scheduled WordPress relay to answer its +/// readiness probe. Matches hosting's startup grace for a relay that has not +/// heartbeated yet; past it the session is treated as dead anyway. +const RELAY_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); + +const RELAY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); + +const RELAY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + #[derive(Debug, Clone, clap::Args)] struct TunnelArgs { /// Application/site id — the app whose database to tunnel to. The CLI mints @@ -83,7 +97,7 @@ struct TunnelArgs { allow_non_loopback: bool, /// Product the app belongs to. Selects which service mints the tunnel token: - /// Node.js Hosting, or the Airo API for agent-enabled WordPress sites. + /// Node.js Hosting, or the Airo API for Managed WordPress sites. #[arg(long, value_enum, value_name = "PRODUCT", default_value_t = TunnelProduct::Nodejs)] product: TunnelProduct, } @@ -114,9 +128,10 @@ pub(super) fn command() -> RuntimeCommandSpec { `--ssl-mode=REQUIRED`) so the session is encrypted across the local \ hop as well; the database may require it. The CLI authorizes with \ your GoDaddy credentials and connects to the app's assigned agent \ - automatically. Pass `--product wordpress` for an agent-enabled \ - WordPress site; the default is a Node.js Hosting app. Runs until \ - interrupted (Ctrl-C).", + automatically. Pass `--product wordpress` for a Managed WordPress \ + site, which starts a short-lived relay and can take up to a few \ + minutes before the port opens; the default is a Node.js Hosting \ + app. Runs until interrupted (Ctrl-C).", ) .with_system("database") .with_tier(Tier::Mutate) @@ -190,19 +205,32 @@ async fn run_tunnel( sender .send(json!({ "type": "step", "name": "authorize", "status": "started" })) .await; - let (agent_url, token) = match mint_agent_token(ctx, &args.app_id, args.product).await { - Ok(pair) => pair, + let target = match mint_tunnel_target(ctx, &args.app_id, args.product).await { + Ok(target) => target, Err(e) => return Err(fail(sender, e).await), }; sender .send(json!({ "type": "step", "name": "authorize", "status": "completed" })) .await; - let ws_url = match build_tunnel_ws_url(&agent_url, &args.app_id) { + let ws_url = match build_tunnel_ws_url(&target.endpoint, &args.app_id) { Ok(url) => url, Err(e) => return Err(fail(sender, e.into_cli_error()).await), }; + if let Some(poll_url) = &target.ready_url { + sender + .send(json!({ "type": "step", "name": "provision", "status": "started" })) + .await; + if let Err(e) = wait_for_relay(poll_url, &target.endpoint).await { + return Err(fail(sender, e.into_cli_error()).await); + } + sender + .send(json!({ "type": "step", "name": "provision", "status": "completed" })) + .await; + } + let token = target.token; + let bind_addr = format!("{}:{}", args.listen_host, args.port); let listener = match TcpListener::bind(&bind_addr).await { Ok(listener) => listener, @@ -310,44 +338,117 @@ async fn run_tunnel( Ok(()) } -/// Mint a short-lived agent token for `app_id` via the hosting API and return -/// `(agent_url, token)`. Steps the CLI's OAuth credential up to *both* -/// deploy-execute and the dedicated `hosting.database.tunnel:execute` scope: -/// authority to publish a deployment does not by itself grant database access, -/// so opening a tunnel requires the separate database-tunnel grant as well. -/// `product` picks the mint; both return the same `{ agentUrl, token }` shape. -async fn mint_agent_token( +/// Where the tunnel connects: the base URL of the WebSocket endpoint, the bearer +/// token it accepts, and — for an on-demand relay — the probe to wait on first. +#[derive(Debug, PartialEq, Eq)] +struct TunnelTarget { + endpoint: String, + token: String, + ready_url: Option, +} + +/// Mint a short-lived tunnel token for `app_id`. Steps the CLI's OAuth +/// credential up to *both* deploy-execute and the dedicated +/// `hosting.database.tunnel:execute` scope: authority to publish a deployment +/// does not by itself grant database access, so opening a tunnel requires the +/// separate database-tunnel grant as well. +async fn mint_tunnel_target( ctx: &CommandContext, app_id: &str, product: TunnelProduct, -) -> cli_engine::Result<(String, String)> { +) -> cli_engine::Result { let required = vec![DEPLOY_EXECUTE.to_owned(), DATABASE_TUNNEL.to_owned()]; let token = ctx.credential_with_scopes(&required).await?.token; let base_url = api_url_for_env(&ctx.middleware.env)?; let client = HostingClient::new(base_url, token); let minted = match product { TunnelProduct::Nodejs => client.get_agent_token(app_id).await, - TunnelProduct::Wordpress => client.get_airo_database_tunnel_token(app_id).await, + TunnelProduct::Wordpress => client.ensure_airo_database_tunnel_session(app_id).await, }; let resp = minted.map_err(|e| GddyError::from(e).into_cli_error())?; - let agent_url = field_str(&resp, "agentUrl")?; - let token = field_str(&resp, "token")?; - Ok((agent_url, token)) + parse_tunnel_target(&resp, product) } -/// Pull a required string field out of the agent-token response, mapping a -/// missing or non-string value to a coded error (the service contract is broken). +fn parse_tunnel_target(resp: &Value, product: TunnelProduct) -> cli_engine::Result { + Ok(match product { + TunnelProduct::Nodejs => TunnelTarget { + endpoint: field_str(resp, "agentUrl")?, + token: field_str(resp, "token")?, + ready_url: None, + }, + TunnelProduct::Wordpress => TunnelTarget { + endpoint: field_str(resp, "url")?, + token: field_str(resp, "token")?, + ready_url: Some(field_str(resp, "pollUrl")?), + }, + }) +} + +/// Pull a required string field out of the mint response, mapping a missing or +/// non-string value to a coded error (the service contract is broken). fn field_str(resp: &Value, key: &str) -> cli_engine::Result { resp.get(key) .and_then(Value::as_str) .map(str::to_owned) .ok_or_else(|| { - GddyError::network(format!("hosting agent-token response missing '{key}'")) + GddyError::network(format!("database tunnel mint response missing '{key}'")) .with_fix("Retry; if it persists, the app may not support database tunneling yet.") .into_cli_error() }) } +/// Poll the relay's readiness probe until it answers 2xx. A reused session +/// answers on the first probe; a new one needs its job scheduled and routed. +async fn wait_for_relay(poll_url: &str, endpoint: &str) -> Result<(), GddyError> { + let probe = validate_poll_url(poll_url, endpoint)?; + let http = crate::http::make_http_client(); + let deadline = tokio::time::Instant::now() + RELAY_READY_TIMEOUT; + loop { + let ready = http + .get(probe.clone()) + .timeout(RELAY_PROBE_TIMEOUT) + .send() + .await + .is_ok_and(|resp| resp.status().is_success()); + if ready { + return Ok(()); + } + if tokio::time::Instant::now() + RELAY_POLL_INTERVAL >= deadline { + return Err(GddyError::network(format!( + "the database tunnel relay did not become ready within {}s", + RELAY_READY_TIMEOUT.as_secs() + )) + .with_fix("Retry the command; the relay is reused if it finishes starting. If it keeps failing, contact support.")); + } + tokio::time::sleep(RELAY_POLL_INTERVAL).await; + } +} + +/// The probe URL is service-supplied, so hold it to the same bar as the +/// endpoint: HTTPS only, and on the relay's own host. +fn validate_poll_url(poll_url: &str, endpoint: &str) -> Result { + let probe = url::Url::parse(poll_url).map_err(|e| { + GddyError::network(format!( + "hosting service returned an invalid poll URL '{poll_url}': {e}" + )) + })?; + if probe.scheme() != "https" { + return Err(GddyError::network(format!( + "poll URL has an insecure or unsupported scheme '{}': expected https", + probe.scheme() + ))); + } + let endpoint_host = url::Url::parse(endpoint) + .ok() + .and_then(|u| u.host_str().map(str::to_ascii_lowercase)); + if probe.host_str().map(str::to_ascii_lowercase) != endpoint_host { + return Err(GddyError::network(format!( + "poll URL '{poll_url}' is not on the relay host" + ))); + } + Ok(probe) +} + /// Handle one accepted MySQL client: open its own agent WebSocket and relay /// bytes until either side closes. Emits `connection` open/close/error events; /// a failure here never tears down the listener. @@ -738,4 +839,61 @@ mod tests { assert_eq!(event["error"]["message"], "boom"); assert_eq!(event["fix"], "do the thing"); } + + #[test] + fn nodejs_target_uses_agent_url_without_readiness_probe() { + let resp = serde_json::json!({ "agentUrl": "https://agent.example", "token": "t" }); + let target = + super::parse_tunnel_target(&resp, super::TunnelProduct::Nodejs).expect("nodejs target"); + assert_eq!( + target, + super::TunnelTarget { + endpoint: "https://agent.example".to_owned(), + token: "t".to_owned(), + ready_url: None, + } + ); + } + + #[test] + fn wordpress_target_uses_relay_url_and_poll_url() { + let resp = serde_json::json!({ + "sessionId": "s1", + "url": "https://dbt-s1.c1.pma.example", + "pollUrl": "https://dbt-s1.c1.pma.example/healthz", + "token": "relay-token", + }); + let target = super::parse_tunnel_target(&resp, super::TunnelProduct::Wordpress) + .expect("wordpress target"); + assert_eq!(target.endpoint, "https://dbt-s1.c1.pma.example"); + assert_eq!(target.token, "relay-token"); + assert_eq!( + target.ready_url.as_deref(), + Some("https://dbt-s1.c1.pma.example/healthz") + ); + } + + #[test] + fn wordpress_target_requires_poll_url() { + let resp = serde_json::json!({ "url": "https://dbt-s1.example", "token": "t" }); + assert!(super::parse_tunnel_target(&resp, super::TunnelProduct::Wordpress).is_err()); + let legacy = serde_json::json!({ "agentUrl": "https://agent.example", "token": "t" }); + assert!(super::parse_tunnel_target(&legacy, super::TunnelProduct::Wordpress).is_err()); + } + + #[test] + fn poll_url_must_be_https_on_the_relay_host() { + let relay = "https://dbt-s1.c1.pma.example"; + assert!(super::validate_poll_url("https://DBT-S1.c1.pma.example/healthz", relay).is_ok()); + for bad in [ + "http://dbt-s1.c1.pma.example/healthz", + "https://evil.example/healthz", + "not a url", + ] { + assert!( + super::validate_poll_url(bad, relay).is_err(), + "expected {bad:?} to be rejected" + ); + } + } } diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index 67b198ee..598cd60c 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -300,15 +300,20 @@ impl HostingClient { .await } - /// Mint the database-tunnel agent token for an Airo-managed app (agent-enabled - /// WordPress). Same response shape and scopes as - /// [`get_agent_token`](Self::get_agent_token), but served by the Airo API at - /// `/v1/airo/hosting/apps/:id/database-tunnel/agent-token`, outside the `/v1/hosting` base. - pub async fn get_airo_database_tunnel_token(&self, app_id: &str) -> Result { + /// Start (or reuse) an on-demand database-tunnel relay for a Managed WordPress + /// app, served by the Airo API at `/v1/airo/hosting/apps/:id/database-tunnel`, + /// outside the `/v1/hosting` base. Same scopes as + /// [`get_agent_token`](Self::get_agent_token). Response shape: + /// `{ sessionId, url, pollUrl, token, variant, expiresAt, reused }`. + pub async fn ensure_airo_database_tunnel_session( + &self, + app_id: &str, + ) -> Result { let url = format!( - "{}/v1/airo/hosting/apps/{app_id}/database-tunnel/agent-token", + "{}/v1/airo/hosting/apps/{app_id}/database-tunnel", self.base_url ); + // Secret response: the body carries the relay's bearer token. self.post_empty_json_inner(url, false).await } diff --git a/rust/src/hosting/client_tests.rs b/rust/src/hosting/client_tests.rs index d7877e09..3109f7b1 100644 --- a/rust/src/hosting/client_tests.rs +++ b/rust/src/hosting/client_tests.rs @@ -559,27 +559,30 @@ async fn get_agent_token_posts_empty_body_and_returns_url_and_token() { } #[tokio::test] -async fn get_airo_database_tunnel_token_posts_to_airo_path() { +async fn ensure_airo_database_tunnel_session_posts_to_airo_path() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(POST) - .path("/v1/airo/hosting/apps/app-1/database-tunnel/agent-token") + .path("/v1/airo/hosting/apps/app-1/database-tunnel") .header("authorization", "Bearer test-token") .json_body(json!({})); then.status(200).json_body(json!({ - "agentUrl": "https://app-1.agent.example", - "token": "minted-agent-jwt" + "sessionId": "s1", + "url": "https://dbt-s1.c1.pma.example", + "pollUrl": "https://dbt-s1.c1.pma.example/healthz", + "token": "relay-token" })); }) .await; let body = client(&server.base_url()) - .get_airo_database_tunnel_token("app-1") + .ensure_airo_database_tunnel_session("app-1") .await - .expect("get airo database tunnel token"); + .expect("ensure airo database tunnel session"); mock.assert_async().await; - assert_eq!(body["agentUrl"], "https://app-1.agent.example"); - assert_eq!(body["token"], "minted-agent-jwt"); + assert_eq!(body["url"], "https://dbt-s1.c1.pma.example"); + assert_eq!(body["pollUrl"], "https://dbt-s1.c1.pma.example/healthz"); + assert_eq!(body["token"], "relay-token"); } From d766324dc16ebfec9413150cac3fcaf6e8ae1ccc Mon Sep 17 00:00:00 2001 From: dcanic Date: Mon, 5 Oct 2026 17:48:51 +0200 Subject: [PATCH 5/9] feat(db): hand the WordPress tunnel's database login over in a private option file Phase 1 of the Managed WordPress tunnel returns WordPress's own database login with the mint. Write it to a mode-600 MySQL option file in a mode-700 temp directory, removed on exit, and print the mysql --defaults-extra-file command, so the password never reaches the terminal or the event stream. Warn when the mint replaced an earlier tunnel, and align the proposal with the implemented phase 1 contract. --- docs/proposals/db-tunnel-mwp.md | 528 ++++++++++++++++++++---------- rust/src/db/mod.rs | 1 + rust/src/db/tunnel.rs | 91 ++++- rust/src/db/tunnel_credentials.rs | 374 +++++++++++++++++++++ rust/src/hosting/client.rs | 11 +- 5 files changed, 813 insertions(+), 192 deletions(-) create mode 100644 rust/src/db/tunnel_credentials.rs diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index c774253f..7b1ecf8f 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -1,53 +1,80 @@ # Proposal: `gddy db tunnel --product wordpress` — MySQL access for Managed WordPress -Status: draft. The CLI, airo-go, and hosting changes are implemented but not -merged. Open work, blockers, and their current state are tracked in -`mhp-core/docs/db-tunnel-on-demand-followups.md`. This document describes the -design only. +Status: draft, revised after the design review of 2026-10-02 (see **Review +response**). + +- **Phase 1 is scoped so that it needs no new capability and no cross-team + design decision:** owner only, one session per app, and WordPress's own + database user. +- What phase 1 leaves out on purpose, the risk each choice carries, and what to + do if security does not accept it are in **Deferred on purpose**. +- Phase 1 is implemented on the branches as of 2026-10-05 (hosting, airo-go, + and the CLI), and not committed yet. **Changes from the first design** lists + what changed. The relay image is not built yet. +- Open work is tracked in `mhp-core/docs/db-tunnel-on-demand-followups.md`. This document covers only what differs for Managed WordPress (product `mwp`, -product app type `mhwp`). The WebSocket protocol, bind safety, events, and the -MySQL security model are the same as for Node.js Hosting; see -[db-tunnel.md](./db-tunnel.md). +product app type `mhwp`). The WebSocket protocol and bind safety are the same as +for Node.js Hosting; see [db-tunnel.md](./db-tunnel.md). The security model is +**not** the same: see **TLS to MySQL**. ## Motivation `gddy db tunnel` gives a developer a local MySQL port that relays to an app's database over a WebSocket. For Node.js Hosting apps, the far end is the app's -long-running **agent**. Managed WordPress apps have no agent: the -`managed-wordpress` composition runs no agent task and defines no `agent` URL. -Running one for the full life of every site only to serve an occasional tunnel -is wasteful. +long-running **agent**. Managed WordPress apps have no agent. Running one for +the full life of every site only to serve an occasional tunnel is wasteful. -Instead, hosting starts a small **on-demand relay job** when a tunnel is -requested, in the same way it starts phpMyAdmin (`pma`) sessions. The relay -speaks the same WebSocket protocol as the agent, so the CLI relay code is -shared. +Instead, hosting starts a small **on-demand relay job** for a tunnel session, as +it does for phpMyAdmin (`pma`) and SSH sessions. The relay speaks the same +WebSocket protocol as the agent, so the CLI's relay code is shared. -## Design decisions +## Phase 1 scope -| Decision | Chosen | Rejected, and why | +| Topic | Phase 1 | Why this choice | | --- | --- | --- | -| What serves the WebSocket | An on-demand `db-tunnel` Nomad job per session, deployed through hosting's existing on-demand job framework (`onDemandJobs` in the composition) | An agent task or sidecar in the main `managed-wordpress` job: it would run for the full life of every site. | -| How the CLI gets a token | hosting mints a relay session token. airo-go only authorizes and proxies. | A hosting mint of an airo-builder agent token (cert2s delegation plus `X-OAuth-Token`): there is no agent to accept it. This design was built and reverted. | -| How MySQL is reached | The relay dials the database host and port that hosting returns. MySQL credentials never leave the database. | Going through the phpMyAdmin proxy: that gives a web console, not a MySQL port. | -| Session sharing | One live session per app, cell, and variant is reused, and every mint gets a fresh token | A new job per CLI run: it starts slowly and wastes resources on reconnects. | -| Token storage | Only a sha256 verifier is stored. The token is bound to one session. | Plaintext tokens, or tokens valid for any session: a leaked row, or a token from one relay, could open another. | -| Infrastructure | Phase 1 reuses the phpMyAdmin DNS zone, Nomad namespace, and hosting API base URL (see **Phase 1 shortcuts**) | New ones now: that needs DNS, Nomad, and config work before the first tunnel. | -| Image ownership | Pioneer images builds the relay image, alongside the phpMyAdmin image. It is registered per app with the same mechanism. | — | +| Who | The app's owner only | The OAuth path names a customer. Collaborator grants are not resolved on it. | +| Sessions | **One live session per app.** A new mint stops the app's previous session and starts a new one ("newest wins"). | Hosting cannot read a job's state, so reuse would need liveness reports. This needs none. | +| Credentials | **WordPress's own database user** for the variant, read by hosting from that variant's `DATABASE` secret and returned once in the mint response | hosting already does this read for phpMyAdmin. A user per session needs DDL on cell MySQL, which nothing in mhp-core can do today. | +| Relay token | A hosting-signed token that the relay checks itself. No hosting routes for the relay. | It avoids two workload-callback routes that would need an argued exception (F1). It is self-contained in hosting. | +| TLS to MySQL | **Enforced by the relay**: it closes a connection whose client does not ask for TLS | The server cannot enforce it, and TLS cannot be required on WordPress's user (F11). | +| Infrastructure | phpMyAdmin's DNS zone, Nomad namespace, SELinux type, and Cilium policy | These are proven by the proof of concept, and already installed in production. | +| Variant | `publish` from the CLI. The API also accepts `staging`. | It keeps the CLI surface small. | + +## Proof of concept + +On 2026-10-02 and 2026-10-05, the reviewer opened MySQL sessions to a test site +on dev cell c10 through a stand-in relay. The stand-in was `websocat`, running +in a throwaway Nomad job, `dbt-poc`, in the `phpmyadmin` namespace. It did not +exercise the mint, tokens, or the CLI. The procedure is in the design review. + +- **Path:** + + ```text + mysql client ─TCP─► gddy CLI ─WSS─► Cloudflare ─HTTPS─► v2-ingress-proxy ─HTTP:80─► relay ─TCP─► cell MySQL + ``` + + `v2-ingress-proxy` runs in the `ingress` namespace and finds backends from + their `ingress.domains=Host(...)` service tags. WebSockets are on for every + cell zone in Cloudflare, in dev and production. +- **Worked:** + - A login as the site's database user, with TLS 1.3. + - An 8 MiB row, a 50 MB result set (about 17 MB/s), and `mysqldump`. + - `label=type:pma_workloads.process` on port `80`, running as uid 65534 with + every capability dropped and a read-only root. +- **Failed:** + - Port `8080`. SELinux denies the bind, and the `phpmyadmin` Cilium policy + admits only TCP 80. + - A silent tunnel. It was cut between 5 and 11 minutes. A ping every 30 + seconds kept it open for 11 minutes. +- **Server settings found:** + - `require_secure_transport` is `0` on every MySQL server, in dev and in all + production series. + - `wait_timeout` is 60 seconds on c10. + - `max_allowed_packet` is 16 MiB. ## How it works -- **Control plane.** One HTTPS call when the command starts, to the **Airo API** - (airo-go). airo-go checks the caller and asks hosting for a relay session. - Hosting creates the session and schedules the relay job, or reuses a live - session. -- **Readiness.** A new relay needs time to be scheduled and routed. The CLI polls - the session's `pollUrl` until it answers, for at most 3 minutes, before it opens - the local port. -- **Data plane.** One WebSocket per local TCP connection, straight from the CLI - to the relay, carrying raw MySQL bytes. It never touches airo-go or hosting. - ```mermaid sequenceDiagram autonumber @@ -56,88 +83,161 @@ sequenceDiagram participant Host as hosting API participant Nomad as Nomad (cell) participant Relay as db-tunnel relay job - participant DB as App MySQL + participant DB as Cell MySQL CLI->>Airo: POST /v1/airo/hosting/apps/{appId}/database-tunnel
Authorization: Bearer - Airo->>Airo: /v1/hosting OAuth chain, TLA gate,
scope hosting.database.tunnel:execute, rate limit - Airo->>Airo: GetCustomerForApp(appId): caller must be the owner - Airo->>Host: POST hosting/v1/apps/{appId}/db-tunnel
service credential, {createdBy, variant?} - Host->>Host: reuse a live session, or create one
(row + sha256 token verifier) - Host->>Nomad: deploy on-demand job "db-tunnel"
host dbt-{sessionId}..pma. - Host-->>Airo: {sessionId, url, pollUrl, token, variant, expiresAt, reused} + Airo->>Airo: OAuth chain, TLA gate, tunnel scope, rate limit + Airo->>Airo: system owner tuple: the caller must own the app
(fail closed) + Airo->>Host: POST hosting/v1/systems/{systemId}/apps/{appId}/db-tunnel
X-On-Behalf-Of, {variant?} + Host->>Host: RequireAppInSystem + Host->>Nomad: stop the app's previous session, if any + Host->>Host: read the variant's DATABASE secret,
sign the session token + Host->>Nomad: deploy "db-tunnel"
env: DB_HOST, DB_PORT, public keys, session id, expiry + Host-->>Airo: {sessionId, url, pollUrl, token, expiresAt,
database: {user, password, name}} Airo-->>CLI: passed through loop until 2xx or 3 minutes CLI->>Relay: GET pollUrl (/healthz) end - CLI->>Relay: per TCP conn: WSS /apps/{appId}/database/tunnel
Authorization: Bearer - Relay->>Host: POST /v1/db-tunnel/redeem {token, sessionId} - Host-->>Relay: {host, port} (no credentials) - Relay->>DB: connect(host, port), then relay raw bytes - Relay->>Host: POST /v1/db-tunnel/heartbeat every 60s (410 → exit) + CLI->>Relay: per TCP conn: WSS /apps/{appId}/database/tunnel
Authorization: Bearer + Relay->>Relay: verify the token. Require CLIENT_SSL
in the client's first MySQL packet. + Relay->>DB: connect(DB_HOST, DB_PORT), then relay raw bytes + Relay-->>CLI: WebSocket ping every 30s ``` ### Step by step -1. **CLI.** `--product wordpress` calls - `HostingClient::ensure_airo_database_tunnel_session`, which sends - `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel` with the CLI's - OAuth token. The token has the same scopes as for Node.js: deploy-execute and - `hosting.database.tunnel:execute`. The response body is not logged, because - it contains the relay token. -2. **airo-go: authenticate, scope, rate limit.** The route is on airo-go's - public `/v1/hosting` group, which accepts only an Authorization Platform - OAuth access token. The token must carry `hosting.database.tunnel:execute`, - so deploy authority alone is not enough. At most 10 requests per minute - with a burst of 5 are allowed per app and customer. -3. **airo-go: ownership.** airo-go looks up the customer who owns the app, and - the caller must be that customer. An unknown app, a failed lookup, or - another customer's app all get the same `404 app not found`. Collaborators - are not resolved on this path. -4. **airo-go → hosting.** airo-go calls hosting's - `POST hosting/v1/apps/{appId}/db-tunnel` with its service credential and no - `X-On-Behalf-Of`. The body carries only attribution (`createdBy`) and an - optional `variant` (`publish` or `staging`). `force` is never forwarded. -5. **hosting: session.** hosting refuses an app with no database. It reuses a - live session in the same cell and variant when at least 15 minutes of its - 1-hour lifetime remain, and mints a fresh token for it. Otherwise it creates - a session and deploys the `db-tunnel` on-demand job. Only a sha256 verifier - of the token is stored. -6. **Relay.** The relay redeems the token for its own session only, and gets - back the database host and port, never credentials. MySQL authentication and - TLS stay end to end between the client and the database. The relay - heartbeats while it runs. If a heartbeat returns `410`, the session is gone - and the relay exits. -7. **Teardown.** Sessions end when they expire, when the relay stops - heartbeating, or when the app is torn down, archived, or moved. hosting - revokes the tokens, marks the session stopped, and stops the Nomad job. A - sweep command (`db-tunnel-session-sweep`) cleans up leftovers. - -### Session lifecycle +1. **CLI.** It sends `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel` + with an OAuth token scoped to deploy-execute and + `hosting.database.tunnel:execute`. The response is not logged, because it + holds the token and a database password. +2. **airo-go: authenticate.** The route is on the public `/v1/hosting` group: + an Authorization Platform OAuth token only, the TLA gate, the tunnel scope, + and 10 requests per minute per app and customer. The limiter is per process, + so it is an abuse brake, not a quota. +3. **airo-go: tenancy.** airo-go reads the app's `systemId`, then that + system's owner tuple from hosting. If the owner type is not + `airoSubscriptionId`, it **fails closed**. Nothing on the app record is + trusted for ownership: there is no fallback to `variables.airoSubscriptionId` + (F7). The subscription's customer must be the caller. Every miss, and every + lookup error, answers `404 app not found`. +4. **airo-go → hosting.** It calls the **tenancy-scoped** route + `POST hosting/v1/systems/{systemId}/apps/{appId}/db-tunnel` with + `X-On-Behalf-Of: customer:`, so hosting checks `RequireAppInSystem` too + (F7). The body carries only `variant` (`publish` or `staging`). Attribution + comes from `X-On-Behalf-Of`. It never carries `force`. The response is + relayed unchanged, with `Cache-Control: no-store`. +5. **hosting: session.** + - hosting refuses an app with no database for the variant (`409`). + - It checks everything that cannot change state first: the variant, the + database, the credentials, the cell, and the relay hostname. A mint that + would fail does not cost the caller the tunnel they already have. + - It reads the variant's credentials the way phpMyAdmin does + (`loadVariantDbCredentials`, shared by both): coordinates from the + variant's `hosting_databases` row, and the user, password, and schema from + the variant's own `DATABASE` secret if it `OwnsOwnDatabase()` (staging), + or from the shared one (publish). + - It stops the app's previous session, if one is live, and reports + `replaced: true`. If that stop cannot be sent, the mint fails with `503` + and the previous tunnel stays. + - It creates the session row, signs the token, and deploys the relay job. + The job's environment holds no secret. One live row per app is enforced + by a unique key, so of two mints at the same instant one wins and the + other gets `409`. +6. **CLI: wait and listen.** The CLI polls `pollUrl` for up to 3 minutes. It + then writes the credentials to a mode-600 MySQL option file in a mode-700 + temporary directory, and prints the + `mysql --defaults-extra-file=… --ssl-mode=REQUIRED` command to use. The + file sets `user`, `password`, `host`, `port`, and `protocol=TCP` under + `[client]`, and `database` under `[mysql]`, so `mysqldump` can use it too. + The file is deleted when the command exits; a killed process leaves it in + the user's temporary directory. The password is never printed or put in an + event. If `replaced` is true, the CLI warns that the previous tunnel was + closed. +7. **Relay.** For each WebSocket, it verifies the token: the signature, the + audience, the expiry, `sessionId == DB_TUNNEL_SESSION_ID`, and + `appId == APP_ID ==` the path's app id. It dials `DB_HOST:DB_PORT`. It + closes the connection if the client's first MySQL packet does not ask for + TLS. It pings every 30 seconds. +8. **End.** The relay exits on its own when it has had no connection for 10 + minutes, or at expiry (see **Expiry**). hosting stops the job when: + - a new session replaces it; + - the session is stopped; + - the app is torn down, archived, or moved to another system or owner; + - the sweep finds the session expired. + +### Expiry | Setting | Value | Effect | | --- | --- | --- | -| Session lifetime | 1 hour | The token and the session expire together. The CLI does not renew them. | -| Reuse window | at least 15 minutes left | A mint reuses a live session only if it has at least this long left. Otherwise it starts a new one. | -| Heartbeat interval | 60 seconds | How often the relay reports that it is alive. | -| Liveness timeout | 5 minutes | With no heartbeat for this long, the session counts as dead: redeem answers `410` and it is not reused. | -| Startup grace | 3 minutes | A new session counts as alive before its first heartbeat for this long. The CLI's readiness wait uses the same limit. | -| Sweep grace | 15 minutes past expiry | The sweep stops sessions older than this, up to 200 per run. | - -## Command surface - -This adds one flag to the flags in [db-tunnel.md](./db-tunnel.md#command-surface): - -| Flag | Required | Default | Purpose | -| --- | --- | --- | --- | -| `--product ` | no | `nodejs` | Selects the service that mints the tunnel token: `nodejs` uses the Node.js Hosting API, `wordpress` uses the Airo API. Any other value is rejected by the argument parser. | +| Session lifetime | 1 hour | The token expires. The relay refuses new connections from then on. | +| Drain | up to 15 minutes after expiry | Connections that are already open keep running, so a `mysqldump` that started near the end is not cut (F6). Then the relay exits. | +| Idle exit | 10 minutes without an open connection | The relay exits. MySQL closes idle sessions after `wait_timeout` (60 seconds on c10) anyway, and the `mysql` client reconnects on its own. | +| Sweep | 15 minutes after the drain ends | Stops jobs that neither the relay nor a stop hook cleaned up | + +A stop for a replacing mint, teardown, archive, or a system move does not wait +for the drain. It cuts open connections at once. + +## TLS to MySQL + +There are two layers of encryption, and only one is end to end: + +- **The WebSocket's TLS is hop by hop.** Cloudflare decrypts it to proxy it, and + `v2-ingress-proxy` decrypts it again. The last hop, from the ingress to the + relay, is plain HTTP on port 80. The token in the `Authorization` header is + readable at each of these. +- **MySQL's own TLS**, negotiated by the client and the server inside the + tunnel, is end to end. It is the only layer that protects queries and + results. + +The server cannot be the backstop: + +- `require_secure_transport` is `OFF` on every MySQL server, in dev and + production. +- It cannot be turned on: WordPress on these cells connects without TLS (no + `MYSQL_CLIENT_FLAGS`), so every site on the server would be cut off. +- `REQUIRE SSL` cannot be set on WordPress's user, for the same reason. + +So in phase 1 **the relay enforces TLS**. A MySQL client's first packet is +either an `SSLRequest`, with the `CLIENT_SSL` capability flag set, or a plain +handshake response. The relay reads only the capability flags of that packet +and closes the connection when `CLIENT_SSL` is not set. It never reads +credentials, which come only after TLS is up. + +The password is not exposed even without TLS: `caching_sha2_password` uses a +challenge or an RSA exchange. The relay check protects the queries and the +results. + +Through a tunnel, the server is reached at `127.0.0.1`. `--ssl-mode=REQUIRED` +encrypts but does not verify the server's identity. `VERIFY_IDENTITY` is not +available (F10). + +## Deferred on purpose + +Each row is a choice made to keep phase 1 free of blockers. "If rejected" is +what to do if security does not accept the phase 1 choice. + +| # | Deferred | Phase 1 instead | Risk accepted | Revisit when | If rejected | +| --- | --- | --- | --- | --- | --- | +| DF1 | A MySQL user per session (F3) | WordPress's own user for the variant | The customer can change that user's password, which breaks the site, and nothing reconciles the secret. The user has `ALL` on its schema and host `%`. The customer can already read and use these credentials through SSH, phpMyAdmin's SQL tab, and the secrets API. | Someone owns DDL on cell MySQL from the mint path | Build DF1 first: a per-session user with `REQUIRE SSL`, dropped at stop. This is blocked on finding who can run DDL. | +| DF2 | A read-only option | The read-write pair only | Writes to the live site's database are one statement away | Customers ask for safe inspection, or security asks for least privilege | Default to the read-only pair. It already exists in the `DATABASE` secret (`ReadOnlyUsername`), but phpMyAdmin notes that the grant has never been tested. | +| DF3 | Concurrent tunnels per app | One session per app. The newest mint wins. | A second terminal or teammate cuts the first tunnel. Every run has a cold start. | The cold start is measured, or users hit the cut | SSH-style reuse: a warm window refreshed only on a reported successful connection, `stale` reports from the CLI, and a short readiness budget for reused sessions | +| DF4 | Collaborators | Owner only | — | Collaborators need tunnels | Stay owner only | +| DF5 | Token renewal | One hour per session. Run the command again after that. | — | Long sessions are needed | A new mint already replaces the session | +| DF6 | Revoking one token | Stopping the session revokes everything | — | — | A short token lifetime with re-mint | +| DF7 | Server-side TLS enforcement | The relay's `CLIENT_SSL` check | The check depends on the relay being correct | DF1 lands (`REQUIRE SSL`) | DF1 | +| DF8 | The relay's own DNS zone, namespace, SELinux type, and Cilium policy | phpMyAdmin's | A relay fault affects the phpMyAdmin namespace's budget and policy | Phase 2 | A narrower SELinux type and a dedicated policy in pioneer-infra, which also frees the port choice | +| DF9 | `--variant staging` in the CLI | `publish` only | — | Staging users ask for it | — | +| DF10 | Fixing the broad secrets read (review X1) and the signup stamp for `airoSubscriptionId` (review X2) | Not used by the tunnel: hosting reads only `DATABASE`, and airo-go fails closed | — | Raised separately | — | -```console -$ gddy db tunnel --app-id --product wordpress -$ mysql --ssl-mode=REQUIRED -h 127.0.0.1 -P 3306 -u -p -``` +## Design decisions -For WordPress, the event stream has an extra `provision` step between -`authorize` and `listening` while the CLI waits for the relay. +| Decision | Chosen | Rejected, and why | +| --- | --- | --- | +| What serves the WebSocket | An on-demand `db-tunnel` Nomad job per session, deployed through hosting's on-demand job framework (`onDemandJobs`) | An agent task or sidecar in the main `managed-wordpress` job: it would run for the full life of every site. | +| Who mints | hosting mints. airo-go only authorizes and proxies. | A hosting mint of an airo-builder agent token: there is no agent to accept it. Built and reverted. | +| How the relay checks a token | Locally, against hosting's public keys in its job environment (F1). SSH already has this shape: its workload gets only a CA public key. | A redeem route and a heartbeat route on hosting: these are workload-callback routes with a multi-use bearer, and the heartbeat had no credential to use before the first connection (F2). | +| How the relay finds MySQL | The database host and port are in the job environment. They are not secret (MWP rule 2). | Learning them at redeem: that needs a callback route. | +| How the customer gets credentials | In the mint response, from the variant's `DATABASE` secret only | The airo-go secrets list: it also returns the `protected` and `auth` groups and admits collaborators (review F3). | ## Authentication and authorization @@ -147,90 +247,180 @@ For WordPress, the event stream has an extra `provision` step between | airo-go | The customer has a shopper ID and passes the TLA gate | `403` | | airo-go | The token has the scope `hosting.database.tunnel:execute` | `403 Insufficient scope` | | airo-go | Rate limit per app and customer | `429` | -| airo-go | The caller is the app's owner | `404 app not found` | -| hosting | The service credential is allowed (`JWTOrCert`: Airo console or CTK cert) | airo-go answers `502` | -| hosting | The app exists and owns a database | `409` | -| relay → hosting | The token is live and belongs to the relay's own session | `401`, or `410` when the session is gone | -| MySQL | The database user's own password, `GRANT`s, and TLS, end to end | a MySQL error | +| airo-go | The system owner tuple resolves, and the caller is the owner | `404 app not found` | +| hosting | The service credential is allowed, and `RequireAppInSystem` passes | airo-go answers `502`, or `404` | +| hosting | The signing key is configured | `503` | +| hosting | The app owns a database for the variant, and its credentials resolve | `409`, or `500` | +| relay | Signature, audience, expiry, session id, app id, path app id | WebSocket `401` | +| relay | The client asks for TLS (`CLIENT_SSL`) | The connection is closed | +| MySQL | WordPress's user, password, and grants | a MySQL error | -The CLI also refuses a `pollUrl` that is not `https` or not on the relay's own -host. +## Relay contract -## Phase 1 shortcuts +The relay image must: -These reuse phpMyAdmin infrastructure and are marked with TODOs in the hosting -code: +- **Port:** listen on port `80`, which is all that `pma_workloads` and the + `phpmyadmin` Cilium policy allow. Answer `GET /healthz` with `200` once it + can take tunnels. +- **Path:** accept `GET /apps/{APP_ID}/database/tunnel` as a WebSocket upgrade, + with `Authorization: Bearer `. +- **Token:** the token is a compact JWT signed with EdDSA (Ed25519), with a + `kid` header. `DB_TUNNEL_TOKEN_PUBLIC_KEYS` is a JSON Web Key Set: + + ```json + {"keys":[{"kty":"OKP","crv":"Ed25519","kid":"k1","x":"","alg":"EdDSA","use":"sig"}]} + ``` + + It holds every key hosting knows, so a relay deployed before a rotation + still verifies tokens signed after it. Pick the key by `kid`, accept only + `alg: EdDSA`, then require: + - `aud == "db-tunnel"` and `iss == "hosting"`; + - an `exp` in the future; + - `sid == DB_TUNNEL_SESSION_ID`; + - `app == APP_ID`, and the path's app id equal to `APP_ID`; + - `variant == DB_TUNNEL_VARIANT`. + + The token also carries `iat`. Answer `401` otherwise, with no detail. +- **TLS:** dial `DB_HOST:DB_PORT` and forward the server greeting. Read the + client's first packet. If its capability flags do not include `CLIENT_SSL` + (`0x00000800`), close both sides. Otherwise relay binary frames in both + directions exactly like the Node.js agent, with frames of up to 32 MiB. +- **Keepalive:** send a WebSocket ping every 30 seconds on every open tunnel, + and close the tunnel if no pong arrives within 65 seconds. +- **Lifetime:** + - Exit after 10 minutes without an open connection. + - At `DB_TUNNEL_EXPIRES_AT`, refuse new connections, let open ones finish + for up to 15 minutes, then exit. + - Hosting does not need to be reachable for any of this. +- **Logging:** never log tokens or MySQL bytes. Log one line per connection: + the session id, open and close times, byte counts, and the close reason + (including `no-tls`). +- **Hardening:** + - `label=type:pma_workloads.process`, with the same production fail-closed + render as the phpMyAdmin job; + - uid 65534, all capabilities dropped, `no-new-privileges`; + - a read-only root and a 16 MB `/tmp`; + - 64 MHz of CPU and 128 MB of memory. + +The job sets `APP_ID`, `DOM_ID`, `DB_HOST`, `DB_PORT`, `DB_TUNNEL_DOMAIN`, +`DB_TUNNEL_SESSION_ID`, `DB_TUNNEL_VARIANT`, `DB_TUNNEL_EXPIRES_AT` (RFC 3339, +UTC), `DB_TUNNEL_TOKEN_PUBLIC_KEYS`, `PORT=80`, `REGION`, `SERVER_ENV`, and +`IMAGE`. None of these are secret. In production the job does not render +unless a SELinux label is configured, the same gate as the phpMyAdmin job. + +hosting signs with `DB_TUNNEL_SIGNING_KEYS`, a JSON object that maps each key +id to a base64 32-byte Ed25519 seed, and `DB_TUNNEL_SIGNING_KEY_ID`, the id to +sign with. Both come from secrets, like hosting's other keys. If either is +missing or invalid, hosting starts anyway and answers every mint with `503`. +To rotate, add the new key to the map and deploy, then switch the key id. -- **DNS.** The relay's hostname is under the phpMyAdmin wildcard zone - (`dbt-{sessionId}..pma.`). It should move to its own zone. -- **Nomad namespace.** The relay job runs in the `phpmyadmin` namespace. It - should get its own. -- **API base URL.** The relay reaches hosting through the same base URL that - phpMyAdmin uses. +## Hosting API -## Ownership +| Route | Caller | Auth | Answer | +| --- | --- | --- | --- | +| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel` | airo-go, support tools | `JWTOrCert` (airo console or CTK certificate) plus `RequireAppInSystem` | `200 {sessionId, domain, url, pollUrl, token, variant, expiresAt, replaced, database: {user, password, name}}`. `400` bad request, `404` app not in system, `409` no database for the variant or a concurrent mint, `500` credentials unavailable, `503` not configured, cell unreachable, no relay hostname, or the previous session could not be stopped. | +| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel/stop` | support tools | same | `200 {stopped, failed, unrevoked}`, or `204` if nothing was live. `unrevoked` counts sessions whose stop could not be sent; they stay live, so a retry sends it again. | -| Owner | Part | -| --- | --- | -| CLI | `--product wordpress`, the session mint call, and the readiness poll | -| airo-go | `POST /v1/hosting/apps/:appId/database-tunnel` on the `/v1/hosting` OAuth chain: scope, rate limit, owner check, and a proxy to hosting. Also wiring that chain in production. | -| hosting | Session tables, the session service, the mint, redeem, heartbeat, and stop routes, the `db-tunnel` job template, teardown, and the sweep command | -| hosting operations | Registering the relay image per environment and scheduling the sweep | -| pioneer images | The relay image, built to the **Relay contract** | +Both answers carry `Cache-Control: no-store`. Attribution is +`X-On-Behalf-Of` when present, otherwise the body's `createdBy`, otherwise the +caller's identity. There are no relay-facing routes. Only hosting holds the +token signing key. -The state of each part is in `mhp-core/docs/db-tunnel-on-demand-followups.md`. +## Changes from the first design -## Hosting API +All rows are done on the branches. The followups doc says how each was +verified. -| Route | Caller | Auth | Answer | -| --- | --- | --- | --- | -| `POST /hosting/v1/apps/:id/db-tunnel` | airo-go, support tools | `JWTOrCert` (Airo console or CTK cert) | `200 {sessionId, domain, url, pollUrl, token, variant, expiresAt, reused}`. `400` for a bad request, `409` for no database, `503` when not configured or the cell is unreachable. | -| `POST /hosting/v1/apps/:id/db-tunnel/stop` | support tools | `JWTOrCert` | `200 {stopped, failed}`, or `204` if nothing was live | -| `POST /hosting/v1/db-tunnel/redeem` | relay | the session token in the body | `200 {sessionId, appId, variant, host, port, expiresAt}`. `401` for a bad token, `410` when the session is gone. | -| `POST /hosting/v1/db-tunnel/heartbeat` | relay | the session token in the body | `200 {ok: true}`. `401` for a bad token, `410` when the session is gone. | +| Area | First design | Phase 1 | +| --- | --- | --- | +| hosting routes | `/v1/apps/:id/db-tunnel` and `/stop`, plus `/v1/db-tunnel/redeem` and `/heartbeat` | `/v1/systems/:systemId/apps/:appId/db-tunnel` and `/stop` behind `RequireAppInSystem`. No redeem or heartbeat. | +| Tokens | Random token, sha256 verifier in `hosting_app_db_tunnel_tokens` | An Ed25519-signed JWT. No token table. A signing key in hosting configuration. | +| Liveness and reuse | `last_seen_at`, startup grace, heartbeat, reuse with at least 15 minutes left | None. One session per app. A new mint stops the previous one. | +| Credentials | None returned | The variant's `DATABASE` user, password, and schema, through `loadVariantDbCredentials`, which phpMyAdmin's `resolveServers` now uses too | +| Job template | Port 8080, `label=disable`, no production gate, `HOSTING_DB_TUNNEL_API` | Port 80, `pma_workloads.process`, the production gate. `DB_HOST`, `DB_PORT`, expiry, and public keys replace the hosting API URL. | +| Namespace constant | `dbTunnelJobNamespace = "phpmyadmin"` | Reuse `pmaJobNamespace` (F8) | +| airo-go | App-id route, `GetCustomerForApp` with the `variables` fallback, service headers | System owner tuple only (`GetSystem`, then the subscription's customer), fail closed, the tenancy-scoped hosting route with `X-On-Behalf-Of` | +| CLI | Reads `url`, `token`, `pollUrl` | Also reads `database` and `replaced`, writes a mode-600 option file (`rust/src/db/tunnel_credentials.rs`), and prints the `mysql` command | -## Relay contract +## Review response -The relay image must: +The design review of 2026-10-02, against mhp-core `main` at `fa9c6e972`. -- Listen on port `8080`. Answer `GET /healthz` with `200` once it can take - tunnels. -- Accept `GET /apps/{APP_ID}/database/tunnel` as a WebSocket upgrade with - `Authorization: Bearer `. Relay binary frames to and from MySQL - exactly like the Node.js agent, with frames of up to 32 MiB. -- Redeem each presented token with `POST {HOSTING_DB_TUNNEL_API}/v1/db-tunnel/redeem` - and the body `{"token": "...", "sessionId": ""}`. Dial - the `host:port` it returns. Refuse the WebSocket on `401` or `410`. -- Send `POST {HOSTING_DB_TUNNEL_API}/v1/db-tunnel/heartbeat` with the same body - every 60 seconds. Exit on `410`. -- Never log tokens, and never receive or log database credentials. -- Run with a read-only root filesystem, all capabilities dropped, - `no-new-privileges`, and a 16 MB `/tmp`, within 64 MHz of CPU and 128 MB of - memory. - -The job sets `APP_ID`, `DOM_ID`, `DB_TUNNEL_DOMAIN`, `DB_TUNNEL_SESSION_ID`, -`DB_TUNNEL_VARIANT`, `HOSTING_DB_TUNNEL_API`, `PORT`, `REGION`, `SERVER_ENV`, -and `IMAGE`. +| # | Finding | Response | Status | +| --- | --- | --- | --- | +| F1 | Redeem and heartbeat are workload-callback routes with a multi-use bearer | Adopted in phase 1. The relay checks a hosting-signed token locally. The database host and port are in the job environment. | Accepted | +| F2 | The relay has no credential to heartbeat with | Moot: there is no heartbeat. The relay's idle exit replaces the wait for the sweep, and one session has one token. | Closed by F1 | +| F3 | Which MySQL credentials the customer uses | Phase 1: WordPress's user for the variant, returned once by the mint from the `DATABASE` secret only, not through the broad secrets list. A per-session user is deferred (DF1), with the risk stated. | Deferred on purpose | +| F4 | No SELinux label or production gate, and 8080 cannot work | Port 80, `pma_workloads.process`, and the phpMyAdmin production gate | Accepted | +| F5 | Nothing keeps an idle tunnel alive | A ping every 30 seconds, and a close after 65 seconds without a pong. `wait_timeout` is listed as a limitation. | Accepted | +| F6 | Session expiry kills in-flight connections | The relay refuses new connections at expiry and drains open ones for up to 15 minutes. Replacing mints and access-changing stops cut at once. | Accepted | +| F7 | airo-go's ownership check is weaker than the phpMyAdmin proxy's | A tenancy-scoped hosting route with `RequireAppInSystem` and `X-On-Behalf-Of`, the system owner tuple only, and fail closed. The signup stamp is raised separately. | Accepted | +| F8 | A second hand-wired copy of the phpMyAdmin session service | One combined stopper, `NewOnDemandSessionStopper`, backs `GetPmaSessionStopper()`, so the teardown, archive, and `update_app_system_id_task` sites stop tunnels too; a worker-container test pins that. The tunnel reuses `pmaJobNamespace` and phpMyAdmin's credential lookup. Removing redeem, heartbeat, liveness, and reuse removed most of the copy. | Accepted | +| F9 | Docs | An on-demand jobs section in `managed-wordpress.md` and an update to invariant 9, with the hosting PR. There are no new workload-callback routes. | Accepted | +| F10 | `410` oracle, path binding, TLS wording | The `410` case is gone with redeem. The relay checks the path app id. `VERIFY_IDENTITY` is not available, which is stated. | Accepted | +| F11 | `require_secure_transport` is `OFF` everywhere | The server-side options are ruled out. The relay enforces `CLIENT_SSL` in phase 1. `REQUIRE SSL` on a per-session user follows DF1. | Accepted, with a phase 1 control | + +Answers to the review's questions: + +1. **Branches and the tracker.** They are on the local mhp-core branch + `dcanic/database-tunnel-on-demand-job`, not pushed yet. +2. **A signed token instead of redeem and heartbeat.** Yes, in phase 1. Reuse + is replaced by one session per app. +3. **The heartbeat token.** Moot. +4. **Credentials.** Phase 1: WordPress's user, returned by the mint from the + `DATABASE` secret. A per-session user is DF1. +5. **SELinux and port.** `pma_workloads.process` on port 80. +6. **A 30-second ping.** Yes. +7. **MySQL TLS.** The relay's `CLIENT_SSL` check in phase 1. `REQUIRE SSL` with + DF1. ## Limitations -- **Owner only.** Collaborator grants are not resolved on the OAuth path. -- **No renewal.** New connections fail once the 1-hour session ends. Run the - command again to get a new session. -- **Publish only from the CLI.** airo-go and hosting accept `staging`, but the - CLI has no `--variant` flag yet. -- **Idle relays.** A relay stays up until its session expires, even after the - CLI exits, because concurrent tunnels can share a session. +- **Owner only, and one tunnel per app.** A new `gddy db tunnel` run for the + same app cuts the previous one. +- **No renewal.** New connections are refused after the 1-hour session. Run + the command again. +- **Idle MySQL sessions close after `wait_timeout`.** It is 60 seconds on c10. + The next query gets `ERROR 4031 … disconnected by the server because of + inactivity`, and the `mysql` client reconnects. This is not a tunnel fault. +- **Packets are limited by `max_allowed_packet`** (16 MiB), which is below the + tunnel's 32 MiB frame limit. +- **The server's identity is not verified.** `VERIFY_IDENTITY` cannot be used + against `127.0.0.1`. +- **Clients must use TLS.** The relay closes connections from clients that do + not ask for it, such as `--ssl-mode=DISABLED`. +- **The token is visible to the proxies.** Cloudflare and `v2-ingress-proxy` + decrypt the WebSocket. The token lives at most 1 hour, works only for one + session's relay, and still needs the database password behind it. +- **It is the live site's database user.** Changing its password breaks the + site (DF1). +- **Cold start on every run.** ## Testing -- **CLI.** `rust/src/db/tunnel.rs` covers the product flag, parsing both mint - response shapes, and the `pollUrl` checks. `rust/src/hosting/client_tests.rs` - covers the request path, the Bearer header, and the parsed response. +- **Proof of concept.** The transport, ingress, SELinux type, keepalive, and + throughput were tested on c10 with a stand-in relay. Tokens, mint, and the + CLI were not. +- **CLI.** `rust/src/db/tunnel.rs` covers the product flag, the mint response + shapes (with and without `database` and `replaced`), and the `pollUrl` + checks. `rust/src/db/tunnel_credentials.rs` covers the `database` field, the + option file's quoting, its `0600`/`0700` modes and removal on drop, and that + no event carries the password. `rust/src/hosting/client_tests.rs` covers the + request path, the Bearer header, and the response. Not yet checked against a + real `mysql` client. - **airo-go.** `handlers/database_tunnel_proxy_handler_test.go` covers the owner - check, the same 404 for every miss, variant forwarding and validation, status - mapping, and, through the real `/v1/hosting` chain, registration, `401`, - scope `403`, and the rate limit. -- **hosting.** The service, sweep, handler, router, and template render tests - (`db_tunnel_*_test.go`, `managed_wordpress_db_tunnel_job_test.go`). -- **End to end.** Not run yet. See the follow-up doc for what it is waiting on. + check through the system owner tuple, the same 404 for every miss (including + a non-subscription owner type, a missing system, lookup errors, and app + `variables` naming the caller's subscription), the system-scoped hosting path + with `X-On-Behalf-Of`, variant forwarding, status mapping, and, through the + real `/v1/hosting` chain, registration, `401`, the scope `403`, and the rate + limit. Passes with `go test -race`. +- **hosting.** Service, sweep, stopper, handler, router, signer, and render + tests cover: the token's claims, signature, expiry, and rotation; credential + resolution for publish and staging; a replacing mint stopping the previous + session, and refusing when that stop fails; the concurrent-mint `409`; every + worker stop site reaching tunnels; and the production render gate. +- **Relay.** The `CLIENT_SSL` check (a plain handshake is closed, an + `SSLRequest` passes), token checks, the ping, the idle exit, and the expiry + drain. +- **End to end.** Not run yet. Measure the cold start. diff --git a/rust/src/db/mod.rs b/rust/src/db/mod.rs index 186bb7ac..9ed7f83d 100644 --- a/rust/src/db/mod.rs +++ b/rust/src/db/mod.rs @@ -6,6 +6,7 @@ use cli_engine::{GroupSpec, Module, RuntimeGroupSpec, Stage}; mod tunnel; +mod tunnel_credentials; /// The `Database` module: the `db` command group. pub fn module() -> Module { diff --git a/rust/src/db/tunnel.rs b/rust/src/db/tunnel.rs index 8dcae952..354c33e7 100644 --- a/rust/src/db/tunnel.rs +++ b/rust/src/db/tunnel.rs @@ -16,10 +16,12 @@ //! - Node.js Hosting: `POST /v1/hosting/nodejs/apps/:id/agent-token` returns the //! app's agent URL and an agent token. //! - `--product wordpress`: Managed WordPress has no per-app agent, so -//! `POST /v1/airo/hosting/apps/:id/database-tunnel` starts (or reuses) an -//! on-demand relay speaking the same WebSocket protocol, and returns its URL, -//! a readiness `pollUrl` and a relay token. The CLI waits for `pollUrl` to -//! answer before listening. +//! `POST /v1/airo/hosting/apps/:id/database-tunnel` starts an on-demand relay +//! speaking the same WebSocket protocol — closing any tunnel already open for +//! the app — and returns its URL, a readiness `pollUrl`, a relay token and +//! WordPress's database login. The CLI waits for `pollUrl` to answer before +//! listening, and writes the login to a private option file +//! ([`super::tunnel_credentials`]) rather than printing it. //! //! The token goes out as `Authorization: Bearer`. The URL and token both come //! from the service — neither is a user-supplied flag. @@ -40,6 +42,7 @@ use tokio_tungstenite::tungstenite::http::{HeaderValue, header::AUTHORIZATION}; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config}; +use super::tunnel_credentials::{DbCredentials, OptionFile, client_host, connect_hint}; use crate::error::GddyError; use crate::hosting::client::HostingClient; use crate::http::api_url_for_env; @@ -66,8 +69,8 @@ const MAX_AGENT_FRAME_BYTES: usize = 32 * 1024 * 1024; const FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); /// How long to wait for a freshly scheduled WordPress relay to answer its -/// readiness probe. Matches hosting's startup grace for a relay that has not -/// heartbeated yet; past it the session is treated as dead anyway. +/// readiness probe: long enough for Nomad to pull the image, place the job and +/// route its hostname. const RELAY_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); const RELAY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); @@ -131,7 +134,11 @@ pub(super) fn command() -> RuntimeCommandSpec { automatically. Pass `--product wordpress` for a Managed WordPress \ site, which starts a short-lived relay and can take up to a few \ minutes before the port opens; the default is a Node.js Hosting \ - app. Runs until interrupted (Ctrl-C).", + app. A WordPress site has one tunnel at a time: opening a new one \ + closes the previous one. Its database login is written to a \ + private MySQL option file, never printed, and the CLI prints the \ + `mysql --defaults-extra-file=...` command that uses it. Runs until \ + interrupted (Ctrl-C).", ) .with_system("database") .with_tier(Tier::Mutate) @@ -212,6 +219,14 @@ async fn run_tunnel( sender .send(json!({ "type": "step", "name": "authorize", "status": "completed" })) .await; + if target.replaced { + sender + .send(json!({ + "type": "warning", + "message": "A database tunnel was already open for this app and has been closed; a site has one tunnel at a time.", + })) + .await; + } let ws_url = match build_tunnel_ws_url(&target.endpoint, &args.app_id) { Ok(url) => url, @@ -245,6 +260,18 @@ async fn run_tunnel( .local_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| bind_addr.clone()); + let local_port = listener.local_addr().map_or(args.port, |a| a.port()); + + // Held until this function returns, which removes the file. + let option_file = match &target.database { + Some(creds) => { + match OptionFile::write(creds, &client_host(&args.listen_host), local_port) { + Ok(file) => Some(file), + Err(e) => return Err(fail(sender, e.into_cli_error()).await), + } + } + None => None, + }; sender .send(json!({ @@ -277,13 +304,11 @@ async fn run_tunnel( .await; } sender - .send(json!({ - "type": "hint", - "message": format!( - "Connect a MySQL client with TLS so the local hop is encrypted, e.g.: mysql --ssl-mode=REQUIRED -h {} -P {} -u -p", - args.listen_host, args.port - ), - })) + .send(connect_hint( + &args.listen_host, + local_port, + option_file.as_ref(), + )) .await; let token = token.as_str(); @@ -339,12 +364,15 @@ async fn run_tunnel( } /// Where the tunnel connects: the base URL of the WebSocket endpoint, the bearer -/// token it accepts, and — for an on-demand relay — the probe to wait on first. +/// token it accepts, and — for an on-demand relay — the probe to wait on first, +/// the database login, and whether an earlier tunnel was closed to make room. #[derive(Debug, PartialEq, Eq)] struct TunnelTarget { endpoint: String, token: String, ready_url: Option, + database: Option, + replaced: bool, } /// Mint a short-lived tunnel token for `app_id`. Steps the CLI's OAuth @@ -375,11 +403,18 @@ fn parse_tunnel_target(resp: &Value, product: TunnelProduct) -> cli_engine::Resu endpoint: field_str(resp, "agentUrl")?, token: field_str(resp, "token")?, ready_url: None, + database: None, + replaced: false, }, TunnelProduct::Wordpress => TunnelTarget { endpoint: field_str(resp, "url")?, token: field_str(resp, "token")?, ready_url: Some(field_str(resp, "pollUrl")?), + database: DbCredentials::from_mint(resp).map_err(GddyError::into_cli_error)?, + replaced: resp + .get("replaced") + .and_then(Value::as_bool) + .unwrap_or(false), }, }) } @@ -397,8 +432,8 @@ fn field_str(resp: &Value, key: &str) -> cli_engine::Result { }) } -/// Poll the relay's readiness probe until it answers 2xx. A reused session -/// answers on the first probe; a new one needs its job scheduled and routed. +/// Poll the relay's readiness probe until it answers 2xx; a new relay needs its +/// job scheduled and its hostname routed first. async fn wait_for_relay(poll_url: &str, endpoint: &str) -> Result<(), GddyError> { let probe = validate_poll_url(poll_url, endpoint)?; let http = crate::http::make_http_client(); @@ -418,7 +453,7 @@ async fn wait_for_relay(poll_url: &str, endpoint: &str) -> Result<(), GddyError> "the database tunnel relay did not become ready within {}s", RELAY_READY_TIMEOUT.as_secs() )) - .with_fix("Retry the command; the relay is reused if it finishes starting. If it keeps failing, contact support.")); + .with_fix("Retry the command; each retry starts a fresh relay. If it keeps failing, contact support.")); } tokio::time::sleep(RELAY_POLL_INTERVAL).await; } @@ -851,6 +886,8 @@ mod tests { endpoint: "https://agent.example".to_owned(), token: "t".to_owned(), ready_url: None, + database: None, + replaced: false, } ); } @@ -862,6 +899,8 @@ mod tests { "url": "https://dbt-s1.c1.pma.example", "pollUrl": "https://dbt-s1.c1.pma.example/healthz", "token": "relay-token", + "replaced": true, + "database": { "user": "wp_u", "password": "wp_p", "name": "wp_db" }, }); let target = super::parse_tunnel_target(&resp, super::TunnelProduct::Wordpress) .expect("wordpress target"); @@ -871,6 +910,22 @@ mod tests { target.ready_url.as_deref(), Some("https://dbt-s1.c1.pma.example/healthz") ); + assert!(target.replaced); + let db = target.database.expect("database login"); + assert_eq!((db.user.as_str(), db.name.as_str()), ("wp_u", "wp_db")); + } + + #[test] + fn wordpress_target_without_database_or_replaced_still_parses() { + let resp = serde_json::json!({ + "url": "https://dbt-s1.example", + "pollUrl": "https://dbt-s1.example/healthz", + "token": "t", + }); + let target = super::parse_tunnel_target(&resp, super::TunnelProduct::Wordpress) + .expect("wordpress target"); + assert!(target.database.is_none()); + assert!(!target.replaced); } #[test] diff --git a/rust/src/db/tunnel_credentials.rs b/rust/src/db/tunnel_credentials.rs new file mode 100644 index 00000000..68f292e0 --- /dev/null +++ b/rust/src/db/tunnel_credentials.rs @@ -0,0 +1,374 @@ +//! Database credentials from a Managed WordPress tunnel mint, kept off the +//! terminal. The mint returns WordPress's own database login once; the CLI +//! writes it to a MySQL option file only the current user can read and prints +//! the path, so the password never reaches the event stream, the scrollback, +//! or another process's argument list. The file and its directory are removed +//! when the tunnel exits. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::error::GddyError; + +/// WordPress's database login for the tunnelled variant. `Debug` is written by +/// hand so the password cannot reach a log line through `{:?}`. +#[derive(Clone, PartialEq, Eq)] +pub(super) struct DbCredentials { + pub(super) user: String, + pub(super) password: String, + pub(super) name: String, +} + +impl std::fmt::Debug for DbCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DbCredentials") + .field("user", &self.user) + .field("password", &"") + .field("name", &self.name) + .finish() + } +} + +impl DbCredentials { + /// Read `database: { user, password, name }` from the mint response. + /// `None` when the service sent no `database` object; an object with a + /// missing or empty field is a broken contract and fails. + pub(super) fn from_mint(resp: &Value) -> Result, GddyError> { + let Some(db) = resp.get("database").filter(|v| !v.is_null()) else { + return Ok(None); + }; + let field = |key: &str| { + db.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + GddyError::network(format!( + "database tunnel mint response has no 'database.{key}'" + )) + .with_fix("Retry; if it persists, contact support.") + }) + }; + Ok(Some(Self { + user: field("user")?, + password: field("password")?, + name: field("name")?, + })) + } +} + +/// A private MySQL option file in a directory of its own. Dropping it removes +/// both, so every exit path that unwinds through `run_tunnel` cleans up; a +/// killed process leaves them behind in the user-only temp directory. +pub(super) struct OptionFile { + dir: PathBuf, + path: PathBuf, + user: String, + database: String, +} + +impl OptionFile { + /// Write `creds` for a client connecting to `host:port` (the local + /// listener). The directory is created `0700` and the file `0600` before + /// any secret is written, so there is no window where another user can + /// read it. + pub(super) fn write(creds: &DbCredentials, host: &str, port: u16) -> Result { + let dir = std::env::temp_dir().join(format!("gddy-db-tunnel-{}", uuid::Uuid::new_v4())); + create_private_dir(&dir).map_err(|e| write_error(&dir, &e))?; + let file = Self { + path: dir.join("my.cnf"), + dir, + user: creds.user.clone(), + database: creds.name.clone(), + }; + let mut handle = + create_private_file(&file.path).map_err(|e| write_error(&file.path, &e))?; + handle + .write_all(render_option_file(creds, host, port).as_bytes()) + .map_err(|e| write_error(&file.path, &e))?; + Ok(file) + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + /// The command a MySQL client runs to use this file. `--defaults-extra-file` + /// must come first on the `mysql` command line. + pub(super) fn mysql_command(&self) -> String { + format!( + "mysql --defaults-extra-file={} --ssl-mode=REQUIRED", + shell_quote(&self.path.to_string_lossy()) + ) + } +} + +impl Drop for OptionFile { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} + +fn write_error(path: &Path, err: &std::io::Error) -> GddyError { + GddyError::config(format!( + "could not write the database option file {}: {err}", + path.display() + )) + .with_fix("Check that the system temp directory is writable (set TMPDIR to another directory), then retry.") +} + +#[cfg(unix)] +fn create_private_dir(dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new().mode(0o700).create(dir) +} + +#[cfg(not(unix))] +fn create_private_dir(dir: &Path) -> std::io::Result<()> { + fs::create_dir(dir) +} + +#[cfg(unix)] +fn create_private_file(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> std::io::Result { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) +} + +/// `[client]` is read by every MySQL tool; `database` sits under `[mysql]` +/// because `mysqldump` and friends reject it as an unknown option. TCP is +/// forced so a `localhost` default never turns into a Unix-socket connection +/// to some other server on this machine. +fn render_option_file(creds: &DbCredentials, host: &str, port: u16) -> String { + format!( + "# Written by `gddy db tunnel`; removed when the tunnel exits.\n\ + [client]\n\ + user={}\n\ + password={}\n\ + host={}\n\ + port={port}\n\ + protocol=TCP\n\ + \n\ + [mysql]\n\ + database={}\n", + option_value(&creds.user), + option_value(&creds.password), + option_value(host), + option_value(&creds.name), + ) +} + +/// Quote an option-file value. MySQL strips matching outer quotes and then +/// unescapes `\\`, `\"`, `\'`, `\n`, `\r`, `\t` and `\b`; quoting also keeps a +/// `#` in a password from starting a comment. +fn option_value(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\'' => out.push_str("\\'"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + other => out.push(other), + } + } + out.push('"'); + out +} + +/// Single-quote a path for a POSIX shell when it holds anything beyond the +/// characters a shell passes through unchanged. +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | ':' | '\\')) + { + return value.to_owned(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// The `hint` event telling the operator how to connect. With an option file it +/// names the file and the non-secret parts of the login — never the password; +/// without one the operator supplies the login. +pub(super) fn connect_hint( + listen_host: &str, + port: u16, + option_file: Option<&OptionFile>, +) -> Value { + match option_file { + Some(file) => json!({ + "type": "hint", + "message": format!( + "Connect with: {} (the login is in that file, readable only by you, and is removed when the tunnel exits)", + file.mysql_command() + ), + "command": file.mysql_command(), + "optionsFile": file.path().to_string_lossy(), + "user": file.user, + "database": file.database, + }), + None => json!({ + "type": "hint", + "message": format!( + "Connect a MySQL client with TLS so the local hop is encrypted, e.g.: mysql --ssl-mode=REQUIRED -h {listen_host} -P {port} -u -p", + ), + }), + } +} + +/// The host a local client should dial for a listener bound to `listen_host`. +/// A wildcard bind is reached over loopback, and `localhost` becomes +/// `127.0.0.1` so the client cannot fall back to a Unix socket. +pub(super) fn client_host(listen_host: &str) -> String { + match listen_host.parse::() { + Ok(ip) if ip.is_unspecified() => "127.0.0.1".to_owned(), + Ok(ip) => ip.to_string(), + Err(_) if listen_host.eq_ignore_ascii_case("localhost") => "127.0.0.1".to_owned(), + Err(_) => listen_host.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn creds() -> DbCredentials { + DbCredentials { + user: "wp_user".to_owned(), + password: "p\"a'ss\\w#rd".to_owned(), + name: "wp_db".to_owned(), + } + } + + #[test] + fn reads_credentials_from_mint() { + let resp = json!({ "database": { "user": "u", "password": "p", "name": "n" } }); + let got = DbCredentials::from_mint(&resp) + .expect("parse") + .expect("present"); + assert_eq!( + (got.user.as_str(), got.password.as_str(), got.name.as_str()), + ("u", "p", "n") + ); + } + + #[test] + fn absent_database_is_none_but_partial_database_fails() { + assert!( + DbCredentials::from_mint(&json!({})) + .expect("parse") + .is_none() + ); + assert!( + DbCredentials::from_mint(&json!({ "database": null })) + .expect("parse") + .is_none() + ); + for partial in [ + json!({ "database": { "user": "u", "name": "n" } }), + json!({ "database": { "user": "", "password": "p", "name": "n" } }), + json!({ "database": { "user": "u", "password": 7, "name": "n" } }), + ] { + assert!(DbCredentials::from_mint(&partial).is_err(), "{partial}"); + } + } + + #[test] + fn debug_never_shows_the_password() { + let shown = format!("{:?}", creds()); + assert!(!shown.contains("w#rd"), "{shown}"); + assert!(shown.contains("")); + } + + #[test] + fn option_values_are_quoted_and_escaped() { + assert_eq!(option_value("plain"), "\"plain\""); + assert_eq!(option_value("p\"a'ss\\w#rd"), r#""p\"a\'ss\\w#rd""#); + assert_eq!(option_value("a\nb\tc"), r#""a\nb\tc""#); + } + + #[test] + fn option_file_forces_tcp_and_keeps_database_out_of_client_group() { + let rendered = render_option_file(&creds(), "127.0.0.1", 3307); + let (client, mysql) = rendered.split_once("[mysql]").expect("mysql group"); + assert!(client.contains("[client]\nuser=\"wp_user\"\n")); + assert!(client.contains("host=\"127.0.0.1\"\nport=3307\nprotocol=TCP\n")); + assert!(!client.contains("database=")); + assert!(mysql.contains("database=\"wp_db\"")); + } + + #[test] + fn option_file_is_private_and_removed_on_drop() { + let file = OptionFile::write(&creds(), "127.0.0.1", 3306).expect("write"); + let path = file.path().to_path_buf(); + let dir = path.parent().expect("dir").to_path_buf(); + let contents = fs::read_to_string(&path).expect("read"); + assert!(contents.contains(&option_value(&creds().password))); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |p: &Path| fs::metadata(p).expect("meta").permissions().mode() & 0o777; + assert_eq!(mode(&path), 0o600); + assert_eq!(mode(&dir), 0o700); + } + let command = file.mysql_command(); + assert!(command.starts_with("mysql --defaults-extra-file=")); + assert!(command.ends_with(" --ssl-mode=REQUIRED")); + assert!(!command.contains("w#rd")); + drop(file); + assert!(!dir.exists(), "directory must be removed on drop"); + } + + #[test] + fn connect_hint_names_the_option_file_and_never_carries_the_password() { + let file = OptionFile::write(&creds(), "127.0.0.1", 3306).expect("option file"); + let hint = connect_hint("127.0.0.1", 3306, Some(&file)); + assert!(!hint.to_string().contains("w#rd"), "{hint}"); + assert_eq!(hint["user"], "wp_user"); + assert_eq!(hint["database"], "wp_db"); + assert_eq!(hint["command"], file.mysql_command()); + + let generic = connect_hint("127.0.0.1", 3307, None); + let message = generic["message"].as_str().expect("message"); + assert!(message.contains("-P 3307 -u -p"), "{message}"); + assert!(generic.get("optionsFile").is_none()); + } + + #[test] + fn shell_quote_only_when_needed() { + assert_eq!(shell_quote("/tmp/gddy-x/my.cnf"), "/tmp/gddy-x/my.cnf"); + assert_eq!(shell_quote("/tmp/a b/my.cnf"), "'/tmp/a b/my.cnf'"); + assert_eq!(shell_quote("/tmp/it's"), r"'/tmp/it'\''s'"); + } + + #[test] + fn client_host_dials_loopback_for_wildcard_and_localhost() { + assert_eq!(client_host("0.0.0.0"), "127.0.0.1"); + assert_eq!(client_host("::"), "127.0.0.1"); + assert_eq!(client_host("LOCALHOST"), "127.0.0.1"); + assert_eq!(client_host("127.0.0.1"), "127.0.0.1"); + assert_eq!(client_host("::1"), "::1"); + assert_eq!(client_host("192.168.1.10"), "192.168.1.10"); + } +} diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index 598cd60c..53d04143 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -300,11 +300,12 @@ impl HostingClient { .await } - /// Start (or reuse) an on-demand database-tunnel relay for a Managed WordPress - /// app, served by the Airo API at `/v1/airo/hosting/apps/:id/database-tunnel`, - /// outside the `/v1/hosting` base. Same scopes as - /// [`get_agent_token`](Self::get_agent_token). Response shape: - /// `{ sessionId, url, pollUrl, token, variant, expiresAt, reused }`. + /// Start an on-demand database-tunnel relay for a Managed WordPress app, + /// closing any tunnel already open for it. Served by the Airo API at + /// `/v1/airo/hosting/apps/:id/database-tunnel`, outside the `/v1/hosting` + /// base. Same scopes as [`get_agent_token`](Self::get_agent_token). + /// Response shape: `{ sessionId, url, pollUrl, token, variant, expiresAt, + /// replaced, database: { user, password, name } }`. pub async fn ensure_airo_database_tunnel_session( &self, app_id: &str, From 89177c9a3a93cbcb45a018754b94b92470905da9 Mon Sep 17 00:00:00 2001 From: dcanic Date: Mon, 5 Oct 2026 19:07:31 +0200 Subject: [PATCH 6/9] feat(db): let the WordPress tunnel's client bring its own database login Match the Node.js tunnel: hosting no longer returns WordPress's login with the mint, so drop the private option file and print the usual `mysql --ssl-mode=REQUIRED -u -p` hint. Align the proposal. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 96 ++++---- rust/src/db/mod.rs | 2 - rust/src/db/tunnel.rs | 53 ++--- rust/src/db/tunnel_credentials.rs | 374 ------------------------------ rust/src/hosting/client.rs | 2 +- 5 files changed, 61 insertions(+), 466 deletions(-) delete mode 100644 rust/src/db/tunnel_credentials.rs diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index 7b1ecf8f..5cf9171b 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -4,13 +4,15 @@ Status: draft, revised after the design review of 2026-10-02 (see **Review response**). - **Phase 1 is scoped so that it needs no new capability and no cross-team - design decision:** owner only, one session per app, and WordPress's own - database user. + design decision:** owner only, one session per app, and, as for Node.js, no + database login handed out: the customer's client logs in with a login the + customer already has. - What phase 1 leaves out on purpose, the risk each choice carries, and what to do if security does not accept it are in **Deferred on purpose**. -- Phase 1 is implemented on the branches as of 2026-10-05 (hosting, airo-go, - and the CLI), and not committed yet. **Changes from the first design** lists - what changed. The relay image is not built yet. +- Phase 1 is implemented and pushed as of 2026-10-05 (mhp-core + `dcanic/database-tunnel-on-demand-job`, CLI `feat/db-tunnel-wordpress`), with + no PR yet. **Changes from the first design** lists what changed. The relay + image is not built yet. - Open work is tracked in `mhp-core/docs/db-tunnel-on-demand-followups.md`. This document covers only what differs for Managed WordPress (product `mwp`, @@ -35,7 +37,7 @@ WebSocket protocol as the agent, so the CLI's relay code is shared. | --- | --- | --- | | Who | The app's owner only | The OAuth path names a customer. Collaborator grants are not resolved on it. | | Sessions | **One live session per app.** A new mint stops the app's previous session and starts a new one ("newest wins"). | Hosting cannot read a job's state, so reuse would need liveness reports. This needs none. | -| Credentials | **WordPress's own database user** for the variant, read by hosting from that variant's `DATABASE` secret and returned once in the mint response | hosting already does this read for phpMyAdmin. A user per session needs DDL on cell MySQL, which nothing in mhp-core can do today. | +| Credentials | **None handed out, as for Node.js.** The mint returns no login; hosting does not read the `DATABASE` secret, and the relay injects nothing. The customer logs in with a login they already have, normally WordPress's own user from `wp-config.php`. | It keeps the Node.js security model ([db-tunnel.md](./db-tunnel.md), **No credential injection anywhere in the path**), and no route returns a password. A user per session needs DDL on cell MySQL, which nothing in mhp-core can do today (DF1). | | Relay token | A hosting-signed token that the relay checks itself. No hosting routes for the relay. | It avoids two workload-callback routes that would need an argued exception (F1). It is self-contained in hosting. | | TLS to MySQL | **Enforced by the relay**: it closes a connection whose client does not ask for TLS | The server cannot enforce it, and TLS cannot be required on WordPress's user (F11). | | Infrastructure | phpMyAdmin's DNS zone, Nomad namespace, SELinux type, and Cilium policy | These are proven by the proof of concept, and already installed in production. | @@ -91,9 +93,9 @@ sequenceDiagram Airo->>Host: POST hosting/v1/systems/{systemId}/apps/{appId}/db-tunnel
X-On-Behalf-Of, {variant?} Host->>Host: RequireAppInSystem Host->>Nomad: stop the app's previous session, if any - Host->>Host: read the variant's DATABASE secret,
sign the session token + Host->>Host: read the variant's database address,
sign the session token Host->>Nomad: deploy "db-tunnel"
env: DB_HOST, DB_PORT, public keys, session id, expiry - Host-->>Airo: {sessionId, url, pollUrl, token, expiresAt,
database: {user, password, name}} + Host-->>Airo: {sessionId, url, pollUrl, token, expiresAt, replaced} Airo-->>CLI: passed through loop until 2xx or 3 minutes CLI->>Relay: GET pollUrl (/healthz) @@ -109,7 +111,7 @@ sequenceDiagram 1. **CLI.** It sends `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel` with an OAuth token scoped to deploy-execute and `hosting.database.tunnel:execute`. The response is not logged, because it - holds the token and a database password. + holds the relay token. 2. **airo-go: authenticate.** The route is on the public `/v1/hosting` group: an Authorization Platform OAuth token only, the TLA gate, the tunnel scope, and 10 requests per minute per app and customer. The limiter is per process, @@ -129,13 +131,11 @@ sequenceDiagram 5. **hosting: session.** - hosting refuses an app with no database for the variant (`409`). - It checks everything that cannot change state first: the variant, the - database, the credentials, the cell, and the relay hostname. A mint that - would fail does not cost the caller the tunnel they already have. - - It reads the variant's credentials the way phpMyAdmin does - (`loadVariantDbCredentials`, shared by both): coordinates from the - variant's `hosting_databases` row, and the user, password, and schema from - the variant's own `DATABASE` secret if it `OwnsOwnDatabase()` (staging), - or from the shared one (publish). + database address, the cell, and the relay hostname. A mint that would + fail does not cost the caller the tunnel they already have. + - It reads only the variant's database address, from its + `hosting_databases` row, as phpMyAdmin does. It does not read the + `DATABASE` secret. - It stops the app's previous session, if one is live, and reports `replaced: true`. If that stop cannot be sent, the mint fails with `503` and the previous tunnel stays. @@ -143,16 +143,11 @@ sequenceDiagram The job's environment holds no secret. One live row per app is enforced by a unique key, so of two mints at the same instant one wins and the other gets `409`. -6. **CLI: wait and listen.** The CLI polls `pollUrl` for up to 3 minutes. It - then writes the credentials to a mode-600 MySQL option file in a mode-700 - temporary directory, and prints the - `mysql --defaults-extra-file=… --ssl-mode=REQUIRED` command to use. The - file sets `user`, `password`, `host`, `port`, and `protocol=TCP` under - `[client]`, and `database` under `[mysql]`, so `mysqldump` can use it too. - The file is deleted when the command exits; a killed process leaves it in - the user's temporary directory. The password is never printed or put in an - event. If `replaced` is true, the CLI warns that the previous tunnel was - closed. +6. **CLI: wait and listen.** The CLI polls `pollUrl` for up to 3 minutes, then + listens and prints the same hint as for Node.js: + `mysql --ssl-mode=REQUIRED -h 127.0.0.1 -P -u -p`. The + customer supplies the user and password. If `replaced` is true, the CLI + warns that the previous tunnel was closed. 7. **Relay.** For each WebSocket, it verifies the token: the signature, the audience, the expiry, `sessionId == DB_TUNNEL_SESSION_ID`, and `appId == APP_ID ==` the path's app id. It dials `DB_HOST:DB_PORT`. It @@ -218,8 +213,8 @@ what to do if security does not accept the phase 1 choice. | # | Deferred | Phase 1 instead | Risk accepted | Revisit when | If rejected | | --- | --- | --- | --- | --- | --- | -| DF1 | A MySQL user per session (F3) | WordPress's own user for the variant | The customer can change that user's password, which breaks the site, and nothing reconciles the secret. The user has `ALL` on its schema and host `%`. The customer can already read and use these credentials through SSH, phpMyAdmin's SQL tab, and the secrets API. | Someone owns DDL on cell MySQL from the mint path | Build DF1 first: a per-session user with `REQUIRE SSL`, dropped at stop. This is blocked on finding who can run DDL. | -| DF2 | A read-only option | The read-write pair only | Writes to the live site's database are one statement away | Customers ask for safe inspection, or security asks for least privilege | Default to the read-only pair. It already exists in the `DATABASE` secret (`ReadOnlyUsername`), but phpMyAdmin notes that the grant has never been tested. | +| DF1 | A MySQL user per session (F3) | No login handed out. The customer brings one, normally WordPress's own user from `wp-config.php`. | The customer has to find a login first. WordPress's user has `ALL` on its schema and host `%`, and changing its password breaks the site. The airo-go secrets list also returns it, but that read is too broad (X1). | Someone owns DDL on cell MySQL from the mint path | Build DF1 first: a per-session user with `REQUIRE SSL`, returned by the mint and dropped at stop. This is blocked on finding who can run DDL. | +| DF2 | A read-only option | Whatever login the customer brings | Writes to the live site's database are one statement away | Customers ask for safe inspection, or security asks for least privilege | Offer the read-only pair. It already exists in the `DATABASE` secret (`ReadOnlyUsername`), but phpMyAdmin notes that the grant has never been tested. | | DF3 | Concurrent tunnels per app | One session per app. The newest mint wins. | A second terminal or teammate cuts the first tunnel. Every run has a cold start. | The cold start is measured, or users hit the cut | SSH-style reuse: a warm window refreshed only on a reported successful connection, `stale` reports from the CLI, and a short readiness budget for reused sessions | | DF4 | Collaborators | Owner only | — | Collaborators need tunnels | Stay owner only | | DF5 | Token renewal | One hour per session. Run the command again after that. | — | Long sessions are needed | A new mint already replaces the session | @@ -227,7 +222,7 @@ what to do if security does not accept the phase 1 choice. | DF7 | Server-side TLS enforcement | The relay's `CLIENT_SSL` check | The check depends on the relay being correct | DF1 lands (`REQUIRE SSL`) | DF1 | | DF8 | The relay's own DNS zone, namespace, SELinux type, and Cilium policy | phpMyAdmin's | A relay fault affects the phpMyAdmin namespace's budget and policy | Phase 2 | A narrower SELinux type and a dedicated policy in pioneer-infra, which also frees the port choice | | DF9 | `--variant staging` in the CLI | `publish` only | — | Staging users ask for it | — | -| DF10 | Fixing the broad secrets read (review X1) and the signup stamp for `airoSubscriptionId` (review X2) | Not used by the tunnel: hosting reads only `DATABASE`, and airo-go fails closed | — | Raised separately | — | +| DF10 | Fixing the broad secrets read (review X1) and the signup stamp for `airoSubscriptionId` (review X2) | Not used by the tunnel: it reads no secret, and airo-go fails closed | — | Raised separately | — | ## Design decisions @@ -237,7 +232,7 @@ what to do if security does not accept the phase 1 choice. | Who mints | hosting mints. airo-go only authorizes and proxies. | A hosting mint of an airo-builder agent token: there is no agent to accept it. Built and reverted. | | How the relay checks a token | Locally, against hosting's public keys in its job environment (F1). SSH already has this shape: its workload gets only a CA public key. | A redeem route and a heartbeat route on hosting: these are workload-callback routes with a multi-use bearer, and the heartbeat had no credential to use before the first connection (F2). | | How the relay finds MySQL | The database host and port are in the job environment. They are not secret (MWP rule 2). | Learning them at redeem: that needs a callback route. | -| How the customer gets credentials | In the mint response, from the variant's `DATABASE` secret only | The airo-go secrets list: it also returns the `protected` and `auth` groups and admits collaborators (review F3). | +| How the customer gets credentials | They bring their own, as for Node.js. No route hands one out, and the relay injects none. | Returning WordPress's login in the mint response (built, then removed on 2026-10-05): it made the hosting mint a new path that returns a plain-text password to any admin, ops, or `ctk` caller (security review S2). phpMyAdmin's redeem hands the password to its container, not to the caller, because phpMyAdmin itself is the MySQL client. | ## Authentication and authorization @@ -250,10 +245,10 @@ what to do if security does not accept the phase 1 choice. | airo-go | The system owner tuple resolves, and the caller is the owner | `404 app not found` | | hosting | The service credential is allowed, and `RequireAppInSystem` passes | airo-go answers `502`, or `404` | | hosting | The signing key is configured | `503` | -| hosting | The app owns a database for the variant, and its credentials resolve | `409`, or `500` | +| hosting | The app owns a database for the variant, and its address resolves | `409`, or `500` | | relay | Signature, audience, expiry, session id, app id, path app id | WebSocket `401` | | relay | The client asks for TLS (`CLIENT_SSL`) | The connection is closed | -| MySQL | WordPress's user, password, and grants | a MySQL error | +| MySQL | The customer's user, password, and grants | a MySQL error | ## Relay contract @@ -318,7 +313,7 @@ To rotate, add the new key to the map and deploy, then switch the key id. | Route | Caller | Auth | Answer | | --- | --- | --- | --- | -| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel` | airo-go, support tools | `JWTOrCert` (airo console or CTK certificate) plus `RequireAppInSystem` | `200 {sessionId, domain, url, pollUrl, token, variant, expiresAt, replaced, database: {user, password, name}}`. `400` bad request, `404` app not in system, `409` no database for the variant or a concurrent mint, `500` credentials unavailable, `503` not configured, cell unreachable, no relay hostname, or the previous session could not be stopped. | +| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel` | airo-go, support tools | `JWTOrCert` (airo console or CTK certificate) plus `RequireAppInSystem` | `200 {sessionId, domain, url, pollUrl, token, variant, expiresAt, replaced}`. `400` bad request, `404` app not in system, `409` no database for the variant or a concurrent mint, `500` no database address, `503` not configured, cell unreachable, no relay hostname, or the previous session could not be stopped. | | `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel/stop` | support tools | same | `200 {stopped, failed, unrevoked}`, or `204` if nothing was live. `unrevoked` counts sessions whose stop could not be sent; they stay live, so a retry sends it again. | Both answers carry `Cache-Control: no-store`. Attribution is @@ -336,11 +331,11 @@ verified. | hosting routes | `/v1/apps/:id/db-tunnel` and `/stop`, plus `/v1/db-tunnel/redeem` and `/heartbeat` | `/v1/systems/:systemId/apps/:appId/db-tunnel` and `/stop` behind `RequireAppInSystem`. No redeem or heartbeat. | | Tokens | Random token, sha256 verifier in `hosting_app_db_tunnel_tokens` | An Ed25519-signed JWT. No token table. A signing key in hosting configuration. | | Liveness and reuse | `last_seen_at`, startup grace, heartbeat, reuse with at least 15 minutes left | None. One session per app. A new mint stops the previous one. | -| Credentials | None returned | The variant's `DATABASE` user, password, and schema, through `loadVariantDbCredentials`, which phpMyAdmin's `resolveServers` now uses too | +| Credentials | None returned | Still none, as for Node.js. Returning WordPress's login was built and then removed (see **Design decisions**). phpMyAdmin's `resolveServers` keeps the shared `loadVariantDbCredentials` lookup. | | Job template | Port 8080, `label=disable`, no production gate, `HOSTING_DB_TUNNEL_API` | Port 80, `pma_workloads.process`, the production gate. `DB_HOST`, `DB_PORT`, expiry, and public keys replace the hosting API URL. | | Namespace constant | `dbTunnelJobNamespace = "phpmyadmin"` | Reuse `pmaJobNamespace` (F8) | | airo-go | App-id route, `GetCustomerForApp` with the `variables` fallback, service headers | System owner tuple only (`GetSystem`, then the subscription's customer), fail closed, the tenancy-scoped hosting route with `X-On-Behalf-Of` | -| CLI | Reads `url`, `token`, `pollUrl` | Also reads `database` and `replaced`, writes a mode-600 option file (`rust/src/db/tunnel_credentials.rs`), and prints the `mysql` command | +| CLI | Reads `url`, `token`, `pollUrl` | Also reads `replaced` and warns. Prints the Node.js `mysql -u -p` hint. | ## Review response @@ -350,25 +345,25 @@ The design review of 2026-10-02, against mhp-core `main` at `fa9c6e972`. | --- | --- | --- | --- | | F1 | Redeem and heartbeat are workload-callback routes with a multi-use bearer | Adopted in phase 1. The relay checks a hosting-signed token locally. The database host and port are in the job environment. | Accepted | | F2 | The relay has no credential to heartbeat with | Moot: there is no heartbeat. The relay's idle exit replaces the wait for the sweep, and one session has one token. | Closed by F1 | -| F3 | Which MySQL credentials the customer uses | Phase 1: WordPress's user for the variant, returned once by the mint from the `DATABASE` secret only, not through the broad secrets list. A per-session user is deferred (DF1), with the risk stated. | Deferred on purpose | +| F3 | Which MySQL credentials the customer uses | Phase 1: as for Node.js, the customer brings their own, normally WordPress's user from `wp-config.php`. No route hands a login out. A per-session user returned by the mint is deferred (DF1), with the risk stated. | Deferred on purpose | | F4 | No SELinux label or production gate, and 8080 cannot work | Port 80, `pma_workloads.process`, and the phpMyAdmin production gate | Accepted | | F5 | Nothing keeps an idle tunnel alive | A ping every 30 seconds, and a close after 65 seconds without a pong. `wait_timeout` is listed as a limitation. | Accepted | | F6 | Session expiry kills in-flight connections | The relay refuses new connections at expiry and drains open ones for up to 15 minutes. Replacing mints and access-changing stops cut at once. | Accepted | | F7 | airo-go's ownership check is weaker than the phpMyAdmin proxy's | A tenancy-scoped hosting route with `RequireAppInSystem` and `X-On-Behalf-Of`, the system owner tuple only, and fail closed. The signup stamp is raised separately. | Accepted | -| F8 | A second hand-wired copy of the phpMyAdmin session service | One combined stopper, `NewOnDemandSessionStopper`, backs `GetPmaSessionStopper()`, so the teardown, archive, and `update_app_system_id_task` sites stop tunnels too; a worker-container test pins that. The tunnel reuses `pmaJobNamespace` and phpMyAdmin's credential lookup. Removing redeem, heartbeat, liveness, and reuse removed most of the copy. | Accepted | +| F8 | A second hand-wired copy of the phpMyAdmin session service | One combined stopper, `NewOnDemandSessionStopper`, backs `GetPmaSessionStopper()`, so the teardown, archive, and `update_app_system_id_task` sites stop tunnels too; a worker-container test pins that. The tunnel reuses `pmaJobNamespace` and phpMyAdmin's database address lookup. Removing redeem, heartbeat, liveness, and reuse removed most of the copy. | Accepted | | F9 | Docs | An on-demand jobs section in `managed-wordpress.md` and an update to invariant 9, with the hosting PR. There are no new workload-callback routes. | Accepted | | F10 | `410` oracle, path binding, TLS wording | The `410` case is gone with redeem. The relay checks the path app id. `VERIFY_IDENTITY` is not available, which is stated. | Accepted | | F11 | `require_secure_transport` is `OFF` everywhere | The server-side options are ruled out. The relay enforces `CLIENT_SSL` in phase 1. `REQUIRE SSL` on a per-session user follows DF1. | Accepted, with a phase 1 control | Answers to the review's questions: -1. **Branches and the tracker.** They are on the local mhp-core branch - `dcanic/database-tunnel-on-demand-job`, not pushed yet. +1. **Branches and the tracker.** They are on the mhp-core branch + `dcanic/database-tunnel-on-demand-job`, pushed on 2026-10-05. 2. **A signed token instead of redeem and heartbeat.** Yes, in phase 1. Reuse is replaced by one session per app. 3. **The heartbeat token.** Moot. -4. **Credentials.** Phase 1: WordPress's user, returned by the mint from the - `DATABASE` secret. A per-session user is DF1. +4. **Credentials.** Phase 1: none handed out; the customer brings their own, + as for Node.js. A per-session user is DF1. 5. **SELinux and port.** `pma_workloads.process` on port 80. 6. **A 30-second ping.** Yes. 7. **MySQL TLS.** The relay's `CLIENT_SSL` check in phase 1. `REQUIRE SSL` with @@ -391,8 +386,9 @@ Answers to the review's questions: not ask for it, such as `--ssl-mode=DISABLED`. - **The token is visible to the proxies.** Cloudflare and `v2-ingress-proxy` decrypt the WebSocket. The token lives at most 1 hour, works only for one - session's relay, and still needs the database password behind it. -- **It is the live site's database user.** Changing its password breaks the + session's relay, and still needs a database password behind it. +- **The customer must already have a login.** The CLI does not supply one. + WordPress's user is in `wp-config.php`; changing its password breaks the site (DF1). - **Cold start on every run.** @@ -402,12 +398,9 @@ Answers to the review's questions: throughput were tested on c10 with a stand-in relay. Tokens, mint, and the CLI were not. - **CLI.** `rust/src/db/tunnel.rs` covers the product flag, the mint response - shapes (with and without `database` and `replaced`), and the `pollUrl` - checks. `rust/src/db/tunnel_credentials.rs` covers the `database` field, the - option file's quoting, its `0600`/`0700` modes and removal on drop, and that - no event carries the password. `rust/src/hosting/client_tests.rs` covers the - request path, the Bearer header, and the response. Not yet checked against a - real `mysql` client. + shapes (with and without `replaced`), and the `pollUrl` checks. + `rust/src/hosting/client_tests.rs` covers the request path, the Bearer + header, and the response. - **airo-go.** `handlers/database_tunnel_proxy_handler_test.go` covers the owner check through the system owner tuple, the same 404 for every miss (including a non-subscription owner type, a missing system, lookup errors, and app @@ -416,8 +409,9 @@ Answers to the review's questions: real `/v1/hosting` chain, registration, `401`, the scope `403`, and the rate limit. Passes with `go test -race`. - **hosting.** Service, sweep, stopper, handler, router, signer, and render - tests cover: the token's claims, signature, expiry, and rotation; credential - resolution for publish and staging; a replacing mint stopping the previous + tests cover: the token's claims, signature, expiry, and rotation; that + WordPress's login reaches neither the mint response nor the job; the + database address for publish and staging; a replacing mint stopping the previous session, and refusing when that stop fails; the concurrent-mint `409`; every worker stop site reaching tunnels; and the production render gate. - **Relay.** The `CLIENT_SSL` check (a plain handshake is closed, an diff --git a/rust/src/db/mod.rs b/rust/src/db/mod.rs index 9ed7f83d..1971183e 100644 --- a/rust/src/db/mod.rs +++ b/rust/src/db/mod.rs @@ -6,8 +6,6 @@ use cli_engine::{GroupSpec, Module, RuntimeGroupSpec, Stage}; mod tunnel; -mod tunnel_credentials; - /// The `Database` module: the `db` command group. pub fn module() -> Module { Module::new("Database", |_ctx| { diff --git a/rust/src/db/tunnel.rs b/rust/src/db/tunnel.rs index 354c33e7..3fa55f0c 100644 --- a/rust/src/db/tunnel.rs +++ b/rust/src/db/tunnel.rs @@ -18,10 +18,9 @@ //! - `--product wordpress`: Managed WordPress has no per-app agent, so //! `POST /v1/airo/hosting/apps/:id/database-tunnel` starts an on-demand relay //! speaking the same WebSocket protocol — closing any tunnel already open for -//! the app — and returns its URL, a readiness `pollUrl`, a relay token and -//! WordPress's database login. The CLI waits for `pollUrl` to answer before -//! listening, and writes the login to a private option file -//! ([`super::tunnel_credentials`]) rather than printing it. +//! the app — and returns its URL, a readiness `pollUrl` and a relay token. +//! The CLI waits for `pollUrl` to answer before listening. As with Node.js, +//! no database login is returned: the customer's client brings its own. //! //! The token goes out as `Authorization: Bearer`. The URL and token both come //! from the service — neither is a user-supplied flag. @@ -42,7 +41,6 @@ use tokio_tungstenite::tungstenite::http::{HeaderValue, header::AUTHORIZATION}; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config}; -use super::tunnel_credentials::{DbCredentials, OptionFile, client_host, connect_hint}; use crate::error::GddyError; use crate::hosting::client::HostingClient; use crate::http::api_url_for_env; @@ -135,10 +133,8 @@ pub(super) fn command() -> RuntimeCommandSpec { site, which starts a short-lived relay and can take up to a few \ minutes before the port opens; the default is a Node.js Hosting \ app. A WordPress site has one tunnel at a time: opening a new one \ - closes the previous one. Its database login is written to a \ - private MySQL option file, never printed, and the CLI prints the \ - `mysql --defaults-extra-file=...` command that uses it. Runs until \ - interrupted (Ctrl-C).", + closes the previous one. Log in with your own database user and \ + password, as for a Node.js app. Runs until interrupted (Ctrl-C).", ) .with_system("database") .with_tier(Tier::Mutate) @@ -260,19 +256,6 @@ async fn run_tunnel( .local_addr() .map(|a| a.to_string()) .unwrap_or_else(|_| bind_addr.clone()); - let local_port = listener.local_addr().map_or(args.port, |a| a.port()); - - // Held until this function returns, which removes the file. - let option_file = match &target.database { - Some(creds) => { - match OptionFile::write(creds, &client_host(&args.listen_host), local_port) { - Ok(file) => Some(file), - Err(e) => return Err(fail(sender, e.into_cli_error()).await), - } - } - None => None, - }; - sender .send(json!({ "type": "listening", @@ -304,11 +287,13 @@ async fn run_tunnel( .await; } sender - .send(connect_hint( - &args.listen_host, - local_port, - option_file.as_ref(), - )) + .send(json!({ + "type": "hint", + "message": format!( + "Connect a MySQL client with TLS so the local hop is encrypted, e.g.: mysql --ssl-mode=REQUIRED -h {} -P {} -u -p", + args.listen_host, args.port + ), + })) .await; let token = token.as_str(); @@ -364,14 +349,13 @@ async fn run_tunnel( } /// Where the tunnel connects: the base URL of the WebSocket endpoint, the bearer -/// token it accepts, and — for an on-demand relay — the probe to wait on first, -/// the database login, and whether an earlier tunnel was closed to make room. +/// token it accepts, and — for an on-demand relay — the probe to wait on first +/// and whether an earlier tunnel was closed to make room. #[derive(Debug, PartialEq, Eq)] struct TunnelTarget { endpoint: String, token: String, ready_url: Option, - database: Option, replaced: bool, } @@ -403,14 +387,12 @@ fn parse_tunnel_target(resp: &Value, product: TunnelProduct) -> cli_engine::Resu endpoint: field_str(resp, "agentUrl")?, token: field_str(resp, "token")?, ready_url: None, - database: None, replaced: false, }, TunnelProduct::Wordpress => TunnelTarget { endpoint: field_str(resp, "url")?, token: field_str(resp, "token")?, ready_url: Some(field_str(resp, "pollUrl")?), - database: DbCredentials::from_mint(resp).map_err(GddyError::into_cli_error)?, replaced: resp .get("replaced") .and_then(Value::as_bool) @@ -886,7 +868,6 @@ mod tests { endpoint: "https://agent.example".to_owned(), token: "t".to_owned(), ready_url: None, - database: None, replaced: false, } ); @@ -900,7 +881,6 @@ mod tests { "pollUrl": "https://dbt-s1.c1.pma.example/healthz", "token": "relay-token", "replaced": true, - "database": { "user": "wp_u", "password": "wp_p", "name": "wp_db" }, }); let target = super::parse_tunnel_target(&resp, super::TunnelProduct::Wordpress) .expect("wordpress target"); @@ -911,12 +891,10 @@ mod tests { Some("https://dbt-s1.c1.pma.example/healthz") ); assert!(target.replaced); - let db = target.database.expect("database login"); - assert_eq!((db.user.as_str(), db.name.as_str()), ("wp_u", "wp_db")); } #[test] - fn wordpress_target_without_database_or_replaced_still_parses() { + fn wordpress_target_without_replaced_still_parses() { let resp = serde_json::json!({ "url": "https://dbt-s1.example", "pollUrl": "https://dbt-s1.example/healthz", @@ -924,7 +902,6 @@ mod tests { }); let target = super::parse_tunnel_target(&resp, super::TunnelProduct::Wordpress) .expect("wordpress target"); - assert!(target.database.is_none()); assert!(!target.replaced); } diff --git a/rust/src/db/tunnel_credentials.rs b/rust/src/db/tunnel_credentials.rs deleted file mode 100644 index 68f292e0..00000000 --- a/rust/src/db/tunnel_credentials.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! Database credentials from a Managed WordPress tunnel mint, kept off the -//! terminal. The mint returns WordPress's own database login once; the CLI -//! writes it to a MySQL option file only the current user can read and prints -//! the path, so the password never reaches the event stream, the scrollback, -//! or another process's argument list. The file and its directory are removed -//! when the tunnel exits. - -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use serde_json::{Value, json}; - -use crate::error::GddyError; - -/// WordPress's database login for the tunnelled variant. `Debug` is written by -/// hand so the password cannot reach a log line through `{:?}`. -#[derive(Clone, PartialEq, Eq)] -pub(super) struct DbCredentials { - pub(super) user: String, - pub(super) password: String, - pub(super) name: String, -} - -impl std::fmt::Debug for DbCredentials { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DbCredentials") - .field("user", &self.user) - .field("password", &"") - .field("name", &self.name) - .finish() - } -} - -impl DbCredentials { - /// Read `database: { user, password, name }` from the mint response. - /// `None` when the service sent no `database` object; an object with a - /// missing or empty field is a broken contract and fails. - pub(super) fn from_mint(resp: &Value) -> Result, GddyError> { - let Some(db) = resp.get("database").filter(|v| !v.is_null()) else { - return Ok(None); - }; - let field = |key: &str| { - db.get(key) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .ok_or_else(|| { - GddyError::network(format!( - "database tunnel mint response has no 'database.{key}'" - )) - .with_fix("Retry; if it persists, contact support.") - }) - }; - Ok(Some(Self { - user: field("user")?, - password: field("password")?, - name: field("name")?, - })) - } -} - -/// A private MySQL option file in a directory of its own. Dropping it removes -/// both, so every exit path that unwinds through `run_tunnel` cleans up; a -/// killed process leaves them behind in the user-only temp directory. -pub(super) struct OptionFile { - dir: PathBuf, - path: PathBuf, - user: String, - database: String, -} - -impl OptionFile { - /// Write `creds` for a client connecting to `host:port` (the local - /// listener). The directory is created `0700` and the file `0600` before - /// any secret is written, so there is no window where another user can - /// read it. - pub(super) fn write(creds: &DbCredentials, host: &str, port: u16) -> Result { - let dir = std::env::temp_dir().join(format!("gddy-db-tunnel-{}", uuid::Uuid::new_v4())); - create_private_dir(&dir).map_err(|e| write_error(&dir, &e))?; - let file = Self { - path: dir.join("my.cnf"), - dir, - user: creds.user.clone(), - database: creds.name.clone(), - }; - let mut handle = - create_private_file(&file.path).map_err(|e| write_error(&file.path, &e))?; - handle - .write_all(render_option_file(creds, host, port).as_bytes()) - .map_err(|e| write_error(&file.path, &e))?; - Ok(file) - } - - pub(super) fn path(&self) -> &Path { - &self.path - } - - /// The command a MySQL client runs to use this file. `--defaults-extra-file` - /// must come first on the `mysql` command line. - pub(super) fn mysql_command(&self) -> String { - format!( - "mysql --defaults-extra-file={} --ssl-mode=REQUIRED", - shell_quote(&self.path.to_string_lossy()) - ) - } -} - -impl Drop for OptionFile { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.dir); - } -} - -fn write_error(path: &Path, err: &std::io::Error) -> GddyError { - GddyError::config(format!( - "could not write the database option file {}: {err}", - path.display() - )) - .with_fix("Check that the system temp directory is writable (set TMPDIR to another directory), then retry.") -} - -#[cfg(unix)] -fn create_private_dir(dir: &Path) -> std::io::Result<()> { - use std::os::unix::fs::DirBuilderExt; - fs::DirBuilder::new().mode(0o700).create(dir) -} - -#[cfg(not(unix))] -fn create_private_dir(dir: &Path) -> std::io::Result<()> { - fs::create_dir(dir) -} - -#[cfg(unix)] -fn create_private_file(path: &Path) -> std::io::Result { - use std::os::unix::fs::OpenOptionsExt; - fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(path) -} - -#[cfg(not(unix))] -fn create_private_file(path: &Path) -> std::io::Result { - fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(path) -} - -/// `[client]` is read by every MySQL tool; `database` sits under `[mysql]` -/// because `mysqldump` and friends reject it as an unknown option. TCP is -/// forced so a `localhost` default never turns into a Unix-socket connection -/// to some other server on this machine. -fn render_option_file(creds: &DbCredentials, host: &str, port: u16) -> String { - format!( - "# Written by `gddy db tunnel`; removed when the tunnel exits.\n\ - [client]\n\ - user={}\n\ - password={}\n\ - host={}\n\ - port={port}\n\ - protocol=TCP\n\ - \n\ - [mysql]\n\ - database={}\n", - option_value(&creds.user), - option_value(&creds.password), - option_value(host), - option_value(&creds.name), - ) -} - -/// Quote an option-file value. MySQL strips matching outer quotes and then -/// unescapes `\\`, `\"`, `\'`, `\n`, `\r`, `\t` and `\b`; quoting also keeps a -/// `#` in a password from starting a comment. -fn option_value(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for c in value.chars() { - match c { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\'' => out.push_str("\\'"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{8}' => out.push_str("\\b"), - other => out.push(other), - } - } - out.push('"'); - out -} - -/// Single-quote a path for a POSIX shell when it holds anything beyond the -/// characters a shell passes through unchanged. -fn shell_quote(value: &str) -> String { - if !value.is_empty() - && value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | ':' | '\\')) - { - return value.to_owned(); - } - format!("'{}'", value.replace('\'', "'\\''")) -} - -/// The `hint` event telling the operator how to connect. With an option file it -/// names the file and the non-secret parts of the login — never the password; -/// without one the operator supplies the login. -pub(super) fn connect_hint( - listen_host: &str, - port: u16, - option_file: Option<&OptionFile>, -) -> Value { - match option_file { - Some(file) => json!({ - "type": "hint", - "message": format!( - "Connect with: {} (the login is in that file, readable only by you, and is removed when the tunnel exits)", - file.mysql_command() - ), - "command": file.mysql_command(), - "optionsFile": file.path().to_string_lossy(), - "user": file.user, - "database": file.database, - }), - None => json!({ - "type": "hint", - "message": format!( - "Connect a MySQL client with TLS so the local hop is encrypted, e.g.: mysql --ssl-mode=REQUIRED -h {listen_host} -P {port} -u -p", - ), - }), - } -} - -/// The host a local client should dial for a listener bound to `listen_host`. -/// A wildcard bind is reached over loopback, and `localhost` becomes -/// `127.0.0.1` so the client cannot fall back to a Unix socket. -pub(super) fn client_host(listen_host: &str) -> String { - match listen_host.parse::() { - Ok(ip) if ip.is_unspecified() => "127.0.0.1".to_owned(), - Ok(ip) => ip.to_string(), - Err(_) if listen_host.eq_ignore_ascii_case("localhost") => "127.0.0.1".to_owned(), - Err(_) => listen_host.to_owned(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn creds() -> DbCredentials { - DbCredentials { - user: "wp_user".to_owned(), - password: "p\"a'ss\\w#rd".to_owned(), - name: "wp_db".to_owned(), - } - } - - #[test] - fn reads_credentials_from_mint() { - let resp = json!({ "database": { "user": "u", "password": "p", "name": "n" } }); - let got = DbCredentials::from_mint(&resp) - .expect("parse") - .expect("present"); - assert_eq!( - (got.user.as_str(), got.password.as_str(), got.name.as_str()), - ("u", "p", "n") - ); - } - - #[test] - fn absent_database_is_none_but_partial_database_fails() { - assert!( - DbCredentials::from_mint(&json!({})) - .expect("parse") - .is_none() - ); - assert!( - DbCredentials::from_mint(&json!({ "database": null })) - .expect("parse") - .is_none() - ); - for partial in [ - json!({ "database": { "user": "u", "name": "n" } }), - json!({ "database": { "user": "", "password": "p", "name": "n" } }), - json!({ "database": { "user": "u", "password": 7, "name": "n" } }), - ] { - assert!(DbCredentials::from_mint(&partial).is_err(), "{partial}"); - } - } - - #[test] - fn debug_never_shows_the_password() { - let shown = format!("{:?}", creds()); - assert!(!shown.contains("w#rd"), "{shown}"); - assert!(shown.contains("")); - } - - #[test] - fn option_values_are_quoted_and_escaped() { - assert_eq!(option_value("plain"), "\"plain\""); - assert_eq!(option_value("p\"a'ss\\w#rd"), r#""p\"a\'ss\\w#rd""#); - assert_eq!(option_value("a\nb\tc"), r#""a\nb\tc""#); - } - - #[test] - fn option_file_forces_tcp_and_keeps_database_out_of_client_group() { - let rendered = render_option_file(&creds(), "127.0.0.1", 3307); - let (client, mysql) = rendered.split_once("[mysql]").expect("mysql group"); - assert!(client.contains("[client]\nuser=\"wp_user\"\n")); - assert!(client.contains("host=\"127.0.0.1\"\nport=3307\nprotocol=TCP\n")); - assert!(!client.contains("database=")); - assert!(mysql.contains("database=\"wp_db\"")); - } - - #[test] - fn option_file_is_private_and_removed_on_drop() { - let file = OptionFile::write(&creds(), "127.0.0.1", 3306).expect("write"); - let path = file.path().to_path_buf(); - let dir = path.parent().expect("dir").to_path_buf(); - let contents = fs::read_to_string(&path).expect("read"); - assert!(contents.contains(&option_value(&creds().password))); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = |p: &Path| fs::metadata(p).expect("meta").permissions().mode() & 0o777; - assert_eq!(mode(&path), 0o600); - assert_eq!(mode(&dir), 0o700); - } - let command = file.mysql_command(); - assert!(command.starts_with("mysql --defaults-extra-file=")); - assert!(command.ends_with(" --ssl-mode=REQUIRED")); - assert!(!command.contains("w#rd")); - drop(file); - assert!(!dir.exists(), "directory must be removed on drop"); - } - - #[test] - fn connect_hint_names_the_option_file_and_never_carries_the_password() { - let file = OptionFile::write(&creds(), "127.0.0.1", 3306).expect("option file"); - let hint = connect_hint("127.0.0.1", 3306, Some(&file)); - assert!(!hint.to_string().contains("w#rd"), "{hint}"); - assert_eq!(hint["user"], "wp_user"); - assert_eq!(hint["database"], "wp_db"); - assert_eq!(hint["command"], file.mysql_command()); - - let generic = connect_hint("127.0.0.1", 3307, None); - let message = generic["message"].as_str().expect("message"); - assert!(message.contains("-P 3307 -u -p"), "{message}"); - assert!(generic.get("optionsFile").is_none()); - } - - #[test] - fn shell_quote_only_when_needed() { - assert_eq!(shell_quote("/tmp/gddy-x/my.cnf"), "/tmp/gddy-x/my.cnf"); - assert_eq!(shell_quote("/tmp/a b/my.cnf"), "'/tmp/a b/my.cnf'"); - assert_eq!(shell_quote("/tmp/it's"), r"'/tmp/it'\''s'"); - } - - #[test] - fn client_host_dials_loopback_for_wildcard_and_localhost() { - assert_eq!(client_host("0.0.0.0"), "127.0.0.1"); - assert_eq!(client_host("::"), "127.0.0.1"); - assert_eq!(client_host("LOCALHOST"), "127.0.0.1"); - assert_eq!(client_host("127.0.0.1"), "127.0.0.1"); - assert_eq!(client_host("::1"), "::1"); - assert_eq!(client_host("192.168.1.10"), "192.168.1.10"); - } -} diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index 53d04143..c93ec0e8 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -305,7 +305,7 @@ impl HostingClient { /// `/v1/airo/hosting/apps/:id/database-tunnel`, outside the `/v1/hosting` /// base. Same scopes as [`get_agent_token`](Self::get_agent_token). /// Response shape: `{ sessionId, url, pollUrl, token, variant, expiresAt, - /// replaced, database: { user, password, name } }`. + /// replaced }`. pub async fn ensure_airo_database_tunnel_session( &self, app_id: &str, From e15e5c4197c39f72bab297ef4007c0fb6ce455bd Mon Sep 17 00:00:00 2001 From: dcanic Date: Tue, 6 Oct 2026 11:14:28 +0200 Subject: [PATCH 7/9] docs(db): describe priority-queue STOPs and the stop route resent count hosting now sends tunnel STOPs on the queue the relay was deployed through, and resends them for stopped sessions whose relay may still be running. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index 5cf9171b..1536a97f 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -160,6 +160,14 @@ sequenceDiagram - the app is torn down, archived, or moved to another system or owner; - the sweep finds the session expired. + hosting sends the STOP on the cell's priority queue, the same queue the job + was deployed through, so it does not wait behind the bulk deploy backlog. + Two queues are not ordered, and even one queue is ordered only on a best-effort + basis, so a STOP can still arrive before its job registers. To cover that, + every later stop or mint for the app sends the STOP again for each stopped + session whose relay could still be running (expiry plus drain not yet + passed). A repeated STOP is harmless. + ### Expiry | Setting | Value | Effect | @@ -314,7 +322,7 @@ To rotate, add the new key to the map and deploy, then switch the key id. | Route | Caller | Auth | Answer | | --- | --- | --- | --- | | `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel` | airo-go, support tools | `JWTOrCert` (airo console or CTK certificate) plus `RequireAppInSystem` | `200 {sessionId, domain, url, pollUrl, token, variant, expiresAt, replaced}`. `400` bad request, `404` app not in system, `409` no database for the variant or a concurrent mint, `500` no database address, `503` not configured, cell unreachable, no relay hostname, or the previous session could not be stopped. | -| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel/stop` | support tools | same | `200 {stopped, failed, unrevoked}`, or `204` if nothing was live. `unrevoked` counts sessions whose stop could not be sent; they stay live, so a retry sends it again. | +| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel/stop` | support tools | same | `200 {stopped, resent, failed, unrevoked}`, or `204` if nothing was live. `unrevoked` counts sessions whose stop could not be sent; they stay live, so a retry sends it again. `resent` counts stopped sessions whose STOP was sent again because their relay could still be running; a failed resend counts in `failed` only. | Both answers carry `Cache-Control: no-store`. Attribution is `X-On-Behalf-Of` when present, otherwise the body's `createdBy`, otherwise the From 34b838ea6618be1dad12499cb389b206c99dd452 Mon Sep 17 00:00:00 2001 From: dcanic Date: Tue, 6 Oct 2026 11:21:37 +0200 Subject: [PATCH 8/9] docs(db): reduce the WordPress tunnel proposal to what the CLI does The server-side design is internal and now lives with the hosting code. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 464 ++++---------------------------- 1 file changed, 57 insertions(+), 407 deletions(-) diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index 1536a97f..deaf5a49 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -1,428 +1,78 @@ # Proposal: `gddy db tunnel --product wordpress` — MySQL access for Managed WordPress -Status: draft, revised after the design review of 2026-10-02 (see **Review -response**). +Status: draft. The CLI side is implemented. The server side is not live yet. -- **Phase 1 is scoped so that it needs no new capability and no cross-team - design decision:** owner only, one session per app, and, as for Node.js, no - database login handed out: the customer's client logs in with a login the - customer already has. -- What phase 1 leaves out on purpose, the risk each choice carries, and what to - do if security does not accept it are in **Deferred on purpose**. -- Phase 1 is implemented and pushed as of 2026-10-05 (mhp-core - `dcanic/database-tunnel-on-demand-job`, CLI `feat/db-tunnel-wordpress`), with - no PR yet. **Changes from the first design** lists what changed. The relay - image is not built yet. -- Open work is tracked in `mhp-core/docs/db-tunnel-on-demand-followups.md`. - -This document covers only what differs for Managed WordPress (product `mwp`, -product app type `mhwp`). The WebSocket protocol and bind safety are the same as -for Node.js Hosting; see [db-tunnel.md](./db-tunnel.md). The security model is -**not** the same: see **TLS to MySQL**. +This document covers only what the CLI does differently for Managed WordPress. +The WebSocket protocol, the local listener, and bind safety are the same as for +Node.js Hosting; see [db-tunnel.md](./db-tunnel.md). The server-side design is +internal to GoDaddy. ## Motivation `gddy db tunnel` gives a developer a local MySQL port that relays to an app's database over a WebSocket. For Node.js Hosting apps, the far end is the app's -long-running **agent**. Managed WordPress apps have no agent. Running one for -the full life of every site only to serve an occasional tunnel is wasteful. - -Instead, hosting starts a small **on-demand relay job** for a tunnel session, as -it does for phpMyAdmin (`pma`) and SSH sessions. The relay speaks the same -WebSocket protocol as the agent, so the CLI's relay code is shared. - -## Phase 1 scope - -| Topic | Phase 1 | Why this choice | -| --- | --- | --- | -| Who | The app's owner only | The OAuth path names a customer. Collaborator grants are not resolved on it. | -| Sessions | **One live session per app.** A new mint stops the app's previous session and starts a new one ("newest wins"). | Hosting cannot read a job's state, so reuse would need liveness reports. This needs none. | -| Credentials | **None handed out, as for Node.js.** The mint returns no login; hosting does not read the `DATABASE` secret, and the relay injects nothing. The customer logs in with a login they already have, normally WordPress's own user from `wp-config.php`. | It keeps the Node.js security model ([db-tunnel.md](./db-tunnel.md), **No credential injection anywhere in the path**), and no route returns a password. A user per session needs DDL on cell MySQL, which nothing in mhp-core can do today (DF1). | -| Relay token | A hosting-signed token that the relay checks itself. No hosting routes for the relay. | It avoids two workload-callback routes that would need an argued exception (F1). It is self-contained in hosting. | -| TLS to MySQL | **Enforced by the relay**: it closes a connection whose client does not ask for TLS | The server cannot enforce it, and TLS cannot be required on WordPress's user (F11). | -| Infrastructure | phpMyAdmin's DNS zone, Nomad namespace, SELinux type, and Cilium policy | These are proven by the proof of concept, and already installed in production. | -| Variant | `publish` from the CLI. The API also accepts `staging`. | It keeps the CLI surface small. | - -## Proof of concept - -On 2026-10-02 and 2026-10-05, the reviewer opened MySQL sessions to a test site -on dev cell c10 through a stand-in relay. The stand-in was `websocat`, running -in a throwaway Nomad job, `dbt-poc`, in the `phpmyadmin` namespace. It did not -exercise the mint, tokens, or the CLI. The procedure is in the design review. - -- **Path:** +long-running agent. Managed WordPress apps have no agent, so the platform +starts a short-lived **relay** for each tunnel session instead. The relay speaks +the same WebSocket protocol as the agent, so the CLI's relay code is shared. - ```text - mysql client ─TCP─► gddy CLI ─WSS─► Cloudflare ─HTTPS─► v2-ingress-proxy ─HTTP:80─► relay ─TCP─► cell MySQL - ``` +## Usage - `v2-ingress-proxy` runs in the `ingress` namespace and finds backends from - their `ingress.domains=Host(...)` service tags. WebSockets are on for every - cell zone in Cloudflare, in dev and production. -- **Worked:** - - A login as the site's database user, with TLS 1.3. - - An 8 MiB row, a 50 MB result set (about 17 MB/s), and `mysqldump`. - - `label=type:pma_workloads.process` on port `80`, running as uid 65534 with - every capability dropped and a read-only root. -- **Failed:** - - Port `8080`. SELinux denies the bind, and the `phpmyadmin` Cilium policy - admits only TCP 80. - - A silent tunnel. It was cut between 5 and 11 minutes. A ping every 30 - seconds kept it open for 11 minutes. -- **Server settings found:** - - `require_secure_transport` is `0` on every MySQL server, in dev and in all - production series. - - `wait_timeout` is 60 seconds on c10. - - `max_allowed_packet` is 16 MiB. - -## How it works - -```mermaid -sequenceDiagram - autonumber - participant CLI as gddy db tunnel
--product wordpress - participant Airo as airo-go
(Airo API) - participant Host as hosting API - participant Nomad as Nomad (cell) - participant Relay as db-tunnel relay job - participant DB as Cell MySQL - - CLI->>Airo: POST /v1/airo/hosting/apps/{appId}/database-tunnel
Authorization: Bearer - Airo->>Airo: OAuth chain, TLA gate, tunnel scope, rate limit - Airo->>Airo: system owner tuple: the caller must own the app
(fail closed) - Airo->>Host: POST hosting/v1/systems/{systemId}/apps/{appId}/db-tunnel
X-On-Behalf-Of, {variant?} - Host->>Host: RequireAppInSystem - Host->>Nomad: stop the app's previous session, if any - Host->>Host: read the variant's database address,
sign the session token - Host->>Nomad: deploy "db-tunnel"
env: DB_HOST, DB_PORT, public keys, session id, expiry - Host-->>Airo: {sessionId, url, pollUrl, token, expiresAt, replaced} - Airo-->>CLI: passed through - loop until 2xx or 3 minutes - CLI->>Relay: GET pollUrl (/healthz) - end - CLI->>Relay: per TCP conn: WSS /apps/{appId}/database/tunnel
Authorization: Bearer - Relay->>Relay: verify the token. Require CLIENT_SSL
in the client's first MySQL packet. - Relay->>DB: connect(DB_HOST, DB_PORT), then relay raw bytes - Relay-->>CLI: WebSocket ping every 30s +```bash +gddy db tunnel --app-id --product wordpress [--port 3306] +mysql --ssl-mode=REQUIRED -h 127.0.0.1 -P 3306 -u -p ``` -### Step by step - -1. **CLI.** It sends `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel` - with an OAuth token scoped to deploy-execute and - `hosting.database.tunnel:execute`. The response is not logged, because it - holds the relay token. -2. **airo-go: authenticate.** The route is on the public `/v1/hosting` group: - an Authorization Platform OAuth token only, the TLA gate, the tunnel scope, - and 10 requests per minute per app and customer. The limiter is per process, - so it is an abuse brake, not a quota. -3. **airo-go: tenancy.** airo-go reads the app's `systemId`, then that - system's owner tuple from hosting. If the owner type is not - `airoSubscriptionId`, it **fails closed**. Nothing on the app record is - trusted for ownership: there is no fallback to `variables.airoSubscriptionId` - (F7). The subscription's customer must be the caller. Every miss, and every - lookup error, answers `404 app not found`. -4. **airo-go → hosting.** It calls the **tenancy-scoped** route - `POST hosting/v1/systems/{systemId}/apps/{appId}/db-tunnel` with - `X-On-Behalf-Of: customer:`, so hosting checks `RequireAppInSystem` too - (F7). The body carries only `variant` (`publish` or `staging`). Attribution - comes from `X-On-Behalf-Of`. It never carries `force`. The response is - relayed unchanged, with `Cache-Control: no-store`. -5. **hosting: session.** - - hosting refuses an app with no database for the variant (`409`). - - It checks everything that cannot change state first: the variant, the - database address, the cell, and the relay hostname. A mint that would - fail does not cost the caller the tunnel they already have. - - It reads only the variant's database address, from its - `hosting_databases` row, as phpMyAdmin does. It does not read the - `DATABASE` secret. - - It stops the app's previous session, if one is live, and reports - `replaced: true`. If that stop cannot be sent, the mint fails with `503` - and the previous tunnel stays. - - It creates the session row, signs the token, and deploys the relay job. - The job's environment holds no secret. One live row per app is enforced - by a unique key, so of two mints at the same instant one wins and the - other gets `409`. -6. **CLI: wait and listen.** The CLI polls `pollUrl` for up to 3 minutes, then - listens and prints the same hint as for Node.js: - `mysql --ssl-mode=REQUIRED -h 127.0.0.1 -P -u -p`. The - customer supplies the user and password. If `replaced` is true, the CLI - warns that the previous tunnel was closed. -7. **Relay.** For each WebSocket, it verifies the token: the signature, the - audience, the expiry, `sessionId == DB_TUNNEL_SESSION_ID`, and - `appId == APP_ID ==` the path's app id. It dials `DB_HOST:DB_PORT`. It - closes the connection if the client's first MySQL packet does not ask for - TLS. It pings every 30 seconds. -8. **End.** The relay exits on its own when it has had no connection for 10 - minutes, or at expiry (see **Expiry**). hosting stops the job when: - - a new session replaces it; - - the session is stopped; - - the app is torn down, archived, or moved to another system or owner; - - the sweep finds the session expired. - - hosting sends the STOP on the cell's priority queue, the same queue the job - was deployed through, so it does not wait behind the bulk deploy backlog. - Two queues are not ordered, and even one queue is ordered only on a best-effort - basis, so a STOP can still arrive before its job registers. To cover that, - every later stop or mint for the app sends the STOP again for each stopped - session whose relay could still be running (expiry plus drain not yet - passed). A repeated STOP is harmless. - -### Expiry - -| Setting | Value | Effect | -| --- | --- | --- | -| Session lifetime | 1 hour | The token expires. The relay refuses new connections from then on. | -| Drain | up to 15 minutes after expiry | Connections that are already open keep running, so a `mysqldump` that started near the end is not cut (F6). Then the relay exits. | -| Idle exit | 10 minutes without an open connection | The relay exits. MySQL closes idle sessions after `wait_timeout` (60 seconds on c10) anyway, and the `mysql` client reconnects on its own. | -| Sweep | 15 minutes after the drain ends | Stops jobs that neither the relay nor a stop hook cleaned up | - -A stop for a replacing mint, teardown, archive, or a system move does not wait -for the drain. It cuts open connections at once. - -## TLS to MySQL - -There are two layers of encryption, and only one is end to end: - -- **The WebSocket's TLS is hop by hop.** Cloudflare decrypts it to proxy it, and - `v2-ingress-proxy` decrypts it again. The last hop, from the ingress to the - relay, is plain HTTP on port 80. The token in the `Authorization` header is - readable at each of these. -- **MySQL's own TLS**, negotiated by the client and the server inside the - tunnel, is end to end. It is the only layer that protects queries and - results. - -The server cannot be the backstop: - -- `require_secure_transport` is `OFF` on every MySQL server, in dev and - production. -- It cannot be turned on: WordPress on these cells connects without TLS (no - `MYSQL_CLIENT_FLAGS`), so every site on the server would be cut off. -- `REQUIRE SSL` cannot be set on WordPress's user, for the same reason. - -So in phase 1 **the relay enforces TLS**. A MySQL client's first packet is -either an `SSLRequest`, with the `CLIENT_SSL` capability flag set, or a plain -handshake response. The relay reads only the capability flags of that packet -and closes the connection when `CLIENT_SSL` is not set. It never reads -credentials, which come only after TLS is up. - -The password is not exposed even without TLS: `caching_sha2_password` uses a -challenge or an RSA exchange. The relay check protects the queries and the -results. - -Through a tunnel, the server is reached at `127.0.0.1`. `--ssl-mode=REQUIRED` -encrypts but does not verify the server's identity. `VERIFY_IDENTITY` is not -available (F10). - -## Deferred on purpose +`--product` defaults to `nodejs`. It only selects the mint endpoint. Everything +else follows from the mint response. -Each row is a choice made to keep phase 1 free of blockers. "If rejected" is -what to do if security does not accept the phase 1 choice. +## What the CLI does -| # | Deferred | Phase 1 instead | Risk accepted | Revisit when | If rejected | -| --- | --- | --- | --- | --- | --- | -| DF1 | A MySQL user per session (F3) | No login handed out. The customer brings one, normally WordPress's own user from `wp-config.php`. | The customer has to find a login first. WordPress's user has `ALL` on its schema and host `%`, and changing its password breaks the site. The airo-go secrets list also returns it, but that read is too broad (X1). | Someone owns DDL on cell MySQL from the mint path | Build DF1 first: a per-session user with `REQUIRE SSL`, returned by the mint and dropped at stop. This is blocked on finding who can run DDL. | -| DF2 | A read-only option | Whatever login the customer brings | Writes to the live site's database are one statement away | Customers ask for safe inspection, or security asks for least privilege | Offer the read-only pair. It already exists in the `DATABASE` secret (`ReadOnlyUsername`), but phpMyAdmin notes that the grant has never been tested. | -| DF3 | Concurrent tunnels per app | One session per app. The newest mint wins. | A second terminal or teammate cuts the first tunnel. Every run has a cold start. | The cold start is measured, or users hit the cut | SSH-style reuse: a warm window refreshed only on a reported successful connection, `stale` reports from the CLI, and a short readiness budget for reused sessions | -| DF4 | Collaborators | Owner only | — | Collaborators need tunnels | Stay owner only | -| DF5 | Token renewal | One hour per session. Run the command again after that. | — | Long sessions are needed | A new mint already replaces the session | -| DF6 | Revoking one token | Stopping the session revokes everything | — | — | A short token lifetime with re-mint | -| DF7 | Server-side TLS enforcement | The relay's `CLIENT_SSL` check | The check depends on the relay being correct | DF1 lands (`REQUIRE SSL`) | DF1 | -| DF8 | The relay's own DNS zone, namespace, SELinux type, and Cilium policy | phpMyAdmin's | A relay fault affects the phpMyAdmin namespace's budget and policy | Phase 2 | A narrower SELinux type and a dedicated policy in pioneer-infra, which also frees the port choice | -| DF9 | `--variant staging` in the CLI | `publish` only | — | Staging users ask for it | — | -| DF10 | Fixing the broad secrets read (review X1) and the signup stamp for `airoSubscriptionId` (review X2) | Not used by the tunnel: it reads no secret, and airo-go fails closed | — | Raised separately | — | - -## Design decisions - -| Decision | Chosen | Rejected, and why | -| --- | --- | --- | -| What serves the WebSocket | An on-demand `db-tunnel` Nomad job per session, deployed through hosting's on-demand job framework (`onDemandJobs`) | An agent task or sidecar in the main `managed-wordpress` job: it would run for the full life of every site. | -| Who mints | hosting mints. airo-go only authorizes and proxies. | A hosting mint of an airo-builder agent token: there is no agent to accept it. Built and reverted. | -| How the relay checks a token | Locally, against hosting's public keys in its job environment (F1). SSH already has this shape: its workload gets only a CA public key. | A redeem route and a heartbeat route on hosting: these are workload-callback routes with a multi-use bearer, and the heartbeat had no credential to use before the first connection (F2). | -| How the relay finds MySQL | The database host and port are in the job environment. They are not secret (MWP rule 2). | Learning them at redeem: that needs a callback route. | -| How the customer gets credentials | They bring their own, as for Node.js. No route hands one out, and the relay injects none. | Returning WordPress's login in the mint response (built, then removed on 2026-10-05): it made the hosting mint a new path that returns a plain-text password to any admin, ops, or `ctk` caller (security review S2). phpMyAdmin's redeem hands the password to its container, not to the caller, because phpMyAdmin itself is the MySQL client. | - -## Authentication and authorization - -| Layer | Check | Failure | -| --- | --- | --- | -| airo-go | The OAuth JWT is valid (`/v1/hosting` chain) | `401` (`503` if the signing keys are unavailable) | -| airo-go | The customer has a shopper ID and passes the TLA gate | `403` | -| airo-go | The token has the scope `hosting.database.tunnel:execute` | `403 Insufficient scope` | -| airo-go | Rate limit per app and customer | `429` | -| airo-go | The system owner tuple resolves, and the caller is the owner | `404 app not found` | -| hosting | The service credential is allowed, and `RequireAppInSystem` passes | airo-go answers `502`, or `404` | -| hosting | The signing key is configured | `503` | -| hosting | The app owns a database for the variant, and its address resolves | `409`, or `500` | -| relay | Signature, audience, expiry, session id, app id, path app id | WebSocket `401` | -| relay | The client asks for TLS (`CLIENT_SSL`) | The connection is closed | -| MySQL | The customer's user, password, and grants | a MySQL error | - -## Relay contract - -The relay image must: - -- **Port:** listen on port `80`, which is all that `pma_workloads` and the - `phpmyadmin` Cilium policy allow. Answer `GET /healthz` with `200` once it - can take tunnels. -- **Path:** accept `GET /apps/{APP_ID}/database/tunnel` as a WebSocket upgrade, - with `Authorization: Bearer `. -- **Token:** the token is a compact JWT signed with EdDSA (Ed25519), with a - `kid` header. `DB_TUNNEL_TOKEN_PUBLIC_KEYS` is a JSON Web Key Set: - - ```json - {"keys":[{"kty":"OKP","crv":"Ed25519","kid":"k1","x":"","alg":"EdDSA","use":"sig"}]} - ``` - - It holds every key hosting knows, so a relay deployed before a rotation - still verifies tokens signed after it. Pick the key by `kid`, accept only - `alg: EdDSA`, then require: - - `aud == "db-tunnel"` and `iss == "hosting"`; - - an `exp` in the future; - - `sid == DB_TUNNEL_SESSION_ID`; - - `app == APP_ID`, and the path's app id equal to `APP_ID`; - - `variant == DB_TUNNEL_VARIANT`. - - The token also carries `iat`. Answer `401` otherwise, with no detail. -- **TLS:** dial `DB_HOST:DB_PORT` and forward the server greeting. Read the - client's first packet. If its capability flags do not include `CLIENT_SSL` - (`0x00000800`), close both sides. Otherwise relay binary frames in both - directions exactly like the Node.js agent, with frames of up to 32 MiB. -- **Keepalive:** send a WebSocket ping every 30 seconds on every open tunnel, - and close the tunnel if no pong arrives within 65 seconds. -- **Lifetime:** - - Exit after 10 minutes without an open connection. - - At `DB_TUNNEL_EXPIRES_AT`, refuse new connections, let open ones finish - for up to 15 minutes, then exit. - - Hosting does not need to be reachable for any of this. -- **Logging:** never log tokens or MySQL bytes. Log one line per connection: - the session id, open and close times, byte counts, and the close reason - (including `no-tls`). -- **Hardening:** - - `label=type:pma_workloads.process`, with the same production fail-closed - render as the phpMyAdmin job; - - uid 65534, all capabilities dropped, `no-new-privileges`; - - a read-only root and a 16 MB `/tmp`; - - 64 MHz of CPU and 128 MB of memory. - -The job sets `APP_ID`, `DOM_ID`, `DB_HOST`, `DB_PORT`, `DB_TUNNEL_DOMAIN`, -`DB_TUNNEL_SESSION_ID`, `DB_TUNNEL_VARIANT`, `DB_TUNNEL_EXPIRES_AT` (RFC 3339, -UTC), `DB_TUNNEL_TOKEN_PUBLIC_KEYS`, `PORT=80`, `REGION`, `SERVER_ENV`, and -`IMAGE`. None of these are secret. In production the job does not render -unless a SELinux label is configured, the same gate as the phpMyAdmin job. - -hosting signs with `DB_TUNNEL_SIGNING_KEYS`, a JSON object that maps each key -id to a base64 32-byte Ed25519 seed, and `DB_TUNNEL_SIGNING_KEY_ID`, the id to -sign with. Both come from secrets, like hosting's other keys. If either is -missing or invalid, hosting starts anyway and answers every mint with `503`. -To rotate, add the new key to the map and deploy, then switch the key id. - -## Hosting API - -| Route | Caller | Auth | Answer | -| --- | --- | --- | --- | -| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel` | airo-go, support tools | `JWTOrCert` (airo console or CTK certificate) plus `RequireAppInSystem` | `200 {sessionId, domain, url, pollUrl, token, variant, expiresAt, replaced}`. `400` bad request, `404` app not in system, `409` no database for the variant or a concurrent mint, `500` no database address, `503` not configured, cell unreachable, no relay hostname, or the previous session could not be stopped. | -| `POST /hosting/v1/systems/:systemId/apps/:appId/db-tunnel/stop` | support tools | same | `200 {stopped, resent, failed, unrevoked}`, or `204` if nothing was live. `unrevoked` counts sessions whose stop could not be sent; they stay live, so a retry sends it again. `resent` counts stopped sessions whose STOP was sent again because their relay could still be running; a failed resend counts in `failed` only. | - -Both answers carry `Cache-Control: no-store`. Attribution is -`X-On-Behalf-Of` when present, otherwise the body's `createdBy`, otherwise the -caller's identity. There are no relay-facing routes. Only hosting holds the -token signing key. - -## Changes from the first design - -All rows are done on the branches. The followups doc says how each was -verified. - -| Area | First design | Phase 1 | -| --- | --- | --- | -| hosting routes | `/v1/apps/:id/db-tunnel` and `/stop`, plus `/v1/db-tunnel/redeem` and `/heartbeat` | `/v1/systems/:systemId/apps/:appId/db-tunnel` and `/stop` behind `RequireAppInSystem`. No redeem or heartbeat. | -| Tokens | Random token, sha256 verifier in `hosting_app_db_tunnel_tokens` | An Ed25519-signed JWT. No token table. A signing key in hosting configuration. | -| Liveness and reuse | `last_seen_at`, startup grace, heartbeat, reuse with at least 15 minutes left | None. One session per app. A new mint stops the previous one. | -| Credentials | None returned | Still none, as for Node.js. Returning WordPress's login was built and then removed (see **Design decisions**). phpMyAdmin's `resolveServers` keeps the shared `loadVariantDbCredentials` lookup. | -| Job template | Port 8080, `label=disable`, no production gate, `HOSTING_DB_TUNNEL_API` | Port 80, `pma_workloads.process`, the production gate. `DB_HOST`, `DB_PORT`, expiry, and public keys replace the hosting API URL. | -| Namespace constant | `dbTunnelJobNamespace = "phpmyadmin"` | Reuse `pmaJobNamespace` (F8) | -| airo-go | App-id route, `GetCustomerForApp` with the `variables` fallback, service headers | System owner tuple only (`GetSystem`, then the subscription's customer), fail closed, the tenancy-scoped hosting route with `X-On-Behalf-Of` | -| CLI | Reads `url`, `token`, `pollUrl` | Also reads `replaced` and warns. Prints the Node.js `mysql -u -p` hint. | - -## Review response - -The design review of 2026-10-02, against mhp-core `main` at `fa9c6e972`. - -| # | Finding | Response | Status | -| --- | --- | --- | --- | -| F1 | Redeem and heartbeat are workload-callback routes with a multi-use bearer | Adopted in phase 1. The relay checks a hosting-signed token locally. The database host and port are in the job environment. | Accepted | -| F2 | The relay has no credential to heartbeat with | Moot: there is no heartbeat. The relay's idle exit replaces the wait for the sweep, and one session has one token. | Closed by F1 | -| F3 | Which MySQL credentials the customer uses | Phase 1: as for Node.js, the customer brings their own, normally WordPress's user from `wp-config.php`. No route hands a login out. A per-session user returned by the mint is deferred (DF1), with the risk stated. | Deferred on purpose | -| F4 | No SELinux label or production gate, and 8080 cannot work | Port 80, `pma_workloads.process`, and the phpMyAdmin production gate | Accepted | -| F5 | Nothing keeps an idle tunnel alive | A ping every 30 seconds, and a close after 65 seconds without a pong. `wait_timeout` is listed as a limitation. | Accepted | -| F6 | Session expiry kills in-flight connections | The relay refuses new connections at expiry and drains open ones for up to 15 minutes. Replacing mints and access-changing stops cut at once. | Accepted | -| F7 | airo-go's ownership check is weaker than the phpMyAdmin proxy's | A tenancy-scoped hosting route with `RequireAppInSystem` and `X-On-Behalf-Of`, the system owner tuple only, and fail closed. The signup stamp is raised separately. | Accepted | -| F8 | A second hand-wired copy of the phpMyAdmin session service | One combined stopper, `NewOnDemandSessionStopper`, backs `GetPmaSessionStopper()`, so the teardown, archive, and `update_app_system_id_task` sites stop tunnels too; a worker-container test pins that. The tunnel reuses `pmaJobNamespace` and phpMyAdmin's database address lookup. Removing redeem, heartbeat, liveness, and reuse removed most of the copy. | Accepted | -| F9 | Docs | An on-demand jobs section in `managed-wordpress.md` and an update to invariant 9, with the hosting PR. There are no new workload-callback routes. | Accepted | -| F10 | `410` oracle, path binding, TLS wording | The `410` case is gone with redeem. The relay checks the path app id. `VERIFY_IDENTITY` is not available, which is stated. | Accepted | -| F11 | `require_secure_transport` is `OFF` everywhere | The server-side options are ruled out. The relay enforces `CLIENT_SSL` in phase 1. `REQUIRE SSL` on a per-session user follows DF1. | Accepted, with a phase 1 control | - -Answers to the review's questions: - -1. **Branches and the tracker.** They are on the mhp-core branch - `dcanic/database-tunnel-on-demand-job`, pushed on 2026-10-05. -2. **A signed token instead of redeem and heartbeat.** Yes, in phase 1. Reuse - is replaced by one session per app. -3. **The heartbeat token.** Moot. -4. **Credentials.** Phase 1: none handed out; the customer brings their own, - as for Node.js. A per-session user is DF1. -5. **SELinux and port.** `pma_workloads.process` on port 80. -6. **A 30-second ping.** Yes. -7. **MySQL TLS.** The relay's `CLIENT_SSL` check in phase 1. `REQUIRE SSL` with - DF1. +1. **Mint.** `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel`, + with an OAuth token that has the deploy-execute scope and + `hosting.database.tunnel:execute`. The response is never logged, because it + holds the relay token. +2. **Read the response.** + + | Field | Use | + | --- | --- | + | `url` | The relay's base URL. The CLI opens `wss://…/apps/{appId}/database/tunnel` on it. | + | `token` | Sent as `Authorization: Bearer ` on every WebSocket. | + | `pollUrl` | A readiness probe on the relay's own host. It must be HTTPS and on the same host as `url`. | + | `expiresAt` | When the session ends. | + | `replaced` | `true` when this mint closed the app's previous tunnel. The CLI warns. | + +3. **Wait.** The relay is started for this session, so the CLI polls + `pollUrl` until it answers `2xx`, for up to 3 minutes. +4. **Listen and relay.** From here on, the behaviour is the same as for + Node.js: one WebSocket per local TCP connection, raw MySQL bytes in binary + frames. The CLI prints the same `mysql` hint. + +No database login is handed out, as for Node.js. The customer logs in with a +login they already have, normally WordPress's own database user from +`wp-config.php`. ## Limitations -- **Owner only, and one tunnel per app.** A new `gddy db tunnel` run for the - same app cuts the previous one. -- **No renewal.** New connections are refused after the 1-hour session. Run - the command again. -- **Idle MySQL sessions close after `wait_timeout`.** It is 60 seconds on c10. - The next query gets `ERROR 4031 … disconnected by the server because of - inactivity`, and the `mysql` client reconnects. This is not a tunnel fault. -- **Packets are limited by `max_allowed_packet`** (16 MiB), which is below the - tunnel's 32 MiB frame limit. -- **The server's identity is not verified.** `VERIFY_IDENTITY` cannot be used - against `127.0.0.1`. -- **Clients must use TLS.** The relay closes connections from clients that do - not ask for it, such as `--ssl-mode=DISABLED`. -- **The token is visible to the proxies.** Cloudflare and `v2-ingress-proxy` - decrypt the WebSocket. The token lives at most 1 hour, works only for one - session's relay, and still needs a database password behind it. -- **The customer must already have a login.** The CLI does not supply one. - WordPress's user is in `wp-config.php`; changing its password breaks the - site (DF1). -- **Cold start on every run.** +- **Owner only.** The app's owner can open a tunnel. Collaborators cannot yet. +- **One tunnel per app.** A new `gddy db tunnel` run for the same app closes the + previous one, and the CLI says so. +- **One hour per session.** After that, new connections are refused. Run the + command again. +- **Clients must use TLS.** Connections from clients that do not ask for TLS, + such as `--ssl-mode=DISABLED`, are closed. `--ssl-mode=REQUIRED` encrypts, but + `VERIFY_IDENTITY` cannot be used against `127.0.0.1`. MariaDB's client does + not accept `--ssl-mode`; use its `--ssl` option. +- **Idle MySQL sessions are closed by the server** after its `wait_timeout`. The + `mysql` client reconnects on the next query. This is not a tunnel fault. +- **Packets are limited by the server's `max_allowed_packet`**, which is below + the tunnel's 32 MiB frame limit. +- **Cold start on every run**, while the relay starts. +- **`publish` only.** The CLI has no `--variant` option yet. ## Testing -- **Proof of concept.** The transport, ingress, SELinux type, keepalive, and - throughput were tested on c10 with a stand-in relay. Tokens, mint, and the - CLI were not. -- **CLI.** `rust/src/db/tunnel.rs` covers the product flag, the mint response - shapes (with and without `replaced`), and the `pollUrl` checks. - `rust/src/hosting/client_tests.rs` covers the request path, the Bearer +- `rust/src/db/tunnel.rs` covers the product flag, the mint response shapes + (with and without `replaced`), and the `pollUrl` checks. +- `rust/src/hosting/client_tests.rs` covers the request path, the Bearer header, and the response. -- **airo-go.** `handlers/database_tunnel_proxy_handler_test.go` covers the owner - check through the system owner tuple, the same 404 for every miss (including - a non-subscription owner type, a missing system, lookup errors, and app - `variables` naming the caller's subscription), the system-scoped hosting path - with `X-On-Behalf-Of`, variant forwarding, status mapping, and, through the - real `/v1/hosting` chain, registration, `401`, the scope `403`, and the rate - limit. Passes with `go test -race`. -- **hosting.** Service, sweep, stopper, handler, router, signer, and render - tests cover: the token's claims, signature, expiry, and rotation; that - WordPress's login reaches neither the mint response nor the job; the - database address for publish and staging; a replacing mint stopping the previous - session, and refusing when that stop fails; the concurrent-mint `409`; every - worker stop site reaching tunnels; and the production render gate. -- **Relay.** The `CLIENT_SSL` check (a plain handshake is closed, an - `SSLRequest` passes), token checks, the ping, the idle exit, and the expiry - drain. -- **End to end.** Not run yet. Measure the cold start. +- End to end: not run yet. From 4bf986ee8f548a7128489915968c0cf9ca2a40aa Mon Sep 17 00:00:00 2001 From: dcanic Date: Wed, 7 Oct 2026 15:07:01 +0200 Subject: [PATCH 9/9] feat(db): mint the WordPress tunnel on the MHWP-prefixed hosting path The edge has no /v1/airo route, so --product mhwp now posts to /v1/hosting/apps/MHWP-{id}/database-tunnel, prefixed like the Node.js agent-token mint, and reuses its request helper. Co-authored-by: Cursor --- docs/proposals/db-tunnel-mwp.md | 2 +- rust/src/db/tunnel.rs | 8 ++++++-- rust/src/hosting/client.rs | 29 +++++++++++++---------------- rust/src/hosting/client_tests.rs | 8 ++++---- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docs/proposals/db-tunnel-mwp.md b/docs/proposals/db-tunnel-mwp.md index b374f3c5..70d95503 100644 --- a/docs/proposals/db-tunnel-mwp.md +++ b/docs/proposals/db-tunnel-mwp.md @@ -29,7 +29,7 @@ mint response. ## What the CLI does -1. **Mint.** `POST {api base}/v1/airo/hosting/apps/{appId}/database-tunnel`, +1. **Mint.** `POST {api base}/v1/hosting/apps/MHWP-{appId}/database-tunnel`, with an OAuth token that has the deploy-execute scope and `hosting.database.tunnel:execute`. The response is never logged, because it holds the relay token. diff --git a/rust/src/db/tunnel.rs b/rust/src/db/tunnel.rs index e5e047f3..60997268 100644 --- a/rust/src/db/tunnel.rs +++ b/rust/src/db/tunnel.rs @@ -16,7 +16,7 @@ //! - `--product nodejs`: `POST /v1/hosting/apps/NODEJS-:id/agent-token` returns //! the app's agent URL and an agent token. //! - `--product mhwp`: Managed WordPress has no per-app agent, so -//! `POST /v1/airo/hosting/apps/:id/database-tunnel` starts an on-demand relay +//! `POST /v1/hosting/apps/MHWP-:id/database-tunnel` starts an on-demand relay //! speaking the same WebSocket protocol — closing any tunnel already open for //! the app — and returns its URL, a readiness `pollUrl` and a relay token. //! The CLI waits for `pollUrl` to answer before listening. As with Node.js, @@ -371,7 +371,11 @@ async fn mint_tunnel_target( let client = HostingClient::new(base_url, token); let minted = match product { HostingAppType::Nodejs => client.get_agent_token(app_id, product.as_str()).await, - HostingAppType::Mhwp => client.ensure_airo_database_tunnel_session(app_id).await, + HostingAppType::Mhwp => { + client + .ensure_database_tunnel_session(app_id, product.as_str()) + .await + } }; let resp = minted.map_err(|e| GddyError::from(e).into_cli_error())?; parse_tunnel_target(&resp, product) diff --git a/rust/src/hosting/client.rs b/rust/src/hosting/client.rs index da391dcf..837a757e 100644 --- a/rust/src/hosting/client.rs +++ b/rust/src/hosting/client.rs @@ -147,7 +147,7 @@ impl HostingClient { // Spec has no request body; Akamai still 411s a POST with no Content-Length. async fn post_empty_json(&self, path: &str) -> Result { - self.post_empty_json_inner(self.url(path), true).await + self.post_empty_json_inner(path, true).await } /// Like [`post_empty_json`](Self::post_empty_json), but the response body is @@ -157,17 +157,17 @@ impl HostingClient { /// verbatim and offers no body-redaction hook, so the suppression happens /// here, at the one call site that needs it. async fn post_empty_json_secret_response(&self, path: &str) -> Result { - self.post_empty_json_inner(self.url(path), false).await + self.post_empty_json_inner(path, false).await } async fn post_empty_json_inner( &self, - url: String, + path: &str, log_response_body: bool, ) -> Result { let request = self .http - .request(Method::POST, url) + .request(Method::POST, self.url(path)) .bearer_auth(&self.token) .header("x-request-id", Self::new_request_id()) .json(&json!({})) @@ -306,22 +306,19 @@ impl HostingClient { .await } - /// Start an on-demand database-tunnel relay for a Managed WordPress app, - /// closing any tunnel already open for it. Served by the Airo API at - /// `/v1/airo/hosting/apps/:id/database-tunnel`, outside the `/v1/hosting` - /// base. Same scopes as [`get_agent_token`](Self::get_agent_token). - /// Response shape: `{ sessionId, url, pollUrl, token, variant, expiresAt, - /// replaced }`. - pub async fn ensure_airo_database_tunnel_session( + /// Start an on-demand database-tunnel relay for an app, closing any tunnel + /// already open for it: `/v1/hosting/apps/{app_type}-{id}/database-tunnel` + /// (e.g. `MHWP-{id}`), prefixed like [`get_agent_token`](Self::get_agent_token) + /// and minted with the same scopes. Response shape: `{ sessionId, url, + /// pollUrl, token, variant, expiresAt, replaced }`. + pub async fn ensure_database_tunnel_session( &self, app_id: &str, + app_type: &str, ) -> Result { - let url = format!( - "{}/v1/airo/hosting/apps/{app_id}/database-tunnel", - self.base_url - ); // Secret response: the body carries the relay's bearer token. - self.post_empty_json_inner(url, false).await + self.post_empty_json_secret_response(&format!("/apps/{app_type}-{app_id}/database-tunnel")) + .await } pub async fn list_deployments( diff --git a/rust/src/hosting/client_tests.rs b/rust/src/hosting/client_tests.rs index b4826e85..a229b441 100644 --- a/rust/src/hosting/client_tests.rs +++ b/rust/src/hosting/client_tests.rs @@ -559,12 +559,12 @@ async fn get_agent_token_posts_empty_body_and_returns_url_and_token() { } #[tokio::test] -async fn ensure_airo_database_tunnel_session_posts_to_airo_path() { +async fn ensure_database_tunnel_session_posts_to_prefixed_app_path() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(POST) - .path("/v1/airo/hosting/apps/app-1/database-tunnel") + .path("/v1/hosting/apps/MHWP-app-1/database-tunnel") .header("authorization", "Bearer test-token") .json_body(json!({})); then.status(200).json_body(json!({ @@ -577,9 +577,9 @@ async fn ensure_airo_database_tunnel_session_posts_to_airo_path() { .await; let body = client(&server.base_url()) - .ensure_airo_database_tunnel_session("app-1") + .ensure_database_tunnel_session("app-1", "MHWP") .await - .expect("ensure airo database tunnel session"); + .expect("ensure database tunnel session"); mock.assert_async().await; assert_eq!(body["url"], "https://dbt-s1.c1.pma.example");