feat: SRV discovery (_smolmail._tcp) and port-scoped trust syntax in the CLI
This commit is contained in:
parent
13c624f2b0
commit
1468f3cbc0
5 changed files with 761 additions and 6 deletions
|
|
@ -20,3 +20,4 @@ clap = { version = "4", features = ["derive"] }
|
|||
anyhow = "1"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
data-encoding = "2"
|
||||
hickory-resolver = "0.24"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ use fumi::client::{self, Pushed, SendDraft, TrustChange};
|
|||
use fumi::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
||||
|
||||
use fumi::store::Store;
|
||||
|
||||
mod srv;
|
||||
use fumi::transport::ID_LEN;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -325,9 +327,39 @@ fn whoami(key: &PathBuf, store: &Store) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses an address and applies SRV discovery when it is TCP and carried no
|
||||
/// explicit port (agreed with bunshin 2026-09-30): the discovered port scopes
|
||||
/// the pin and the SRV target is only a dial hint, so the domain keeps every
|
||||
/// identity role. A miss keeps the default port — one DNS lookup on the
|
||||
/// failure path and nothing else. Discovery happens when an address is
|
||||
/// given; the stored account is dialed at the port it was registered with,
|
||||
/// so an operator moving ports does not move existing accounts.
|
||||
fn parse_dial_address(raw: &str) -> Result<Address> {
|
||||
let mut addr = Address::parse(raw)?;
|
||||
if addr.scheme == fumi::address::Scheme::Tcp && !port_is_explicit(raw) {
|
||||
if let Some(endpoint) = srv::discover(&addr.host) {
|
||||
addr.port = endpoint.port;
|
||||
addr = addr.with_dial(&endpoint.target);
|
||||
}
|
||||
}
|
||||
Ok(addr)
|
||||
}
|
||||
|
||||
/// Whether the address text states a port after the host: the same
|
||||
/// right-hand split `trust` uses, over the host part of either address
|
||||
/// form, so an invalid suffix is not a port and an explicit port is never
|
||||
/// overridden by discovery.
|
||||
fn port_is_explicit(raw: &str) -> bool {
|
||||
let host_part = raw.rsplit_once('@').map_or(raw, |(_, host)| host);
|
||||
let host_part = host_part.split('/').next().unwrap_or(host_part);
|
||||
host_part
|
||||
.rsplit_once(':')
|
||||
.is_some_and(|(_, port)| port.parse::<u16>().is_ok())
|
||||
}
|
||||
|
||||
fn restore(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let master = load_master(key)?;
|
||||
let addr = Address::parse(address)?;
|
||||
let addr = parse_dial_address(address)?;
|
||||
let index = client::restore(store, &master, &addr, timeout)?;
|
||||
let identity = fumi::account::Identity::from_seed(identity_seed(&master, index));
|
||||
println!("restored {} at rotation {index}", addr.short());
|
||||
|
|
@ -394,7 +426,7 @@ fn register(
|
|||
timeout: u64,
|
||||
) -> Result<()> {
|
||||
let account = load_account(key, store)?;
|
||||
let addr = Address::parse(address)?;
|
||||
let addr = parse_dial_address(address)?;
|
||||
client::register(store, &addr, &account, invite, timeout)?;
|
||||
println!("registered {}", addr.short());
|
||||
println!("share: {}", addr.uri(&account.me().pk()));
|
||||
|
|
@ -402,7 +434,7 @@ fn register(
|
|||
}
|
||||
|
||||
fn resolve(store: &Store, address: &str, timeout: u64) -> Result<()> {
|
||||
let addr = Address::parse(address)?;
|
||||
let addr = parse_dial_address(address)?;
|
||||
if addr.identity.is_some() {
|
||||
return Err(
|
||||
anyhow!("that address already carries a key; use `import` instead").into(),
|
||||
|
|
@ -559,7 +591,7 @@ fn send(cli: &Cli, store: &Store) -> Result<()> {
|
|||
unreachable!("send dispatches only from the Send command")
|
||||
};
|
||||
let account = load_account(&cli.key, store)?;
|
||||
let addr = Address::parse(address)?;
|
||||
let addr = parse_dial_address(address)?;
|
||||
let text = match (body, file) {
|
||||
(Some(text), _) => text.clone(),
|
||||
(None, Some(path)) if path == "-" => read_stdin()?,
|
||||
|
|
|
|||
75
cli/src/srv.rs
Normal file
75
cli/src/srv.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! SRV discovery for portless TCP addresses (agreed with bunshin
|
||||
//! 2026-09-30): `_smolmail._tcp.<host>` states which target and port serve a
|
||||
//! domain's mail. Discovery is routing only — a lying record lands the client
|
||||
//! on a server with the wrong static key, where the handshake dies exactly as
|
||||
//! it does today; the pin stays out of band. No record or a failing lookup
|
||||
//! means the default port, so the feature is purely additive and old
|
||||
//! deployments never notice it existed.
|
||||
|
||||
/// One endpoint as an SRV record states it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Endpoint {
|
||||
/// The host to dial — a routing fact only, never an identity.
|
||||
pub target: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// One SRV record, in the shape `pick` reasons about.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Record {
|
||||
pub priority: u16,
|
||||
pub target: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// The label the convention reserves (ratified with bunshin): it matches the
|
||||
/// RNS destination `smolmail.server` and the reference client's name.
|
||||
pub const SERVICE: &str = "_smolmail._tcp";
|
||||
|
||||
/// Picks the endpoint: lowest priority wins and weight is ignored —
|
||||
/// fetching mail needs no client-side load balancing.
|
||||
pub fn pick(records: &[Record]) -> Option<&Record> {
|
||||
records.iter().min_by_key(|record| record.priority)
|
||||
}
|
||||
|
||||
/// Queries `_smolmail._tcp.<host>` through the system resolver. Discovery
|
||||
/// never fails an operation: any miss or error is simply no endpoint, and
|
||||
/// the caller keeps the default port.
|
||||
pub fn discover(host: &str) -> Option<Endpoint> {
|
||||
let name = format!("{SERVICE}.{host}.");
|
||||
let resolver = hickory_resolver::Resolver::from_system_conf().ok()?;
|
||||
let srv = resolver.srv_lookup(name).ok()?;
|
||||
let records: Vec<Record> = srv
|
||||
.iter()
|
||||
.map(|record| Record {
|
||||
priority: record.priority(),
|
||||
target: record.target().to_string(),
|
||||
port: record.port(),
|
||||
})
|
||||
.collect();
|
||||
pick(&records).map(|record| Endpoint {
|
||||
target: record.target.trim_end_matches('.').to_string(),
|
||||
port: record.port,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lowest_priority_wins_and_weight_is_ignored() {
|
||||
let records = [
|
||||
Record { priority: 10, target: "a.example.net".into(), port: 1962 },
|
||||
Record { priority: 5, target: "b.example.net".into(), port: 1963 },
|
||||
Record { priority: 10, target: "c.example.net".into(), port: 1964 },
|
||||
];
|
||||
let picked = pick(&records).expect("records exist");
|
||||
assert_eq!((picked.target.as_str(), picked.port), ("b.example.net", 1963));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_records_is_no_endpoint() {
|
||||
assert!(pick(&[]).is_none());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue