Relay: who may dial the link (RL-33) #272

Merged
jcoffey-dev merged 1 commits from relay/link-allow-from into main 2026-10-31 01:42:36 +00:00
10 changed files with 320 additions and 24 deletions

No files matched your search

+136
View File
@@ -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<Range> {
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::<IpAddr>().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::<u8>().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<Range>);
impl AllowList {
/// The list, or the first entry that isn't an address or range.
pub fn parse(entries: &[String]) -> Result<AllowList, String> {
entries
.iter()
.map(|entry| Range::parse(entry).ok_or_else(|| entry.clone()))
.collect::<Result<Vec<_>, _>>()
.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::<Vec<_>>()).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}"
);
}
}
}
+1
View File
@@ -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;
+19
View File
@@ -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<String>,
/// The STARTTLS certificate for port 25.
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp_certificate: Option<SmtpCertificate>,
@@ -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<String>,
/// 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<String>,
}
/// Live while a link client has push on for `Inbound` (spec "Live mode").
+39
View File
@@ -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<Option<Vec<String>>>,
}
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,
}
}
+14 -7
View File
@@ -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<String>);
@@ -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<String, Value>) -> 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<String, Value>) -> MethodResult {
fn relay_get(app: &App, caller: &Caller, args: &Map<String, Value>) -> MethodResult {
let ids = string_list(args, "ids")?;
let wanted = ids
.as_ref()
@@ -252,7 +258,7 @@ fn relay_get(app: &App, args: &Map<String, Value>) -> 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.
+19 -11
View File
@@ -136,6 +136,10 @@ pub async fn serve(app: Arc<App>, 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<App>, 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<App>, listener: TcpListener) -> std::io::Result<()>
}
}
async fn route(app: Arc<App>, conn: u64, req: Request<Incoming>) -> Response<Body> {
async fn route(app: Arc<App>, caller: jmap::Caller, req: Request<Incoming>) -> Response<Body> {
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<Incoming>) -> Response<Body> {
json_response(StatusCode::OK, &session)
}
async fn api(app: Arc<App>, conn: u64, req: Request<Incoming>) -> Response<Body> {
async fn api(app: Arc<App>, caller: jmap::Caller, req: Request<Incoming>) -> Response<Body> {
let body = match Limited::new(req.into_body(), MAX_REQUEST_SIZE)
.collect()
.await
@@ -278,7 +286,7 @@ async fn api(app: Arc<App>, conn: u64, req: Request<Incoming>) -> Response<Body>
}
},
};
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<App>, rest: &str) -> Response<Body> {
.expect("response")
}
fn websocket(app: Arc<App>, conn: u64, mut req: Request<Incoming>) -> Response<Body> {
fn websocket(app: Arc<App>, caller: jmap::Caller, mut req: Request<Incoming>) -> Response<Body> {
let headers = req.headers();
let is_upgrade = headers
.get(header::UPGRADE)
@@ -388,7 +396,7 @@ fn websocket(app: Arc<App>, conn: u64, mut req: Request<Incoming>) -> Response<B
return;
};
let ws = WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, None).await;
run_websocket(app, conn, ws).await;
run_websocket(app, caller, ws).await;
});
Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
@@ -417,7 +425,7 @@ impl PushState {
}
}
async fn run_websocket<S>(app: Arc<App>, conn: u64, ws: WebSocketStream<S>)
async fn run_websocket<S>(app: Arc<App>, caller: jmap::Caller, ws: WebSocketStream<S>)
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();
+22 -2
View File
@@ -43,6 +43,9 @@ Usage:
Options:
--state <dir> state directory (default /var/lib/inbuxa-relay)
--link-listen <addr> 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<String>,
state: String,
link_listen: SocketAddr,
link_allow_any: bool,
discard_held: bool,
}
@@ -65,6 +69,7 @@ fn parse_args() -> Result<Args, String> {
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<Args, String> {
.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<Args, String> {
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<Stopped, String> {
fn run(paths: &Paths, link_listen: SocketAddr, link_allow_any: bool) -> Result<Stopped, String> {
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<Stopped, String> {
},
};
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()
+42
View File
@@ -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"]
+14 -4
View File
@@ -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:
+14
View File
@@ -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}");