diff --git a/crates/relay-proto/src/allow.rs b/crates/relay-proto/src/allow.rs new file mode 100644 index 00000000..936f623a --- /dev/null +++ b/crates/relay-proto/src/allow.rs @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Who may dial the link (RL-33): `linkAllowFrom`, a list of addresses and +//! CIDR ranges. Empty means anyone. + +use std::net::IpAddr; + +/// The most entries `linkAllowFrom` takes. +pub const MAX_ENTRIES: usize = 64; + +/// One entry: an address and how many leading bits of it count. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Range { + addr: IpAddr, + bits: u8, +} + +impl Range { + /// `192.0.2.7`, `192.0.2.0/24`, `2001:db8::/32`. An IPv4 address given + /// as IPv6 (`::ffff:192.0.2.7`) is taken as the IPv4 one. + pub fn parse(text: &str) -> Option { + let text = text.trim(); + let (addr, bits) = match text.split_once('/') { + Some((addr, bits)) => (addr, Some(bits)), + None => (text, None), + }; + let mut addr = addr.parse::().ok()?.to_canonical(); + let max = if addr.is_ipv4() { 32 } else { 128 }; + let bits = match bits { + Some(bits) if !bits.is_empty() && bits.bytes().all(|b| b.is_ascii_digit()) => { + bits.parse::().ok().filter(|bits| *bits <= max)? + } + Some(_) => return None, + None => max, + }; + addr = mask(addr, bits); + Some(Range { addr, bits }) + } + + pub fn contains(&self, ip: IpAddr) -> bool { + let ip = ip.to_canonical(); + ip.is_ipv4() == self.addr.is_ipv4() && mask(ip, self.bits) == self.addr + } +} + +fn mask(addr: IpAddr, bits: u8) -> IpAddr { + match addr { + IpAddr::V4(v4) => { + let keep = u32::MAX.checked_shl(32 - u32::from(bits)).unwrap_or(0); + IpAddr::V4((u32::from(v4) & keep).into()) + } + IpAddr::V6(v6) => { + let keep = u128::MAX.checked_shl(128 - u32::from(bits)).unwrap_or(0); + IpAddr::V6((u128::from(v6) & keep).into()) + } + } +} + +/// `linkAllowFrom`, parsed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AllowList(Vec); + +impl AllowList { + /// The list, or the first entry that isn't an address or range. + pub fn parse(entries: &[String]) -> Result { + entries + .iter() + .map(|entry| Range::parse(entry).ok_or_else(|| entry.clone())) + .collect::, _>>() + .map(AllowList) + } + + /// Whether `ip` may dial: always, when the list is empty. + pub fn allows(&self, ip: IpAddr) -> bool { + self.0.is_empty() || self.0.iter().any(|range| range.contains(ip)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn list(entries: &[&str]) -> AllowList { + AllowList::parse(&entries.iter().map(|e| e.to_string()).collect::>()).unwrap() + } + + fn ip(text: &str) -> IpAddr { + text.parse().unwrap() + } + + #[test] + fn ranges() { + let allow = list(&["192.0.2.0/24", "198.51.100.7", "2001:db8:1::/48"]); + assert!(allow.allows(ip("192.0.2.1"))); + assert!(allow.allows(ip("192.0.2.255"))); + assert!(!allow.allows(ip("192.0.3.1"))); + assert!(allow.allows(ip("198.51.100.7"))); + assert!(!allow.allows(ip("198.51.100.8"))); + assert!(allow.allows(ip("2001:db8:1:ffff::1"))); + assert!(!allow.allows(ip("2001:db8:2::1"))); + // An IPv4 client on a [::] listener arrives mapped + assert!(allow.allows(ip("::ffff:192.0.2.9"))); + assert!(!allow.allows(ip("::ffff:192.0.3.9"))); + // Host bits are ignored, as a range is meant + assert!(list(&["192.0.2.77/24"]).allows(ip("192.0.2.1"))); + assert!(list(&["::ffff:203.0.113.5"]).allows(ip("203.0.113.5"))); + assert!(list(&["0.0.0.0/0"]).allows(ip("203.0.113.5"))); + assert!(!list(&["0.0.0.0/0"]).allows(ip("2001:db8::1"))); + assert!(list(&["::/0"]).allows(ip("2001:db8::1"))); + assert!(AllowList::default().allows(ip("203.0.113.5"))); + } + + #[test] + fn refused() { + for bad in [ + "", + "example.com", + "192.0.2.0/33", + "2001:db8::/129", + "192.0.2.0/", + "192.0.2.0/+8", + "192.0.2.0/24/1", + "192.0.2", + ] { + assert_eq!( + AllowList::parse(&[bad.to_string()]), + Err(bad.to_string()), + "{bad}" + ); + } + } +} diff --git a/crates/relay-proto/src/lib.rs b/crates/relay-proto/src/lib.rs index c5161a02..a1bcdabe 100644 --- a/crates/relay-proto/src/lib.rs +++ b/crates/relay-proto/src/lib.rs @@ -11,6 +11,7 @@ //! dials out to it. Both sides use this crate, so the wire types, the join //! proof, recipient hashing and the sealed spool format are defined once. +pub mod allow; pub mod jmap; pub mod pin; pub mod recipient; diff --git a/crates/relay-proto/src/types.rs b/crates/relay-proto/src/types.rs index 1bba80e4..a44c8247 100644 --- a/crates/relay-proto/src/types.rs +++ b/crates/relay-proto/src/types.rs @@ -67,6 +67,9 @@ pub struct RelaySettings { pub outbound_expiry: u32, /// TLS where neither DANE nor MTA-STS asks for it. pub outbound_tls: OutboundTls, + /// Addresses and CIDR ranges that may dial the link port; empty for + /// anyone (RL-33). + pub link_allow_from: Vec, /// The STARTTLS certificate for port 25. #[serde(skip_serializing_if = "Option::is_none")] pub smtp_certificate: Option, @@ -94,6 +97,7 @@ impl Default for RelaySettings { outbound_retry: vec![120, 300, 600, 1800, 3600, 7200, 14_400], outbound_expiry: 3 * 86_400, outbound_tls: OutboundTls::default(), + link_allow_from: Vec::new(), smtp_certificate: None, sealing_key: None, recipient_key: None, @@ -137,6 +141,17 @@ impl RelaySettings { if self.outbound_retry.is_empty() || self.outbound_retry.contains(&0) { out.push("outboundRetry needs intervals above zero".into()); } + if self.link_allow_from.len() > crate::allow::MAX_ENTRIES { + out.push(format!( + "linkAllowFrom takes at most {} entries", + crate::allow::MAX_ENTRIES + )); + } + if let Err(entry) = crate::allow::AllowList::parse(&self.link_allow_from) { + out.push(format!( + "linkAllowFrom: {entry} is not an address or CIDR range" + )); + } if self.sealing_key.is_none() { out.push("sealingKey is required".into()); } @@ -220,6 +235,10 @@ pub struct RelayStatus { pub last_error: Option, /// False in a container image: update the image instead (RL-29). pub self_upgrade: bool, + /// The address the asking link connection comes from, as the relay sees + /// it: what `linkAllowFrom` has to let in (RL-33). + #[serde(skip_serializing_if = "Option::is_none")] + pub link_from: Option, } /// Live while a link client has push on for `Inbound` (spec "Live mode"). diff --git a/crates/relay/src/app.rs b/crates/relay/src/app.rs index a964e3d5..51c980b9 100644 --- a/crates/relay/src/app.rs +++ b/crates/relay/src/app.rs @@ -14,6 +14,7 @@ use crate::{ state::{self, Identity, Paths}, }; use inbuxa_relay_proto::{ + allow::AllowList, time, types::{Mode, RelaySettings, RelayStatus, SpoolStatus, type_name}, }; @@ -65,6 +66,9 @@ pub struct App { pub upgrading: AtomicBool, /// Wakes the run loop to exit for the supervisor's restart. pub restart: Notify, + /// `run --link-allow-any`: the `linkAllowFrom` to ignore, until the + /// downstream sets another (RL-33). + pub link_allow_any: Mutex>>, } impl App { @@ -103,9 +107,43 @@ impl App { exe: crate::upgrade::current_exe(), upgrading: AtomicBool::new(false), restart: Notify::new(), + link_allow_any: Mutex::new(None), }) } + /// Whether `ip` may dial the link (RL-33): anyone until the first + /// settings, so a relay can join, and while `--link-allow-any` ignores + /// the list it was started with. + pub fn link_allows(&self, ip: IpAddr) -> bool { + let Some(settings) = self.settings() else { + return true; + }; + if settings.link_allow_from.is_empty() + || self + .link_allow_any + .lock() + .expect("link_allow_any") + .as_ref() + .is_some_and(|ignored| *ignored == settings.link_allow_from) + { + return true; + } + // Settings are checked before they are kept, so this parses + AllowList::parse(&settings.link_allow_from).map_or(true, |list| list.allows(ip)) + } + + /// The downstream set `linkAllowFrom`: a list other than the one + /// `--link-allow-any` ignores ends it. + pub fn link_allow_from_changed(&self, list: &[String]) { + let mut ignored = self.link_allow_any.lock().expect("link_allow_any"); + if ignored.as_deref().is_some_and(|ignored| ignored != list) { + *ignored = None; + eprintln!( + "inbuxa-relay: the downstream set linkAllowFrom; --link-allow-any no longer applies" + ); + } + } + pub fn conn_id(&self) -> u64 { self.next_conn.fetch_add(1, Ordering::Relaxed) } @@ -191,6 +229,7 @@ impl App { certificate_not_after: None, last_error: self.last_error.lock().expect("last_error").clone(), self_upgrade: self.self_upgrade, + link_from: None, } } diff --git a/crates/relay/src/link/jmap.rs b/crates/relay/src/link/jmap.rs index c09ac78b..b698ea11 100644 --- a/crates/relay/src/link/jmap.rs +++ b/crates/relay/src/link/jmap.rs @@ -16,7 +16,7 @@ use inbuxa_relay_proto::{ jmap::{Invocation, MethodError, Request, Response, method}, recipient::{RecipientDomain, RecipientHash}, time, token, - types::{NewOutbound, RELAY_SINGLETON, RelaySettings, Verdict, type_name}, + types::{NewOutbound, RELAY_SINGLETON, RelaySettings, RelayStatus, Verdict, type_name}, }; use rustls_pki_types::{CertificateDer, pem::PemObject}; use serde_json::{Map, Value, json}; @@ -37,9 +37,11 @@ pub struct RequestError { pub detail: String, } -/// Who is calling: the connection, for leases. +/// Who is calling: the connection, for leases, and where it comes from. +#[derive(Clone, Copy)] pub struct Caller { pub conn: u64, + pub peer: std::net::IpAddr, } type Failure = (MethodError, Option); @@ -103,7 +105,7 @@ fn call_one(app: &App, caller: &Caller, call: &Invocation) -> MethodResult { } match call.name() { method::RELAY_JOIN => relay_join(app, args), - method::RELAY_GET => relay_get(app, args), + method::RELAY_GET => relay_get(app, caller, args), method::RELAY_SET => relay_set(app, args), method::RELAY_UPGRADE => relay_upgrade(app, args), method::RECIPIENT_DOMAIN_GET => recipient_domain_get(app), @@ -218,7 +220,7 @@ fn relay_join(app: &App, args: &Map) -> MethodResult { })) } -fn relay_object(app: &App) -> Value { +fn relay_object(app: &App, caller: &Caller) -> Value { let settings = app.settings().map(|s| (*s).clone()).unwrap_or_default(); let mut value = serde_json::to_value(&settings).expect("settings serialize"); let obj = value.as_object_mut().expect("object"); @@ -230,7 +232,11 @@ fn relay_object(app: &App) -> Value { obj.insert("id".into(), Value::String(RELAY_SINGLETON.into())); obj.insert( "status".into(), - serde_json::to_value(app.status()).expect("status serializes"), + serde_json::to_value(RelayStatus { + link_from: Some(caller.peer.to_string()), + ..app.status() + }) + .expect("status serializes"), ); obj.insert( "relayVersion".into(), @@ -239,7 +245,7 @@ fn relay_object(app: &App) -> Value { value } -fn relay_get(app: &App, args: &Map) -> MethodResult { +fn relay_get(app: &App, caller: &Caller, args: &Map) -> MethodResult { let ids = string_list(args, "ids")?; let wanted = ids .as_ref() @@ -252,7 +258,7 @@ fn relay_get(app: &App, args: &Map) -> MethodResult { Ok(json!({ "accountId": ACCOUNT_ID, "state": app.relay_state(), - "list": if wanted { vec![relay_object(app)] } else { vec![] }, + "list": if wanted { vec![relay_object(app, caller)] } else { vec![] }, "notFound": not_found, })) } @@ -431,6 +437,7 @@ fn apply_settings(app: &App, patch: &Value) -> Result<(), Value> { } crate::state::save_settings(&app.paths, &settings) .map_err(|e| set_error("serverFail", e.to_string()))?; + app.link_allow_from_changed(&settings.link_allow_from); app.settings.send_replace(Some(Arc::new(settings))); // The first Relay/set after the proof completes the join. diff --git a/crates/relay/src/link/mod.rs b/crates/relay/src/link/mod.rs index d7e65390..a0a11d43 100644 --- a/crates/relay/src/link/mod.rs +++ b/crates/relay/src/link/mod.rs @@ -136,6 +136,10 @@ pub async fn serve(app: Arc, listener: TcpListener) -> std::io::Result<()> continue; } }; + // Dropped before TLS, with no reply (RL-33) + if !app.link_allows(peer.ip()) { + continue; + } let now = inbuxa_relay_proto::time::now(); if !handshakes.lock().expect("handshakes").allow(peer.ip(), now) { continue; @@ -151,11 +155,15 @@ pub async fn serve(app: Arc, listener: TcpListener) -> std::io::Result<()> _ => return, }; let conn = app.conn_id(); + let caller = jmap::Caller { + conn, + peer: peer.ip().to_canonical(), + }; let service = { let app = app.clone(); service_fn(move |req| { let app = app.clone(); - async move { Ok::<_, std::convert::Infallible>(route(app, conn, req).await) } + async move { Ok::<_, std::convert::Infallible>(route(app, caller, req).await) } }) }; let _ = hyper::server::conn::http1::Builder::new() @@ -172,12 +180,12 @@ pub async fn serve(app: Arc, listener: TcpListener) -> std::io::Result<()> } } -async fn route(app: Arc, conn: u64, req: Request) -> Response { +async fn route(app: Arc, caller: jmap::Caller, req: Request) -> Response { let path = req.uri().path().to_string(); match (req.method(), path.as_str()) { (&Method::GET, "/jmap/session" | "/.well-known/jmap") => session(&app, &req), - (&Method::POST, "/jmap/api") => api(app, conn, req).await, - (&Method::GET, "/jmap/ws") => websocket(app, conn, req), + (&Method::POST, "/jmap/api") => api(app, caller, req).await, + (&Method::GET, "/jmap/ws") => websocket(app, caller, req), (&Method::POST, "/jmap/upload/relay") => upload(app, req).await, (&Method::GET, p) if p.starts_with("/jmap/download/relay/") => { download(app, &p["/jmap/download/relay/".len()..]).await @@ -245,7 +253,7 @@ fn session(app: &App, req: &Request) -> Response { json_response(StatusCode::OK, &session) } -async fn api(app: Arc, conn: u64, req: Request) -> Response { +async fn api(app: Arc, caller: jmap::Caller, req: Request) -> Response { let body = match Limited::new(req.into_body(), MAX_REQUEST_SIZE) .collect() .await @@ -278,7 +286,7 @@ async fn api(app: Arc, conn: u64, req: Request) -> Response } }, }; - match jmap::handle(&app, &jmap::Caller { conn }, request) { + match jmap::handle(&app, &caller, request) { Ok(response) => json_response(StatusCode::OK, &response), Err(e) => problem( StatusCode::from_u16(e.status).unwrap_or(StatusCode::BAD_REQUEST), @@ -355,7 +363,7 @@ async fn download(app: Arc, rest: &str) -> Response { .expect("response") } -fn websocket(app: Arc, conn: u64, mut req: Request) -> Response { +fn websocket(app: Arc, caller: jmap::Caller, mut req: Request) -> Response { let headers = req.headers(); let is_upgrade = headers .get(header::UPGRADE) @@ -388,7 +396,7 @@ fn websocket(app: Arc, conn: u64, mut req: Request) -> Response(app: Arc, conn: u64, ws: WebSocketStream) +async fn run_websocket(app: Arc, caller: jmap::Caller, ws: WebSocketStream) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { @@ -468,7 +476,7 @@ where detail: Some(e.to_string()), }), Ok(ClientFrame::Request { id, request }) => { - Some(match jmap::handle(&app, &jmap::Caller { conn }, request) { + Some(match jmap::handle(&app, &caller, request) { Ok(response) => ServerFrame::Response { request_id: id, response }, Err(e) => ServerFrame::RequestError { request_id: id, @@ -523,7 +531,7 @@ where push.types = None; set_live(&app, &mut push); app.link_clients.fetch_sub(1, Ordering::Relaxed); - if app.spool.lock().expect("spool").release_conn(conn) { + if app.spool.lock().expect("spool").release_conn(caller.conn) { app.inbound_changed(); } app.relay_changed(); diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs index afbf2641..bc4af630 100644 --- a/crates/relay/src/main.rs +++ b/crates/relay/src/main.rs @@ -43,6 +43,9 @@ Usage: Options: --state state directory (default /var/lib/inbuxa-relay) --link-listen link address and port (default [::]:443) + --link-allow-any take the link from anywhere, ignoring the + linkAllowFrom it has until the downstream sets + another: for a list that locked the downstream out Everything else is set from the downstream's inbuxa-admin. @@ -55,6 +58,7 @@ struct Args { token: Option, state: String, link_listen: SocketAddr, + link_allow_any: bool, discard_held: bool, } @@ -65,6 +69,7 @@ fn parse_args() -> Result { let mut state = state::DEFAULT_DIR.to_string(); let mut link_listen: SocketAddr = "[::]:443".parse().expect("address"); let mut discard_held = false; + let mut link_allow_any = false; while let Some(arg) = args.next() { match arg.as_str() { "--state" => state = args.next().ok_or("--state needs a directory")?, @@ -76,6 +81,7 @@ fn parse_args() -> Result { .map_err(|_| "--link-listen: an address and port, like [::]:443")?; } "--discard-held" => discard_held = true, + "--link-allow-any" => link_allow_any = true, "-h" | "--help" | "help" => return Err(String::new()), "-V" | "--version" => { println!("inbuxa-relay {}", app::VERSION); @@ -92,6 +98,7 @@ fn parse_args() -> Result { token, state, link_listen, + link_allow_any, discard_held, }) } @@ -113,7 +120,7 @@ fn main() -> ExitCode { Some(token) => join(&paths, token), None => Err("join needs the token from inbuxa-admin".into()), }, - "run" => match run(&paths, args.link_listen) { + "run" => match run(&paths, args.link_listen, args.link_allow_any) { Ok(Stopped::Upgraded) => return ExitCode::from(upgrade::RESTART_EXIT), Ok(Stopped::Asked) => Ok(()), Err(e) => Err(e), @@ -167,7 +174,7 @@ enum Stopped { Upgraded, } -fn run(paths: &Paths, link_listen: SocketAddr) -> Result { +fn run(paths: &Paths, link_listen: SocketAddr, link_allow_any: bool) -> Result { paths.create_dirs().map_err(|e| e.to_string())?; let _lock = RunLock::acquire(paths).map_err(|e| e.to_string())?; let identity = match Identity::load(paths).map_err(|e| e.to_string())? { @@ -187,6 +194,19 @@ fn run(paths: &Paths, link_listen: SocketAddr) -> Result { }, }; let app = Arc::new(App::open(Paths::new(&paths.root), identity).map_err(|e| e.to_string())?); + if link_allow_any { + let list = app + .settings() + .map(|s| s.link_allow_from.clone()) + .unwrap_or_default(); + if !list.is_empty() { + eprintln!( + "inbuxa-relay: --link-allow-any: taking the link from anywhere until the \ + downstream sets linkAllowFrom again" + ); + } + *app.link_allow_any.lock().expect("link_allow_any") = Some(list); + } // A relay is small: a few threads carry it, whatever the host has. let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4)); let runtime = tokio::runtime::Builder::new_multi_thread() diff --git a/crates/relay/src/tests_e2e.rs b/crates/relay/src/tests_e2e.rs index 0b1f9af0..8d8db001 100644 --- a/crates/relay/src/tests_e2e.rs +++ b/crates/relay/src/tests_e2e.rs @@ -736,6 +736,48 @@ async fn relay_end_to_end() { "one upgrade at a time" ); + // RL-T25: who may dial the link. Relay/get says where this link comes + // from, which is what a list has to let in. + let relay = ws.ok("Relay/get", json!({})).await; + assert_eq!( + relay["list"][0]["status"]["linkFrom"], "127.0.0.1", + "{relay}" + ); + let mut allow = async |list: Value| { + let set = ws + .ok( + "Relay/set", + json!({ "update": { "singleton": { "linkAllowFrom": list }}}), + ) + .await; + set["updated"] == json!({ "singleton": null }) + }; + assert!(!allow(json!(["not an address"])).await, "refused"); + assert!(allow(json!(["192.0.2.0/24"])).await); + // A connection from elsewhere is closed before TLS, with no reply + let mut tcp = TcpStream::connect(link_addr).await.unwrap(); + let mut buf = [0u8; 16]; + let read = tokio::time::timeout( + Duration::from_secs(2), + tokio::io::AsyncReadExt::read(&mut tcp, &mut buf), + ) + .await + .expect("closed, not left open"); + assert!(matches!(read, Ok(0) | Err(_)), "{read:?}"); + assert!(down.tls().await.is_err()); + // --link-allow-any ignores the list it started with... + *app.link_allow_any.lock().unwrap() = Some(vec!["192.0.2.0/24".into()]); + assert!(down.tls().await.is_ok()); + // ...until the downstream sets another, which then applies + assert!(allow(json!(["192.0.2.0/24", "127.0.0.0/8"])).await); + assert!(app.link_allow_any.lock().unwrap().is_none()); + assert!(down.tls().await.is_ok()); + assert!(allow(json!(["::ffff:192.0.2.1"])).await); + assert!(down.tls().await.is_err()); + // The link already open was never cut + assert!(allow(json!(null)).await, "back to anyone"); + assert!(down.tls().await.is_ok()); + // Events went up the link. let events = ws.ok("RelayEvent/get", json!({})).await; let kinds: Vec<&str> = events["list"] diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index 2e09142a..a6b6133e 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -154,7 +154,13 @@ the protocol the rest of the suite already speaks. link from anyone, so it can join. A list that leaves the downstream out would lock it out for good, so: inbuxa-admin warns when no node's address it knows is in the list, and `inbuxa-relay run --link-allow-any` - ignores the list until the downstream pushes a new one. + ignores the list until the downstream pushes a new one. The addresses it + knows are the relay's: `Relay/get` answers each link with `linkFrom`, + the address that connection comes from, which each node keeps with its + link status. A connection already open stays open when the list + changes. Entries are addresses or ranges (`192.0.2.0/24`, + `2001:db8::/48`), at most 64; an IPv4 client of a `[::]` listener is + matched as IPv4. - **RL-34. A new link CA without rejoining.** inbuxa-admin can replace the link CA, for instance when a node's certificate may have leaked, since a node certificate can't be revoked one by one. The downstream makes the @@ -255,7 +261,7 @@ with the server's own `inbuxa:` objects. In particular, the relay's | `sealingKey` | downstream | The downstream's public sealing key | | `linkAllowFrom` | downstream | Addresses and CIDR ranges that may dial the link port. Default empty: anyone (RL-33) | | `nextLinkCaPin` | downstream | A second link CA to trust during a replacement (RL-34). Default null | -| `status` | relay | `{startedAt, linkClients, mode, spool: {inbound, outbound, bytes}, recipientState, certificateNotAfter, linkCaPins, lastError, selfUpgrade}`; `linkCaPins` lists the CAs it trusts (RL-34); `selfUpgrade` is false in a container image (RL-29) | +| `status` | relay | `{startedAt, linkClients, mode, spool: {inbound, outbound, bytes}, recipientState, certificateNotAfter, linkCaPins, lastError, selfUpgrade, linkFrom}`; `linkCaPins` lists the CAs it trusts (RL-34); `selfUpgrade` is false in a container image (RL-29); `linkFrom` is the address the asking link connection comes from (RL-33) | Settings are cached in the state directory as the downstream last set them, so a reboot without the link comes back with the same settings (RL-22). @@ -983,8 +989,12 @@ Packaging (step 6, RL-31): can replace the binary, with `Restart=always`, the one capability, and the filesystem read-only but for that directory and the state directory. -- Still to come in step 6: the installer path and the docs page (with - the WireGuard setup, RL-32). +- The installer installs a relay on a machine of its own with + `inbuxa relay install --token -` (inbuxa-installer #45): the release + binary, checked against a pinned checksum and its signature, the user, + the unit, the join. The docs page, "inbuxa Relay" under Running it, + covers the installer, a host by hand, the container image and the + WireGuard setup (RL-32). Step 6 is done. Order: diff --git a/tests/src/system/relay.rs b/tests/src/system/relay.rs index 2777dd6f..2b59d554 100644 --- a/tests/src/system/relay.rs +++ b/tests/src/system/relay.rs @@ -254,6 +254,18 @@ pub async fn test(test: &mut TestServer) { .await; assert_eq!(response["notUpdated"][&id]["properties"][0], "settings"); assert_eq!(response["notUpdated"][&other]["properties"][0], "settings"); + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"update": {&id: {"settings/linkAllowFrom": ["192.0.2.0/24", "relay.example"]}}}), + ) + .await; + assert!( + response["notUpdated"][&id]["description"] + .as_str() + .is_some_and(|d| d.contains("relay.example is not an address or CIDR range")), + "{response}" + ); let (_, response) = call( admin, "inbuxa:Relay/set", @@ -874,6 +886,8 @@ pub async fn link(test: &mut TestServer) { let recipients = link["recipients"].as_u64().unwrap_or_default(); assert!(recipients >= 3, "{relay}"); assert_eq!(link["lastError"], Value::Null, "{relay}"); + // Where the relay sees this node's link from, for linkAllowFrom (RL-33) + assert_eq!(link["relay"]["linkFrom"], "127.0.0.1", "{relay}"); // The relay is this build's: not older (RL-28) assert_eq!(relay["olderThanServer"], false, "{relay}");