feat: SRV discovery (_smolmail._tcp) and port-scoped trust syntax in the CLI

This commit is contained in:
randogoth 2026-09-30 13:49:29 +03:00
parent 13c624f2b0
commit 1468f3cbc0
5 changed files with 761 additions and 6 deletions

View file

@ -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"

View file

@ -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
View 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());
}
}