diff --git a/CLAUDE.md b/CLAUDE.md index 41104e5f..6768cdf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,7 +195,9 @@ hand-maintained per-file tree drifts out of sync with the code. verb that talks to a service rather than acting locally: it POSTs `/api/agents` to swarm-controller over the daemon's unix socket and prints the queued job id without waiting (the DAG settling would not - mean the agent is up). Does not link `swarm-controller` — its wire + mean the agent is up). With `--swarm`, the controller verbs go + through the swarm domain's gateway instead, as the operator's own SSO + login (`swarmctl login`). Does not link `swarm-controller` — its wire shapes are a local mirror kept in this crate, not a shared crate, so the two binaries can be deployed independently. ⚠️ Its user store is authelia's own **`users.yml`, read and diff --git a/Cargo.lock b/Cargo.lock index 6b2e88f5..6d7f6459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4949,19 +4949,25 @@ name = "swarmctl" version = "0.1.0" dependencies = [ "anyhow", + "base64", "bytes", "chrono", "clap", "clap-markdown", "clap_complete", + "getrandom 0.3.4", "hive-types", "http-body-util", "hyper", "hyper-util", + "reqwest", + "rustix", "serde", "serde_json", "serde_norway", + "sha2 0.11.0", "tokio", + "url", ] [[package]] diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index 47474846..7e3107eb 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -281,6 +281,17 @@ All three matter. A change that silently deleted the browser page would pass a deny-only check, and one that quietly stopped denying would pass a page-only check. The person who introduced the split verified it this way. +## Operator CLI login + +`swarmctl login` logs an operator in as their own account, from any +machine, through the public `swarmctl` client authelia always registers: no +secret, authorization code with PKCE, a loopback redirect, and a refresh +token. Its access token carries `authelia.bearer.authz` with the audience +`https:///api/`, so the gateway admits it at the controller's API and +nowhere else, under the same access rule as a browser session there. +Revocation is per account: once authelia stops accepting the account, the +gateway refuses its token too. See [`swarmctl/README.md`](../../swarmctl/README.md#remote-use-swarmctl-login). + ## What this doesn't do - **It doesn't disable local login — except on Grafana.** Forgejo and diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index ea9b3588..aad42920 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -27,6 +27,7 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl user list`↴](#swarmctl-user-list) * [`swarmctl forge`↴](#swarmctl-forge) * [`swarmctl forge make-admin`↴](#swarmctl-forge-make-admin) +* [`swarmctl login`↴](#swarmctl-login) * [`swarmctl completions`↴](#swarmctl-completions) ## `swarmctl` @@ -41,6 +42,7 @@ swarm-level operator CLI * `schedule` — Manage scheduled prompts * `user` — Manage subjects in the swarm's SSO provider * `forge` — Manage human accounts on the swarm's forge +* `login` — Log in to the swarm named by `--swarm` as your own SSO account * `completions` — Generate a shell completion script for `swarmctl` and print it to stdout ###### **Options:** @@ -49,6 +51,8 @@ swarm-level operator CLI * `--users-file ` — Host-side path of authelia's users database — that is, the path inside the container, prefixed with the container's root. This is the only user store: it's read before every change and written in place, and `swarm-authelia-bridge` writes the same file. +* `--swarm ` — The swarm's domain, as `https://`. Given, the `agent`, `schedule` and `forge` verbs go through its gateway as the account `swarmctl login` logged in, instead of over the controller's socket. Falls back to `SWARMCTL_SWARM` +* `--ca-file ` — A PEM CA certificate to trust beside the system roots, for a swarm whose gateway serves a certificate from the swarm's own CA. Falls back to `SWARMCTL_CA_FILE` @@ -75,7 +79,7 @@ Asks the swarm-controller to insert its agent-creation job graph — SSO identit **This returns as soon as it queues the work.** It doesn't wait, and a finished graph would not mean the agent is up either: the last node publishes a deploy, after which the hive converges on its own clock. Watch the swarm UI's job view, or the hive itself, for the rest. -No approval gate guards this: running this binary already means being root on the controller's host. +No approval gate guards this: the caller is root on the controller's host, or logged in through the swarm domain's SSO gate. **Usage:** `swarmctl agent create [OPTIONS] --hive ` @@ -465,6 +469,16 @@ The forge creates a human's account on their first SSO login, and this fails unt +## `swarmctl login` + +Log in to the swarm named by `--swarm` as your own SSO account. + +Opens the swarm's SSO login in your browser (and prints the URL) and receives the answer on a loopback port, so run it where that browser runs. swarmctl keeps the login, mode 0600, under `$XDG_STATE_HOME/swarmctl/` and refreshes it as it's used. To log out, delete that file. + +**Usage:** `swarmctl login` + + + ## `swarmctl completions` Generate a shell completion script for `swarmctl` and print it to stdout. diff --git a/nix/host-modules/swarm-authelia-service.nix b/nix/host-modules/swarm-authelia-service.nix index ec2c0c41..eb9b1006 100644 --- a/nix/host-modules/swarm-authelia-service.nix +++ b/nix/host-modules/swarm-authelia-service.nix @@ -171,6 +171,7 @@ in type = lib.types.enum [ "interactive" "machine" + "native" ]; default = "interactive"; example = "machine"; @@ -185,6 +186,12 @@ in client's own, so it receives an access token and no id-token. + `native` is a public client a person runs on their own + machine (RFC 8252): no secret, authorization code with + PKCE through a loopback `redirectUris` entry, and a refresh + token. Its access token carries `authelia.bearer.authz` + for its `audience`, so the gateway admits it as that person. + This is declared rather than inferred from an empty `redirectUris`, because authelia permits only the grants a client names — omitting `grant_types` yields diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 52324885..2c465c9b 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -221,6 +221,18 @@ let redirectUris = [ ]; }; + # `swarmctl login`: an operator's own account, from wherever they run the + # CLI. The id is the one `swarmctl/src/remote.rs` sends. The audience is + # the controller's API on the swarm apex, which authelia matches as a URL + # prefix; the redirect matches a loopback callback on any port. + swarmctlClient = { + id = "swarmctl"; + description = "swarmctl (swarm operator CLI)"; + kind = "native"; + redirectUris = [ "http://127.0.0.1/callback" ]; + audience = [ "https://${swarmDomain}/api/" ]; + }; + # Derived from the client list rather than carrying its own `enable`; # `docs/swarm/sso.md` says why that is one fact instead of two. # @@ -290,7 +302,19 @@ let '' printf -- ' - client_id: %s\n' ${lib.escapeShellArg c.id} printf -- ' client_name: %s\n' ${lib.escapeShellArg c.description} - printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})" + '' + # A native client is public: it has no secret, so none is minted for it. + + ( + if c.kind == "native" then + '' + printf -- ' public: true\n' + '' + else + '' + printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})" + '' + ) + + '' printf -- ' authorization_policy: one_factor\n' '' # Read plainly, and that is a property of the list rather than of @@ -298,7 +322,7 @@ let # `oidc.clients`, so the module system has applied the submodule and # each option's default is present. A derived entry that names only # the fields it cares about still arrives with the rest filled in. - + lib.optionalString (c.tokenEndpointAuthMethod != null) '' + + lib.optionalString (c.kind != "native" && c.tokenEndpointAuthMethod != null) '' printf -- ' token_endpoint_auth_method: %s\n' ${lib.escapeShellArg c.tokenEndpointAuthMethod} '' # Flow-style YAML, matching `scopes` below. The values are client ids @@ -316,6 +340,25 @@ let printf -- ' grant_types: ["client_credentials"]\n' printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n' '' + # What authelia 4.39.20 requires of a public client holding + # `authelia.bearer.authz`; its validator refuses the client without + # any one of these lines. + else if c.kind == "native" then + '' + printf -- ' token_endpoint_auth_method: none\n' + printf -- ' require_pkce: true\n' + printf -- ' pkce_challenge_method: S256\n' + printf -- ' require_pushed_authorization_requests: true\n' + printf -- ' consent_mode: explicit\n' + printf -- ' response_types: [code]\n' + printf -- ' response_modes: [form_post]\n' + printf -- ' grant_types: [authorization_code, refresh_token]\n' + printf -- ' scopes: [offline_access, authelia.bearer.authz]\n' + printf -- ' redirect_uris:\n' + ${lib.concatMapStrings (u: '' + printf -- ' - %s\n' ${lib.escapeShellArg u} + '') c.redirectUris} + '' else '' printf -- ' scopes: [openid, profile, email, groups]\n' @@ -382,7 +425,7 @@ let ${lib.concatMapStrings (c: '' mint ${lib.escapeShellArg c.id} - '') cfg.oidc.clients} + '') (lib.filter (c: c.kind != "native") cfg.oidc.clients)} # Re-rendered every boot, deliberately: the secret is minted once, # but the metadata around it (a new redirect URI, a renamed client) @@ -507,7 +550,8 @@ in # — see `oidcEnabled`'s comment above. services.hyperhive.swarm.authelia.oidc.clients = lib.optionals cfg.oidc.hiveIdentities (hiveClients ++ agentClients) - ++ [ bridgeClient ]; + ++ [ bridgeClient ] + ++ lib.optional (swarmDomain != null) swarmctlClient; # A redirect URI on a machine client is not harmless-but-unused: it # means whoever wrote it believes a browser is involved. Failing here @@ -582,13 +626,17 @@ in # Asserting here moves the same failure to evaluation, where a # wrong value costs a build and nothing else. { - assertion = lib.all (c: !c.bearerAuthz || c.audience != [ ]) cfg.oidc.clients; + assertion = lib.all ( + c: !(c.bearerAuthz || c.kind == "native") || c.audience != [ ] + ) cfg.oidc.clients; message = "services.hyperhive.swarm.authelia.oidc.clients: " + lib.concatStringsSep ", " ( - map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.audience == [ ]) cfg.oidc.clients) + map (c: "client '${c.id}'") ( + lib.filter (c: (c.bearerAuthz || c.kind == "native") && c.audience == [ ]) cfg.oidc.clients + ) ) - + " sets bearerAuthz but declares no audience. The audience is " + + " sets bearerAuthz or kind = \"native\" but declares no audience. The audience is " + "what authorises a bearer token at a given URL, so without " + "one the scope grants access to nothing and authelia refuses " + "the configuration outright."; diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index c168e409..7cba8a8d 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -38,18 +38,23 @@ let inherit (stylixTheme) stylixThemeColors themedColorsCss; # Repeated verbatim by every location that should be operator-gated - # (`/`, `/api/`, `/api/docs/`) rather than set once on the server: + # (`/`, `/api/docs/`; `/api/` takes `authRequest` with its own 401 + # handler) rather than set once on the server: # nginx's `auth_request` does NOT inherit across sibling locations, so # setting it only on the page would leave this UI's own API and API # docs reachable without a session. - swarmAuthRequest = '' + authRequest = '' auth_request /__hive_authelia; # Captured BEFORE the error_page jump: inside the 401 handler # `$request_uri` is the internal one, so building the return # link there sends the operator back to the auth subrequest # instead of the page they asked for. auth_request_set $target_url $scheme://$http_host$request_uri; - error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url; + ''; + loginRedirect = "https://${autheliaCfg.domain}/?rd=$target_url"; + swarmAuthRequest = '' + ${authRequest} + error_page 401 =302 ${loginRedirect}; ''; swarmCfg = config.services.hyperhive.swarm; hiveDomain = config.services.hyperhive.domain; @@ -218,13 +223,24 @@ in # `Remote-User` from authelia's answer, set here so it replaces # any the client sent: swarm-controller takes it as the sender of # the text the operator types to an agent (`agent_input.rs`). + # A refused caller that sent an `Authorization` header (`swarmctl + # --swarm`) gets the 401 itself; a browser still gets the login. + # The bearer token stops here and never reaches the controller. extraConfig = '' - ${swarmAuthRequest} + ${authRequest} + error_page 401 = @swarm_api_unauthorized; auth_request_set $authelia_user $upstream_http_remote_user; proxy_set_header Remote-User $authelia_user; + proxy_set_header Authorization ""; ''; }; - # ⚠️ THE ONE LOCATION ON THIS VHOST WITH NO `swarmAuthRequest`, + "@swarm_api_unauthorized".extraConfig = '' + if ($http_authorization) { + return 401; + } + return 302 ${loginRedirect}; + ''; + # ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`, # and that is deliberate rather than an omission. # # A forge webhook is a machine POST carrying an HMAC signature diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index e7de4502..d50ab957 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -234,6 +234,52 @@ let in v.forceSSL && !(v.addSSL or false); } + { + # `swarmctl --swarm` reads a 401 as "log in again"; a 302 would hand it + # the login page instead. The `/` clause is the control: the browser + # route still redirects, so a pass means the two routes differ. + name = "the controller API answers a refused bearer caller 401 and a browser the login"; + ok = + let + l = allLocal.services.nginx.virtualHosts."t.local".locations; + api = l."/api/".extraConfig; + refused = l."@swarm_api_unauthorized".extraConfig; + in + lib.hasInfix "error_page 401 = @swarm_api_unauthorized;" api + && !(lib.hasInfix "=302" api) + && lib.hasInfix ''proxy_set_header Authorization "";'' api + && lib.hasInfix "if ($http_authorization) {\n return 401;\n}" refused + && lib.hasInfix "return 302 https://auth.t.local/?rd=$target_url;" refused + && lib.hasInfix "error_page 401 =302 https://auth.t.local/" l."/".extraConfig; + } + { + # authelia 4.39.20 refuses a bearer-scoped public client missing any of + # these at startup, after a green deploy; and a public client must not + # be handed a secret. + name = "the swarmctl login client is public, bearer-scoped to the controller API, and gets no secret"; + ok = + let + s = allLocal.containers.swarm-authelia.config.systemd.services.authelia-swarm-secrets.script; + entry = lib.last (lib.splitString "client_id: %s\\n' swarmctl" s); + client = lib.head (lib.splitString "client_id: %s" entry); + in + lib.all (line: lib.hasInfix line client) [ + "public: true" + "token_endpoint_auth_method: none" + "require_pkce: true" + "pkce_challenge_method: S256" + "require_pushed_authorization_requests: true" + "consent_mode: explicit" + "response_modes: [form_post]" + "grant_types: [authorization_code, refresh_token]" + "scopes: [offline_access, authelia.bearer.authz]" + "audience: [%s]\\n' https://t.local/api/" + "' - %s\\n' http://127.0.0.1/callback" + ] + && !(lib.hasInfix "client_secret" client) + && !(lib.hasInfix "mint swarmctl" s) + && lib.hasInfix "mint swarm-authelia-bridge" s; + } { name = "a hive with matrix off serves no matrix discovery endpoint"; ok = diff --git a/swarmctl/Cargo.toml b/swarmctl/Cargo.toml index 25098920..3fb5ab0f 100644 --- a/swarmctl/Cargo.toml +++ b/swarmctl/Cargo.toml @@ -10,6 +10,8 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +# PKCE verifier, state and S256 challenge for `swarmctl login`. +base64.workspace = true chrono.workspace = true # `agent create` — bare HTTP/1.1 client over swarm-controller's unix # socket. No new workspace dependency: `bytes`, `http-body-util`, @@ -18,14 +20,22 @@ bytes = "1" clap.workspace = true clap-markdown = "0.1" clap_complete.workspace = true +getrandom.workspace = true hive-types.workspace = true http-body-util.workspace = true -hyper.workspace = true +# `server`: the loopback listener `swarmctl login` receives its code on. +hyper = { workspace = true, features = ["server"] } hyper-util.workspace = true +# HTTPS to the swarm domain and its SSO provider, for `--swarm`. +reqwest.workspace = true +# `flock` on the login cache, which a refresh must not race. +rustix.workspace = true serde.workspace = true serde_json.workspace = true serde_norway = "0.9.42" +sha2.workspace = true tokio.workspace = true +url.workspace = true [lints] workspace = true diff --git a/swarmctl/README.md b/swarmctl/README.md index 5907ce03..7b2fbfbb 100644 --- a/swarmctl/README.md +++ b/swarmctl/README.md @@ -1,7 +1,9 @@ # swarmctl Swarm-level operator CLI. Runs as **root on the host running -`swarm-controller`**, and acts on that host directly. +`swarm-controller`**, and acts on that host directly; or, for the verbs +that talk to the controller, from anywhere as your own SSO account (see +[Remote use](#remote-use-swarmctl-login)). Distinct from `hivectl`, which drives one hive's `hive-c0re` over its admin socket. This crate does not link `swarm-controller`, for the same @@ -19,15 +21,11 @@ allocated inside it, at activation — see the uid-assignment issue) or world-readable password hashes. Both are worse than root. So this binary **serves** no socket, publishes no HTTP route and has no -privileged helper. When a verb has to run as a non-root user or from -another host, the answer is a **group-gated admin socket**, separate from -the controller's `0666` gateway-facing one — not a widening of what root -does here. +privileged helper. That rules out a way in, not a way out: `agent create` is a _client_ of -the controller's socket, because the work it asks for is a job graph only -the controller can queue (see below). Nothing here becomes reachable by -that. +the controller, because the work it asks for is a job graph only the +controller can queue (see below). Nothing here becomes reachable by that. ## One file, two writers @@ -53,6 +51,8 @@ an error. | `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` | | `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container | | `SWARM_CONTROLLER_SOCKET` | the controller's unix socket, for `agent` and `forge` verbs | +| `SWARMCTL_SWARM` | `https://`: reach the controller there, not the socket | +| `SWARMCTL_CA_FILE` | a PEM CA to trust beside the system roots, for `SWARMCTL_SWARM` | Every verb and flag: [`docs/tools/swarmctl-cli.md`](../docs/tools/swarmctl-cli.md). The sections below cover why each verb behaves as it does. @@ -102,8 +102,9 @@ the swarm UI's job view for the rest. The new agent starts `paused`: it doesn't drive turns until you set it `up` in the swarm UI. -No approval gate, for the same reason nothing else here has one: running -this binary already means being root on the controller's host. +No approval gate, for the same reason nothing else here has one: the +caller is root on the controller's host, or logged in through the swarm +domain's SSO gate. `--hive` is required. It is an _address_ — where the deploy message goes — not an attribute of the agent, so there is no sensible default. The @@ -169,3 +170,27 @@ same socket as `agent create`; each verb waits for the stored result. What a schedule does once stored is in [`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The shapes are mirrored in `src/schedule.rs`, for the same reason as `agent create`'s. + +## Remote use: `swarmctl login` + +The `agent`, `schedule` and `forge` verbs also work from any machine, as +your own SSO account, through the swarm domain's gateway: + +```console +$ export SWARMCTL_SWARM=https://swarm.example.com +$ swarmctl login +Log in to https://swarm.example.com/ in your browser: +… +logged in to https://swarm.example.com/; login kept in ~/.local/state/swarmctl/swarm.example.com.json +$ swarmctl schedule list +``` + +`login` opens the swarm's SSO login in a browser and receives the result on +a loopback port, so run it on the machine whose browser you use. swarmctl +keeps the login, mode 0600, under `$XDG_STATE_HOME/swarmctl/` (default +`~/.local/state`) and refreshes it as it's used. When it can't be refreshed, +any verb says to run `swarmctl login` again. To log out, delete the file. + +A swarm whose gateway serves a certificate from the swarm's own CA needs +that CA: `--ca-file` or `SWARMCTL_CA_FILE`. The `user` verbs edit the local +users file and have no remote form. diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index 700ac7e1..315c34bf 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -29,6 +29,8 @@ use hyper_util::rt::TokioIo; use serde::{Deserialize, Serialize}; use tokio::net::UnixStream; +use crate::remote::Target; + /// Body of `POST /api/agents`. Mirrors the controller's own /// `CreateAgentRequest` — see this module's doc comment. #[derive(Serialize)] @@ -70,7 +72,7 @@ struct MintForgeTokenResponse { /// built here keeps that cost inside the one arm that incurs it, the same /// way `PathArgs::resolve` is called only in the arms that need the /// deployment's env vars. -pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> { +pub(crate) fn create(target: &Target, name: &str, hive: &str) -> Result<()> { // Client-side before the round trip, so a typo is an immediate local // error rather than a 400 the operator has to wait for. The controller // validates both again — that is the gate that matters, this is the @@ -79,10 +81,10 @@ pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> { let hive = parse_ident(hive, "hive")?; let rt = tokio::runtime::Builder::new_current_thread() - .enable_io() + .enable_all() .build() .context("starting a tokio runtime for the controller request")?; - let resp = rt.block_on(post_create(socket, &name, &hive))?; + let resp = rt.block_on(post_create(target, &name, &hive))?; println!("queued: job node {}", resp.node_id); println!( @@ -110,17 +112,17 @@ pub(crate) fn parse_ident(value: &str, what: &str) -> Result { /// /// Synchronous for the same reason [`create`] is, and built on the same /// round trip. -pub(crate) fn mint_identity(socket: &Path, name: &str) -> Result<()> { +pub(crate) fn mint_identity(target: &Target, name: &str) -> Result<()> { // Client-side first, so a typo is a local error rather than a 400 the // operator waits for. The controller validates it again. let name = parse_ident(name, "agent name")?; let rt = tokio::runtime::Builder::new_current_thread() - .enable_io() + .enable_all() .build() .context("starting a tokio runtime for the controller request")?; let resp: MintIdentityResponse = rt.block_on(post( - socket, + target, &format!("/api/agents/{name}/identity"), &serde_json::json!({}), "mint-identity", @@ -139,15 +141,15 @@ pub(crate) fn mint_identity(socket: &Path, name: &str) -> Result<()> { /// /// Synchronous for the same reason [`create`] is, and built on the same /// round trip. No `--hive`: the token's store path has no hive in it. -pub(crate) fn mint_forge_token(socket: &Path, name: &str) -> Result<()> { +pub(crate) fn mint_forge_token(target: &Target, name: &str) -> Result<()> { let name = parse_ident(name, "agent name")?; let rt = tokio::runtime::Builder::new_current_thread() - .enable_io() + .enable_all() .build() .context("starting a tokio runtime for the controller request")?; let resp: MintForgeTokenResponse = rt.block_on(post( - socket, + target, &format!("/api/agents/{name}/forge-token"), &serde_json::json!({}), "mint-forge-token", @@ -162,10 +164,10 @@ pub(crate) fn mint_forge_token(socket: &Path, name: &str) -> Result<()> { Ok(()) } -/// One `POST /api/agents` round trip over the controller's unix socket. -async fn post_create(socket: &Path, name: &str, hive: &str) -> Result { +/// One `POST /api/agents` round trip at the controller. +async fn post_create(target: &Target, name: &str, hive: &str) -> Result { post( - socket, + target, "/api/agents", &CreateAgentRequest { name, hive }, "create-agent", @@ -173,25 +175,25 @@ async fn post_create(socket: &Path, name: &str, hive: &str) -> Result( - socket: &Path, + target: &Target, uri: &str, request: &Req, what: &str, ) -> Result { - send(socket, hyper::Method::POST, uri, Some(request), what).await + send(target, hyper::Method::POST, uri, Some(request), what).await } -/// One request to `uri` over the controller's unix socket, with `request` as -/// its JSON body when there is one. A `204` decodes as JSON `null`, so a -/// caller expecting no body asks for `()`. +/// One request to `uri` at the controller, with `request` as its JSON body +/// when there is one. A `204` decodes as JSON `null`, so a caller expecting no +/// body asks for `()`. pub(crate) async fn send( - socket: &Path, + target: &Target, method: hyper::Method, uri: &str, request: Option<&Req>, @@ -202,6 +204,34 @@ pub(crate) async fn send( .transpose() .with_context(|| format!("serialising the {what} request"))?; + let (status, body) = match target { + Target::Socket(socket) => over_socket(socket, method, uri, body, what).await?, + Target::Swarm(swarm) => swarm.request(method, uri, body, what).await?, + }; + + if !status.is_success() { + bail!("{}", describe_error(status, &body)); + } + let body = if status == hyper::StatusCode::NO_CONTENT { + bytes::Bytes::from_static(b"null") + } else { + body + }; + serde_json::from_slice(&body).with_context(|| { + format!( + "swarm-controller answered {status} but its body is not a {what} response: {}", + String::from_utf8_lossy(&body).trim() + ) + }) +} + +async fn over_socket( + socket: &Path, + method: hyper::Method, + uri: &str, + body: Option>, + what: &str, +) -> Result<(hyper::StatusCode, bytes::Bytes)> { let stream = UnixStream::connect(socket) .await .with_context(|| connect_hint(socket))?; @@ -237,21 +267,7 @@ pub(crate) async fn send( .await .context("reading swarm-controller's response")? .to_bytes(); - - if !status.is_success() { - bail!("{}", describe_error(status, &body)); - } - let body = if status == hyper::StatusCode::NO_CONTENT { - bytes::Bytes::from_static(b"null") - } else { - body - }; - serde_json::from_slice(&body).with_context(|| { - format!( - "swarm-controller answered {status} but its body is not a {what} response: {}", - String::from_utf8_lossy(&body).trim() - ) - }) + Ok((status, body)) } /// Turn a failed response into one line the operator can act on. diff --git a/swarmctl/src/forge.rs b/swarmctl/src/forge.rs index c7f3f1f6..564e7ef0 100644 --- a/swarmctl/src/forge.rs +++ b/swarmctl/src/forge.rs @@ -6,12 +6,11 @@ //! socket; the shape below mirrors the controller's own private //! `MakeForgeAdminResponse` for the reason that module's doc comment gives. -use std::path::Path; - use anyhow::{Context as _, Result}; use serde::Deserialize; use crate::agent::{parse_ident, post}; +use crate::remote::Target; /// Success body of `POST /api/forge/users/{name}/admin`. #[derive(Deserialize)] @@ -29,15 +28,15 @@ enum AdminChange { /// Run `swarmctl forge make-admin`. /// /// Synchronous for the same reason `agent create` is. -pub(crate) fn make_admin(socket: &Path, name: &str) -> Result<()> { +pub(crate) fn make_admin(target: &Target, name: &str) -> Result<()> { let name = parse_ident(name, "user name")?; let rt = tokio::runtime::Builder::new_current_thread() - .enable_io() + .enable_all() .build() .context("starting a tokio runtime for the controller request")?; let resp: MakeForgeAdminResponse = rt.block_on(post( - socket, + target, &format!("/api/forge/users/{name}/admin"), &serde_json::json!({}), "forge-make-admin", diff --git a/swarmctl/src/inference.rs b/swarmctl/src/inference.rs index 8e3f866b..840e1508 100644 --- a/swarmctl/src/inference.rs +++ b/swarmctl/src/inference.rs @@ -9,12 +9,11 @@ //! The shapes below mirror the controller's `InferenceSettings` and //! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives. -use std::path::Path; - use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Serialize}; use crate::agent::{parse_ident, send}; +use crate::remote::Target; /// One agent's settings; an absent field is not set at swarm level. #[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -66,7 +65,7 @@ impl SetFlags { fn runtime() -> Result { tokio::runtime::Builder::new_current_thread() - .enable_io() + .enable_all() .build() .context("starting a tokio runtime for the controller request") } @@ -79,10 +78,10 @@ fn member(agent: &str) -> Result { } /// Run `swarmctl agent inference get`. -pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> { +pub(crate) fn get(target: &Target, agent: &str) -> Result<()> { let uri = member(agent)?; let view: View = runtime()?.block_on(send( - socket, + target, hyper::Method::GET, &uri, None::<&()>, @@ -98,11 +97,11 @@ pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> { /// Run `swarmctl agent inference set`: read the agent's settings, write the /// flags over them, and store the result. -pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> { +pub(crate) fn set(target: &Target, agent: &str, flags: SetFlags) -> Result<()> { let uri = member(agent)?; let rt = runtime()?; let current: View = rt.block_on(send( - socket, + target, hyper::Method::GET, &uri, None::<&()>, @@ -110,7 +109,7 @@ pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> { ))?; let wanted = flags.merge(current.configured.unwrap_or_default())?; let stored: Settings = rt.block_on(send( - socket, + target, hyper::Method::PUT, &uri, Some(&wanted), @@ -121,10 +120,10 @@ pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> { } /// Run `swarmctl agent inference clear`. -pub(crate) fn clear(socket: &Path, agent: &str) -> Result<()> { +pub(crate) fn clear(target: &Target, agent: &str) -> Result<()> { let uri = member(agent)?; runtime()?.block_on(send::<(), ()>( - socket, + target, hyper::Method::DELETE, &uri, None, diff --git a/swarmctl/src/login.rs b/swarmctl/src/login.rs new file mode 100644 index 00000000..8ad55cbc --- /dev/null +++ b/swarmctl/src/login.rs @@ -0,0 +1,387 @@ +//! `swarmctl login` — the OAuth 2.0 authorization-code flow with PKCE against +//! the swarm's authelia, as the public `swarmctl` client. +//! +//! authelia only lets a client holding `authelia.bearer.authz` use a pushed +//! authorization request (PAR), explicit consent, and a `form_post` response: +//! the code arrives as a browser form POST to a loopback port this process +//! listens on. The registered redirect URI carries no port; authelia matches a +//! loopback redirect on any port (RFC 8252 §7.3). + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{Context as _, Result, bail}; +use base64::Engine as _; +use http_body_util::{BodyExt as _, Full}; +use hyper_util::rt::TokioIo; +use serde::Deserialize; +use sha2::Digest as _; +use tokio::net::TcpListener; + +use crate::remote::{CLIENT_ID, Cached, Swarm, lock, oauth_json, token_request, write_cache}; + +/// authelia's PAR `request_uri` lives this long, so waiting longer is futile. +const WAIT: Duration = Duration::from_mins(5); +const CALLBACK_PATH: &str = "/callback"; + +/// Run `swarmctl login`. +pub(crate) fn login(swarm: &Swarm) -> Result<()> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting a tokio runtime for the login")?; + let path = swarm.cache_path()?; + rt.block_on(async { + let http = swarm.http()?; + let auth = discover_auth(&http, swarm).await?; + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("opening a loopback port for the login callback")?; + let redirect_uri = format!( + "http://127.0.0.1:{}{CALLBACK_PATH}", + listener.local_addr()?.port() + ); + let verifier = random_token()?; + let state = random_token()?; + let audience = swarm.audience(); + + let par: ParResponse = oauth_json( + http.post(format!("{auth}/api/oidc/pushed-authorization-request")) + .form(&[ + ("client_id", CLIENT_ID), + ("response_type", "code"), + ("response_mode", "form_post"), + ("redirect_uri", &redirect_uri), + ("scope", "offline_access authelia.bearer.authz"), + ("audience", &audience), + ("state", &state), + ("code_challenge", &challenge(&verifier)), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .with_context(|| format!("pushing the authorization request to {auth}"))?, + ) + .await + .context("pushing the authorization request")?; + + let mut url = url::Url::parse(&format!("{auth}/api/oidc/authorization"))?; + url.query_pairs_mut() + .append_pair("client_id", CLIENT_ID) + .append_pair("request_uri", &par.request_uri); + eprintln!("Log in to {} in your browser:\n\n {url}\n", swarm.origin); + open_browser(url.as_str()); + + let code = tokio::time::timeout(WAIT, receive_code(&listener, &state, &auth)) + .await + .context("the login was not completed within 5 minutes")??; + + let token = token_request( + &http, + &auth, + &[ + ("grant_type", "authorization_code"), + ("client_id", CLIENT_ID), + ("code", &code), + ("redirect_uri", &redirect_uri), + ("code_verifier", &verifier), + ], + ) + .await + .context("exchanging the authorization code")?; + let refresh_token = token + .refresh_token + .context("authelia issued no refresh token")?; + let _lock = lock(&path)?; + write_cache( + &path, + &Cached { + auth, + access_token: token.access_token, + refresh_token, + expires_at: chrono::Utc::now().timestamp() + token.expires_in, + }, + ) + })?; + println!( + "logged in to {}; login kept in {}", + swarm.origin, + path.display() + ); + Ok(()) +} + +#[derive(Deserialize)] +struct ParResponse { + request_uri: String, +} + +/// authelia's origin, read off the login redirect the gateway gives a +/// request that carries no credential. +async fn discover_auth(http: &reqwest::Client, swarm: &Swarm) -> Result { + let url = swarm.origin.join("api/")?; + let resp = http + .get(url.clone()) + .send() + .await + .with_context(|| format!("reaching {url}"))?; + let location = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| url::Url::parse(v).ok()) + .filter(|l| resp.status().is_redirection() && l.scheme() == "https"); + let Some(location) = location else { + bail!( + "{url} answered {} without an https redirect to the swarm's login, so its SSO \ + provider is unknown", + resp.status() + ); + }; + Ok(location.origin().ascii_serialization()) +} + +/// 32 bytes from the kernel CSPRNG, base64url: a PKCE verifier (RFC 7636 §4.1 +/// asks for 43–128 characters) or an unguessable `state`. +fn random_token() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes) + .map_err(|e| anyhow::anyhow!("drawing randomness from the kernel: {e}"))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} + +/// The S256 PKCE challenge for `verifier`. +fn challenge(verifier: &str) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest(verifier)) +} + +/// Best effort: the URL is printed first, for a host with no browser. +fn open_browser(url: &str) { + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + let _ = std::process::Command::new(opener) + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); +} + +/// What one request to the loopback listener means. +#[derive(Debug, PartialEq, Eq)] +enum Callback { + /// Not the callback, or not this login's: answer it and keep waiting. + Ignored(hyper::StatusCode, &'static str), + Code(String), + /// authelia answered this login with an error. + Refused(String), +} + +/// Judge one request. A `state` other than this login's is refused without +/// ending the wait: a stale tab from an earlier attempt must not be able to +/// complete or abort this one. +fn callback(method: &hyper::Method, path: &str, body: &[u8], state: &str, auth: &str) -> Callback { + if method != hyper::Method::POST || path != CALLBACK_PATH { + return Callback::Ignored(hyper::StatusCode::NOT_FOUND, "not found"); + } + let field = |name: &str| { + url::form_urlencoded::parse(body) + .find(|(k, _)| k == name) + .map(|(_, v)| v.into_owned()) + }; + if field("state").as_deref() != Some(state) { + return Callback::Ignored( + hyper::StatusCode::BAD_REQUEST, + "This login answer is not for the swarmctl login that is waiting.", + ); + } + if let Some(iss) = field("iss") + && iss.trim_end_matches('/') != auth + { + return Callback::Refused(format!("the login answer came from {iss}, not {auth}")); + } + if let Some(error) = field("error") { + let description = field("error_description").unwrap_or_default(); + return Callback::Refused(format!("authelia refused the login: {error} {description}")); + } + match field("code") { + Some(code) => Callback::Code(code), + None => Callback::Refused("the login answer carried no code".into()), + } +} + +/// Serve the loopback listener until this login's answer arrives. +async fn receive_code(listener: &TcpListener, state: &str, auth: &str) -> Result { + loop { + let (stream, _) = listener.accept().await.context("accepting the callback")?; + let outcome: Arc>> = Arc::default(); + let service = hyper::service::service_fn(|req: hyper::Request| { + let outcome = Arc::clone(&outcome); + async move { + let (parts, body) = req.into_parts(); + let body = body.collect().await?.to_bytes(); + let judged = callback(&parts.method, parts.uri.path(), &body, state, auth); + let (status, text) = match &judged { + Callback::Ignored(status, text) => (*status, *text), + Callback::Code(_) => ( + hyper::StatusCode::OK, + "swarmctl is logged in. You can close this tab.", + ), + Callback::Refused(_) => ( + hyper::StatusCode::BAD_REQUEST, + "The login failed; swarmctl prints why.", + ), + }; + if !matches!(judged, Callback::Ignored(..)) { + *outcome.lock().expect("callback lock") = Some(judged); + } + Ok::<_, hyper::Error>( + hyper::Response::builder() + .status(status) + .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Full::new(bytes::Bytes::from_static(text.as_bytes()))) + .expect("static response"), + ) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .keep_alive(false) + .serve_connection(TokioIo::new(stream), service) + .await; + match outcome.lock().expect("callback lock").take() { + Some(Callback::Code(code)) => return Ok(code), + Some(Callback::Refused(why)) => bail!("{why}"), + Some(Callback::Ignored(..)) | None => {} + } + } +} + +#[cfg(test)] +mod tests { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + use super::*; + + const AUTH: &str = "https://auth.example.test"; + + /// RFC 7636 appendix B. + #[test] + fn the_challenge_matches_the_rfc_vector() { + assert_eq!( + challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn verifiers_are_long_enough_url_safe_and_fresh() { + let a = random_token().expect("random"); + let b = random_token().expect("random"); + assert_eq!(a.len(), 43); + assert!( + a.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + ); + assert_ne!(a, b); + } + + fn post(body: &str) -> Callback { + callback( + &hyper::Method::POST, + CALLBACK_PATH, + body.as_bytes(), + "s1", + AUTH, + ) + } + + #[test] + fn the_matching_state_yields_the_code() { + let body = format!( + "code=c%2B1&iss={}&scope=x&state=s1", + AUTH.replace(':', "%3A") + ); + assert_eq!(post(&body), Callback::Code("c+1".into())); + } + + #[test] + fn a_wrong_or_missing_state_is_refused_without_ending_the_wait() { + for body in ["code=c1&state=s2", "code=c1"] { + assert!( + matches!( + post(body), + Callback::Ignored(hyper::StatusCode::BAD_REQUEST, _) + ), + "{body}" + ); + } + } + + #[test] + fn an_error_or_a_foreign_issuer_ends_the_login() { + assert!(matches!( + post("error=access_denied&state=s1"), + Callback::Refused(_) + )); + assert!(matches!( + post("code=c1&iss=https%3A%2F%2Fevil.test&state=s1"), + Callback::Refused(_) + )); + } + + #[test] + fn other_paths_and_methods_are_ignored() { + let get = callback(&hyper::Method::GET, CALLBACK_PATH, b"state=s1", "s1", AUTH); + let favicon = callback( + &hyper::Method::POST, + "/favicon.ico", + b"state=s1", + "s1", + AUTH, + ); + assert!(matches!( + get, + Callback::Ignored(hyper::StatusCode::NOT_FOUND, _) + )); + assert!(matches!( + favicon, + Callback::Ignored(hyper::StatusCode::NOT_FOUND, _) + )); + } + + /// Over a real socket: a stale tab's POST is answered 400 and the + /// listener keeps waiting for the one carrying this login's state. + #[tokio::test] + async fn the_listener_skips_a_wrong_state_and_returns_the_right_code() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let server = tokio::spawn(async move { receive_code(&listener, "s1", AUTH).await }); + + let send = |body: &'static str| async move { + let mut s = tokio::net::TcpStream::connect(addr).await.expect("connect"); + let req = format!( + "POST /callback HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: \ + application/x-www-form-urlencoded\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + s.write_all(req.as_bytes()).await.expect("write"); + let mut answer = String::new(); + s.read_to_string(&mut answer).await.expect("read"); + answer + }; + assert!( + send("code=stale&state=s0") + .await + .starts_with("HTTP/1.1 400") + ); + assert!( + send("code=fresh&state=s1") + .await + .starts_with("HTTP/1.1 200") + ); + assert_eq!(server.await.expect("join").expect("code"), "fresh"); + } +} diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 0f9337e1..717ce805 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -12,16 +12,14 @@ //! root. //! //! So this binary **serves** no socket, publishes no HTTP route and has -//! no privileged helper. When a verb eventually has to run as a non-root -//! user or from another host, the answer is a **group-gated admin -//! socket** — separate from the controller's `0666` gateway-facing one — -//! not a widening of what root does here. +//! no privileged helper. //! -//! Acting directly is not the same as acting *alone*, though: `agent -//! create` is a client of the controller's own unix socket, because the -//! work it asks for is a job graph only the controller can queue (see -//! [`agent`]). That is the opposite direction from the socket the -//! paragraph above rules out — nothing here becomes reachable by it. +//! Acting directly is not the same as acting *alone*, though: the `agent`, +//! `schedule` and `forge` verbs are clients of the controller, because the +//! work they ask for is only the controller's to do (see [`agent`]). They +//! reach it over its unix socket on its host, or, with `--swarm`, through +//! the swarm domain's gateway as the operator's own SSO account +//! ([`remote`]). The `user` verbs act on the local users file only. //! //! Distinct from `hivectl`, which drives one hive's `hive-c0re` over its //! admin socket. This crate deliberately does not link `swarm-controller`, @@ -30,6 +28,8 @@ mod agent; mod forge; mod inference; +mod login; +mod remote; mod schedule; mod users; @@ -42,6 +42,7 @@ use std::process::Command; use anyhow::{Context, Result, bail}; use clap::{Args, Parser, Subcommand}; +use remote::Target; use users::{User, UserStore}; #[derive(Parser)] @@ -49,10 +50,51 @@ use users::{User, UserStore}; struct Cli { #[command(flatten)] paths: PathArgs, + #[command(flatten)] + remote: RemoteArgs, #[command(subcommand)] command: Verb, } +/// Which swarm to reach through its domain rather than the local socket. +#[derive(Args)] +struct RemoteArgs { + /// The swarm's domain, as `https://`. Given, the `agent`, + /// `schedule` and `forge` verbs go through its gateway as the account + /// `swarmctl login` logged in, instead of over the controller's socket. + /// Falls back to `SWARMCTL_SWARM`. + #[arg(long, value_name = "URL")] + swarm: Option, + /// A PEM CA certificate to trust beside the system roots, for a swarm + /// whose gateway serves a certificate from the swarm's own CA. Falls back + /// to `SWARMCTL_CA_FILE`. + #[arg(long, value_name = "PATH")] + ca_file: Option, +} + +impl RemoteArgs { + fn swarm(&self) -> Result> { + let url = self + .swarm + .clone() + .or_else(|| std::env::var("SWARMCTL_SWARM").ok()) + .filter(|u| !u.is_empty()); + let ca_file = self + .ca_file + .clone() + .or_else(|| std::env::var_os("SWARMCTL_CA_FILE").map(PathBuf::from)); + url.map(|u| remote::Swarm::parse(&u, ca_file)).transpose() + } + + /// The swarm when one is named, the controller's socket otherwise. + fn target(&self, socket: Option) -> Result { + Ok(match self.swarm()? { + Some(swarm) => Target::Swarm(swarm), + None => Target::Socket(path_from(socket, "SWARM_CONTROLLER_SOCKET")?), + }) + } +} + /// Where the deployment put the things this CLI has to touch. /// /// Every one of these is supplied by the nix module that installs @@ -141,6 +183,14 @@ enum Verb { #[command(subcommand)] command: ForgeVerb, }, + /// Log in to the swarm named by `--swarm` as your own SSO account. + /// + /// Opens the swarm's SSO login in your browser (and prints the URL) and + /// receives the answer on a loopback port, so run it where that browser + /// runs. swarmctl keeps the login, mode 0600, under + /// `$XDG_STATE_HOME/swarmctl/` and refreshes it as it's used. To log out, + /// delete that file. + Login, /// Emit the full CLI reference as `CommonMark` to stdout. /// /// Hidden tooling command used by the docs build to keep the published @@ -183,8 +233,8 @@ enum AgentVerb { /// own clock. Watch the swarm UI's job view, or the hive itself, for /// the rest. /// - /// No approval gate guards this: running this binary already means - /// being root on the controller's host. + /// No approval gate guards this: the caller is root on the controller's + /// host, or logged in through the swarm domain's SSO gate. Create(AgentCreateArgs), /// Queue a re-mint of an existing agent's identity at the swarm's /// secret store. @@ -446,8 +496,8 @@ struct ControllerSocketArg { } impl ControllerSocketArg { - fn resolve(self) -> Result { - path_from(self.controller_socket, "SWARM_CONTROLLER_SOCKET") + fn resolve(self, remote: &RemoteArgs) -> Result { + remote.target(self.controller_socket) } } @@ -521,7 +571,11 @@ struct UpdateArgs { } fn main() -> Result<()> { - let Cli { paths, command } = Cli::parse(); + let Cli { + paths, + remote, + command, + } = Cli::parse(); match command { // Resolves its own socket path, not `PathArgs`: this verb needs // none of the `SWARMCTL_AUTHELIA_*` values, and requiring them @@ -530,35 +584,40 @@ fn main() -> Result<()> { Verb::Agent { command: AgentVerb::Create(args), } => { - let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; - agent::create(&socket, &args.name, &args.hive) + let target = remote.target(args.controller_socket)?; + agent::create(&target, &args.name, &args.hive) } // Same socket-resolution reasoning as `Create` above. Verb::Agent { command: AgentVerb::MintIdentity(args), } => { - let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; - agent::mint_identity(&socket, &args.name) + let target = remote.target(args.controller_socket)?; + agent::mint_identity(&target, &args.name) } // Same socket-resolution reasoning as `Create` above. Verb::Agent { command: AgentVerb::MintForgeToken(args), } => { - let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; - agent::mint_forge_token(&socket, &args.name) + let target = remote.target(args.controller_socket)?; + agent::mint_forge_token(&target, &args.name) } Verb::Agent { command: AgentVerb::Inference { command }, - } => inference(command), + } => inference(&remote, command), // Same socket-resolution reasoning as `Create` above. Verb::Forge { command: ForgeVerb::MakeAdmin(args), } => { - let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; - forge::make_admin(&socket, &args.name) + let target = remote.target(args.controller_socket)?; + forge::make_admin(&target, &args.name) } // Same socket-resolution reasoning as `Create` above. - Verb::Schedule { command } => schedule(command), + Verb::Schedule { command } => schedule(&remote, command), + Verb::Login => login::login( + &remote + .swarm()? + .context("`swarmctl login` needs `--swarm` or `SWARMCTL_SWARM`")?, + ), // Resolved lazily, inside the one arm that actually touches the // deployment env vars — see the `MarkdownDocs` doc comment above // for why an unconditional resolve up front would be wrong. @@ -598,11 +657,11 @@ fn main() -> Result<()> { } } -fn inference(command: InferenceVerb) -> Result<()> { +fn inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> { match command { - InferenceVerb::Get(a) => inference::get(&a.socket.resolve()?, &a.agent), + InferenceVerb::Get(a) => inference::get(&a.socket.resolve(remote)?, &a.agent), InferenceVerb::Set(a) => inference::set( - &a.socket.resolve()?, + &a.socket.resolve(remote)?, &a.agent, inference::SetFlags { model: a.model, @@ -610,14 +669,14 @@ fn inference(command: InferenceVerb) -> Result<()> { subagent_models: a.subagent_models, }, ), - InferenceVerb::Clear(a) => inference::clear(&a.socket.resolve()?, &a.agent), + InferenceVerb::Clear(a) => inference::clear(&a.socket.resolve(remote)?, &a.agent), } } -fn schedule(command: ScheduleVerb) -> Result<()> { +fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> { match command { ScheduleVerb::Add(a) => schedule::add( - &a.socket.resolve()?, + &a.socket.resolve(remote)?, &schedule::AddFlags { name: &a.name, agents: &a.agents, @@ -627,7 +686,7 @@ fn schedule(command: ScheduleVerb) -> Result<()> { paused: a.paused, }, ), - ScheduleVerb::List(a) => schedule::list(&a.socket.resolve()?, a.agent.as_deref()), + ScheduleVerb::List(a) => schedule::list(&a.socket.resolve(remote)?, a.agent.as_deref()), ScheduleVerb::Edit(a) => { let edit = schedule::EditRequest::new(schedule::EditFlags { name: a.name, @@ -638,11 +697,11 @@ fn schedule(command: ScheduleVerb) -> Result<()> { add_agents: a.add_agents, remove_agents: a.remove_agents, })?; - schedule::edit(&a.socket.resolve()?, &a.id, &edit) + schedule::edit(&a.socket.resolve(remote)?, &a.id, &edit) } - ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, true), - ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, false), - ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve()?, &a.id), + ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, true), + ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve(remote)?, &a.id, false), + ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve(remote)?, &a.id), } } diff --git a/swarmctl/src/remote.rs b/swarmctl/src/remote.rs new file mode 100644 index 00000000..d3c2f7a2 --- /dev/null +++ b/swarmctl/src/remote.rs @@ -0,0 +1,352 @@ +//! Reaching swarm-controller through the swarm domain's gateway instead of its +//! unix socket, as the operator's own SSO account. +//! +//! The gateway's `/api/` location runs authelia's `auth_request` and accepts an +//! OAuth 2.0 bearer token in place of a session cookie. `swarmctl login` +//! ([`crate::login`]) obtains one for the public `swarmctl` client and this +//! module caches it, refreshes it and presents it. +//! +//! ⚠️ authelia **rotates** the refresh token on every use, and presenting an +//! already-used one revokes the whole login. Every refresh therefore runs under +//! an exclusive lock on the cache, and rereads the cache once it holds it. + +use std::fs::{self, DirBuilder, File, OpenOptions}; +use std::io::Write as _; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; + +/// Registered under the same id in `swarm-authelia.nix`. +pub(crate) const CLIENT_ID: &str = "swarmctl"; + +/// Refresh this long before the access token's stated expiry, so a token +/// doesn't lapse between the check and the gateway's look at it. +const EXPIRY_MARGIN_SECS: i64 = 60; + +/// Where a controller request goes. +pub(crate) enum Target { + /// swarm-controller's unix socket on this host. + Socket(PathBuf), + /// The swarm domain's gateway, with the cached login. + Swarm(Swarm), +} + +/// A swarm reached by its domain. +pub(crate) struct Swarm { + /// `https://`, with no path. + pub origin: url::Url, + /// A PEM CA certificate to trust beside the system roots. + pub ca_file: Option, +} + +impl Swarm { + pub(crate) fn parse(url: &str, ca_file: Option) -> Result { + let parsed = + url::Url::parse(url).with_context(|| format!("--swarm {url:?} is not a URL"))?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + bail!("--swarm {url:?} must be an https:// URL naming the swarm's domain"); + } + let origin = url::Url::parse(&parsed.origin().ascii_serialization()) + .context("re-parsing the swarm's origin")?; + Ok(Self { origin, ca_file }) + } + + /// What the token must name for the gateway to admit it at `/api/`. + /// authelia matches it as a URL prefix. + pub(crate) fn audience(&self) -> String { + format!("{}api/", self.origin) + } + + /// An HTTP client that never follows a redirect: from the gateway one + /// means "log in", and following it would land on the login page. + pub(crate) fn http(&self) -> Result { + let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()); + if let Some(path) = &self.ca_file { + let pem = fs::read(path) + .with_context(|| format!("reading the CA certificate {}", path.display()))?; + let cert = reqwest::Certificate::from_pem(&pem) + .with_context(|| format!("parsing {} as a PEM certificate", path.display()))?; + builder = builder.add_root_certificate(cert); + } + builder.build().context("building the HTTPS client") + } + + pub(crate) fn cache_path(&self) -> Result { + let state = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state"))) + .context( + "neither XDG_STATE_HOME nor HOME is set, so there is nowhere to keep the login", + )?; + let host = self.origin.host_str().unwrap_or_default(); + let name = match self.origin.port() { + Some(port) => format!("{host}_{port}.json"), + None => format!("{host}.json"), + }; + Ok(state.join("swarmctl").join(name)) + } + + fn not_logged_in(&self) -> String { + format!( + "not logged in to {} — run `swarmctl --swarm {} login`", + self.origin, self.origin + ) + } + + /// One request to `uri` (a path under the origin) with the cached login. + pub(crate) async fn request( + &self, + method: hyper::Method, + uri: &str, + body: Option>, + what: &str, + ) -> Result<(hyper::StatusCode, bytes::Bytes)> { + let http = self.http()?; + let token = self.access_token(&http).await?; + let url = self.origin.join(uri).context("joining the request path")?; + let mut req = http.request(method, url).bearer_auth(token); + if let Some(body) = body { + req = req + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + let resp = req + .send() + .await + .with_context(|| format!("sending the {what} request to {}", self.origin))?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + bail!( + "{} refused the cached login (401). {}", + self.origin, + self.not_logged_in() + ); + } + if status.is_redirection() { + bail!( + "{} answered {status} instead of the controller's response; its `/api/` \ + did not accept a bearer token", + self.origin + ); + } + let body = resp.bytes().await.context("reading the response")?; + Ok((status, body)) + } + + /// The cached access token, refreshed first if it is about to expire. + async fn access_token(&self, http: &reqwest::Client) -> Result { + let path = self.cache_path()?; + let now = chrono::Utc::now().timestamp(); + let cached = read_cache(&path)?.with_context(|| self.not_logged_in())?; + if !needs_refresh(&cached, now) { + return Ok(cached.access_token); + } + let _lock = lock(&path)?; + let cached = read_cache(&path)?.with_context(|| self.not_logged_in())?; + if !needs_refresh(&cached, now) { + return Ok(cached.access_token); + } + let token = token_request( + http, + &cached.auth, + &[ + ("grant_type", "refresh_token"), + ("client_id", CLIENT_ID), + ("refresh_token", &cached.refresh_token), + ], + ) + .await + .with_context(|| format!("refreshing the login. {}", self.not_logged_in()))?; + let fresh = refreshed(cached, token, now); + write_cache(&path, &fresh)?; + Ok(fresh.access_token) + } +} + +/// The token cache: one file per swarm, mode 0600, in a 0700 directory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Cached { + /// authelia's origin, where the refresh goes. + pub auth: String, + pub access_token: String, + pub refresh_token: String, + /// Unix seconds. + pub expires_at: i64, +} + +/// authelia's token endpoint answer. +#[derive(Deserialize)] +pub(crate) struct TokenResponse { + pub access_token: String, + pub expires_in: i64, + pub refresh_token: Option, +} + +pub(crate) fn needs_refresh(cached: &Cached, now: i64) -> bool { + cached.expires_at - EXPIRY_MARGIN_SECS <= now +} + +/// The cache after a refresh. The new refresh token replaces the old one, +/// which is dead from the moment it was used. +pub(crate) fn refreshed(cached: Cached, token: TokenResponse, now: i64) -> Cached { + Cached { + refresh_token: token.refresh_token.unwrap_or(cached.refresh_token), + access_token: token.access_token, + expires_at: now + token.expires_in, + auth: cached.auth, + } +} + +/// POST a form to authelia's token endpoint. +pub(crate) async fn token_request( + http: &reqwest::Client, + auth: &str, + form: &[(&str, &str)], +) -> Result { + let resp = http + .post(format!("{auth}/api/oidc/token")) + .form(form) + .send() + .await + .with_context(|| format!("calling {auth}/api/oidc/token"))?; + oauth_json(resp).await +} + +/// Decode an OAuth 2.0 JSON answer, or turn its `error` into the message. +pub(crate) async fn oauth_json( + resp: reqwest::Response, +) -> Result { + let status = resp.status(); + let body = resp.bytes().await.context("reading authelia's response")?; + if !status.is_success() { + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let field = |k| v.get(k).and_then(serde_json::Value::as_str).unwrap_or(""); + return Err(anyhow!( + "authelia answered {status}: {} {}", + field("error"), + field("error_description") + )); + } + serde_json::from_slice(&body).context("decoding authelia's response") +} + +pub(crate) fn read_cache(path: &Path) -> Result> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .with_context(|| format!("decoding the login cache {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("reading the login cache {}", path.display())), + } +} + +/// Write through a 0600 temp file and rename, so the tokens are never +/// readable by anyone else, not even for the moment between create and chmod. +pub(crate) fn write_cache(path: &Path, cached: &Cached) -> Result<()> { + let dir = path + .parent() + .context("the login cache path has no directory")?; + DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("creating {}", dir.display()))?; + let tmp = path.with_extension("json.tmp"); + let _ = fs::remove_file(&tmp); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp) + .with_context(|| format!("creating {}", tmp.display()))?; + file.write_all(&serde_json::to_vec(cached)?) + .and_then(|()| file.sync_all()) + .with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display())) +} + +/// Hold an exclusive lock beside the cache until the returned file drops. +pub(crate) fn lock(path: &Path) -> Result { + if let Some(dir) = path.parent() { + DirBuilder::new().recursive(true).mode(0o700).create(dir)?; + } + let lock_path = path.with_extension("lock"); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .mode(0o600) + .open(&lock_path) + .with_context(|| format!("opening {}", lock_path.display()))?; + rustix::fs::flock(&file, rustix::fs::FlockOperation::LockExclusive) + .with_context(|| format!("locking {}", lock_path.display()))?; + Ok(file) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt as _; + + use super::*; + + fn cached(expires_at: i64) -> Cached { + Cached { + auth: "https://auth.example.test".into(), + access_token: "at-old".into(), + refresh_token: "rt-old".into(), + expires_at, + } + } + + #[test] + fn a_token_is_refreshed_only_inside_the_margin() { + assert!(!needs_refresh( + &cached(1_000), + 1_000 - EXPIRY_MARGIN_SECS - 1 + )); + assert!(needs_refresh(&cached(1_000), 1_000 - EXPIRY_MARGIN_SECS)); + assert!(needs_refresh(&cached(1_000), 2_000)); + } + + /// authelia rotates the refresh token; keeping the used one would make + /// the next refresh revoke the whole login. + #[test] + fn a_refresh_replaces_both_tokens_and_the_expiry() { + let token = TokenResponse { + access_token: "at-new".into(), + expires_in: 3599, + refresh_token: Some("rt-new".into()), + }; + let fresh = refreshed(cached(0), token, 5_000); + assert_eq!(fresh.access_token, "at-new"); + assert_eq!(fresh.refresh_token, "rt-new"); + assert_eq!(fresh.expires_at, 8_599); + assert_eq!(fresh.auth, "https://auth.example.test"); + } + + #[test] + fn the_cache_is_written_owner_only_and_reads_back() { + let dir = std::env::temp_dir().join(format!("swarmctl-cache-{}", std::process::id())); + let path = dir.join("swarmctl").join("swarm.example.test.json"); + let _ = fs::remove_dir_all(&dir); + + assert_eq!(read_cache(&path).expect("missing is not an error"), None); + write_cache(&path, &cached(42)).expect("writes"); + write_cache(&path, &cached(43)).expect("overwrites"); + + let mode = |p: &Path| fs::metadata(p).expect("stat").permissions().mode() & 0o777; + assert_eq!(mode(&path), 0o600); + assert_eq!(mode(path.parent().expect("dir")), 0o700); + assert_eq!(read_cache(&path).expect("reads"), Some(cached(43))); + fs::remove_dir_all(&dir).expect("cleanup"); + } + + #[test] + fn the_audience_is_the_api_prefix_of_the_origin() { + let swarm = Swarm::parse("https://swarm.example.test/some/page", None).expect("parses"); + assert_eq!(swarm.audience(), "https://swarm.example.test/api/"); + assert!(Swarm::parse("http://swarm.example.test", None).is_err()); + } +} diff --git a/swarmctl/src/schedule.rs b/swarmctl/src/schedule.rs index b7becc07..83adaa28 100644 --- a/swarmctl/src/schedule.rs +++ b/swarmctl/src/schedule.rs @@ -8,12 +8,11 @@ //! The shapes below mirror the controller's own `ScheduleView` and request //! bodies, for the reason [`crate::agent`]'s module doc gives. -use std::path::Path; - use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Serialize}; use crate::agent::{parse_ident, send}; +use crate::remote::Target; /// One schedule as the controller shows it. #[derive(Debug, Deserialize)] @@ -142,7 +141,7 @@ fn agents(names: &[String]) -> Result> { fn runtime() -> Result { tokio::runtime::Builder::new_current_thread() - .enable_io() + .enable_all() .build() .context("starting a tokio runtime for the controller request") } @@ -167,7 +166,7 @@ pub(crate) struct AddFlags<'a> { } /// Run `swarmctl schedule add`. -pub(crate) fn add(socket: &Path, flags: &AddFlags<'_>) -> Result<()> { +pub(crate) fn add(target: &Target, flags: &AddFlags<'_>) -> Result<()> { let request = AddRequest { name: flags.name, agents: agents(flags.agents)?, @@ -177,7 +176,7 @@ pub(crate) fn add(socket: &Path, flags: &AddFlags<'_>) -> Result<()> { paused: flags.paused, }; let added: ScheduleView = runtime()?.block_on(send( - socket, + target, hyper::Method::POST, COLLECTION, Some(&request), @@ -189,10 +188,10 @@ pub(crate) fn add(socket: &Path, flags: &AddFlags<'_>) -> Result<()> { } /// Run `swarmctl schedule list`, optionally only the schedules naming `agent`. -pub(crate) fn list(socket: &Path, agent: Option<&str>) -> Result<()> { +pub(crate) fn list(target: &Target, agent: Option<&str>) -> Result<()> { let agent = agent.map(|a| parse_ident(a, "agent name")).transpose()?; let schedules: Vec = runtime()?.block_on(send( - socket, + target, hyper::Method::GET, COLLECTION, None::<&()>, @@ -212,9 +211,9 @@ pub(crate) fn list(socket: &Path, agent: Option<&str>) -> Result<()> { } /// Run `swarmctl schedule edit`. -pub(crate) fn edit(socket: &Path, id: &str, edit: &EditRequest) -> Result<()> { +pub(crate) fn edit(target: &Target, id: &str, edit: &EditRequest) -> Result<()> { let edited: ScheduleView = runtime()?.block_on(send( - socket, + target, hyper::Method::PATCH, &member(id)?, Some(edit), @@ -225,15 +224,15 @@ pub(crate) fn edit(socket: &Path, id: &str, edit: &EditRequest) -> Result<()> { } /// Run `swarmctl schedule pause` or `resume`. -pub(crate) fn set_paused(socket: &Path, id: &str, paused: bool) -> Result<()> { - edit(socket, id, &EditRequest::paused(paused)) +pub(crate) fn set_paused(target: &Target, id: &str, paused: bool) -> Result<()> { + edit(target, id, &EditRequest::paused(paused)) } /// Run `swarmctl schedule rm`. -pub(crate) fn remove(socket: &Path, id: &str) -> Result<()> { +pub(crate) fn remove(target: &Target, id: &str) -> Result<()> { let uri = member(id)?; runtime()?.block_on(send::<(), ()>( - socket, + target, hyper::Method::DELETE, &uri, None,