swarmctl: log in with the operator's own SSO account and reach the controller through the swarm domain
`swarmctl --swarm https://<swarm> login` runs the OAuth 2.0 authorization-code flow with PKCE against the swarm's authelia as a new public `swarmctl` client: a pushed authorization request, the browser login and explicit consent, and the code delivered by form_post to a loopback port. The access and refresh tokens are cached at mode 0600 under $XDG_STATE_HOME/swarmctl/ and refreshed under a file lock, since authelia rotates the refresh token and revokes the login when a used one is presented again. With --swarm (or SWARMCTL_SWARM), the agent, schedule and forge verbs send their requests to https://<swarm>/api/ with the bearer token instead of over the controller's unix socket, which stays the default. authelia registers the client on every authelia host with a swarm domain: `kind = "native"`, public, no minted secret, scopes offline_access and authelia.bearer.authz, audience https://<swarm>/api/ (matched as a URL prefix), redirect http://127.0.0.1/callback (any loopback port). Every field is one authelia 4.39.20's validator requires for a public client holding the bearer scope. The swarm apex's /api/ answers a refused caller that sent an Authorization header with the 401 itself instead of the login redirect, so the CLI can tell "log in again" apart from an answer; a browser still gets the redirect. The bearer token is not forwarded to the controller. Refs #4409 (mara: c94764, c94782).
This commit is contained in:
parent
5c15580060
commit
2df19e4899
17 changed files with 1118 additions and 122 deletions
|
|
@ -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
|
||||
|
|
|
|||
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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://<swarm>/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
|
||||
|
|
|
|||
|
|
@ -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 <PATH>` — 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 <URL>` — The swarm's domain, as `https://<domain>`. 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 <PATH>` — 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 <HIVE> <NAME>`
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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://<swarm domain>`: 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.
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
///
|
||||
/// 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<CreateAgentResponse> {
|
||||
/// One `POST /api/agents` round trip at the controller.
|
||||
async fn post_create(target: &Target, name: &str, hive: &str) -> Result<CreateAgentResponse> {
|
||||
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<CreateAgen
|
|||
.await
|
||||
}
|
||||
|
||||
/// One JSON `POST` to `uri` over the controller's unix socket.
|
||||
/// One JSON `POST` to `uri` at the controller.
|
||||
///
|
||||
/// `what` names the request in error messages — the operator needs to know
|
||||
/// which call failed, and every other part of this function is identical
|
||||
/// between the two verbs.
|
||||
pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
||||
socket: &Path,
|
||||
target: &Target,
|
||||
uri: &str,
|
||||
request: &Req,
|
||||
what: &str,
|
||||
) -> Result<Resp> {
|
||||
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<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
||||
socket: &Path,
|
||||
target: &Target,
|
||||
method: hyper::Method,
|
||||
uri: &str,
|
||||
request: Option<&Req>,
|
||||
|
|
@ -202,6 +204,34 @@ pub(crate) async fn send<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
|||
.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<Vec<u8>>,
|
||||
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<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
|||
.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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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::Runtime> {
|
||||
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<String> {
|
|||
}
|
||||
|
||||
/// 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,
|
||||
|
|
|
|||
387
swarmctl/src/login.rs
Normal file
387
swarmctl/src/login.rs
Normal file
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.context("accepting the callback")?;
|
||||
let outcome: Arc<Mutex<Option<Callback>>> = Arc::default();
|
||||
let service = hyper::service::service_fn(|req: hyper::Request<hyper::body::Incoming>| {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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://<domain>`. 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<String>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
impl RemoteArgs {
|
||||
fn swarm(&self) -> Result<Option<remote::Swarm>> {
|
||||
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<PathBuf>) -> Result<Target> {
|
||||
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<PathBuf> {
|
||||
path_from(self.controller_socket, "SWARM_CONTROLLER_SOCKET")
|
||||
fn resolve(self, remote: &RemoteArgs) -> Result<Target> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
352
swarmctl/src/remote.rs
Normal file
352
swarmctl/src/remote.rs
Normal file
|
|
@ -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://<swarm domain>`, with no path.
|
||||
pub origin: url::Url,
|
||||
/// A PEM CA certificate to trust beside the system roots.
|
||||
pub ca_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Swarm {
|
||||
pub(crate) fn parse(url: &str, ca_file: Option<PathBuf>) -> Result<Self> {
|
||||
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<reqwest::Client> {
|
||||
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<PathBuf> {
|
||||
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<Vec<u8>>,
|
||||
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<String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<TokenResponse> {
|
||||
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<T: serde::de::DeserializeOwned>(
|
||||
resp: reqwest::Response,
|
||||
) -> Result<T> {
|
||||
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<Option<Cached>> {
|
||||
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<File> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<String>> {
|
|||
|
||||
fn runtime() -> Result<tokio::runtime::Runtime> {
|
||||
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<ScheduleView> = 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue