Relay: who may dial the link (RL-33) #272
No files matched your search
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
Reference in new issue
Block a user