Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions demo/relay/leaf0.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ tls.key = ["leaf0.key"]

# To authenticate peers by certificate, trust client certificates signed by
# this CA and switch `[auth]` below to the auth server, which grants them
# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore
# certificates, so the relay refuses a client CA alongside them.
# as cluster peers through `--mtls-publish`, `--mtls-subscribe`, and
# `--mtls-peer`. Public rules ignore certificates, so the relay refuses a
# client CA alongside them.
# tls.root = ["ca.pem"]

[connect]
Expand Down Expand Up @@ -44,7 +45,7 @@ connect = ["https://localhost:4443/"]
public = "**"

# To exercise JWT + public-pattern auth instead, drop `public` above, run
# `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`,
# `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**' --mtls-peer`,
# point the relay at it, and uncomment `listen.tls.root` above:
# url = "http://127.0.0.1:4440/"

Expand Down
7 changes: 4 additions & 3 deletions demo/relay/leaf1.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ tls.key = ["leaf1.key"]

# To authenticate peers by certificate, trust client certificates signed by
# this CA and switch `[auth]` below to the auth server, which grants them
# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore
# certificates, so the relay refuses a client CA alongside them.
# as cluster peers through `--mtls-publish`, `--mtls-subscribe`, and
# `--mtls-peer`. Public rules ignore certificates, so the relay refuses a
# client CA alongside them.
# tls.root = ["ca.pem"]

[connect]
Expand Down Expand Up @@ -44,7 +45,7 @@ connect = ["https://localhost:4443/", "https://localhost:4444/"]
public = "**"

# To exercise JWT + public-pattern auth instead, drop `public` above, run
# `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`,
# `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**' --mtls-peer`,
# point the relay at it, and uncomment `listen.tls.root` above:
# url = "http://127.0.0.1:4440/"

Expand Down
2 changes: 1 addition & 1 deletion demo/relay/prod.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ key = "key.pem"
# moq auth serve --listen 127.0.0.1:4440 --key root.jwk \
# --public-subscribe 'anon/**' --public-subscribe 'demo/**' \
# --public-publish 'anon/**' --public-publish 'demo/viewer/**' \
# --mtls-publish '**' --mtls-subscribe '**'
# --mtls-publish '**' --mtls-subscribe '**' --mtls-peer
#
# For multiple keys with rotation, give it `--key-dir /path/to/keys/` instead:
# keys are named {kid}.jwk and resolved by the kid in the JWT header.
Expand Down
7 changes: 4 additions & 3 deletions demo/relay/root.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ tls.key = ["root.key"]

# To authenticate peers by certificate, trust client certificates signed by
# this CA and switch `[auth]` below to the auth server, which grants them
# through `--mtls-publish` / `--mtls-subscribe`. Public rules ignore
# certificates, so the relay refuses a client CA alongside them.
# as cluster peers through `--mtls-publish`, `--mtls-subscribe`, and
# `--mtls-peer`. Public rules ignore certificates, so the relay refuses a
# client CA alongside them.
# `just ca` generates the CA.
# tls.root = ["ca.pem"]

Expand All @@ -31,6 +32,6 @@ listen = "[::]:4443"
public = "**"

# To exercise JWT + public-pattern auth instead, drop `public` above, run
# `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**'`,
# `moq auth serve --key root.jwk --public-subscribe 'anon/**' --public-subscribe 'demo/**' --public-publish 'anon/**' --mtls-publish '**' --mtls-subscribe '**' --mtls-peer`,
# point the relay at it, and uncomment `listen.tls.root` above:
# url = "http://127.0.0.1:4440/"
18 changes: 13 additions & 5 deletions doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,16 @@ verifies `listen.tls.root`, so a stream-only relay (no `listen.bind`) refuses
to start with it too.

Cluster peers are admitted the same way, so a mesh runs
`moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server
granting its cluster CA everything); see [Clustering](/bin/relay/cluster).
`moq auth serve --mtls-publish '**' --mtls-subscribe '**' --mtls-peer` (or a
server granting its cluster CA everything with `peer: true`); see
[Clustering](/bin/relay/cluster). `--mtls-peer` marks every certificate as
another relay, whose broadcasts entered the cluster elsewhere; leave it off
when certificates identify clients. `--mtls-upstream` also marks those relays
[upstream](/bin/relay/cluster#upstream-links) and needs `--mtls-peer`.
Adding or removing either flag on a running server ends each live mTLS session
at its next re-check, from the `--revalidate` cadence or a
[push](#the-contract) such as `moq auth revalidate`, so the mesh redials once;
until then, a session keeps its old marks.
The LAN mesh credential on `/.cluster/<credential>` stays relay-internal: it
is a secret the relay minted for itself, checked locally, and never a request
to the server.
Expand All @@ -272,7 +280,7 @@ hold, answered over the contract above.
moq auth serve --listen 127.0.0.1:4440 \
--key-dir /etc/moq/keys \
--public-subscribe 'anon/**' --public-publish 'anon/**' \
--mtls-publish '**' --mtls-subscribe '**' \
--mtls-publish '**' --mtls-subscribe '**' --mtls-peer \
--tier edge --expires 1d --revalidate 1m --limit-remote 64
```

Expand Down Expand Up @@ -300,7 +308,7 @@ certificate, not both.

`--mtls-*` and `--public-*` are rooted at `/`, like a token with an empty root,
and a session they reach nothing at is refused. Cluster peers are admitted by
certificate; a mesh needs `--mtls-publish '**' --mtls-subscribe '**'`.
certificate; a mesh needs `--mtls-publish '**' --mtls-subscribe '**' --mtls-peer`.

Every grant carries `--tier`. As in 0.14, nothing is re-checked or closed by
default: a session lives until its token's `exp` or its certificate's notAfter.
Expand Down Expand Up @@ -338,7 +346,7 @@ anonymous rules move too.
| `--auth-public-publish` / `--auth-public-subscribe` | `--public-publish` / `--public-subscribe`, as patterns |
| `--auth-public-api URL` | your own server answering the contract |
| `--auth-mtls-tier LABEL` | `--tier LABEL` (one tier per server) |
| `listen.tls.root` alone admitting a peer unscoped | `--mtls-publish '**' --mtls-subscribe '**'` |
| `listen.tls.root` alone admitting a peer unscoped | `--mtls-publish '**' --mtls-subscribe '**' --mtls-peer` |
| `--auth-api` (token or proxy mode), `Cache-Control` | `--auth-url` pointed at any server answering the contract; `revalidate` and `expires` in the grant |
| `--auth-domain` | your server reads `server_name` and decides |

Expand Down
23 changes: 14 additions & 9 deletions doc/bin/relay/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,11 @@ The mark belongs to the link, whichever side dialed. A peer this relay dials
is upstream when its `connect` or `connect_api` entry says so. A peer that
dials in is upstream when its [grant](/bin/relay/auth#the-contract) sets
`"upstream": true` beside `"peer": true`, so an edge whose auth server grants
that to core certificates treats a core that dials it as upstream too. A
relay that predates the mark treats every link as transit, so a cluster
that to core certificates treats a core that dials it as upstream too.
`moq auth serve --mtls-peer --mtls-upstream` grants it to every certificate,
so use it only where nothing but cores dial in with mTLS: on a hub that
leaves dial into, it marks every leaf upstream, and the hub stops forwarding
between them. A relay that predates the mark treats every link as transit, so a cluster
migrates one region at a time.

Which relay dials which is still the peer list's job: there are no roles and
Expand Down Expand Up @@ -279,10 +282,12 @@ URL, `token` on a peer object, or a shared `cluster.token` file for every
listed peer). The
accepting relay admits a peer through the same lease as any client: its
certificate is reported to the auth server, which grants it, so a mesh needs
`moq auth serve --mtls-publish '**' --mtls-subscribe '**'` (or a server of
your own that grants the cluster CA) behind `--auth-url`. A relay on
`--auth-public '**'` admits peers through that grant instead, as long as they
send no `cluster.token`: public rules refuse a token. LAN peers
`moq auth serve --mtls-publish '**' --mtls-subscribe '**' --mtls-peer` (or a
server of your own that grants the cluster CA with `peer: true`) behind
`--auth-url`. A relay on `--auth-public '**'` admits peers through that grant
instead, as long as they send no `cluster.token`: public rules refuse a token.
Such a peer is admitted as a client, so what it forwards counts as ingested
here. LAN peers
authenticate with the mDNS credential on `/.cluster/<credential>`, a secret
the relay minted for itself and checks locally, and never receive
`cluster.token`. Dials retry forever with capped backoff, so a rejected peer
Expand All @@ -291,9 +296,9 @@ is loud in the logs rather than fatal. See [Authentication](/bin/relay/auth#mtls
A relay records whether each route entered here or came from a peer, which the
hop list alone cannot say: a client and a peer each add one hop. Routes over a
dial this relay made, and over an accepted LAN peer, count as a peer's. An
accepted peer counts only when its grant sets `peer: true`; otherwise it looks
like a client ingesting here. An embedder reads this as `Route::source()` and
filters with `origin::Consumer::local()`.
accepted peer counts only when its grant sets `peer: true`, as `--mtls-peer`
does; otherwise it looks like a client ingesting here. An embedder reads this
as `Route::source()` and filters with `origin::Consumer::local()`.

The `/nodes` [internal endpoint](/bin/relay/http#get-nodes) lists the peers
this relay dialed and holds a session with.
4 changes: 2 additions & 2 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ Other changes to a deployment:
- **`moq auth serve` never re-checks or expires by default**, as 0.14 never
did. `--revalidate` needs `--expires`, and `--limit-*` needs `--revalidate`.
- **mTLS admits nothing on its own.** A verified client certificate is reported
to the auth server, which grants it. `moq auth serve --mtls-publish '**' --mtls-subscribe '**'` restores the old full access for every certificate
the relay's client CA verifies, so keep that CA to cluster peers.
to the auth server, which grants it. `moq auth serve --mtls-publish '**' --mtls-subscribe '**' --mtls-peer` restores the old full access for every certificate
the relay's client CA verifies, as a cluster peer, so keep that CA to cluster peers.
- **`moq --listen` needs auth.** A CLI listener refuses to start without
`--auth-url` or `--auth-public` instead of accepting everyone.
- **Gossip discovery is removed.** A relay dials only the peers it lists or
Expand Down
2 changes: 1 addition & 1 deletion nix/modules/moq-relay.nix
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ in
"${pkgs.moq-cli}/bin/moq auth serve"
"--listen 127.0.0.1:${toString cfg.auth.port}"
"--key ${authKey}"
"--mtls-publish '**' --mtls-subscribe '**'"
"--mtls-publish '**' --mtls-subscribe '**' --mtls-peer"
]
++ lib.optionals (cfg.auth.publicPath != null) [
"--public-publish '${publicPattern}' --public-subscribe '${publicPattern}'"
Expand Down
64 changes: 58 additions & 6 deletions rs/moq-auth/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ pub struct Policy {
/// What a session presenting a verified certificate is granted, rooted at `/`;
/// empty refuses it.
pub mtls: Permissions,
/// Mark every session presenting a verified certificate as a cluster
/// [peer](Grant::peer), for a mesh whose relays dial each other with mTLS.
pub mtls_peer: bool,
/// Mark every certificate's peer as [upstream](Grant::upstream) too. Requires
/// [`mtls_peer`](Self::mtls_peer).
pub mtls_upstream: bool,
/// The tier stamped on every grant.
pub tier: Option<String>,
/// How often the relay re-checks each grant; `None` never re-checks. The contract
Expand Down Expand Up @@ -135,7 +141,7 @@ impl Policy {
if jwt.is_some() && request.tls.is_some() {
return Err(Refusal::TokenAndCertificate);
}
let (permissions, expires) = if let Some(jwt) = jwt {
let (permissions, expires, certificate) = if let Some(jwt) = jwt {
let claims = self.verify(jwt).await?;
let permissions = claims.authorize(&request.path).map_err(|err| match err {
crate::Error::RootMismatch(path) => Refusal::RootMismatch {
Expand All @@ -145,17 +151,17 @@ impl Policy {
crate::Error::NoAccess(path) => Refusal::NoAccess { path },
other => Refusal::InvalidToken(other.to_string()),
})?;
(permissions, claims.expires)
(permissions, claims.expires, false)
} else if let Some(peer) = &request.tls {
if self.mtls.is_empty() {
return Err(Refusal::NoMtlsGrant);
}
(authorize(&self.mtls, &request.path)?, peer.expires)
(authorize(&self.mtls, &request.path)?, peer.expires, true)
} else {
if self.public.is_empty() {
return Err(Refusal::NoPublicGrant);
}
(authorize(&self.public, &request.path)?, None)
(authorize(&self.public, &request.path)?, None, false)
};

let mut grant = Grant::new(permissions.publish, permissions.subscribe);
Expand All @@ -166,6 +172,8 @@ impl Policy {
};
grant.revalidate = self.revalidate;
grant.tier = self.tier.clone();
grant.peer = certificate && self.mtls_peer;
grant.upstream = certificate && self.mtls_upstream;
Ok(grant)
}

Expand Down Expand Up @@ -331,9 +339,12 @@ pub struct Server {

impl Server {
/// A server answering with `policy`, refusing one that asks for a re-check without
/// a bound, bounds grants past the clock's range, or caps sessions without the
/// re-check that ages out a dead relay's.
/// a bound, bounds grants past the clock's range, caps sessions without the
/// re-check that ages out a dead relay's, or marks an upstream that is not a peer.
pub fn new(policy: Policy) -> crate::Result<Self> {
if policy.mtls_upstream && !policy.mtls_peer {
return Err(crate::Error::UpstreamWithoutPeer);
}
if policy.revalidate.is_some() && policy.expires.is_none() {
return Err(crate::Error::UnboundedRevalidate);
}
Expand Down Expand Up @@ -824,11 +835,52 @@ mod tests {
// A certificate without a bound gets none by default, like 0.14.
let grant = policy.decide(&with_peer(request("/"), None)).await.unwrap();
assert_eq!(grant.expires, None);
// A certificate is a client unless the policy says it is a relay.
assert!(!grant.peer);

// The certificate does not stand in for a public grant.
assert_eq!(policy.decide(&request("/")).await.unwrap_err(), Refusal::NoPublicGrant);
}

/// `mtls_peer` and `mtls_upstream` mark a certificate's grant, and only a certificate's.
#[tokio::test]
async fn mtls_peer_marks_only_certificates() {
let (dir, key) = key_dir();
let policy = Policy {
keys: Some(Keys::Dir(dir.path().into())),
public: rules(&["**"], &["**"]),
mtls: rules(&["**"], &["**"]),
mtls_peer: true,
..Default::default()
};
let grant = policy.decide(&with_peer(request("/"), None)).await.unwrap();
assert!(grant.peer && !grant.upstream);

let policy = Policy {
mtls_upstream: true,
..policy
};
let grant = policy.decide(&with_peer(request("/"), None)).await.unwrap();
assert!(grant.peer && grant.upstream);
grant.validate().unwrap();
let grant = policy.decide(&request("/")).await.unwrap();
assert!(!grant.peer && !grant.upstream);
let jwt = sign(&key, "", &["**"], &[], None);
let grant = policy.decide(&with_token(request("/"), &jwt)).await.unwrap();
assert!(!grant.peer && !grant.upstream);
}

/// An upstream that is not a peer would answer grants every relay refuses.
#[test]
fn a_server_refuses_an_upstream_that_is_not_a_peer() {
let policy = Policy {
mtls: rules(&["**"], &["**"]),
mtls_upstream: true,
..Default::default()
};
assert!(matches!(Server::new(policy), Err(Error::UpstreamWithoutPeer)));
}

/// A certificate would override a JWT meant to narrow it, and a JWT would narrow or
/// refuse a peer by accident, so presenting both is refused, even with a bad JWT.
#[tokio::test]
Expand Down
26 changes: 26 additions & 0 deletions rs/moq-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,14 @@ pub struct Serve {
#[usage(long)]
mtls_subscribe: Vec<Pattern>,

/// Mark sessions with a verified certificate as cluster peers: relays forwarding what entered elsewhere.
#[usage(long)]
mtls_peer: bool,

/// Mark those peers upstream: never offered a route learned from another upstream. Needs `--mtls-peer`.
#[usage(long)]
mtls_upstream: bool,

/// The tier label stamped on every grant, handed to the relay's stats.
#[usage(long)]
tier: Option<String>,
Expand Down Expand Up @@ -426,6 +434,12 @@ impl Serve {
"--limit-token and --limit-remote need --revalidate, which ages out the slots of dead relays"
);
}
if self.mtls_peer && self.mtls_publish.is_empty() && self.mtls_subscribe.is_empty() {
anyhow::bail!("--mtls-peer needs --mtls-publish or --mtls-subscribe, or every certificate is refused");
}
if self.mtls_upstream && !self.mtls_peer {
anyhow::bail!("--mtls-upstream needs --mtls-peer; only a cluster peer can be upstream");
}
// 0.14 read `anon` as the prefix `anon/`, and a pattern reads it as exactly the
// broadcast `anon`, so either silent reading would mislead someone upgrading.
let flags = [
Expand Down Expand Up @@ -460,6 +474,8 @@ impl Serve {
};
policy.public = rules(&self.public_publish, &self.public_subscribe);
policy.mtls = rules(&self.mtls_publish, &self.mtls_subscribe);
policy.mtls_peer = self.mtls_peer;
policy.mtls_upstream = self.mtls_upstream;
policy.tier = self.tier.clone();
policy.revalidate = revalidate;
policy.expires = expires;
Expand Down Expand Up @@ -653,6 +669,8 @@ mod tests {
"**",
"--mtls-subscribe",
"**",
"--mtls-peer",
"--mtls-upstream",
"--tier",
"internal",
"--revalidate",
Expand All @@ -673,6 +691,7 @@ mod tests {
);
assert!(policy.public.publish.is_empty());
assert_eq!(policy.mtls.publish, ["**".parse().unwrap()].into_iter().collect());
assert!(policy.mtls_peer && policy.mtls_upstream);
assert_eq!(policy.tier.as_deref(), Some("internal"));
assert_eq!(policy.revalidate, Some(std::time::Duration::from_secs(30)));
assert_eq!(policy.expires, Some(std::time::Duration::from_secs(7200)));
Expand All @@ -694,6 +713,7 @@ mod tests {
let bare = serve(&["moq", "auth", "serve"]).policy().unwrap();
assert!(bare.keys.is_none());
assert!(bare.public.is_empty() && bare.mtls.is_empty());
assert!(!bare.mtls_peer && !bare.mtls_upstream);
assert_eq!(bare.revalidate, None);
assert_eq!(bare.expires, None);

Expand All @@ -705,6 +725,12 @@ mod tests {
// Only a re-check keeps a slot alive.
(&["--limit-token", "3"], "need --revalidate"),
(&["--limit-remote", "3", "--expires", "1h"], "need --revalidate"),
// A peer marking with nothing granted to certificates marks nobody.
(&["--mtls-peer"], "--mtls-peer needs"),
(
&["--mtls-subscribe", "**", "--mtls-upstream"],
"--mtls-upstream needs --mtls-peer",
),
] {
let argv: Vec<&str> = ["moq", "auth", "serve"].iter().chain(args).copied().collect();
let err = serve(&argv).policy().unwrap_err().to_string();
Expand Down
Loading
Loading