swarm options: no swarm.* value depends on a deploy option or this host's hive
- swarm.serviceDomains lists every swarm service name on every host. The
store's issuing role is built from it, so the store now admits a leaf
for grafana, metrics, otel, logs, the bao UI and the apex whichever host
runs the store. Each gateway's own leaf stays narrowed by
localServiceDomains.
- swarm.matrix.apiUrl defaults to http://<swarm.matrix.gatewayHost>
(null when gatewayHost is null) instead of loopback on the matrix host.
- The authelia vhost proxies /bridge/ to swarm-authelia-bridge with the
prefix stripped, and deploy.swarm-controller.authBridgeUrl defaults to
${swarm.authelia.url}/bridge on every host. swarm.authelia.bridgeUrl is
removed.
- swarm-authelia-bridge admits only swarm-controller's OIDC client on
every request (403 for any other active token), configured by
SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID (renamed from
..._OPERATOR_READER_CLIENT_ID, which gated ListOperators alone).
- swarm.peerHives is removed; swarm-wireguard.nix filters this host's own
hive out of swarm.hives itself.
Closes #4933
This commit is contained in:
parent
207b089498
commit
bed4358e19
21 changed files with 226 additions and 283 deletions
|
|
@ -25,18 +25,17 @@ simply owns the file it writes. No elevated privilege anywhere.
|
|||
`ListOperators` (the operator group's usernames).
|
||||
- Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||
7662), against `swarm-controller`'s **existing** machine-client identity
|
||||
(already minted for the queue connection) — no new credential. The two
|
||||
agent verbs admit any active token. ⚠️ `ListOperators` admits only the
|
||||
client named by `SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID`
|
||||
(`swarm-controller`'s) and answers every other active token `403`:
|
||||
authelia issues machine tokens to every hive and its agents too.
|
||||
(already minted for the queue connection) — no new credential. ⚠️ Every
|
||||
request admits only the client named by
|
||||
`SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID` (`swarm-controller`'s) and
|
||||
answers every other active token `403`: authelia issues machine tokens to
|
||||
every hive and its agents too.
|
||||
- No restart of authelia after a write — relies on
|
||||
`authentication_backend.file.watch`, confirmed working against the
|
||||
pinned 4.39.20 build during this design work.
|
||||
- Network-facing rather than unix-socket-only: `swarm-authelia`'s container
|
||||
shares the host netns, so the same listener serves both a co-located
|
||||
`swarm-controller` (loopback) and a split-host one (bind wider + firewall)
|
||||
with no separate transport.
|
||||
- Listens on loopback. The authelia vhost proxies `/bridge/` to it with
|
||||
the prefix stripped, so `swarm-controller` on any host calls
|
||||
`https://<swarm.authelia.domain>/bridge/requests`.
|
||||
|
||||
## One store, two writers
|
||||
|
||||
|
|
|
|||
|
|
@ -11,13 +11,10 @@
|
|||
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
|
||||
//! `swarmctl/README.md`'s own identical analysis of this same file).
|
||||
//!
|
||||
//! Network-facing, not unix-socket-only: `swarm-authelia`'s container
|
||||
//! shares the host netns (`privateNetwork = false`, same as `hive-forge`/
|
||||
//! `hive-matrix`), so one listener serves a co-located `swarm-controller`
|
||||
//! (loopback) and a split-host one (bind wider, firewall it) with the
|
||||
//! same code path — no separate transport for the cross-host case.
|
||||
//! Listens on loopback; the authelia vhost proxies `/bridge/` to it, so a
|
||||
//! `swarm-controller` on any host reaches it at the same address.
|
||||
//! Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||
//! 7662) against `swarm-controller`'s **existing** machine-client
|
||||
//! 7662), and admits only `swarm-controller`'s **existing** machine-client
|
||||
//! identity (already minted for the queue connection) — no new
|
||||
//! credential to mint or deliver, and always local/fast to verify since
|
||||
//! this process runs right next to the authelia it asks.
|
||||
|
|
@ -25,7 +22,8 @@
|
|||
//! One endpoint, `POST /requests`, body =
|
||||
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent
|
||||
//! exists as an authelia subject, list the ones that do, and list the
|
||||
//! operator group (to `swarm-controller` only, see `permit`). Not a
|
||||
//! operator group. Every request is answered to `swarm-controller` only,
|
||||
//! see `classify`. Not a
|
||||
//! wholesale-replace-the-file API — this process owns rendering
|
||||
//! `users.yml` internally; see `store` module.
|
||||
|
||||
|
|
@ -53,8 +51,8 @@ struct Config {
|
|||
introspection_url: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
/// The one client [`BridgeRequest::ListOperators`] answers.
|
||||
operator_reader_client_id: String,
|
||||
/// The one client this bridge answers: `swarm-controller`'s.
|
||||
controller_client_id: String,
|
||||
/// The authelia group whose members are operators.
|
||||
operator_group: String,
|
||||
}
|
||||
|
|
@ -68,7 +66,7 @@ impl Config {
|
|||
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
|
||||
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
|
||||
client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?,
|
||||
operator_reader_client_id: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID")?,
|
||||
controller_client_id: env_var("SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID")?,
|
||||
operator_group: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP")?,
|
||||
})
|
||||
}
|
||||
|
|
@ -146,28 +144,20 @@ async fn handle_request(
|
|||
headers: HeaderMap,
|
||||
Json(req): Json<BridgeRequest>,
|
||||
) -> Response {
|
||||
let caller = match authorize(&state, &headers).await {
|
||||
Ok(caller) => caller,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
respond(&state.config, &state.write_lock, caller.as_deref(), req).await
|
||||
if let Err(resp) = authorize(&state, &headers).await {
|
||||
return resp;
|
||||
}
|
||||
respond(&state.config, &state.write_lock, req).await
|
||||
}
|
||||
|
||||
/// Everything after authentication: the per-request [`permit`] check, then
|
||||
/// the operation. Takes the config and the write lock rather than
|
||||
/// [`AppState`], whose HTTP client only [`authorize`] uses.
|
||||
/// Everything after authentication: the operation itself. Takes the config
|
||||
/// and the write lock rather than [`AppState`], whose HTTP client only
|
||||
/// [`authorize`] uses.
|
||||
async fn respond(
|
||||
config: &Config,
|
||||
write_lock: &tokio::sync::Mutex<()>,
|
||||
caller: Option<&str>,
|
||||
req: BridgeRequest,
|
||||
) -> Response {
|
||||
if let Err(refusal) = permit(&req, caller, &config.operator_reader_client_id) {
|
||||
// The client id is not a credential; naming it is what lets an
|
||||
// operator see which client asked for something it may not have.
|
||||
tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client");
|
||||
return refuse(refusal);
|
||||
}
|
||||
let (op, result) = match req {
|
||||
BridgeRequest::EnsureAgentIdentity { name } => {
|
||||
("ensure_identity", handle(config, write_lock, name).await)
|
||||
|
|
@ -194,10 +184,10 @@ async fn respond(
|
|||
/// "the op itself failed," a different case from "the caller was never let
|
||||
/// in."
|
||||
///
|
||||
/// Anything short of an active token is refused; **which status** depends
|
||||
/// on whose credential failed, see [`Refusal`]. `Ok` carries the client the
|
||||
/// token was issued to, for [`permit`].
|
||||
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<Option<String>, Response> {
|
||||
/// Anything short of an active token from the controller's client is
|
||||
/// refused; **which status** depends on whose credential failed, see
|
||||
/// [`Refusal`].
|
||||
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
|
||||
let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||
return Err(refuse(Refusal::Caller));
|
||||
};
|
||||
|
|
@ -215,41 +205,37 @@ async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<Option<Strin
|
|||
token,
|
||||
)
|
||||
.await;
|
||||
classify(verdict).map_err(refuse)
|
||||
classify(verdict, &state.config.controller_client_id).map_err(refuse)
|
||||
}
|
||||
|
||||
/// The admission decision, and the only place that makes it.
|
||||
///
|
||||
/// Fail-closed: `Ok(())` is reachable from exactly one variant. Split out
|
||||
/// of [`authorize`] so the rule is testable without an authelia — and it is
|
||||
/// the *real* rule, not a restatement of it, because this is the function
|
||||
/// the request path calls.
|
||||
fn classify(verdict: introspect::Verdict) -> Result<Option<String>, Refusal> {
|
||||
/// Fail-closed: `Ok(())` is reachable only from an active token issued to
|
||||
/// `controller`. authelia also issues machine tokens to every hive and every
|
||||
/// hive's agents, and those are as active as the controller's, so they are
|
||||
/// refused `403`; a token attributed to no client is refused too, rather
|
||||
/// than compared as a blank. Split out of [`authorize`] so the rule is
|
||||
/// testable without an authelia — and it is the *real* rule, not a
|
||||
/// restatement of it, because this is the function the request path calls.
|
||||
fn classify(verdict: introspect::Verdict, controller: &str) -> Result<(), Refusal> {
|
||||
match verdict {
|
||||
introspect::Verdict::Active { client_id } => Ok(client_id),
|
||||
introspect::Verdict::Active { client_id } if client_id.as_deref() == Some(controller) => {
|
||||
Ok(())
|
||||
}
|
||||
introspect::Verdict::Active { client_id } => {
|
||||
// The client id is not a credential; naming it is what lets an
|
||||
// operator see which client was refused.
|
||||
tracing::warn!(
|
||||
?client_id,
|
||||
"refused an active token issued to another client"
|
||||
);
|
||||
Err(Refusal::Forbidden)
|
||||
}
|
||||
introspect::Verdict::Inactive => Err(Refusal::Caller),
|
||||
introspect::Verdict::Unavailable => Err(Refusal::Bridge),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an admitted caller may make this particular request.
|
||||
///
|
||||
/// [`BridgeRequest::ListOperators`] answers only `operator_reader`, the
|
||||
/// controller's client: authelia also issues machine tokens to every hive and
|
||||
/// every hive's agents, and those are as active as the controller's. A caller
|
||||
/// with no client id is refused it too, rather than compared as a blank.
|
||||
fn permit(
|
||||
req: &BridgeRequest,
|
||||
client_id: Option<&str>,
|
||||
operator_reader: &str,
|
||||
) -> Result<(), Refusal> {
|
||||
match req {
|
||||
BridgeRequest::EnsureAgentIdentity { .. } | BridgeRequest::ListAgentIdentities => Ok(()),
|
||||
BridgeRequest::ListOperators if client_id == Some(operator_reader) => Ok(()),
|
||||
BridgeRequest::ListOperators => Err(Refusal::Forbidden),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whose credential was at fault when a request was refused.
|
||||
///
|
||||
/// The distinction is the whole point: these used to be one 401, so a
|
||||
|
|
@ -262,13 +248,14 @@ enum Refusal {
|
|||
/// This bridge could not get an answer out of authelia at all, so the
|
||||
/// request was never evaluated.
|
||||
Bridge,
|
||||
/// The token is valid, but its client may not make this request.
|
||||
/// The token is valid, but was issued to a client other than the
|
||||
/// controller's.
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// `401` for the caller; `503` for us; `403` for a valid caller asking
|
||||
/// for something scoped to another client.
|
||||
/// `401` for the caller; `503` for us; `403` for a valid token issued
|
||||
/// to a client other than the controller's.
|
||||
///
|
||||
/// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing
|
||||
/// about the caller's credential was determined — and it is also the
|
||||
|
|
@ -456,34 +443,58 @@ async fn generate_password(bin: &std::path::Path) -> Result<String> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, permit, respond,
|
||||
BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, respond,
|
||||
};
|
||||
|
||||
const CONTROLLER: &str = "swarm-controller";
|
||||
|
||||
fn active(client_id: Option<&str>) -> Verdict {
|
||||
Verdict::Active {
|
||||
client_id: client_id.map(str::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail-closed, asserted per variant rather than as one negation:
|
||||
/// splitting blame out of a single denial is exactly the change that
|
||||
/// could have widened admission, so each refusing variant is named on
|
||||
/// its own line.
|
||||
#[test]
|
||||
fn only_an_active_token_is_admitted() {
|
||||
assert_eq!(classify(active(Some(CONTROLLER)), CONTROLLER), Ok(()));
|
||||
assert_eq!(
|
||||
classify(Verdict::Active {
|
||||
client_id: Some("swarm-controller".to_owned())
|
||||
}),
|
||||
Ok(Some("swarm-controller".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
classify(Verdict::Inactive),
|
||||
classify(Verdict::Inactive, CONTROLLER),
|
||||
Err(Refusal::Caller),
|
||||
"authelia evaluated the token and said no"
|
||||
);
|
||||
assert_eq!(
|
||||
classify(Verdict::Unavailable),
|
||||
classify(Verdict::Unavailable, CONTROLLER),
|
||||
Err(Refusal::Bridge),
|
||||
"a bridge that cannot ask must not admit — this variant reports a \
|
||||
different fault, it is not a softer denial"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trust boundary. Every caller below holds a token authelia calls
|
||||
/// active — a hive and a hive's agents are issued machine tokens too —
|
||||
/// and only the controller's client is admitted. The first assertion is
|
||||
/// the control: the same verdict shape with the controller's id passes.
|
||||
#[test]
|
||||
fn only_the_controllers_client_is_admitted() {
|
||||
assert_eq!(classify(active(Some(CONTROLLER)), CONTROLLER), Ok(()));
|
||||
for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] {
|
||||
assert_eq!(
|
||||
classify(active(Some(other)), CONTROLLER),
|
||||
Err(Refusal::Forbidden),
|
||||
"{other} holds an active token but is not the controller"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
classify(active(None), CONTROLLER),
|
||||
Err(Refusal::Forbidden),
|
||||
"a token authelia attributed to no client is not the controller's"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the split, asserted on the value rather than
|
||||
/// on message text — the previous code picked its status by matching on
|
||||
/// a log message's wording, which is what let the two collapse.
|
||||
|
|
@ -521,51 +532,8 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
const CONTROLLER: &str = "swarm-controller";
|
||||
|
||||
/// The trust boundary on the operator list. Every caller below holds a
|
||||
/// token authelia calls active — a hive and a hive's agents are issued
|
||||
/// machine tokens too — and only the controller's client is answered.
|
||||
#[test]
|
||||
fn only_the_controller_may_list_operators() {
|
||||
let req = BridgeRequest::ListOperators;
|
||||
assert_eq!(permit(&req, Some(CONTROLLER), CONTROLLER), Ok(()));
|
||||
for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] {
|
||||
assert_eq!(
|
||||
permit(&req, Some(other), CONTROLLER),
|
||||
Err(Refusal::Forbidden),
|
||||
"{other} holds an active token but is not the controller"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
permit(&req, None, CONTROLLER),
|
||||
Err(Refusal::Forbidden),
|
||||
"a token authelia attributed to no client is not the controller's"
|
||||
);
|
||||
}
|
||||
|
||||
/// The control for the test above: the agent verbs stay open to any
|
||||
/// admitted caller, so the refusal there comes from the scoping and not
|
||||
/// from `permit` refusing everyone who is not the controller.
|
||||
#[test]
|
||||
fn the_agent_verbs_stay_open_to_any_active_caller() {
|
||||
for req in [
|
||||
BridgeRequest::EnsureAgentIdentity {
|
||||
name: "atlas".to_owned(),
|
||||
},
|
||||
BridgeRequest::ListAgentIdentities,
|
||||
] {
|
||||
assert_eq!(
|
||||
permit(&req, Some("hive-alpha"), CONTROLLER),
|
||||
Ok(()),
|
||||
"{req:?}"
|
||||
);
|
||||
assert_eq!(permit(&req, None, CONTROLLER), Ok(()), "{req:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_refusal_is_403_not_401() {
|
||||
fn a_refused_client_is_403_not_401() {
|
||||
assert_eq!(
|
||||
Refusal::Forbidden.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
|
|
@ -598,29 +566,16 @@ mod tests {
|
|||
introspection_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
operator_reader_client_id: CONTROLLER.to_owned(),
|
||||
controller_client_id: CONTROLLER.to_owned(),
|
||||
operator_group: "admins".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The request path, not only [`permit`]: a dispatch that skipped the
|
||||
/// check would answer the hive below with the operator list.
|
||||
#[tokio::test]
|
||||
async fn the_request_path_answers_the_operator_list_to_the_controller_only() {
|
||||
async fn the_operator_list_is_the_operator_groups_members() {
|
||||
let config = config_with_one_operator();
|
||||
let lock = tokio::sync::Mutex::new(());
|
||||
for caller in [Some("hive-alpha"), None] {
|
||||
let refused = respond(&config, &lock, caller, BridgeRequest::ListOperators).await;
|
||||
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{caller:?}");
|
||||
}
|
||||
|
||||
let answered = respond(
|
||||
&config,
|
||||
&lock,
|
||||
Some(CONTROLLER),
|
||||
BridgeRequest::ListOperators,
|
||||
)
|
||||
.await;
|
||||
let answered = respond(&config, &lock, BridgeRequest::ListOperators).await;
|
||||
assert_eq!(answered.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(answered.into_body(), usize::MAX)
|
||||
.await
|
||||
|
|
|
|||
Loading…
Reference in a new issue