diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000000..d1a802e5d8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# LLVM codegen recurses deep enough on the apple-darwin target to overflow the +# 2 MiB default rustc codegen-worker stack; 64 MiB clears it. Harmless on other targets. +[env] +RUST_MIN_STACK = "67108864" diff --git a/container-runtime/package-lock.json b/container-runtime/package-lock.json index 11ff6b74a2..b082565336 100644 --- a/container-runtime/package-lock.json +++ b/container-runtime/package-lock.json @@ -50,19 +50,21 @@ "isomorphic-fetch": "^3.0.0", "mime": "^4.1.0", "yaml": "^2.8.3", - "zod": "4.3.6", + "zod": "4.4.3", "zod-deep-partial": "^1.2.0" }, "devDependencies": { "@types/jest": "^30.0.0", + "eslint": "^9.39.4", "jest": "^30.0.0", "peggy": "^3.0.2", - "prettier": "^3.8.3", + "prettier": "3.8.3", "ts-jest": "^29.4.9", "ts-node": "^10.9.1", "ts-pegjs": "^4.2.1", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.61.0" } }, "node_modules/@ampproject/remapping": { diff --git a/container-runtime/src/Adapters/EffectCreator.ts b/container-runtime/src/Adapters/EffectCreator.ts index 218a5badee..981f5498d9 100644 --- a/container-runtime/src/Adapters/EffectCreator.ts +++ b/container-runtime/src/Adapters/EffectCreator.ts @@ -315,7 +315,9 @@ export function makeEffects(context: EffectContext): Effects { T.Effects["setHealth"] > }, - setBackupProgress(...[options]: Parameters) { + setBackupProgress( + ...[options]: Parameters + ) { return rpcRound("set-backup-progress", options) as ReturnType< T.Effects["setBackupProgress"] > diff --git a/core/Cargo.lock b/core/Cargo.lock index 6203124027..28803f5c72 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -468,6 +468,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -1328,6 +1340,19 @@ dependencies = [ "libc", ] +[[package]] +name = "crab_nat" +version = "0.7.6" +source = "git+https://github.com/Start9Labs/crab_nat.git?branch=feat%2Fcustom-pcp-options#83edcc1f8b9d97a25007de84a7b3b15814517f75" +dependencies = [ + "bytes", + "displaydoc", + "num_enum", + "rand 0.10.1", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "crc" version = "3.4.0" @@ -2583,6 +2608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" dependencies = [ "async-trait", + "bitflags 2.11.1", "cfg-if", "data-encoding", "futures-channel", @@ -2592,8 +2618,13 @@ dependencies = [ "idna", "ipnet", "jni 0.22.4", + "lru-cache", + "parking_lot 0.12.5", "rand 0.10.1", + "ring", + "rustls-pki-types", "thiserror 2.0.18", + "time", "tinyvec", "tokio", "tracing", @@ -2606,6 +2637,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" dependencies = [ + "bitflags 2.11.1", "data-encoding", "idna", "ipnet", @@ -2614,8 +2646,10 @@ dependencies = [ "prefix-trie", "rand 0.10.1", "ring", + "rustls-pki-types", "serde", "thiserror 2.0.18", + "time", "tinyvec", "tracing", "url", @@ -3014,6 +3048,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + [[package]] name = "ignore" version = "0.4.25" @@ -3755,6 +3809,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -6576,6 +6639,7 @@ dependencies = [ "const_format", "cookie", "cookie_store", + "crab_nat", "der", "digest 0.10.7", "divrem", @@ -6591,6 +6655,7 @@ dependencies = [ "hashing-serializer", "hex", "hickory-server", + "hkdf", "hmac", "http", "http-body-util", @@ -6598,6 +6663,7 @@ dependencies = [ "hyper-util", "id-pool", "iddqd", + "igd-next", "imbl", "imbl-value 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)", "include_dir", @@ -6687,6 +6753,7 @@ dependencies = [ "uuid", "visit-rs", "x25519-dalek", + "xmltree", "zbus", ] @@ -8120,7 +8187,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -8729,6 +8796,15 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/core/Cargo.toml b/core/Cargo.toml index d5c47c164d..d989b46268 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -100,7 +100,8 @@ futures = "0.3.28" gpt = "4.1.0" hashing-serializer = "0.1.1" hex = "0.4.3" -hickory-server = { version = "0.26.1", features = ["resolver"] } +hickory-server = { version = "0.26.1", features = ["resolver", "dnssec-ring"] } +hkdf = "0.12" hmac = "0.12.1" http = "1.0.0" http-body-util = "0.1" @@ -234,6 +235,9 @@ uuid = { version = "1.4.1", features = ["v4"] } visit-rs = "0.1.1" x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } zbus = "5.1.1" +igd-next = { version = "0.17.1", default-features = false, features = ["aio_tokio"] } +xmltree = "0.10" +crab_nat = { git = "https://github.com/Start9Labs/crab_nat.git", branch = "feat/custom-pcp-options" } [dev-dependencies] clap_mangen = "0.2.33" diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 219c1353ea..a5d9abc57f 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -5659,6 +5659,13 @@ about.persist-new-notification: fr_FR: "Persister une nouvelle notification" pl_PL: "Utrwal nowe powiadomienie" +about.promote-or-demote-device-kind: + en_US: "Promote a device to a server or demote it to a client" + de_DE: "Ein Gerät zu einem Server hochstufen oder zu einem Client herabstufen" + es_ES: "Promover un dispositivo a servidor o degradarlo a cliente" + fr_FR: "Promouvoir un appareil en serveur ou le rétrograder en client" + pl_PL: "Awansuj urządzenie do serwera lub zdegraduj je do klienta" + about.promote-os-registry: en_US: "Promote an OS version from one registry to another" de_DE: "Eine OS-Version von einer Registry in eine andere heraufstufen" @@ -5981,6 +5988,13 @@ about.set-default-outbound-gateway: fr_FR: "Définir la passerelle sortante par défaut" pl_PL: "Ustaw domyślną bramę wychodzącą" +about.set-device-wan: + en_US: "Override the WAN IP for a single device" + de_DE: "Die WAN-IP für ein einzelnes Gerät überschreiben" + es_ES: "Anular la IP WAN para un solo dispositivo" + fr_FR: "Remplacer l'IP WAN pour un seul appareil" + pl_PL: "Zastąp adres IP WAN dla pojedynczego urządzenia" + about.set-echoip-urls: en_US: "Set the Echo IP service URLs" de_DE: "Die Echo-IP-Dienst-URLs festlegen" @@ -6072,6 +6086,13 @@ about.set-subnet-dns: fr_FR: "Définir le DNS du sous-réseau" pl_PL: "Ustaw DNS podsieci" +about.set-subnet-wan: + en_US: "Assign the WAN IP a subnet's traffic uses" + de_DE: "Die WAN-IP zuweisen, die der Datenverkehr eines Subnetzes verwendet" + es_ES: "Asignar la IP WAN que usa el tráfico de una subred" + fr_FR: "Attribuer l'IP WAN qu'utilise le trafic d'un sous-réseau" + pl_PL: "Przypisz adres IP WAN używany przez ruch podsieci" + about.set-user-interface-password: en_US: "Set user interface password" de_DE: "Passwort der Benutzeroberfläche festlegen" @@ -6289,9 +6310,58 @@ about.update-port-forward-label: fr_FR: "Mettre à jour le libellé d'une redirection de port" pl_PL: "Zaktualizuj etykietę przekierowania portu" +about.update-tunnel: + en_US: "Replace a gateway's WireGuard config in place, keeping its identity" + de_DE: "Die WireGuard-Konfiguration eines Gateways direkt ersetzen, unter Beibehaltung seiner Identität" + es_ES: "Reemplazar la configuración WireGuard de un gateway in situ, conservando su identidad" + fr_FR: "Remplacer la configuration WireGuard d'une passerelle sur place, en conservant son identité" + pl_PL: "Zastąp konfigurację WireGuard bramy w miejscu, zachowując jej tożsamość" + about.view-edit-gateway-configs: en_US: "View and edit gateway configurations" de_DE: "Gateway-Konfigurationen anzeigen und bearbeiten" es_ES: "Ver y editar configuraciones de gateway" fr_FR: "Voir et modifier les configurations de passerelle" pl_PL: "Wyświetl i edytuj konfiguracje bramy" + +about.add-or-replace-a-dns-record: + en_US: "Add or replace a DNS record" + de_DE: "Einen DNS-Eintrag hinzufügen oder ersetzen" + es_ES: "Agregar o reemplazar un registro DNS" + fr_FR: "Ajouter ou remplacer un enregistrement DNS" + pl_PL: "Dodaj lub zastąp rekord DNS" + +about.allow-or-deny-device-dns-injection: + en_US: "Allow or deny a device to inject DNS records" + de_DE: "Einem Gerät das Einfügen von DNS-Einträgen erlauben oder verweigern" + es_ES: "Permitir o denegar que un dispositivo inyecte registros DNS" + fr_FR: "Autoriser ou refuser à un appareil d'injecter des enregistrements DNS" + pl_PL: "Zezwól lub odmów urządzeniu wstrzykiwania rekordów DNS" + +about.allow-or-deny-device-auto-port-forward: + en_US: "Allow or deny a device to auto-create port forwards" + de_DE: "Einem Gerät das automatische Erstellen von Portweiterleitungen erlauben oder verweigern" + es_ES: "Permitir o denegar que un dispositivo cree reenvíos de puertos automáticamente" + fr_FR: "Autoriser ou refuser à un appareil de créer automatiquement des redirections de ports" + pl_PL: "Zezwól lub odmów urządzeniu automatycznego tworzenia przekierowań portów" + +about.list-injected-dns-records: + en_US: "List injected DNS records" + de_DE: "Eingefügte DNS-Einträge auflisten" + es_ES: "Listar registros DNS inyectados" + fr_FR: "Lister les enregistrements DNS injectés" + pl_PL: "Wyświetl wstrzyknięte rekordy DNS" + +about.remove-a-dns-record: + en_US: "Remove a DNS record" + de_DE: "Einen DNS-Eintrag entfernen" + es_ES: "Eliminar un registro DNS" + fr_FR: "Supprimer un enregistrement DNS" + pl_PL: "Usuń rekord DNS" + +about.view-or-edit-injected-dns-records: + en_US: "View or edit injected DNS records" + de_DE: "Eingefügte DNS-Einträge anzeigen oder bearbeiten" + es_ES: "Ver o editar registros DNS inyectados" + fr_FR: "Voir ou modifier les enregistrements DNS injectés" + pl_PL: "Wyświetl lub edytuj wstrzyknięte rekordy DNS" diff --git a/core/man/start-tunnel/start-tunnel-device-set-dns-injection.1 b/core/man/start-tunnel/start-tunnel-device-set-dns-injection.1 new file mode 100644 index 0000000000..7ab0aa4812 --- /dev/null +++ b/core/man/start-tunnel/start-tunnel-device-set-dns-injection.1 @@ -0,0 +1,22 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH start-tunnel-device-set-dns-injection 1 "set-dns-injection " +.SH NAME +start\-tunnel\-device\-set\-dns\-injection \- Allow or deny a device to inject DNS records +.SH SYNOPSIS +\fBstart\-tunnel device set\-dns\-injection\fR [\fB\-\-enabled\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fISUBNET\fR> <\fIIP\fR> +.SH DESCRIPTION +Allow or deny a device to inject DNS records +.SH OPTIONS +.TP +\fB\-\-enabled\fR + +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fISUBNET\fR> + +.TP +<\fIIP\fR> + diff --git a/core/man/start-tunnel/start-tunnel-device.1 b/core/man/start-tunnel/start-tunnel-device.1 index 878454d921..048ce2abc1 100644 --- a/core/man/start-tunnel/start-tunnel-device.1 +++ b/core/man/start-tunnel/start-tunnel-device.1 @@ -22,5 +22,8 @@ List devices in a subnet start\-tunnel\-device\-remove(1) Remove device from subnet .TP +start\-tunnel\-device\-set\-dns\-injection(1) +Allow or deny a device to inject DNS records +.TP start\-tunnel\-device\-show\-config(1) Show WireGuard configuration for device diff --git a/core/man/start-tunnel/start-tunnel-dns-add.1 b/core/man/start-tunnel/start-tunnel-dns-add.1 new file mode 100644 index 0000000000..22197af5cb --- /dev/null +++ b/core/man/start-tunnel/start-tunnel-dns-add.1 @@ -0,0 +1,25 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH start-tunnel-dns-add 1 "add " +.SH NAME +start\-tunnel\-dns\-add \- Add or replace a DNS record +.SH SYNOPSIS +\fBstart\-tunnel dns add\fR <\fB\-\-type\fR> [\fB\-\-ttl\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fINAME\fR> <\fIVALUE\fR> +.SH DESCRIPTION +Add or replace a DNS record +.SH OPTIONS +.TP +\fB\-\-type\fR \fI\fR + +.TP +\fB\-\-ttl\fR \fI\fR + +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fINAME\fR> + +.TP +<\fIVALUE\fR> + diff --git a/core/man/start-tunnel/start-tunnel-dns-list.1 b/core/man/start-tunnel/start-tunnel-dns-list.1 new file mode 100644 index 0000000000..6831e4ec2d --- /dev/null +++ b/core/man/start-tunnel/start-tunnel-dns-list.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH start-tunnel-dns-list 1 "list " +.SH NAME +start\-tunnel\-dns\-list \- List injected DNS records +.SH SYNOPSIS +\fBstart\-tunnel dns list\fR [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +List injected DNS records +.SH OPTIONS +.TP +\fB\-\-format\fR + +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/core/man/start-tunnel/start-tunnel-dns-remove.1 b/core/man/start-tunnel/start-tunnel-dns-remove.1 new file mode 100644 index 0000000000..7eacfb22a3 --- /dev/null +++ b/core/man/start-tunnel/start-tunnel-dns-remove.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH start-tunnel-dns-remove 1 "remove " +.SH NAME +start\-tunnel\-dns\-remove \- Remove a DNS record +.SH SYNOPSIS +\fBstart\-tunnel dns remove\fR [\fB\-\-type\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fINAME\fR> +.SH DESCRIPTION +Remove a DNS record +.SH OPTIONS +.TP +\fB\-\-type\fR \fI\fR + +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fINAME\fR> + diff --git a/core/man/start-tunnel/start-tunnel-dns.1 b/core/man/start-tunnel/start-tunnel-dns.1 new file mode 100644 index 0000000000..40bd20023a --- /dev/null +++ b/core/man/start-tunnel/start-tunnel-dns.1 @@ -0,0 +1,23 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH start-tunnel-dns 1 "dns " +.SH NAME +start\-tunnel\-dns \- View or edit injected DNS records +.SH SYNOPSIS +\fBstart\-tunnel dns\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR> +.SH DESCRIPTION +View or edit injected DNS records +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.SH SUBCOMMANDS +.TP +start\-tunnel\-dns\-add(1) +Add or replace a DNS record +.TP +start\-tunnel\-dns\-list(1) +List injected DNS records +.TP +start\-tunnel\-dns\-remove(1) +Remove a DNS record diff --git a/core/man/start-tunnel/start-tunnel.1 b/core/man/start-tunnel/start-tunnel.1 index 9774116ba2..735af02785 100644 --- a/core/man/start-tunnel/start-tunnel.1 +++ b/core/man/start-tunnel/start-tunnel.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH start-tunnel 1 "start-tunnel 0.4.0-beta.9" +.TH start-tunnel 1 "start-tunnel 0.4.0-beta.10" .SH NAME start\-tunnel .SH SYNOPSIS @@ -60,6 +60,9 @@ Commands to interact with the db i.e. dump and apply start\-tunnel\-device(1) Add, remove, or list devices in subnets .TP +start\-tunnel\-dns(1) +View or edit injected DNS records +.TP start\-tunnel\-port\-forward(1) Commands for managing port forwards .TP @@ -75,4 +78,4 @@ Commands for checking and applying tunnel updates start\-tunnel\-web(1) Commands for managing the tunnel web interface .SH VERSION -v0.4.0\-beta.9 +v0.4.0\-beta.10 diff --git a/core/src/bins/mod.rs b/core/src/bins/mod.rs index e2e7032267..539cb9aca8 100644 --- a/core/src/bins/mod.rs +++ b/core/src/bins/mod.rs @@ -159,6 +159,11 @@ impl MultiExecutable { backtrace_on_stack_overflow::enable() }; + // ring = rustls process-default provider; the dual-provider build can't auto-pick one. + tokio_rustls::rustls::crypto::ring::default_provider() + .install_default() + .ok(); + set_locale_from_env(); let mut popped = Vec::with_capacity(2); diff --git a/core/src/net/dns.rs b/core/src/net/dns.rs index 3ab01d6319..fb5d0b6471 100644 --- a/core/src/net/dns.rs +++ b/core/src/net/dns.rs @@ -212,7 +212,6 @@ pub async fn dump_table( struct ResolveMap { private_domains: BTreeMap, Weak<()>)>, services: BTreeMap, BTreeMap>>, - challenges: BTreeMap)>, } pub struct DnsController { @@ -499,38 +498,6 @@ impl RequestHandler for Resolver { let query = req.query; let name = query.name(); - if STARTOS.zone_of(name) && query.query_type() == RecordType::TXT { - let name_str = - InternedString::intern(name.to_lowercase().to_utf8().trim_end_matches('.')); - if let Some(txt_value) = self.resolve.mutate(|r| { - r.challenges.retain(|_, (_, weak)| weak.strong_count() > 0); - r.challenges.remove(&name_str).map(|(val, _)| val) - }) { - let mut header = Metadata::response_from_request(&request.metadata); - header.recursion_available = true; - return response_handle - .send_response( - MessageResponseBuilder::from_message_request(&*request).build( - header, - &[Record::from_rdata( - query.name().to_owned().into(), - 0, - hickory_server::proto::rr::RData::TXT( - hickory_server::proto::rr::rdata::TXT::new(vec![ - txt_value.to_string(), - ]), - ), - )], - [], - [], - [], - ), - ) - .await - .map(Some); - } - } - if let Some(ip) = self.resolve(name, req.src.ip()) { match query.query_type() { RecordType::A => { @@ -853,34 +820,6 @@ impl DnsController { } } - pub fn add_challenge( - &self, - domain: InternedString, - value: InternedString, - ) -> Result, Error> { - if let Some(resolve) = Weak::upgrade(&self.resolve) { - resolve.mutate(|writable| { - let entry = writable - .challenges - .entry(domain) - .or_insert_with(|| (value.clone(), Weak::new())); - let rc = if let Some(rc) = Weak::upgrade(&entry.1) { - rc - } else { - let new = Arc::new(()); - *entry = (value, Arc::downgrade(&new)); - new - }; - Ok(rc) - }) - } else { - Err(Error::new( - eyre!("{}", t!("net.dns.server-thread-exited")), - crate::ErrorKind::Network, - )) - } - } - pub fn gc_private_domains<'a, BK: Ord + 'a>( &self, domains: impl IntoIterator + 'a, diff --git a/core/src/net/dns_update/mod.rs b/core/src/net/dns_update/mod.rs new file mode 100644 index 0000000000..1a9707f589 --- /dev/null +++ b/core/src/net/dns_update/mod.rs @@ -0,0 +1,357 @@ +//! Best-effort RFC 2136 (DNS UPDATE) client. When a private domain is enabled +//! on a gateway, push an `A` record to the gateway's DNS server so other LAN +//! devices can resolve it; withdraw it when the domain is disabled. +//! +//! The gateway requires a TSIG signature (RFC 8945) keyed off the WireGuard PSK +//! it shares with us, so each UPDATE is signed with a key derived from the PSK +//! NetworkManager holds for that gateway's interface and bound to our address on +//! it. A gateway that doesn't accept RFC 2136 just means the domain only +//! resolves on StartOS's own resolver, as before; a gateway with no PSK (e.g. a +//! plain router) gets an unsigned, best-effort update. + +pub mod rfc2136; + +use std::collections::{BTreeMap, BTreeSet}; +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use hickory_server::proto::op::update_message::{append, delete_rrset}; +use hickory_server::proto::op::{Message, ResponseCode}; +use hickory_server::proto::rr::rdata::A; +use hickory_server::proto::rr::rdata::tsig::TsigAlgorithm; +use hickory_server::proto::rr::{Name, RData, Record, RecordSet, RecordType, TSigner}; +use hkdf::Hkdf; +use imbl::OrdMap; +use imbl_value::InternedString; +use sha2::Sha256; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; +use tokio::time::{interval, timeout}; + +use crate::db::model::public::NetworkInterfaceInfo; +use crate::net::port_map::candidate_gateways; +use crate::prelude::*; +use crate::util::sync::Watch; +use crate::GatewayId; + +const DNS_PORT: u16 = 53; +const RECORD_TTL: u32 = 300; +const QUERY_TIMEOUT: Duration = Duration::from_secs(2); +/// Re-assert desired records so they survive a gateway DNS restart. +const REFRESH_INTERVAL: Duration = Duration::from_secs(180); + +const TSIG_INFO: &[u8] = b"startos-dns-update-v1"; +/// TSIG time window (seconds); both ends run NTP so 5 min is ample. +pub(crate) const TSIG_FUDGE: u16 = 300; + +/// Fixed TSIG key name shared by signer (server) and verifier (gateway); a pure +/// key identifier, built identically on both sides. +pub(crate) fn tsig_key_name() -> Name { + Name::from_ascii("startos-dns-update.").expect("static valid name") +} + +/// Per-device TSIG HMAC key derived from the WireGuard PSK. Both sides derive it +/// identically; a sandboxed service can't read the root-only PSK, so it can't +/// forge a valid signature. +pub(crate) fn derive_tsig_key(psk: &[u8; 32]) -> [u8; 32] { + let mut out = [0u8; 32]; + Hkdf::::new(None, psk) + .expand(TSIG_INFO, &mut out) + .expect("32 <= 255*32 output bytes"); + out +} + +/// HMAC-SHA256 TSIG signer/verifier for a derived key. +pub(crate) fn tsig_signer(key: [u8; 32]) -> TSigner { + TSigner::new(key.to_vec(), TsigAlgorithm::HmacSha256, tsig_key_name(), TSIG_FUDGE) + .expect("HmacSha256 supported; static name valid") +} + +/// (gateway this target belongs to, DNS server to update, our address on that +/// subnet / the A record value). The gateway id resolves the TSIG key. +type Target = (GatewayId, Ipv4Addr, Ipv4Addr); + +/// Resolves a gateway's WireGuard PSK (async D-Bus call into NetworkManager), so +/// the controller can derive that gateway's TSIG signing key. `Ok(Some)` is a +/// real key, `Ok(None)` is a definitively keyless gateway (cacheable), and +/// `Err(())` is a transient lookup failure (NOT cached, so the next tick retries +/// instead of permanently downgrading an expected-signed gateway to unsigned). +type PskLookup = Arc< + dyn Fn(GatewayId) -> Pin, ()>> + Send>> + + Send + + Sync, +>; + +enum Command { + Add { + fqdn: InternedString, + gateways: BTreeSet, + }, + Gc { + rm: BTreeSet, + }, +} + +/// Mirrors [`crate::net::dns::DnsController`]'s private-domain API so the +/// net-service reconcile can drive both in lock-step. +#[derive(Clone)] +pub struct DnsUpdateController { + req: mpsc::UnboundedSender, +} + +impl DnsUpdateController { + pub fn new( + mut net_iface: Watch>, + psk: PskLookup, + ) -> Self { + let (req, mut recv) = mpsc::unbounded_channel::(); + tokio::spawn(async move { + let mut desired: BTreeMap> = BTreeMap::new(); + // Currently-published targets, kept so we can withdraw stale ones. + let mut active: BTreeMap> = BTreeMap::new(); + // Per-gateway TSIG signer (or `None` for keyless gateways), cleared + // on a network change since a re-imported config can rotate the PSK. + let mut signers: BTreeMap> = BTreeMap::new(); + let mut ifaces = net_iface.read_and_mark_seen(); + let mut refresh = interval(REFRESH_INTERVAL); + refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + cmd = recv.recv() => match cmd { + Some(Command::Add { fqdn, gateways }) => { + let changed = desired.get(&fqdn) != Some(&gateways); + desired.insert(fqdn.clone(), gateways); + if changed { + reconcile_one(&fqdn, &desired, &mut active, &ifaces, &psk, &mut signers).await; + } + } + Some(Command::Gc { rm }) => { + for fqdn in rm { + desired.remove(&fqdn); + reconcile_one(&fqdn, &desired, &mut active, &ifaces, &psk, &mut signers).await; + } + } + None => break, + }, + _ = net_iface.changed() => { + ifaces = net_iface.read(); + signers.clear(); + for fqdn in desired.keys().cloned().collect::>() { + reconcile_one(&fqdn, &desired, &mut active, &ifaces, &psk, &mut signers).await; + } + } + _ = refresh.tick() => { + for (fqdn, targets) in &active { + if let Ok(name) = fqdn_to_name(fqdn) { + for (gw, server, ip) in targets { + let signer = signer_for(gw, &psk, &mut signers).await; + apply(&name, *server, *ip, signer.as_ref()).await; + } + } + } + } + } + } + }); + Self { req } + } + + pub fn add(&self, fqdn: InternedString, gateways: BTreeSet) { + self.req.send(Command::Add { fqdn, gateways }).ok(); + } + + pub fn gc(&self, rm: BTreeSet) { + if !rm.is_empty() { + self.req.send(Command::Gc { rm }).ok(); + } + } +} + +/// The (resolver, our-ip) pairs for a private domain on one gateway: one per +/// IPv4 subnet, pointing at our address there and sent to that subnet's +/// resolver (its NM gateway / `.1`). The caller pairs each with its gateway id. +fn targets_for(info: &NetworkInterfaceInfo) -> Vec<(Ipv4Addr, Ipv4Addr)> { + let Some(ip_info) = &info.ip_info else { + return Vec::new(); + }; + let resolvers = candidate_gateways(info); + let mut out = Vec::new(); + for subnet in &ip_info.subnets { + let IpAddr::V4(our_ip) = subnet.addr() else { + continue; + }; + // Prefer a resolver on the same subnet as our address. + let server = resolvers + .iter() + .copied() + .find(|r| subnet.contains(&IpAddr::V4(*r))) + .or_else(|| match subnet.hosts().next() { + Some(IpAddr::V4(v4)) => Some(v4), + _ => None, + }); + if let Some(server) = server { + out.push((server, our_ip)); + } + } + out +} + +/// Resolve (and cache) a gateway's TSIG signer. `None` for a gateway with no +/// PSK, whose update then goes out unsigned (best-effort). A transient lookup +/// failure returns `None` for this attempt but is NOT cached, so the next +/// reconcile / refresh tick retries rather than wedging the gateway on unsigned. +async fn signer_for( + gw: &GatewayId, + psk: &PskLookup, + cache: &mut BTreeMap>, +) -> Option { + if let Some(signer) = cache.get(gw) { + return signer.clone(); + } + match psk(gw.clone()).await { + Ok(key) => { + let signer = key.map(|key| tsig_signer(derive_tsig_key(&key))); + cache.insert(gw.clone(), signer.clone()); + signer + } + Err(()) => None, + } +} + +async fn reconcile_one( + fqdn: &InternedString, + desired: &BTreeMap>, + active: &mut BTreeMap>, + ifaces: &OrdMap, + psk: &PskLookup, + signers: &mut BTreeMap>, +) { + let Ok(name) = fqdn_to_name(fqdn) else { + return; + }; + let want: BTreeSet = desired + .get(fqdn) + .into_iter() + .flatten() + .filter_map(|gw| ifaces.get(gw).map(|info| (gw, info))) + .flat_map(|(gw, info)| { + targets_for(info) + .into_iter() + .map(move |(server, ip)| (gw.clone(), server, ip)) + }) + .collect(); + let had = active.remove(fqdn).unwrap_or_default(); + for (gw, server, ip) in had.difference(&want) { + let signer = signer_for(gw, psk, signers).await; + withdraw(&name, *server, *ip, signer.as_ref()).await; + } + for (gw, server, ip) in &want { + let signer = signer_for(gw, psk, signers).await; + apply(&name, *server, *ip, signer.as_ref()).await; + } + if !want.is_empty() { + active.insert(fqdn.clone(), want); + } +} + +fn fqdn_to_name(fqdn: &str) -> Result { + let mut name = Name::from_utf8(fqdn).with_kind(ErrorKind::ParseUrl)?; + name.set_fqdn(true); + Ok(name) +} + +/// Zone for an RFC 2136 update: the FQDN's parent is a valid origin, and our +/// own servers don't enforce it strictly. +fn zone_of(fqdn: &Name) -> Name { + let base = fqdn.base_name(); + if base.is_root() { + fqdn.clone() + } else { + base + } +} + +async fn apply(fqdn: &Name, server: Ipv4Addr, ip: Ipv4Addr, signer: Option<&TSigner>) { + let zone = zone_of(fqdn); + // Replace: drop any existing A rrset for the name, then add ours. + let delete = delete_rrset( + Record::update0(fqdn.clone(), 0, RecordType::A), + zone.clone(), + false, + ); + let mut rrset = RecordSet::new(fqdn.clone(), RecordType::A, 0); + rrset.insert( + Record::from_rdata(fqdn.clone(), RECORD_TTL, RData::A(A::from(ip))), + 0, + ); + let add = append(rrset, zone, false, false); + for msg in [delete, add] { + if let Err(e) = send(server, ip, &msg, signer).await { + tracing::debug!("RFC 2136 update of {fqdn} on {server} failed: {e}"); + return; + } + } + tracing::debug!("published {fqdn} -> {ip} via RFC 2136 on {server}"); +} + +async fn withdraw(fqdn: &Name, server: Ipv4Addr, ip: Ipv4Addr, signer: Option<&TSigner>) { + let msg = delete_rrset( + Record::update0(fqdn.clone(), 0, RecordType::A), + zone_of(fqdn), + false, + ); + if let Err(e) = send(server, ip, &msg, signer).await { + tracing::debug!("RFC 2136 delete of {fqdn} on {server} failed: {e}"); + } +} + +async fn send( + server: Ipv4Addr, + local_ip: Ipv4Addr, + message: &Message, + signer: Option<&TSigner>, +) -> Result<(), Error> { + // Sign with the gateway's TSIG key (derived from the WG PSK) when we have + // one; `finalize` mutates, so sign a clone. + let bytes = match signer { + Some(signer) => { + let mut signed = message.clone(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + signed + .finalize(signer, now) + .map_err(|e| Error::new(eyre!("TSIG sign DNS UPDATE: {e}"), ErrorKind::Network))?; + signed.to_vec() + } + None => message.to_vec(), + } + .map_err(|e| Error::new(eyre!("encode DNS UPDATE: {e}"), ErrorKind::Network))?; + // Bind to our address on the gateway so the server authorizes us by source IP. + let socket = UdpSocket::bind(SocketAddr::new(IpAddr::V4(local_ip), 0)) + .await + .with_kind(ErrorKind::Network)?; + socket + .connect(SocketAddr::new(IpAddr::V4(server), DNS_PORT)) + .await + .with_kind(ErrorKind::Network)?; + socket.send(&bytes).await.with_kind(ErrorKind::Network)?; + let mut buf = [0u8; 1232]; + let n = timeout(QUERY_TIMEOUT, socket.recv(&mut buf)) + .await + .map_err(|_| Error::new(eyre!("timed out"), ErrorKind::Network))? + .with_kind(ErrorKind::Network)?; + let resp = Message::from_vec(&buf[..n]) + .map_err(|e| Error::new(eyre!("decode DNS response: {e}"), ErrorKind::Network))?; + match resp.metadata.response_code { + // NXRRSet on a delete (nothing to remove) is fine. + ResponseCode::NoError | ResponseCode::NXRRSet => Ok(()), + other => Err(Error::new( + eyre!("DNS UPDATE refused: {other}"), + ErrorKind::Network, + )), + } +} diff --git a/core/src/net/dns_update/rfc2136.rs b/core/src/net/dns_update/rfc2136.rs new file mode 100644 index 0000000000..7a755684d1 --- /dev/null +++ b/core/src/net/dns_update/rfc2136.rs @@ -0,0 +1,460 @@ +//! Shared server-side RFC 2136 (DNS UPDATE) handling, used by both StartTunnel +//! (in this crate) and StartWRT's `startwrt-ctrld` (which imports this crate). +//! +//! [`DnsInjector`] is an in-memory store of injected DNS records plus per-gateway +//! policy plug-ins: an authorizer (does this source IP's "allow DNS injection" +//! toggle permit it?), a TSIG key lookup (the per-device key derived from that +//! device's WireGuard PSK), and an `on_change` hook (persist the records — to +//! PatchDb on the tunnel, an addn-hosts file on StartWRT). [`InjectingHandler`] +//! wraps a forwarding `RequestHandler`: an injected-name `Query` is answered +//! locally, a TSIG-authenticated `Update` mutates the store, everything else is +//! forwarded unchanged. +//! +//! UPDATEs are authenticated by **TSIG** (RFC 8945): the source IP alone is +//! forgeable by any co-located service that can emit on the tunnel interface, so +//! every UPDATE must carry a valid HMAC keyed off the device's root-only WG PSK. +//! TSIG proves the signer holds that PSK (no forgery) but not freshness: there's +//! no anti-replay state, so a captured signature replays within `TSIG_FUDGE`. +//! That's bounded to records the sending device may already mutate and is +//! idempotent (the client re-asserts every few minutes), so it's accepted as a +//! limitation rather than tracked. Manual CRUD via [`DnsInjector::upsert`] / +//! [`DnsInjector::delete`] is an admin path and bypasses both the authorizer and +//! TSIG. + +use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::Arc; + +use hickory_server::net::runtime::Time; +use hickory_server::proto::op::{Header, HeaderCounts, Metadata, OpCode, ResponseCode}; +use hickory_server::proto::rr::rdata::{CNAME, TXT}; +use hickory_server::proto::rr::{DNSClass, LowerName, Name, RData, Record, RecordType}; + +use crate::prelude::*; +use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo}; +use hickory_server::zone_handler::{Catalog, MessageResponseBuilder}; + +use crate::util::sync::SyncMutex; + +/// One injected record. Kept Rust-only; each gateway maps it to its own +/// serializable view for the API/UI. +#[derive(Clone, Debug)] +pub struct InjectedRecord { + pub name: Name, + pub rtype: RecordType, + pub rdata: RData, + pub ttl: u32, + /// Device IP that injected this; unspecified for manual entries. + pub source: IpAddr, +} + +impl InjectedRecord { + /// Render to the text form gateways persist and show; `source` is `None` + /// for a manual record. + pub fn to_parts(&self) -> (String, String, String, u32, Option) { + let source = if self.source.is_unspecified() { + None + } else { + Some(self.source) + }; + ( + self.name.to_utf8().trim_end_matches('.').to_string(), + self.rtype.to_string(), + self.rdata.to_string(), + self.ttl, + source, + ) + } + + /// Parse the text form back into a record; pass `Ipv4Addr::UNSPECIFIED` + /// as `source` for a manual record. + pub fn from_parts( + name: &str, + rtype: &str, + value: &str, + ttl: u32, + source: IpAddr, + ) -> Result { + let mut n = Name::from_utf8(name).with_kind(ErrorKind::ParseUrl)?; + n.set_fqdn(true); + let (rtype, rdata) = parse_rdata(rtype, value)?; + Ok(Self { + name: n, + rtype, + rdata, + ttl, + source, + }) + } +} + +fn parse_rdata(rtype: &str, value: &str) -> Result<(RecordType, RData), Error> { + let invalid = |what: &str| Error::new(eyre!("invalid {what}: {value}"), ErrorKind::InvalidRequest); + Ok(match rtype.to_ascii_uppercase().as_str() { + "A" => ( + RecordType::A, + RData::A(value.parse::().map_err(|_| invalid("A record"))?.into()), + ), + "AAAA" => ( + RecordType::AAAA, + RData::AAAA(value.parse::().map_err(|_| invalid("AAAA record"))?.into()), + ), + "CNAME" => ( + RecordType::CNAME, + RData::CNAME(CNAME(Name::from_utf8(value).with_kind(ErrorKind::ParseUrl)?)), + ), + "TXT" => (RecordType::TXT, RData::TXT(TXT::new(vec![value.to_string()]))), + other => { + return Err(Error::new( + eyre!("unsupported DNS record type: {other}"), + ErrorKind::InvalidRequest, + )); + } + }) +} + +type Authorizer = Box bool + Send + Sync>; +/// The per-device derived TSIG key for a source IP, or `None` if it isn't an +/// allowed DNS-injection device. +type KeyLookup = Box Option<[u8; 32]> + Send + Sync>; +type OnChange = Box) + Send + Sync>; + +pub struct DnsInjector { + records: SyncMutex>>, + authorize: Authorizer, + tsig_key: KeyLookup, + on_change: OnChange, +} + +impl DnsInjector { + pub fn new( + initial: Vec, + authorize: impl Fn(IpAddr) -> bool + Send + Sync + 'static, + tsig_key: impl Fn(IpAddr) -> Option<[u8; 32]> + Send + Sync + 'static, + on_change: impl Fn(Vec) + Send + Sync + 'static, + ) -> Arc { + let mut records: BTreeMap> = BTreeMap::new(); + for r in initial { + records.entry(LowerName::from(&r.name)).or_default().push(r); + } + Arc::new(Self { + records: SyncMutex::new(records), + authorize: Box::new(authorize), + tsig_key: Box::new(tsig_key), + on_change: Box::new(on_change), + }) + } + + /// Verify the RFC 8945 TSIG on a raw UPDATE request from `src`: rejects an + /// absent/invalid/out-of-window signature, or a src with no injection key. + /// The key is derived from the device's WireGuard PSK, which a sandboxed + /// service can't read — so it can't forge a signature for the server's IP. + fn verify_tsig(&self, src: IpAddr, request_bytes: &[u8]) -> bool { + let Some(key) = (self.tsig_key)(src) else { + return false; + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + match super::tsig_signer(key).verify_message_byte(request_bytes, None, true) { + Ok((_, _, valid)) => valid.contains(&now), + Err(_) => false, + } + } + + /// Every injected record, in stable order. + pub fn list(&self) -> Vec { + self.records + .peek(|m| m.values().flatten().cloned().collect()) + } + + fn notify(&self) { + (self.on_change)(self.list()); + } + + /// Manually add or replace a record (admin action; skips authorization). + pub fn upsert(&self, record: InjectedRecord) { + self.records.mutate(|m| { + let v = m.entry(LowerName::from(&record.name)).or_default(); + v.retain(|r| !(r.rtype == record.rtype && r.rdata == record.rdata)); + v.push(record); + }); + self.notify(); + } + + /// Manually delete records for a name (optionally a single type). + pub fn delete(&self, name: &Name, rtype: Option) { + self.records.mutate(|m| { + let key = LowerName::from(name); + match rtype { + None => { + m.remove(&key); + } + Some(rt) => { + if let Some(v) = m.get_mut(&key) { + v.retain(|r| r.rtype != rt); + if v.is_empty() { + m.remove(&key); + } + } + } + } + }); + self.notify(); + } + + fn lookup(&self, name: &LowerName, rtype: RecordType) -> Vec { + self.records.peek(|m| { + m.get(name) + .into_iter() + .flatten() + .filter(|r| rtype == RecordType::ANY || r.rtype == rtype) + .map(|r| Record::from_rdata(r.name.clone(), r.ttl, r.rdata.clone())) + .collect() + }) + } + + /// Whether any record (of any type) exists for `name`; distinguishes an + /// authoritative NODATA from a name we don't serve. + fn contains_name(&self, name: &LowerName) -> bool { + self.records.peek(|m| m.contains_key(name)) + } + + /// Apply an UPDATE's records (RFC 2136 §2.5) from `src`, after authorizing. + fn apply_update(&self, src: IpAddr, updates: &[Record]) -> ResponseCode { + if !(self.authorize)(src) { + return ResponseCode::Refused; + } + self.records.mutate(|m| { + for rec in updates { + let key = LowerName::from(&rec.name); + match rec.dns_class { + // Add to an RRset. + DNSClass::IN => { + let record = InjectedRecord { + name: rec.name.clone(), + rtype: rec.record_type(), + rdata: rec.data.clone(), + ttl: rec.ttl, + source: src, + }; + let v = m.entry(key).or_default(); + v.retain(|r| { + !(r.rtype == record.rtype && r.rdata == record.rdata) + }); + v.push(record); + } + // Delete an RRset (a whole type, or every type for the name). + DNSClass::ANY => { + if rec.record_type() == RecordType::ANY { + m.remove(&key); + } else if let Some(v) = m.get_mut(&key) { + v.retain(|r| r.rtype != rec.record_type()); + if v.is_empty() { + m.remove(&key); + } + } + } + // Delete one specific record. + DNSClass::NONE => { + if let Some(v) = m.get_mut(&key) { + let rdata = rec.data.clone(); + v.retain(|r| r.rdata != rdata); + if v.is_empty() { + m.remove(&key); + } + } + } + _ => {} + } + } + }); + self.notify(); + ResponseCode::NoError + } +} + +/// Wraps a forwarding `Catalog` with injected-record answering and authorized +/// RFC 2136 UPDATE handling. Non-injected queries fall through to the `Catalog` +/// (a `ForwardZoneHandler` pointed at upstream / dnsmasq). +pub struct InjectingHandler { + injector: Arc, + forwarder: Catalog, +} + +impl InjectingHandler { + pub fn new(injector: Arc, forwarder: Catalog) -> Self { + Self { + injector, + forwarder, + } + } +} + +fn header_with_code(request: &Request, code: ResponseCode) -> Metadata { + let mut header = Metadata::response_from_request(&request.metadata); + header.recursion_available = true; + header.response_code = code; + header +} + +fn fallback_info(header: Metadata) -> ResponseInfo { + Header { + metadata: header, + counts: HeaderCounts::default(), + } + .into() +} + +#[async_trait::async_trait] +impl RequestHandler for InjectingHandler { + async fn handle_request( + &self, + request: &Request, + mut response_handle: R, + ) -> ResponseInfo { + match request.metadata.op_code { + OpCode::Update => { + let src = request.src().ip(); + // Require a valid TSIG (keyed off the device's WireGuard PSK) + // before touching the store: source IP alone is forgeable by any + // co-located service that can emit on the tunnel interface. + let code = if !self.injector.verify_tsig(src, request.as_slice()) { + ResponseCode::Refused + } else { + // Re-decode the raw message: MessageRequest hides the + // authority section where the update RRs live. + match hickory_server::proto::op::Message::from_vec(request.as_slice()) { + Ok(msg) => self.injector.apply_update(src, &msg.authorities), + Err(_) => ResponseCode::FormErr, + } + }; + let header = header_with_code(request, code); + response_handle + .send_response( + MessageResponseBuilder::from_message_request(&*request) + .build(header, [], [], [], []), + ) + .await + .unwrap_or_else(|_| fallback_info(header)) + } + OpCode::Query => { + let req = request.request_info().ok(); + let answers = req + .as_ref() + .map(|req| self.injector.lookup(req.query.name(), req.query.query_type())) + .unwrap_or_default(); + // Authoritative for any injected name: serve records or NODATA. + // Forwarding a held name's missing-type query lets upstream NXDOMAIN + // poison it (RFC 8020) — e.g. an AAAA probe for an A-only domain. + let known = req + .as_ref() + .is_some_and(|req| self.injector.contains_name(req.query.name())); + if answers.is_empty() && !known { + return self.forwarder.handle_request::(request, response_handle).await; + } + let header = header_with_code(request, ResponseCode::NoError); + response_handle + .send_response( + MessageResponseBuilder::from_message_request(&*request) + .build(header, &answers, [], [], []), + ) + .await + .unwrap_or_else(|_| fallback_info(header)) + } + _ => self.forwarder.handle_request::(request, response_handle).await, + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + fn injector() -> Arc { + DnsInjector::new(Vec::new(), |_| true, |_| None, |_| {}) + } + + fn fqdn(s: &str) -> LowerName { + let mut n = Name::from_utf8(s).unwrap(); + n.set_fqdn(true); + LowerName::from(&n) + } + + /// An A-only injected name must answer NODATA (not forward) for an AAAA + /// query: the name is held, so `contains_name` is true even though the + /// queried type is absent. Forwarding would let upstream NXDOMAIN poison it. + // tokio runtime required: DnsInjector's SyncMutex spawns a lock watchdog. + #[tokio::test] + async fn held_name_is_nodata_not_forwarded() { + let inj = injector(); + inj.upsert( + InjectedRecord::from_parts( + "scrunge.goop", + "A", + "10.59.0.2", + 300, + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + ) + .unwrap(), + ); + let q = fqdn("scrunge.goop"); + assert_eq!(inj.lookup(&q, RecordType::A).len(), 1, "A query resolves"); + assert!(inj.lookup(&q, RecordType::AAAA).is_empty(), "no AAAA record"); + assert!(inj.contains_name(&q), "held name -> handler answers NODATA"); + assert!(!inj.contains_name(&fqdn("nope.goop")), "unknown name -> handler forwards"); + } + + #[test] + fn tsig_key_derivation_is_deterministic() { + let a = super::super::derive_tsig_key(&[1u8; 32]); + assert_eq!(a, super::super::derive_tsig_key(&[1u8; 32]), "same psk -> same key"); + assert_ne!(a, super::super::derive_tsig_key(&[2u8; 32]), "different psk -> different key"); + } + + // A signed UPDATE built the way the server signs it must verify; an unsigned + // one, a wrong key, or a src with no key must be rejected. + #[tokio::test] + async fn tsig_gates_updates() { + use hickory_server::proto::op::update_message::append; + use hickory_server::proto::rr::RecordSet; + use hickory_server::proto::rr::rdata::A; + + let build = |sign_key: Option<[u8; 32]>| { + let mut name = Name::from_utf8("host.example.com").unwrap(); + name.set_fqdn(true); + let mut rrset = RecordSet::new(name.clone(), RecordType::A, 0); + rrset.insert( + Record::from_rdata(name.clone(), 300, RData::A(A::from(Ipv4Addr::new(10, 59, 0, 2)))), + 0, + ); + let mut msg = append(rrset, name.base_name(), false, false); + if let Some(key) = sign_key { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + msg.finalize(&super::super::tsig_signer(key), now).unwrap(); + } + msg.to_vec().unwrap() + }; + + let src = IpAddr::V4(Ipv4Addr::new(10, 59, 0, 2)); + let key_a = super::super::derive_tsig_key(&[7u8; 32]); + let key_b = super::super::derive_tsig_key(&[9u8; 32]); + let signed = build(Some(key_a)); + let unsigned = build(None); + + let inj = DnsInjector::new(Vec::new(), |_| true, move |ip| (ip == src).then_some(key_a), |_| {}); + assert!(inj.verify_tsig(src, &signed), "valid TSIG from the right key accepted"); + assert!(!inj.verify_tsig(src, &unsigned), "unsigned UPDATE rejected"); + assert!( + !inj.verify_tsig(IpAddr::V4(Ipv4Addr::new(10, 59, 0, 9)), &signed), + "no key for src rejected" + ); + + let inj_b = DnsInjector::new(Vec::new(), |_| true, move |_| Some(key_b), |_| {}); + assert!(!inj_b.verify_tsig(src, &signed), "wrong key rejected"); + } +} diff --git a/core/src/net/forward.rs b/core/src/net/forward.rs index 31bfac47a3..c197cd222d 100644 --- a/core/src/net/forward.rs +++ b/core/src/net/forward.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; -use std::net::{IpAddr, SocketAddrV4}; +use std::net::{IpAddr, Ipv4Addr, SocketAddrV4}; use std::sync::{Arc, Weak}; use std::time::Duration; @@ -16,6 +16,7 @@ use tokio::sync::mpsc; use crate::context::{CliContext, RpcContext}; use crate::db::model::public::NetworkInterfaceInfo; +use crate::net::port_map::{PortMapController, candidate_gateways}; use crate::prelude::*; use crate::util::Invoke; use crate::util::future::NonDetachingJoinHandle; @@ -69,7 +70,7 @@ impl AvailablePorts { ErrorKind::Network, )) } - /// Try to allocate a specific port. Returns Some(port) if available, None if taken/restricted. + /// Allocate a specific port; `None` if taken or restricted. pub fn try_alloc(&mut self, port: u16, ssl: bool) -> Option { if is_restricted(port) || self.0.contains_key(&port) { return None; @@ -78,10 +79,9 @@ impl AvailablePorts { Some(port) } - /// Try to allocate `count` contiguous non-ssl ports starting at `start`. - /// All ports in `[start, start + count)` must be free and non-restricted; - /// otherwise nothing is allocated and `Err` is returned describing the - /// first offending port. + /// Allocate `count` contiguous non-ssl ports from `start`. All-or-nothing: + /// if any port is taken or restricted, allocates none and `Err`s on the + /// first offender. pub fn try_alloc_range(&mut self, start: u16, count: u16) -> Result<(), Error> { if count == 0 { return Err(Error::new( @@ -119,7 +119,6 @@ impl AvailablePorts { self.0.insert(port, ssl); } - /// Returns whether a given allocated port is SSL. pub fn is_ssl(&self, port: u16) -> bool { self.0.get(&port).copied().unwrap_or(false) } @@ -163,10 +162,9 @@ pub fn forward_api() -> ParentHandler { struct ForwardMapping { source: SocketAddrV4, target: SocketAddrV4, - /// Number of contiguous ports forwarded starting at `source.port()` / - /// `target.port()`. `1` is a single-port forward; values > 1 produce a - /// single nft rule covering the whole range (port-preserving when the two - /// bases are equal, otherwise an offset verdict map). + /// Contiguous ports forwarded from `source.port()` / `target.port()`. `> 1` + /// becomes one nft rule for the range (port-preserving when the bases match, + /// else an offset verdict map). count: u16, target_prefix: u8, src_filter: Option, @@ -200,7 +198,6 @@ impl PortForwardState { return Ok(rc); } } else { - // Different target, count, or src_filter, need to remove old and add new if let Some(mapping) = self.mappings.remove(&source) { unforward( mapping.source, @@ -306,16 +303,14 @@ pub struct PortForwardController { _thread: NonDetachingJoinHandle<()>, } -/// Native nftables table that owns all of StartOS's packet-filter / NAT rules -/// (forwarding, base forward policy, policy-routing marks, tunnel). Coexists -/// with lxc-net / wg-quick, which keep their own iptables-nft rules in separate -/// tables on the shared nf_tables datapath. +/// Native nftables table owning all of StartOS's packet-filter / NAT rules. +/// Coexists with lxc-net / wg-quick, which keep their own iptables-nft rules in +/// separate tables on the shared nf_tables datapath. pub const NFT_TABLE: &str = "startos"; -/// Ensure `table ip startos` and its base chains exist. Idempotent: nft's `add -/// table`/`add chain` are no-ops when the object already exists. The forward -/// chain defaults to `drop` (replacing `iptables -P FORWARD DROP`); ACCEPT -/// rules are added into it by callers and the forward-port script. +/// Ensure `table ip startos` and its base chains exist. Idempotent (nft's `add +/// table`/`add chain` are no-ops if present). The forward chain defaults to +/// `drop` (replacing `iptables -P FORWARD DROP`); callers add ACCEPT rules. pub async fn nft_ensure_base() -> Result<(), Error> { Command::new("nft") .arg(include_str!("startos-base.nft")) @@ -324,9 +319,8 @@ pub async fn nft_ensure_base() -> Result<(), Error> { Ok(()) } -/// Rules in `chain` tagged with `comment`, as `(handle, body)` where `body` is -/// the rule text preceding the `comment "..."` token. -async fn nft_rules_with_comment(chain: &str, comment: &str) -> Vec<(u32, String)> { +/// `nft -a list chain ip startos ` output, empty on error. +async fn nft_list_chain(chain: &str) -> String { let out = Command::new("nft") .arg("-a") .arg("list") @@ -337,8 +331,15 @@ async fn nft_rules_with_comment(chain: &str, comment: &str) -> Vec<(u32, String) .invoke(ErrorKind::Network) .await .unwrap_or_default(); + String::from_utf8_lossy(&out).into_owned() +} + +/// Rules in `chain` tagged with `comment`, as `(handle, body)` where `body` is +/// the rule text preceding the `comment "..."` token. +async fn nft_rules_with_comment(chain: &str, comment: &str) -> Vec<(u32, String)> { let needle = format!("comment \"{comment}\""); - String::from_utf8_lossy(&out) + nft_list_chain(chain) + .await .lines() .filter_map(|line| { let handle = line.rsplit_once("# handle ")?.1.trim().parse::().ok()?; @@ -348,20 +349,30 @@ async fn nft_rules_with_comment(chain: &str, comment: &str) -> Vec<(u32, String) .collect() } +/// Comment tags in `chain` of `table ip startos` beginning with `prefix`. Used +/// to prune orphaned per-device/per-subnet rules whose owner no longer exists. +pub(crate) async fn nft_comments_with_prefix(chain: &str, prefix: &str) -> Vec { + nft_list_chain(chain) + .await + .lines() + .filter_map(|line| { + let after = line.split_once("comment \"")?.1; + let tag = after.split_once('"')?.0; + tag.starts_with(prefix).then(|| tag.to_owned()) + }) + .collect() +} + /// Idempotently install (or, with `undo`, remove) the rule tagged `comment` in -/// `chain` of `table ip startos`. The reconcile is applied as a single atomic -/// nft transaction — every prior rule with this comment is deleted and the new -/// rule added in one invocation — and is a no-op when the chain already holds -/// exactly the desired rule. `prepend` inserts at the top of the chain (needed -/// for the mark-restore rule, which must run before the per-interface set-mark -/// rules). +/// `chain` of `table ip startos`, via one atomic nft transaction that drops +/// every prior copy of this comment and adds the desired rule. No-op when the +/// chain already holds exactly that rule. `prepend` inserts at the chain top +/// (needed for the mark-restore rule, which must precede the set-mark rules). /// -/// Lock-free under concurrency: the only way the transaction can fail is a -/// stale handle — another reconcile of the *same* comment deleted/replaced the -/// rule between our list and our delete. That race is benign (nft commits -/// atomically, so the other writer's rule is already in place) and only arises -/// for callers without a single-writer guarantee (e.g. the tunnel masq rules); -/// we warn, re-read, and retry, converging via the no-op check above. +/// Lock-free: the only failure is a stale handle — a concurrent reconcile of +/// the *same* comment replaced the rule between our list and delete. Benign (nft +/// commits atomically), so we warn, re-read, and retry to convergence. Only +/// arises for callers without a single-writer guarantee (e.g. tunnel masq). pub async fn nft_rule( chain: &str, comment: &str, @@ -387,9 +398,8 @@ pub async fn nft_rule( } } - // Single atomic transaction: drop every prior copy, then add the - // desired rule. Convergent from any starting state (0, 1, or N stale - // copies), with no window where the rule is missing or duplicated. + // Drop every prior copy, then add the desired rule, in one transaction: + // no window where the rule is missing or duplicated. let mut script = String::new(); for (handle, _) in &existing { writeln!(script, "delete rule ip startos {chain} handle {handle}").unwrap(); @@ -412,9 +422,8 @@ pub async fn nft_rule( .await { Ok(_) => return Ok(()), - // Stale handle: a concurrent reconcile of this comment won the race. - // Re-read and retry; the no-op check above usually returns on the - // next pass. Any other error is real and surfaces immediately. + // Stale handle: a concurrent reconcile won the race; re-read and + // retry. Any other error is real and surfaces immediately. Err(e) if e.source.to_string().contains("No such file or directory") => { tracing::warn!( "nft_rule {chain}/{comment}: stale handle on attempt {attempt}/{MAX_ATTEMPTS}" @@ -505,9 +514,8 @@ impl PortForwardController { } /// Like [`add_forward`] but covers `count` contiguous ports per protocol - /// (TCP + UDP) starting at `source.port()` / `target.port()`. The two - /// bases may differ; the forward maps the source range onto the target - /// range by offset. + /// (TCP + UDP) from `source.port()` / `target.port()`, mapped by offset + /// (the two bases may differ). pub async fn add_forward_range( &self, source: SocketAddrV4, @@ -553,9 +561,8 @@ impl PortForwardController { struct InterfaceForwardRequest { external: u16, target: SocketAddrV4, - /// Number of contiguous ports starting at `external` / `target.port()`. - /// `1` is a single-port forward; values > 1 cover a contiguous range - /// (port-preserving when the bases are equal, else an offset map). + /// Contiguous ports from `external` / `target.port()` (port-preserving when + /// the bases are equal, else an offset map). count: u16, target_prefix: u8, reqs: ForwardRequirements, @@ -565,13 +572,15 @@ struct InterfaceForwardRequest { #[derive(Clone)] struct InterfaceForwardEntry { external: u16, - /// `count` is shared across all targets keyed off the same `external` - /// start port — `AvailablePorts` prevents overlapping allocations, so a - /// range and a single-port forward can never coexist at the same start. + /// Shared across all targets at this `external` start — `AvailablePorts` + /// prevents overlap, so a range and a single-port forward can't coexist here. count: u16, targets: BTreeMap)>, - // Maps source SocketAddr -> strong reference for the forward created in PortForwardController forwards: BTreeMap>, + // (local IP, external port) pairs we've asked the upstream gateway (via + // PCP/NAT-PMP/UPnP) to forward here. Tracked so the mapping is withdrawn + // when the forward is dropped; a range contributes one entry per port. + mapped: BTreeSet<(Ipv4Addr, u16)>, } impl IdOrdItem for InterfaceForwardEntry { @@ -590,6 +599,7 @@ impl InterfaceForwardEntry { count, targets: BTreeMap::new(), forwards: BTreeMap::new(), + mapped: BTreeSet::new(), } } @@ -597,8 +607,17 @@ impl InterfaceForwardEntry { &mut self, ip_info: &OrdMap, port_forward: &PortForwardController, + pmap: &PortMapController, ) -> Result<(), Error> { let mut keep = BTreeSet::::new(); + // (local IP, external start) -> (port count, internal start, candidate + // upstream gateways) to open upstream. The internal port is the target's, + // so the gateway maps external->internal faithfully (e.g. an 80->443 + // redirect); it equals the external for ordinary port-preserving forwards. + // Only public (WAN-facing) forwards need this; private subnets are already + // reachable. A `count > 1` range is one PCP PORT_SET request (RFC 7753), + // skipped on gateways without it (UPnP/NAT-PMP can't map ranges). + let mut want = BTreeMap::<(Ipv4Addr, u16), (u16, u16, Vec)>::new(); for (gw_id, info) in ip_info.iter() { if let Some(ip_info) = &info.ip_info { @@ -617,7 +636,8 @@ impl InterfaceForwardEntry { continue; } - let src_filter = if reqs.public_gateways.contains(gw_id) { + let public = reqs.public_gateways.contains(gw_id); + let src_filter = if public { None } else if reqs.private_ips.contains(&IpAddr::V4(ip)) { Some(subnet.trunc()) @@ -626,6 +646,20 @@ impl InterfaceForwardEntry { }; keep.insert(addr); + if public { + // The gateway forwards to the port StartOS listens + // on at its LAN IP (== external); our own nftables + // rule DNATs that to the container target locally. + let internal = self.external; + want.entry((ip, self.external)).or_insert_with(|| { + let gws = candidate_gateways(info); + tracing::debug!( + "auto-port-mapping {ip}:{}->{internal} on gateway {gw_id} via {gws:?} (reqs {reqs})", + self.external, + ); + (self.count, internal, gws) + }); + } let fwd_rc = port_forward .add_forward_range( addr, @@ -643,9 +677,21 @@ impl InterfaceForwardEntry { } } - // Remove forwards that should no longer exist (drops the strong references) + // Dropping the strong refs lets PortForwardController gc the rules. self.forwards.retain(|addr, _| keep.contains(addr)); + for (ip, port) in self.mapped.iter().filter(|key| !want.contains_key(key)) { + pmap.remove(*ip, *port); + } + for ((ip, external), (count, internal, gateways)) in &want { + if *count > 1 { + pmap.ensure_range(*ip, *external, *internal, *count, gateways.clone()); + } else { + pmap.ensure(*ip, *external, *internal, gateways.clone()); + } + } + self.mapped = want.into_keys().collect(); + Ok(()) } @@ -661,6 +707,7 @@ impl InterfaceForwardEntry { }: InterfaceForwardRequest, ip_info: &OrdMap, port_forward: &PortForwardController, + pmap: &PortMapController, ) -> Result, Error> { if external != self.external { return Err(Error::new( @@ -669,14 +716,10 @@ impl InterfaceForwardEntry { )); } if count != self.count { - // The count changed because the range was resized, or a single-port - // forward and a range swapped at this external start (AvailablePorts - // freed the old allocation and handed the same start port back). - // The old count no longer applies: adopt the new one and rebuild - // this entry's forwards from scratch so the underlying iptables - // rules (whose chain name encodes the count) are reconciled cleanly. - // `state` entries are never evicted, so without this a count change - // at a reused external port would be a hard error until restart. + // A resize, or a single-port forward and a range swapped at this + // reused start port. The nft chain name encodes the count, so rebuild + // from scratch; `state` entries are never evicted, so otherwise a + // count change here would be a hard error until restart. self.count = count; self.targets.clear(); self.forwards.clear(); @@ -697,7 +740,7 @@ impl InterfaceForwardEntry { entry.2 = Arc::downgrade(&rc); } - self.update(ip_info, port_forward).await?; + self.update(ip_info, port_forward, pmap).await?; Ok(rc) } @@ -706,22 +749,25 @@ impl InterfaceForwardEntry { &mut self, ip_info: &OrdMap, port_forward: &PortForwardController, + pmap: &PortMapController, ) -> Result<(), Error> { self.targets.retain(|_, (_, _, rc)| rc.strong_count() > 0); - self.update(ip_info, port_forward).await + self.update(ip_info, port_forward, pmap).await } } struct InterfaceForwardState { port_forward: PortForwardController, + pmap: PortMapController, state: IdOrdMap, } impl InterfaceForwardState { - fn new(port_forward: PortForwardController) -> Self { + fn new(port_forward: PortForwardController, pmap: PortMapController) -> Self { Self { port_forward, + pmap, state: IdOrdMap::new(), } } @@ -737,7 +783,7 @@ impl InterfaceForwardState { self.state .entry(request.external) .or_insert_with(|| InterfaceForwardEntry::new(request.external, count)) - .update_request(request, ip_info, &self.port_forward) + .update_request(request, ip_info, &self.port_forward, &self.pmap) .await } @@ -746,7 +792,7 @@ impl InterfaceForwardState { ip_info: &OrdMap, ) -> Result<(), Error> { for mut entry in self.state.iter_mut() { - entry.gc(ip_info, &self.port_forward).await?; + entry.gc(ip_info, &self.port_forward, &self.pmap).await?; } self.port_forward.gc().await @@ -812,12 +858,15 @@ pub struct InterfacePortForwardController { } impl InterfacePortForwardController { - pub fn new(mut ip_info: Watch>) -> Self { + pub fn new( + mut ip_info: Watch>, + pmap: PortMapController, + ) -> Self { let port_forward = PortForwardController::new(); let (req_send, mut req_recv) = mpsc::unbounded_channel::(); let thread = NonDetachingJoinHandle::from(tokio::spawn(async move { - let mut state = InterfaceForwardState::new(port_forward); + let mut state = InterfaceForwardState::new(port_forward, pmap); let mut interfaces = ip_info.read_and_mark_seen(); loop { tokio::select! { @@ -863,9 +912,9 @@ impl InterfacePortForwardController { .await } - /// Add a `count`-port contiguous forward starting at `external` / - /// `target.port()`. `count == 1` is equivalent to [`add`]. For `count > - /// 1` the external and target bases may differ (offset-mapped). + /// Add a `count`-port contiguous forward from `external` / `target.port()`. + /// `count == 1` equals [`add`]; for `count > 1` the bases may differ + /// (offset-mapped). pub async fn add_range( &self, external: u16, diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index b60015930b..fe90eb5c0a 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -157,6 +157,350 @@ async fn forget_iface( ctx.net_controller.net_iface.forget(&gateway).await } +/// The WireGuard preshared key NetworkManager holds for `interface`'s peer, if +/// any. `GetSecrets` is root-only, so a sandboxed service can't read it — which +/// is what makes a TSIG key derived from it unforgeable. `None` for a +/// non-WireGuard gateway or a peer configured without a PSK. +pub(crate) async fn wireguard_psk(interface: &str) -> Result, Error> { + let connection = Connection::system().await?; + let netman = NetworkManagerProxy::new(&connection).await?; + let device = netman.get_device_by_ip_iface(interface).await?; + if &*device == "/" { + return Ok(None); + } + let device = DeviceProxy::new(&connection, device).await?; + let ac = device.active_connection().await?; + if &*ac == "/" { + return Ok(None); + } + let ac = active_connection::ActiveConnectionProxy::new(&connection, ac).await?; + let settings = ConnectionSettingsProxy::new(&connection, ac.connection().await?).await?; + let secrets = settings.get_secrets("wireguard").await?; + let Some(peers) = secrets.get("wireguard").and_then(|wg| wg.get("peers")) else { + return Ok(None); + }; + let peers = peers + .downcast_ref::() + .with_kind(ErrorKind::Network)?; + for peer in peers.inner() { + let Ok(dict) = peer.downcast_ref::() else { + continue; + }; + let psk = dict + .get::<_, String>(&zbus::zvariant::Str::from_static("preshared-key")) + .with_kind(ErrorKind::Network)?; + if let Some(psk) = psk.filter(|s| !s.is_empty()) { + return Ok(Some(psk.parse::>()?.0)); + } + } + Ok(None) +} + +/// Update2 flag: persist the change to disk (NM_SETTINGS_UPDATE2_FLAG_TO_DISK). +const NM_UPDATE2_TO_DISK: u32 = 0x1; +/// Resolver priority for an imported WireGuard config's `DNS =` — preferred but +/// not exclusive (positive, below the LAN default). Mirrors import_wireguard. +const WG_DNS_PRIORITY: i32 = 10; + +struct WgPeer { + public_key: String, + preshared_key: Option, + endpoint: Option, + persistent_keepalive: Option, +} + +/// The fields of a WireGuard `.conf` we map onto a NetworkManager connection. +/// `AllowedIPs` is intentionally dropped: like `sanitize_config` (tunnel.rs) we +/// force a full-tunnel `0.0.0.0/0, ::/0` so every gateway routes the same way. +struct WgConfig { + private_key: String, + addresses: Vec<(IpAddr, u8)>, + dns: Vec, + listen_port: Option, + peers: Vec, +} + +impl WgConfig { + fn parse(config: &str) -> Result { + let invalid = |what: &str| { + Error::new( + eyre!("invalid WireGuard config: {what}"), + ErrorKind::InvalidRequest, + ) + }; + enum Section { + None, + Interface, + Peer, + } + let mut section = Section::None; + let mut private_key = None; + let mut addresses = Vec::new(); + let mut dns = Vec::new(); + let mut listen_port = None; + let mut peers: Vec = Vec::new(); + + for raw in config.lines() { + let line = raw.split(['#', ';']).next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + if line.eq_ignore_ascii_case("[interface]") { + section = Section::Interface; + continue; + } + if line.eq_ignore_ascii_case("[peer]") { + section = Section::Peer; + peers.push(WgPeer { + public_key: String::new(), + preshared_key: None, + endpoint: None, + persistent_keepalive: None, + }); + continue; + } + let Some((key, val)) = line.split_once('=') else { + continue; + }; + let (key, val) = (key.trim().to_ascii_lowercase(), val.trim()); + match section { + Section::Interface => match key.as_str() { + "privatekey" => private_key = Some(val.to_string()), + "address" => { + for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) { + let (ip, prefix) = match part.split_once('/') { + Some((ip, p)) => ( + ip.trim().parse().map_err(|_| invalid("address"))?, + p.trim().parse().map_err(|_| invalid("prefix"))?, + ), + None => { + let ip: IpAddr = part.parse().map_err(|_| invalid("address"))?; + (ip, if ip.is_ipv4() { 32 } else { 128 }) + } + }; + if (ip.is_ipv4() && prefix > 32) || (ip.is_ipv6() && prefix > 128) { + return Err(invalid("address prefix out of range")); + } + addresses.push((ip, prefix)); + } + } + "dns" => { + for part in val.split(',').map(str::trim) { + if let Ok(ip) = part.parse::() { + dns.push(ip); + } + } + } + "listenport" => listen_port = val.parse().ok(), + _ => {} + }, + Section::Peer => { + if let Some(p) = peers.last_mut() { + match key.as_str() { + "publickey" => p.public_key = val.to_string(), + "presharedkey" => p.preshared_key = Some(val.to_string()), + "endpoint" => p.endpoint = Some(val.to_string()), + "persistentkeepalive" => p.persistent_keepalive = val.parse().ok(), + _ => {} + } + } + } + Section::None => {} + } + } + + Ok(Self { + private_key: private_key.ok_or_else(|| invalid("missing PrivateKey"))?, + addresses, + dns, + listen_port, + peers, + }) + } + + /// Build the NetworkManager settings dict that `nmcli connection import` of + /// this (sanitized) config would produce, so an in-place Update2 is + /// equivalent to a fresh import. + fn to_nm_settings( + &self, + iface: &str, + uuid: Option<&str>, + ) -> Result>, Error> { + let ov = |v: ZValue<'_>| v.try_to_owned().with_kind(ErrorKind::Network); + let mut settings: HashMap> = HashMap::new(); + + let mut conn = HashMap::from([ + ("id".into(), ov(iface.into())?), + ("type".into(), ov("wireguard".into())?), + ("interface-name".into(), ov(iface.into())?), + ]); + // On add NM generates the uuid; on update we keep the existing one so the + // profile is replaced rather than redefined. + if let Some(uuid) = uuid { + conn.insert("uuid".into(), ov(uuid.into())?); + } + settings.insert("connection".into(), conn); + + let mut wg: HashMap = HashMap::new(); + wg.insert("private-key".into(), ov(self.private_key.as_str().into())?); + if let Some(port) = self.listen_port { + wg.insert("listen-port".into(), ov(port.into())?); + } + let peers: Vec> = self + .peers + .iter() + .map(|p| { + let mut d: HashMap = HashMap::new(); + d.insert("public-key".into(), p.public_key.as_str().into()); + if let Some(psk) = &p.preshared_key { + d.insert("preshared-key".into(), psk.as_str().into()); + } + if let Some(ep) = &p.endpoint { + d.insert("endpoint".into(), ep.as_str().into()); + } + if let Some(ka) = p.persistent_keepalive { + d.insert("persistent-keepalive".into(), ka.into()); + } + d.insert( + "allowed-ips".into(), + vec!["0.0.0.0/0".to_string(), "::/0".to_string()].into(), + ); + d + }) + .collect(); + wg.insert("peers".into(), ov(peers.into())?); + settings.insert("wireguard".into(), wg); + + for (proto, is_v4) in [("ipv4", true), ("ipv6", false)] { + let addrs: Vec<_> = self + .addresses + .iter() + .filter(|(ip, _)| ip.is_ipv4() == is_v4) + .collect(); + let dns: Vec<_> = self.dns.iter().filter(|ip| ip.is_ipv4() == is_v4).collect(); + let mut s: HashMap = HashMap::new(); + if addrs.is_empty() { + s.insert("method".into(), ov("disabled".into())?); + } else { + s.insert("method".into(), ov("manual".into())?); + let address_data: Vec> = addrs + .iter() + .map(|(ip, prefix)| { + HashMap::from([ + ("address".into(), ip.to_string().into()), + ("prefix".into(), (*prefix as u32).into()), + ]) + }) + .collect(); + s.insert("address-data".into(), ov(address_data.into())?); + // import_wireguard sets dns-priority on every active protocol, + // regardless of whether this config carries resolvers. + s.insert("dns-priority".into(), ov(WG_DNS_PRIORITY.into())?); + if !dns.is_empty() { + let dns_val = if is_v4 { + // ipv4.dns is au: the address octets as a little-endian u32. + ZValue::from( + dns.iter() + .filter_map(|ip| match ip { + IpAddr::V4(v4) => Some(u32::from_le_bytes(v4.octets())), + _ => None, + }) + .collect::>(), + ) + } else { + // ipv6.dns is aay: each address as 16 raw bytes. + ZValue::from( + dns.iter() + .filter_map(|ip| match ip { + IpAddr::V6(v6) => Some(v6.octets().to_vec()), + _ => None, + }) + .collect::>>(), + ) + }; + s.insert("dns".into(), ov(dns_val)?); + s.insert("dns-search".into(), ov(vec!["~".to_string()].into())?); + s.insert("dns-priority".into(), ov(WG_DNS_PRIORITY.into())?); + } + } + settings.insert(proto.into(), s); + } + + Ok(settings) + } +} + +/// Apply a re-issued WireGuard config to `interface`'s existing NM connection in +/// place via D-Bus Update2 + Device.Reapply. The wg device is never deleted, so +/// the tunnel — and the request that triggered the update, when it rides this +/// same tunnel — is not torn down. Reapply applies the change to the live device +/// without bringing the interface down (NM supports this for WireGuard). +pub(crate) async fn update_wireguard_config(interface: &str, config: &str) -> Result<(), Error> { + let parsed = WgConfig::parse(config)?; + + let connection = Connection::system().await?; + let netman = NetworkManagerProxy::new(&connection).await?; + let device = netman.get_device_by_ip_iface(interface).await?; + if &*device == "/" { + return Err(Error::new( + eyre!("{interface} not found in NetworkManager"), + ErrorKind::NotFound, + )); + } + let device_proxy = DeviceProxy::new(&connection, device).await?; + let ac = device_proxy.active_connection().await?; + if &*ac == "/" { + return Err(Error::new( + eyre!("{interface} has no active connection to update"), + ErrorKind::InvalidRequest, + )); + } + let ac_proxy = active_connection::ActiveConnectionProxy::new(&connection, ac).await?; + let settings = + ConnectionSettingsProxy::new(&connection, ac_proxy.connection().await?).await?; + + // Preserve the connection's uuid so this updates the existing profile rather + // than defining a new one. + let existing = settings.get_settings().await?; + let uuid = existing + .get("connection") + .and_then(|c| c.get("uuid")) + .and_then(|u| u.downcast_ref::().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| { + Error::new( + eyre!("{interface} connection is missing its uuid"), + ErrorKind::Network, + ) + })?; + + settings + .update2( + parsed.to_nm_settings(interface, Some(uuid.as_str()))?, + NM_UPDATE2_TO_DISK, + HashMap::new(), + ) + .await?; + device_proxy.reapply(HashMap::new(), 0, 0).await?; + Ok(()) +} + +/// Create `interface`'s WireGuard connection from a config and bring it up, using +/// the same parse + conversion as [`update_wireguard_config`] so an added gateway +/// and an updated one are byte-for-byte identical. Replaces the old +/// `nmcli connection import` helper. +pub(crate) async fn add_wireguard_config(interface: &str, config: &str) -> Result<(), Error> { + let settings = WgConfig::parse(config)?.to_nm_settings(interface, None)?; + let connection = Connection::system().await?; + let netman = NetworkManagerProxy::new(&connection).await?; + // `/` for device + specific_object: NM creates the virtual wg device on activation. + let root = zbus::zvariant::ObjectPath::try_from("/").with_kind(ErrorKind::Network)?; + netman + .add_and_activate_connection(settings, &root, &root) + .await?; + Ok(()) +} + #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[group(skip)] #[ts(export)] @@ -249,6 +593,32 @@ pub async fn check_port( _ => None, }) .unwrap_or(Ipv4Addr::UNSPECIFIED); + + // If automatic port forwarding (PCP/NAT-PMP/UPnP) already succeeded for this + // port, it's reachable — skip the remote echo service and report success. + // The mapping reports the gateway-assigned external IP, so we still know our + // public address without a third-party round-trip. + for ip in ip_info.subnets.iter().filter_map(|s| match s.addr() { + IpAddr::V4(v4) => Some(v4), + _ => None, + }) { + if let Ok(Some(IpAddr::V4(ip))) = tokio::time::timeout( + Duration::from_secs(2), + ctx.net_controller.port_map.mapped_external_ip(ip, port), + ) + .await + { + let hairpinning = check_hairpin(gateway, local_ipv4, ip, port).await; + return Ok(CheckPortRes { + ip, + port, + open_externally: true, + open_internally, + hairpinning, + }); + } + } + let client = reqwest::Client::builder(); #[cfg(target_os = "linux")] let client = client @@ -332,14 +702,19 @@ async fn check_hairpin(_: GatewayId, _: Ipv4Addr, _: Ipv4Addr, _: u16) -> bool { pub struct CheckDnsParams { #[arg(help = "help.arg.gateway-id")] pub gateway: GatewayId, + #[arg(help = "help.arg.fqdn")] + pub fqdn: InternedString, } +/// Verify a private domain works on the LAN by asking the LAN's own DNS +/// server(s) for `fqdn` and confirming the answer is one of this server's +/// addresses on that gateway — i.e. the record actually resolves correctly on +/// the LAN, rather than merely checking whether we are the LAN's DNS server. pub async fn check_dns( ctx: RpcContext, - CheckDnsParams { gateway }: CheckDnsParams, + CheckDnsParams { gateway, fqdn }: CheckDnsParams, ) -> Result { use hickory_server::net::runtime::TokioRuntimeProvider; - use hickory_server::proto::rr::RData; use hickory_server::resolver::Resolver; use hickory_server::resolver::config::{ResolverConfig, ResolverOpts}; @@ -356,49 +731,34 @@ pub async fn check_dns( ) })?; - for dns_ip in &gw_ip_info.dns_servers { - // Case 1: DHCP DNS == server IP → immediate success - if gw_ip_info.subnets.iter().any(|s| s.addr() == *dns_ip) { - return Ok(true); - } + // The private domain should resolve to one of our addresses on this LAN. + let expected: BTreeSet = gw_ip_info.subnets.iter().map(|s| s.addr()).collect(); + if expected.is_empty() { + return Ok(false); + } - // Case 2: DHCP DNS is on LAN but not the server → TXT challenge check - if gw_ip_info.subnets.iter().any(|s| s.contains(dns_ip)) { - let nonce = rand::random::(); - let challenge_domain = InternedString::intern(format!("_dns-check-{nonce}.startos")); - let challenge_value = - InternedString::intern(crate::rpc_continuations::Guid::new().as_ref()); - - let _guard = ctx - .net_controller - .dns - .add_challenge(challenge_domain.clone(), challenge_value.clone())?; - - let mut config = ResolverConfig::from_parts(None, Vec::new(), Vec::new()); - config.add_name_server(forward_name_server(SocketAddr::new(*dns_ip, 53))); - let mut opts = ResolverOpts::default(); - opts.timeout = Duration::from_secs(5); - opts.attempts = 1; - - let resolver = Resolver::builder_with_config(config, TokioRuntimeProvider::default()) - .with_options(opts) - .build() - .map_err(|e| Error::new(eyre!("{e}"), ErrorKind::Network))?; - let txt_lookup = resolver.txt_lookup(&*challenge_domain).await; - - return Ok(match txt_lookup { - Ok(lookup) => lookup.answers().iter().any(|record| { - matches!(&record.data, RData::TXT(txt) if txt - .txt_data - .iter() - .any(|data| data.as_ref() == challenge_value.as_bytes())) - }), - Err(_) => false, - }); + // Query each resolver this gateway advertises — for a StartTunnel link this + // is the imported config's `DNS =` (the in-tunnel `.1` that injects/serves + // the record), now tracked in dns_servers (see poll_ip_info). + for dns_ip in &gw_ip_info.dns_servers { + let mut config = ResolverConfig::from_parts(None, Vec::new(), Vec::new()); + config.add_name_server(forward_name_server(SocketAddr::new(*dns_ip, 53))); + let mut opts = ResolverOpts::default(); + opts.timeout = Duration::from_secs(5); + opts.attempts = 1; + + let resolver = Resolver::builder_with_config(config, TokioRuntimeProvider::default()) + .with_options(opts) + .build() + .map_err(|e| Error::new(eyre!("{e}"), ErrorKind::Network))?; + + if let Ok(lookup) = resolver.lookup_ip(&*fqdn).await { + if lookup.iter().any(|ip| expected.contains(&ip)) { + return Ok(true); + } } } - // Case 3: No DNS servers in subnet → failure Ok(false) } @@ -485,6 +845,16 @@ pub async fn set_outbound_gateway( trait NetworkManager { fn get_device_by_ip_iface(&self, iface: &str) -> Result; + /// Add a persistent connection from the given settings and activate it in one + /// call. Pass `/` for device/specific_object to let NM pick or create the + /// device (a virtual WireGuard interface is created on activation). + fn add_and_activate_connection( + &self, + connection: HashMap>, + device: &zbus::zvariant::ObjectPath<'_>, + specific_object: &zbus::zvariant::ObjectPath<'_>, + ) -> Result<(OwnedObjectPath, OwnedObjectPath), Error>; + #[zbus(property)] fn all_devices(&self) -> Result, Error>; @@ -538,6 +908,11 @@ trait ConnectionSettings { fn get_settings(&self) -> Result>, Error>; + fn get_secrets( + &self, + setting_name: &str, + ) -> Result>, Error>; + fn update2( &self, settings: HashMap>, @@ -578,8 +953,10 @@ trait Ip6Config { #[zbus(property)] fn gateway(&self) -> Result; + // IP6Config has no `NameserverData`; its resolvers are `Nameservers` (aay), + // each a raw 16-byte address. #[zbus(property)] - fn nameserver_data(&self) -> Result, Error>; + fn nameservers(&self) -> Result>, Error>; } #[derive(Clone, Debug, DeserializeDict, ZValue, ZType)] @@ -637,8 +1014,10 @@ impl TryFrom for Dhcp4Options { } mod device { + use std::collections::HashMap; + use zbus::proxy; - use zbus::zvariant::OwnedObjectPath; + use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use crate::prelude::*; @@ -649,6 +1028,16 @@ mod device { pub trait Device { fn delete(&self) -> Result<(), Error>; + /// Apply changed connection settings to the live device without + /// deactivating it. Empty `connection` reapplies the active connection's + /// current (just-updated) settings; `version_id` 0 skips the version check. + fn reapply( + &self, + connection: HashMap>, + version_id: u64, + flags: u32, + ) -> Result<(), Error>; + #[zbus(property)] fn ip_interface(&self) -> Result; @@ -822,6 +1211,12 @@ async fn watcher( } } +/// Sentinel key used to rate-limit the per-interface UPnP WAN-IP probe inside +/// the echoip rate-limit map (it is not a real echoip URL and is never fetched). +fn upnp_probe_key() -> Url { + Url::parse("upnp://get-external-ip-address").unwrap() +} + async fn get_wan_ipv4( iface: &str, base_url: &Url, @@ -953,6 +1348,9 @@ async fn reconcile_mangle_rules(policy_ifaces: &BTreeMap) -> Res script.push_str( "add rule ip startos mangle_prerouting meta mark 0x00000000 meta mark set ct mark comment \"restore-mark\"\n", ); + // Divert replies destined to a local transparent (SNI-demux) socket back into + // it: mark them so the priority-49 ip rule routes them to the local table. + script.push_str(&crate::net::transparent::divert_mark_rule()); script.push_str( "add rule ip startos mangle_output meta mark 0x00000000 meta mark set ct mark comment \"restore-mark\"\n", ); @@ -1065,7 +1463,7 @@ async fn watch_ip( .with_stream(ip6_proxy.receive_address_data_changed().await.stub()) .with_stream(ip6_proxy.receive_gateway_changed().await.stub()) .with_stream( - ip6_proxy.receive_nameserver_data_changed().await.stub(), + ip6_proxy.receive_nameservers_changed().await.stub(), ); let dhcp4_proxy = if &*dhcp4_config != "/" { @@ -1304,6 +1702,24 @@ async fn poll_ip_info( ); } } + // Applied resolvers from the active IP config — the only source for a static + // link, e.g. an imported WireGuard config's `DNS =` (NM stores it as + // ipv4.dns and surfaces it here); DHCP options miss it entirely. + dns_servers.extend( + ip4_proxy + .nameserver_data() + .await? + .into_iter() + .filter_map(|ns| ns.address.parse::().log_err()), + ); + dns_servers.extend( + ip6_proxy + .nameservers() + .await? + .into_iter() + .filter_map(|raw| <[u8; 16]>::try_from(raw).ok()) + .map(|octets| IpAddr::V6(Ipv6Addr::from(octets))), + ); let scope_id = if_nametoindex(iface.as_str()).with_kind(ErrorKind::Network)?; let subnets: OrdSet = addresses.into_iter().map(IpNet::try_from).try_collect()?; @@ -1369,23 +1785,45 @@ async fn poll_ip_info( }; let mut wan_ip = None; let mut err = None; + let local_ipv4 = subnets.iter().find_map(|s| match s.addr() { + IpAddr::V4(v4) => Some(v4), + _ => None, + }); + let forwardable = !subnets.is_empty() + && !matches!( + device_type, + Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback) + ); + + // Ask the gateway's own UPnP IGD first. This works for a home router and + // for a StartTunnel gateway, which answers GetExternalIPAddress over the + // WireGuard link (see crate::tunnel::forward::igd). Rate-limited like echoip below, + // keyed by a sentinel URL in the shared rate-limit map. + if let Some(local_ipv4) = local_ipv4.filter(|_| forwardable) { + let upnp_probe_key = upnp_probe_key(); + if echoip_ratelimit_state + .get(&upnp_probe_key) + .map_or(true, |i| i.elapsed() > Duration::from_secs(300)) + { + echoip_ratelimit_state.insert(upnp_probe_key, Instant::now()); + match crate::net::port_map::upnp::get_external_ipv4(local_ipv4).await { + Ok(Some(ip)) => wan_ip = Some(ip), + Ok(None) => (), + Err(e) => tracing::debug!("UPnP WAN IP probe on {iface} failed: {e}"), + } + } + } + for echoip_url in echoip_urls { + if wan_ip.is_some() { + break; + } if echoip_ratelimit_state .get(&echoip_url) .map_or(true, |i| i.elapsed() > Duration::from_secs(300)) - && !subnets.is_empty() - && !matches!( - device_type, - Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback) - ) + && forwardable { - let local_ipv4 = subnets - .iter() - .find_map(|s| match s.addr() { - IpAddr::V4(v4) => Some(v4), - _ => None, - }) - .unwrap_or(Ipv4Addr::UNSPECIFIED); + let local_ipv4 = local_ipv4.unwrap_or(Ipv4Addr::UNSPECIFIED); match get_wan_ipv4(iface.as_str(), &echoip_url, local_ipv4).await { Ok(a) => { wan_ip = a; @@ -2115,3 +2553,105 @@ impl Accept for WildcardListener { Poll::Pending } } + +#[cfg(test)] +mod wg_config_tests { + use super::*; + + fn as_str(v: &OwnedValue) -> String { + v.downcast_ref::().unwrap().to_string() + } + + #[test] + fn parses_interface_and_peer() { + let config = "[Interface]\n\ + PrivateKey = aPrivateKey=\n\ + Address = 10.59.0.2/24, fd00::2/64\n\ + DNS = 10.59.0.1\n\ + ListenPort = 51820\n\ + \n\ + [Peer] # the tunnel server\n\ + PublicKey = aPublicKey=\n\ + PresharedKey = aPSK=\n\ + Endpoint = 1.2.3.4:51820\n\ + AllowedIPs = 10.0.0.0/8\n\ + PersistentKeepalive = 25\n"; + let p = WgConfig::parse(config).unwrap(); + assert_eq!(p.private_key, "aPrivateKey="); + assert_eq!( + p.addresses, + vec![ + (IpAddr::V4(Ipv4Addr::new(10, 59, 0, 2)), 24), + (IpAddr::V6("fd00::2".parse().unwrap()), 64), + ] + ); + assert_eq!(p.dns, vec![IpAddr::V4(Ipv4Addr::new(10, 59, 0, 1))]); + assert_eq!(p.listen_port, Some(51820)); + assert_eq!(p.peers.len(), 1); + assert_eq!(p.peers[0].public_key, "aPublicKey="); + assert_eq!(p.peers[0].preshared_key.as_deref(), Some("aPSK=")); + assert_eq!(p.peers[0].endpoint.as_deref(), Some("1.2.3.4:51820")); + assert_eq!(p.peers[0].persistent_keepalive, Some(25)); + } + + // ipv4.dns is `au` with each address as little-endian octets — the format we + // captured from a real NM 1.52 import (10.x -> from_le_bytes). A regression + // here would silently point the gateway at the wrong DNS server. + #[test] + fn builds_nm_settings_matching_import() { + let config = "[Interface]\n\ + PrivateKey = aPrivateKey=\n\ + Address = 10.59.0.2/24\n\ + DNS = 10.59.0.1\n\ + [Peer]\n\ + PublicKey = aPublicKey=\n\ + Endpoint = 1.2.3.4:51820\n\ + AllowedIPs = 10.0.0.0/8\n"; + let s = WgConfig::parse(config) + .unwrap() + .to_nm_settings("wg0", Some("the-uuid")) + .unwrap(); + + assert_eq!(as_str(&s["connection"]["uuid"]), "the-uuid"); + assert_eq!(as_str(&s["connection"]["type"]), "wireguard"); + assert_eq!(as_str(&s["connection"]["interface-name"]), "wg0"); + assert_eq!(as_str(&s["wireguard"]["private-key"]), "aPrivateKey="); + + let ipv4 = &s["ipv4"]; + assert_eq!(as_str(&ipv4["method"]), "manual"); + let dns = Vec::::try_from(ipv4["dns"].try_clone().unwrap()).unwrap(); + assert_eq!(dns, vec![u32::from_le_bytes([10, 59, 0, 1])]); + let priority = i32::try_from(ipv4["dns-priority"].try_clone().unwrap()).unwrap(); + assert_eq!(priority, WG_DNS_PRIORITY); + + // No ipv6 address in this config, so ipv6 is disabled (matches import). + assert_eq!(as_str(&s["ipv6"]["method"]), "disabled"); + + // Peer's allowed-ips is forced to full-tunnel regardless of the config. + let peers = Vec::>::try_from( + s["wireguard"]["peers"].try_clone().unwrap(), + ) + .unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(as_str(&peers[0]["public-key"]), "aPublicKey="); + let allowed = Vec::::try_from(peers[0]["allowed-ips"].try_clone().unwrap()).unwrap(); + assert_eq!(allowed, vec!["0.0.0.0/0".to_string(), "::/0".to_string()]); + } + + #[test] + fn rejects_out_of_range_prefix() { + assert!(WgConfig::parse("[Interface]\nPrivateKey = k=\nAddress = 10.0.0.2/40\n").is_err()); + assert!(WgConfig::parse("[Interface]\nPrivateKey = k=\nAddress = fd00::2/200\n").is_err()); + } + + // add() lets NM generate the uuid; update() preserves the existing one so the + // same profile is replaced in place rather than redefined. + #[test] + fn uuid_present_only_on_update() { + let parsed = + WgConfig::parse("[Interface]\nPrivateKey = k=\nAddress = 10.0.0.2/24\n").unwrap(); + assert!(!parsed.to_nm_settings("wg0", None).unwrap()["connection"].contains_key("uuid")); + let updated = parsed.to_nm_settings("wg0", Some("uuid-x")).unwrap(); + assert_eq!(as_str(&updated["connection"]["uuid"]), "uuid-x"); + } +} diff --git a/core/src/net/host/address.rs b/core/src/net/host/address.rs index 6e73cfc28a..8d46d9827a 100644 --- a/core/src/net/host/address.rs +++ b/core/src/net/host/address.rs @@ -413,7 +413,7 @@ pub async fn add_private_domain( .await .result?; - check_dns(ctx, CheckDnsParams { gateway }).await + check_dns(ctx, CheckDnsParams { gateway, fqdn }).await } pub async fn remove_private_domain( diff --git a/core/src/net/mod.rs b/core/src/net/mod.rs index c8e025ec61..354b102fab 100644 --- a/core/src/net/mod.rs +++ b/core/src/net/mod.rs @@ -2,6 +2,7 @@ use rpc_toolkit::{Context, HandlerExt, ParentHandler}; pub mod acme; pub mod dns; +pub mod dns_update; pub mod forward; pub mod gateway; pub mod host; @@ -9,11 +10,13 @@ pub mod http; pub mod keys; pub mod mdns; pub mod net_controller; +pub mod port_map; pub mod service_interface; pub mod socks; pub mod ssl; pub mod static_server; pub mod tls; +pub mod transparent; pub mod tunnel; pub mod utils; pub mod vhost; diff --git a/core/src/net/net_controller.rs b/core/src/net/net_controller.rs index a45b1726e7..ab9c3c53a8 100644 --- a/core/src/net/net_controller.rs +++ b/core/src/net/net_controller.rs @@ -16,12 +16,14 @@ use crate::db::model::Database; use crate::db::model::public::GatewayType; use crate::hostname::ServerHostname; use crate::net::dns::DnsController; +use crate::net::dns_update::DnsUpdateController; use crate::net::forward::{ ForwardRequirements, InterfacePortForwardController, START9_BRIDGE_IFACE, nft_rule, }; use crate::net::gateway::NetworkInterfaceController; use crate::net::host::binding::{AddSslOptions, BindId, BindOptions, RangeGatewayAccess}; use crate::net::host::{Host, Hosts, host_for}; +use crate::net::port_map::{PortMapController, candidate_gateways}; use crate::net::service_interface::HostnameMetadata; use crate::net::socks::SocksController; use crate::net::vhost::{AlpnInfo, DynVHostTarget, ProxyTarget, VHostController}; @@ -38,7 +40,9 @@ pub struct NetController { pub(super) tls_client_config: Arc, pub(crate) net_iface: Arc, pub(super) dns: DnsController, + pub(super) dns_update: DnsUpdateController, pub(super) forward: InterfacePortForwardController, + pub(crate) port_map: PortMapController, pub(super) socks: SocksController, pub(crate) callbacks: Arc, } @@ -82,6 +86,9 @@ impl NetController { let hostname = peek.as_public().as_server_info().as_hostname().de()?; drop(peek); let branding = crate::net::ssl::CertBranding::start_os(&hostname); + // One PortMapController shared by the forward and vhost controllers so a + // single query answers "is this port automatically forwarded?". + let port_map = PortMapController::new(); Ok(Self { db: db.clone(), vhost: VHostController::new( @@ -91,10 +98,27 @@ impl NetController { branding, passthroughs, max_proxy_conns_per_target, + port_map.clone(), ), tls_client_config, dns: DnsController::init(db, &net_iface.watcher).await?, - forward: InterfacePortForwardController::new(net_iface.watcher.subscribe()), + dns_update: DnsUpdateController::new( + net_iface.watcher.subscribe(), + Arc::new(|gw: GatewayId| -> std::pin::Pin< + Box, ()>> + Send>, + > { + Box::pin(async move { + crate::net::gateway::wireguard_psk(gw.as_str()) + .await + .map_err(|_| ()) + }) + }), + ), + forward: InterfacePortForwardController::new( + net_iface.watcher.subscribe(), + port_map.clone(), + ), + port_map, net_iface, socks, callbacks: Arc::new(ServiceCallbacks::default()), @@ -163,6 +187,11 @@ struct HostBinds { forwards: BTreeMap)>, vhosts: BTreeMap<(Option, u16), (ProxyTarget, Arc<()>)>, private_dns: BTreeMap>, + /// Device LAN IPs for which we've asked the upstream gateway to map external + /// 80 -> 443 (the HTTP→HTTPS redirect — a pure PortMapController mapping with + /// no LAN forward, since StartOS serves 80/443 itself). Tracked so the + /// mapping is withdrawn when 443 stops being publicly exposed. + redirect_maps: BTreeSet, } pub struct NetServiceData { @@ -333,6 +362,16 @@ impl NetServiceData { .flat_map(|a| a.metadata.gateways()) .cloned() .collect(); + // Declare which address makes each gateway public, so a stray + // auto-port-map can be traced back to the exposure driving it. + for a in enabled_addresses.iter().filter(|a| a.public) { + tracing::debug!( + "port {external}: public address {} (ip={}) on gateway(s) {:?}", + a.hostname, + a.metadata.is_ip(), + a.metadata.gateways().collect::>(), + ); + } let fwd_private: BTreeSet = enabled_addresses .iter() .filter(|a| !a.public) @@ -455,6 +494,56 @@ impl NetServiceData { } } + // Best-effort HTTP→HTTPS redirect: when something is publicly exposed on + // 443, ask the upstream gateway(s) to map external 80 -> the host's 443 + // (PCP/NAT-PMP/UPnP) so plain http:// auto-redirects to https. This is a + // pure upstream port-map via PortMapController, NOT an nft LAN forward: + // StartOS already listens on 80/443 itself, so there is no container to + // DNAT to (unlike a service forward, this is the one case where external + // != internal). Best-effort; skipped if 80 is a real forward. + let mut redirect_ips: BTreeSet = BTreeSet::new(); + if !forwards.contains_key(&80) { + let mut redirect_gateways: BTreeSet = BTreeSet::new(); + if let Some((_, _, reqs)) = forwards.get(&443) { + redirect_gateways.extend(reqs.public_gateways.iter().cloned()); + } + for ((_, port), target) in &vhosts { + if *port == 443 { + redirect_gateways.extend(target.public.iter().cloned()); + } + } + for gw_id in &redirect_gateways { + let Some(info) = net_ifaces.get(gw_id) else { + continue; + }; + let Some(ip_info) = &info.ip_info else { + continue; + }; + let gws = candidate_gateways(info); + if gws.is_empty() { + continue; + } + for subnet in ip_info.subnets.iter() { + if let IpAddr::V4(ip) = subnet.addr() { + if redirect_ips.insert(ip) { + ctrl.port_map.ensure(ip, 80, 443, gws.clone()); + } + } + } + } + } + // Withdraw redirect maps that no longer apply (443 unexposed, or 80 became + // a real forward). + let stale: Vec = binds + .redirect_maps + .difference(&redirect_ips) + .copied() + .collect(); + for ip in stale { + ctrl.port_map.remove(ip, 80); + } + binds.redirect_maps = redirect_ips; + // ── Phase 3: Reconcile ── let all = binds .forwards @@ -501,6 +590,45 @@ impl NetServiceData { } ctrl.forward.gc().await?; + // PCP HOSTNAME mappings come only from PUBLIC domain vhosts: each binds + // its FQDN on the shared external port so the gateway demultiplexes + // inbound TLS by SNI. Computed before the drain loop consumes `vhosts`. + let mut hostname_maps: BTreeMap<(Ipv4Addr, u16), (u16, Vec, Vec)> = + BTreeMap::new(); + for ((maybe_host, external), target) in vhosts.iter() { + let Some(hostname) = maybe_host else { continue }; + if target.public.is_empty() { + continue; + } + for gw_id in &target.public { + let Some(info) = net_ifaces.get(gw_id) else { + continue; + }; + if matches!(info.gateway_type, Some(GatewayType::OutboundOnly)) { + continue; + } + let Some(ip_info) = &info.ip_info else { continue }; + let gateways = candidate_gateways(info); + if gateways.is_empty() { + continue; + } + for subnet in &ip_info.subnets { + let IpAddr::V4(local_ip) = subnet.addr() else { + continue; + }; + let entry = hostname_maps + .entry((local_ip, *external)) + .or_insert_with(|| (*external, gateways.clone(), Vec::new())); + let name = hostname.to_string(); + if !entry.2.contains(&name) { + entry.2.push(name); + } + } + } + } + ctrl.vhost + .sync_hostname_mappings((self.id.clone(), id.clone()), hostname_maps); + let all = binds .vhosts .keys() @@ -540,11 +668,15 @@ impl NetServiceData { } }); for (fqdn, gateways) in private_dns { + // Best-effort: also publish the record to the gateway's own DNS via + // RFC 2136 so LAN devices not using StartOS's resolver can resolve it. + ctrl.dns_update.add(fqdn.clone(), gateways.clone()); binds .private_dns .insert(fqdn.clone(), ctrl.dns.add_private_domain(fqdn, gateways)?); } ctrl.dns.gc_private_domains(&rm)?; + ctrl.dns_update.gc(rm); Ok(()) } diff --git a/core/src/net/port_map/client.rs b/core/src/net/port_map/client.rs new file mode 100644 index 0000000000..69be4580a7 --- /dev/null +++ b/core/src/net/port_map/client.rs @@ -0,0 +1,674 @@ +//! Best-effort automatic port mapping on a public address's upstream gateway. +//! +//! Tries PCP (RFC 6887), then NAT-PMP, then UPnP IGD — one code path for a home +//! router and a StartTunnel gateway (PCP over WireGuard, see +//! [`crate::tunnel::forward::pcp`]). PCP/NAT-PMP via `crab_nat`, UPnP via +//! [`crate::net::port_map::upnp`]. +//! +//! All best-effort: failures are logged, never surfaced to the nftables forward +//! reconcile, so a gateway with none of these just falls back to a manual +//! forward. `ensure`/`remove` are fire-and-forget sends so a slow or absent +//! gateway never blocks the forward path. + +use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr}; +use std::num::NonZeroU16; +use std::time::Duration; + +use crab_nat::{InternetProtocol, PortMapping, PortMappingOptions, TimeoutConfig, pcp}; +use igd_next::aio::Gateway; +use igd_next::aio::tokio::Tokio; +use tokio::net::UdpSocket; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::{Instant, interval, timeout}; + +use crate::db::model::public::NetworkInterfaceInfo; +use crate::net::port_map::pcp::capability::has_start9_capability; +use crate::net::port_map::pcp::hostname::OPTION_HOSTNAME; +use crate::net::port_map::pcp::portset::{OPTION_PORT_SET, PortSet}; +use crate::net::port_map::server::PCP_PORT; +use crate::net::port_map::upnp; +use crate::prelude::*; + +/// Re-assert/renew every desired mapping on this cadence (well under the PCP +/// lease, and enough to recover a UPnP mapping lost to a gateway reboot). +const REFRESH_INTERVAL: Duration = Duration::from_secs(180); +/// Retry floor for a desired-but-not-active mapping, so a reconcile burst can't +/// busy-loop a failing apply yet boot/tunnel-restart races still recover in +/// seconds rather than waiting for the 180s refresh. +const RETRY_INTERVAL: Duration = Duration::from_secs(15); +const GATEWAY_CACHE_TTL: Duration = Duration::from_secs(600); +const PCP_LIFETIME_SECONDS: u32 = 3600; +/// Fail fast onto UPnP instead of the crate's multi-minute RFC backoff when a +/// gateway doesn't speak PCP/NAT-PMP. +const PCP_TIMEOUTS: TimeoutConfig = TimeoutConfig { + initial_timeout: Duration::from_millis(250), + max_retries: 1, + max_retry_timeout: Some(Duration::from_secs(1)), +}; + +/// (local IP, external port, optional SNI hostname). Hostname is part of the +/// identity: many hostnames share one external port via gateway SNI demux, each +/// an independent mapping, so adding/removing one never tears down the others. +type MappingKey = (Ipv4Addr, u16, Option); + +/// Candidate PCP/NAT-PMP servers for a gateway interface: the NM default +/// gateway (router) plus each subnet's `.1` (covers a StartTunnel, whose server +/// is the subnet's `.1`, and is the usual router address otherwise). +pub fn candidate_gateways(info: &NetworkInterfaceInfo) -> Vec { + let mut out = Vec::new(); + let mut push = |ip: Ipv4Addr| { + if !ip.is_unspecified() && !ip.is_loopback() && !ip.is_broadcast() && !out.contains(&ip) { + out.push(ip); + } + }; + if let Some(ip_info) = &info.ip_info { + for ip in &ip_info.lan_ip { + // Only a gateway on one of this interface's own subnets can forward + // for it; NM may report a default-route gateway belonging to another + // interface (e.g. the LAN router inherited by a WireGuard link). + if let IpAddr::V4(v4) = ip { + if ip_info.subnets.iter().any(|s| s.contains(ip)) { + push(*v4); + } + } + } + for subnet in &ip_info.subnets { + if let Some(IpAddr::V4(v4)) = subnet.hosts().next() { + push(v4); + } + } + } + out +} + +#[derive(Clone)] +struct Spec { + internal_port: u16, + gateways: Vec, + /// Contiguous ports to map via PCP PORT_SET (RFC 7753); `1` is single-port. + /// `> 1` is PCP-only and skipped where the gateway won't grant the full + /// range (UPnP/NAT-PMP can't map ranges). Always `1` for HOSTNAME mappings. + count: u16, +} + +enum Active { + Pcp(PortMapping), + Upnp { external_ip: Option }, +} + +enum Command { + Ensure { + key: MappingKey, + spec: Spec, + }, + Remove { + key: MappingKey, + }, + /// Gateway-assigned external IP for an active mapping on + /// `(local_ip, external_port)`, to confirm reachability without a remote + /// echo. `None` if not mapped or the external IP is unknown. + ExternalIp { + local_ip: Ipv4Addr, + external_port: u16, + resp: oneshot::Sender>, + }, +} + +#[derive(Clone)] +pub struct PortMapController { + req: mpsc::UnboundedSender, +} + +impl PortMapController { + pub fn new() -> Self { + let (req, mut recv) = mpsc::unbounded_channel::(); + // Detached: `tokio::spawn` won't abort on drop; loop exits when all + // senders are gone. + tokio::spawn(async move { + let mut state = State::default(); + let mut refresh = interval(REFRESH_INTERVAL); + refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + cmd = recv.recv() => match cmd { + Some(Command::Ensure { key, spec }) => state.ensure(key, spec).await, + Some(Command::Remove { key }) => state.remove(key).await, + Some(Command::ExternalIp { local_ip, external_port, resp }) => { + let ip = state + .active + .iter() + .find(|(k, _)| k.0 == local_ip && k.1 == external_port) + .and_then(|(_, a)| match a { + Active::Pcp(m) => m.external_ip(), + Active::Upnp { external_ip } => external_ip.map(IpAddr::V4), + }); + let _ = resp.send(ip); + } + None => break, + }, + _ = refresh.tick() => state.refresh().await, + } + } + }); + Self { req } + } + + pub fn ensure( + &self, + local_ip: Ipv4Addr, + external_port: u16, + internal_port: u16, + gateways: Vec, + ) { + self.send_ensure(local_ip, external_port, internal_port, gateways, None, 1); + } + + /// Like [`ensure`](Self::ensure) but binds one FQDN via PCP HOSTNAME so the + /// gateway SNI-demuxes this external port. PCP-only; each hostname is an + /// independent mapping sharing the port. + pub fn ensure_hostname( + &self, + local_ip: Ipv4Addr, + external_port: u16, + internal_port: u16, + gateways: Vec, + hostname: String, + ) { + self.send_ensure(local_ip, external_port, internal_port, gateways, Some(hostname), 1); + } + + /// Map `count` contiguous ports starting at `external_port` via the PCP + /// PORT_SET option (RFC 7753). PCP-only; skipped on gateways that don't + /// grant the full range. + pub fn ensure_range( + &self, + local_ip: Ipv4Addr, + external_port: u16, + internal_port: u16, + count: u16, + gateways: Vec, + ) { + self.send_ensure(local_ip, external_port, internal_port, gateways, None, count); + } + + fn send_ensure( + &self, + local_ip: Ipv4Addr, + external_port: u16, + internal_port: u16, + gateways: Vec, + hostname: Option, + count: u16, + ) { + self.req + .send(Command::Ensure { + key: (local_ip, external_port, hostname), + spec: Spec { + internal_port, + gateways, + count, + }, + }) + .ok(); + } + + pub fn remove(&self, local_ip: Ipv4Addr, external_port: u16) { + self.req + .send(Command::Remove { + key: (local_ip, external_port, None), + }) + .ok(); + } + + /// Remove the SNI HOSTNAME mapping for `hostname` on + /// `(local_ip, external_port)`, leaving any other hostnames on that port. + pub fn remove_hostname(&self, local_ip: Ipv4Addr, external_port: u16, hostname: String) { + self.req + .send(Command::Remove { + key: (local_ip, external_port, Some(hostname)), + }) + .ok(); + } + + /// Gateway-assigned external IP if a mapping is active for + /// `(local_ip, external_port)`, else `None`. `Some` means the port was + /// forwarded automatically, so a remote reachability check can be skipped. + pub async fn mapped_external_ip( + &self, + local_ip: Ipv4Addr, + external_port: u16, + ) -> Option { + let (resp, rx) = oneshot::channel(); + self.req + .send(Command::ExternalIp { + local_ip, + external_port, + resp, + }) + .ok()?; + rx.await.ok().flatten() + } +} + +impl Default for PortMapController { + fn default() -> Self { + Self::new() + } +} + +#[derive(Default)] +struct State { + desired: BTreeMap, + active: BTreeMap, + upnp_cache: BTreeMap, Instant)>, + /// Last apply() attempt per key, to rate-limit on-demand retries of + /// not-yet-active mappings (see RETRY_INTERVAL). + last_attempt: BTreeMap, + /// Per-gateway PCP ANNOUNCE result ("speaks the Start9 HOSTNAME + /// extension"); a positive verdict is trusted longer than a negative one. + hostname_caps: BTreeMap, +} + +impl State { + async fn ensure(&mut self, key: MappingKey, spec: Spec) { + let changed = self.desired.get(&key).map_or(true, |s| { + s.internal_port != spec.internal_port + || s.gateways != spec.gateways + || s.count != spec.count + }); + self.desired.insert(key.clone(), spec); + // Reapply on a spec change, or to retry a not-yet-active mapping — but + // rate-limited to RETRY_INTERVAL per key so a reconcile burst can't + // busy-loop one the gateway can't satisfy. + let stale = !self.active.contains_key(&key) + && self + .last_attempt + .get(&key) + .map_or(true, |t| t.elapsed() >= RETRY_INTERVAL); + if changed || stale { + self.teardown(key.clone()).await; + self.apply(key).await; + } + } + + async fn remove(&mut self, key: MappingKey) { + self.desired.remove(&key); + self.last_attempt.remove(&key); + self.teardown(key).await; + } + + async fn refresh(&mut self) { + for key in self.desired.keys().cloned().collect::>() { + match self.active.get_mut(&key) { + Some(Active::Pcp(m)) => { + if let Err(e) = m.renew().await { + tracing::debug!("PCP/NAT-PMP renew for {key:?} failed, re-mapping: {e}"); + self.teardown(key.clone()).await; + self.apply(key).await; + } + } + // UPnP has no lease; re-assert in case a gateway reboot dropped + // it. `None` retries a prior failure. + Some(Active::Upnp { .. }) | None => { + self.teardown(key.clone()).await; + self.apply(key).await; + } + } + } + self.upnp_cache + .retain(|_, (_, at)| at.elapsed() < GATEWAY_CACHE_TTL); + self.hostname_caps.retain(|_, (ok, at)| { + at.elapsed() < if *ok { GATEWAY_CACHE_TTL } else { RETRY_INTERVAL } + }); + self.last_attempt.retain(|k, _| self.desired.contains_key(k)); + } + + async fn teardown(&mut self, key: MappingKey) { + match self.active.remove(&key) { + Some(Active::Pcp(m)) => { + if let Err((e, _)) = m.try_drop().await { + tracing::debug!("PCP/NAT-PMP unmap for {key:?} failed: {e}"); + } + } + Some(Active::Upnp { .. }) => { + let (local_ip, external_port, _) = key; + if let Some(gw) = self.gateway_for(local_ip).await { + upnp::remove_port(gw, external_port).await.log_err(); + } + } + None => {} + } + } + + async fn apply(&mut self, key: MappingKey) { + let Some(spec) = self.desired.get(&key).cloned() else { + return; + }; + // Stamp before attempting so a permanently-failing mapping is also + // rate-limited, not just successful ones. + self.last_attempt.insert(key.clone(), Instant::now()); + let (local_ip, external_port, hostname) = (key.0, key.1, key.2.clone()); + let (Some(ext), Some(intl)) = ( + NonZeroU16::new(external_port), + NonZeroU16::new(spec.internal_port), + ) else { + return; + }; + + // HOSTNAME (SNI-demux) mapping: PCP-only, since NAT-PMP/UPnP can't demux + // by SNI. Other hostnames on the same port are separate mappings. + if let Some(hostname) = &hostname { + let options = [pcp::PcpOption { + code: OPTION_HOSTNAME, + data: hostname.as_bytes().to_vec(), + }]; + for gw in &spec.gateways { + // Never hand a Private-Use OPTION_HOSTNAME to a gateway that + // hasn't confirmed it speaks the extension via ANNOUNCE. + if !self.gateway_supports_hostname(local_ip, *gw).await { + tracing::debug!("PCP HOSTNAME skip {gw}: no ANNOUNCE confirmation of support"); + continue; + } + match pcp::port_mapping( + pcp::BaseMapRequest::new(IpAddr::V4(*gw), IpAddr::V4(local_ip), InternetProtocol::Tcp, intl), + None, + None, + PortMappingOptions { + external_port: Some(ext), + lifetime_seconds: Some(PCP_LIFETIME_SECONDS), + timeout_config: Some(PCP_TIMEOUTS), + }, + &options, + ) + .await + { + // Require the gateway to echo the HOSTNAME option too: it + // confirms the binding took, independent of the ANNOUNCE marker. + Ok(m) + if m.external_port() == ext + && m.response_options().iter().any(|o| o.code == OPTION_HOSTNAME) => + { + tracing::debug!( + "PCP HOSTNAME mapped {external_port}->{local_ip}:{} {hostname} via {gw}", + spec.internal_port, + ); + self.active.insert(key, Active::Pcp(m)); + return; + } + Ok(m) => { + let _ = m.try_drop().await; + } + Err(e) => tracing::debug!( + "PCP HOSTNAME map {local_ip}:{external_port} {hostname} via {gw} failed: {e}" + ), + } + } + return; + } + + // Range mapping via PCP PORT_SET (RFC 7753), PCP-only. A gateway lacking + // PORT_SET silently maps a single port; detect the missing/short grant + // and skip rather than forward a partial range. + if spec.count > 1 { + let range_size = spec.count; + let option = pcp::PcpOption { + code: OPTION_PORT_SET, + data: PortSet { + size: range_size, + first_internal_port: spec.internal_port, + parity: false, + } + .to_payload(), + }; + for gw in &spec.gateways { + match pcp::port_mapping( + pcp::BaseMapRequest::new(IpAddr::V4(*gw), IpAddr::V4(local_ip), InternetProtocol::Tcp, intl), + None, + None, + PortMappingOptions { + external_port: Some(ext), + lifetime_seconds: Some(PCP_LIFETIME_SECONDS), + timeout_config: Some(PCP_TIMEOUTS), + }, + std::slice::from_ref(&option), + ) + .await + { + Ok(m) if m.external_port() == ext => { + let granted = m + .response_options() + .iter() + .find(|o| o.code == OPTION_PORT_SET) + .and_then(|o| PortSet::from_payload(&o.data)) + .map_or(1, |ps| ps.size); + if granted >= range_size { + tracing::debug!( + "PCP PORT_SET mapped {external_port}+{range_size}->{local_ip}:{} via {gw}", + spec.internal_port + ); + self.active.insert(key, Active::Pcp(m)); + return; + } + tracing::debug!( + "gateway {gw} granted {granted}/{range_size} PORT_SET ports for {local_ip}:{external_port}; skipping range" + ); + let _ = m.try_drop().await; + } + Ok(m) => { + let _ = m.try_drop().await; + } + Err(e) => tracing::debug!( + "PCP PORT_SET map {local_ip}:{external_port} via {gw} failed: {e}" + ), + } + } + return; + } + + // PCP first, NAT-PMP fallback (crab_nat), against each candidate gateway. + for gw in &spec.gateways { + match PortMapping::new( + IpAddr::V4(*gw), + IpAddr::V4(local_ip), + InternetProtocol::Tcp, + intl, + PortMappingOptions { + external_port: Some(ext), + lifetime_seconds: Some(PCP_LIFETIME_SECONDS), + timeout_config: Some(PCP_TIMEOUTS), + }, + ) + .await + { + Ok(m) if m.external_port() == ext => { + tracing::debug!( + "{} mapped {external_port}->{local_ip}:{} via {gw}", + m.mapping_type(), + spec.internal_port + ); + self.active.insert(key, Active::Pcp(m)); + return; + } + // A different external port is useless for a fixed public port. + Ok(m) => { + let _ = m.try_drop().await; + } + Err(e) => { + tracing::debug!("PCP/NAT-PMP map {local_ip}:{external_port} via {gw} failed: {e}") + } + } + } + + // Fall back to UPnP. + let added = match self.gateway_for(local_ip).await { + Some(gw) => match upnp::add_port(gw, external_port, local_ip, spec.internal_port).await { + Ok(()) => { + tracing::debug!("UPnP mapped {external_port}->{local_ip}:{}", spec.internal_port); + true + } + Err(e) => { + tracing::debug!("UPnP map {local_ip}:{external_port} failed: {e}"); + false + } + }, + None => false, + }; + if added { + // Best-effort external IP (local IGD query) so a reachability check + // can short-circuit; `get_external_ipv4` discards private/CGNAT. + let external_ip = upnp::get_external_ipv4(local_ip).await.ok().flatten(); + self.active.insert(key, Active::Upnp { external_ip }); + } else { + // Re-discover next time in case the gateway went away. + self.upnp_cache.remove(&local_ip); + } + } + + async fn gateway_for(&mut self, local_ip: Ipv4Addr) -> Option<&Gateway> { + let fresh = self + .upnp_cache + .get(&local_ip) + .map_or(false, |(_, at)| at.elapsed() < GATEWAY_CACHE_TTL); + if !fresh { + match upnp::discover(local_ip).await { + Ok(g) => { + self.upnp_cache.insert(local_ip, (g, Instant::now())); + } + Err(e) => { + tracing::debug!("no UPnP gateway on {local_ip}: {e}"); + self.upnp_cache.remove(&local_ip); + return None; + } + } + } + self.upnp_cache.get(&local_ip).map(|(g, _)| g) + } + + /// Whether `gw` answered a PCP ANNOUNCE with the Start9 capability marker, + /// cached per gateway (a yes trusted for GATEWAY_CACHE_TTL, a no re-probed + /// after RETRY_INTERVAL). Gates OPTION_HOSTNAME while we ride a Private-Use + /// option code. + async fn gateway_supports_hostname(&mut self, local_ip: Ipv4Addr, gw: Ipv4Addr) -> bool { + if let Some((ok, at)) = self.hostname_caps.get(&gw) { + let ttl = if *ok { GATEWAY_CACHE_TTL } else { RETRY_INTERVAL }; + if at.elapsed() < ttl { + return *ok; + } + } + let ok = probe_announce(local_ip, gw).await; + self.hostname_caps.insert(gw, (ok, Instant::now())); + ok + } +} + +/// A datagram is a Start9 ANNOUNCE response iff it's a SUCCESS ANNOUNCE reply +/// (version 2, response bit set on opcode 0) carrying the capability marker. +fn announce_marker_ok(resp: &[u8]) -> bool { + resp.len() >= 24 + && resp[0] == 2 + && resp[1] == 0x80 + && resp[3] == 0 + && has_start9_capability(&resp[24..]) +} + +/// Send a PCP ANNOUNCE to `gw:5351` and report whether it answers with the +/// Start9 capability marker. Raw UDP since crab_nat exposes no ANNOUNCE; +/// best-effort — any error/timeout/non-marker reply is "not supported". +async fn probe_announce(local_ip: Ipv4Addr, gw: Ipv4Addr) -> bool { + // Bind the gateway-facing source IP (as the crab_nat MAP path does) so the + // ANNOUNCE egresses the right interface — e.g. the WireGuard tunnel to a + // StartTunnel gateway — and the reply routes back to us. + let Ok(sock) = UdpSocket::bind((local_ip, 0)).await else { + return false; + }; + if sock.connect((gw, PCP_PORT)).await.is_err() { + return false; + } + // Bare 24-byte PCP header: version 2, opcode 0 (ANNOUNCE), client IP. + let mut req = [0u8; 24]; + req[0] = 2; + req[8..24].copy_from_slice(&local_ip.to_ipv6_mapped().octets()); + let mut buf = [0u8; 1100]; + let attempts = PCP_TIMEOUTS.max_retries as u32 + 1; + for attempt in 0..attempts { + if sock.send(&req).await.is_err() { + return false; + } + let dur = if attempt == 0 { + PCP_TIMEOUTS.initial_timeout + } else { + PCP_TIMEOUTS + .max_retry_timeout + .unwrap_or(Duration::from_secs(1)) + }; + // Retransmit on a lost or garbled reply (RFC 6887 §8.3) rather than + // giving up on the first delivered-but-non-marker datagram. + if let Ok(Ok(n)) = timeout(dur, sock.recv(&mut buf)).await { + if announce_marker_ok(&buf[..n]) { + return true; + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec() -> Spec { + // No gateways: apply() does no network I/O, so these tests exercise the + // keying/identity logic only. + Spec { + internal_port: 443, + gateways: Vec::new(), + count: 1, + } + } + + // Distinct hostnames on the same external port are independent mappings; + // removing one (or adding a plain mapping) never clobbers the others. + #[tokio::test] + async fn distinct_hostnames_share_a_port_without_clobbering() { + let ip = Ipv4Addr::new(10, 59, 0, 2); + let a: MappingKey = (ip, 443, Some("a.example.com".into())); + let b: MappingKey = (ip, 443, Some("b.example.com".into())); + let plain: MappingKey = (ip, 443, None); + + let mut state = State::default(); + state.ensure(a.clone(), spec()).await; + state.ensure(b.clone(), spec()).await; + assert!(state.desired.contains_key(&a)); + assert!(state.desired.contains_key(&b), "adding b clobbered a's siblings"); + + state.ensure(plain.clone(), spec()).await; + assert_eq!(state.desired.len(), 3, "plain mapping is a distinct identity"); + + state.remove(a.clone()).await; + assert!(!state.desired.contains_key(&a)); + assert!(state.desired.contains_key(&b), "removing a dropped b"); + assert!(state.desired.contains_key(&plain)); + } + + // The client accepts only a SUCCESS ANNOUNCE reply carrying the exact marker. + #[test] + fn announce_marker_recognized() { + use crate::net::port_map::pcp::capability::encode_start9_capability_option; + let mut resp = vec![0u8; 24]; + resp[0] = 2; + resp[1] = 0x80; // RESPONSE_BIT | opcode 0 (ANNOUNCE) + encode_start9_capability_option(&mut resp); + assert!(announce_marker_ok(&resp)); + + let mut not_response = resp.clone(); + not_response[1] = 0x00; + assert!(!announce_marker_ok(¬_response)); + + let mut not_success = resp.clone(); + not_success[3] = 4; + assert!(!announce_marker_ok(¬_success)); + + assert!(!announce_marker_ok(&resp[..24]), "no marker option"); + } +} diff --git a/core/src/net/port_map/mod.rs b/core/src/net/port_map/mod.rs new file mode 100644 index 0000000000..acd2c6fa34 --- /dev/null +++ b/core/src/net/port_map/mod.rs @@ -0,0 +1,10 @@ +//! NAT port-mapping protocols: a client that asks an upstream gateway to open +//! ports (PCP/NAT-PMP/UPnP) and a reusable server that answers such requests +//! (see [`server`]). + +mod client; +pub mod pcp; +pub mod server; +pub mod upnp; + +pub use client::{PortMapController, candidate_gateways}; diff --git a/core/src/net/port_map/pcp/capability.rs b/core/src/net/port_map/pcp/capability.rs new file mode 100644 index 0000000000..8d5c0da2a1 --- /dev/null +++ b/core/src/net/port_map/pcp/capability.rs @@ -0,0 +1,78 @@ +//! Interim PCP capability marker: a gateway answers ANNOUNCE (opcode 0) with +//! this Start9 vendor option so a client confirms HOSTNAME support before +//! emitting OPTION_HOSTNAME (224, a Private-Use code that could collide with +//! another vendor). Once HOSTNAME has an IANA-assigned code this discovery is +//! moot, so it lives only in the implementation — NOT in the RFC draft. Shared +//! verbatim by every gateway (StartTunnel, StartWRT) and the StartOS client. + +/// Start9 capability option code (Private Use range, next free after 224). +pub const OPTION_START9_CAPABILITY: u8 = 225; +/// Fixed "I speak HOSTNAME" marker: "S9" tag, a discriminator byte, and a +/// format version byte (lets a future format bump without reusing code 225). +pub const START9_CAPABILITY_MAGIC: [u8; 4] = *b"S9\x3b\x01"; + +/// Append the Start9 capability option (RFC 6887 §7.3 framing, 32-bit padded). +pub fn encode_start9_capability_option(buf: &mut Vec) { + super::encode_pcp_option(buf, OPTION_START9_CAPABILITY, &START9_CAPABILITY_MAGIC); +} + +/// True iff the option area carries the exact Start9 capability marker. +pub fn has_start9_capability(tail: &[u8]) -> bool { + super::pcp_options(tail) + .flatten() + .any(|(code, value)| code == OPTION_START9_CAPABILITY && value == START9_CAPABILITY_MAGIC) +} + +#[cfg(test)] +mod tests { + use super::super::encode_pcp_option; + use super::*; + + #[test] + fn encode_size_and_framing() { + let mut b = Vec::new(); + encode_start9_capability_option(&mut b); + assert_eq!(b.len(), 8); + assert_eq!(b.len() % 4, 0); + assert_eq!(b[0], OPTION_START9_CAPABILITY); + assert_eq!(b[1], 0); + assert_eq!(u16::from_be_bytes([b[2], b[3]]), 4); + assert_eq!(&b[4..8], &START9_CAPABILITY_MAGIC); + } + + #[test] + fn recognizes_exact_marker() { + let mut b = Vec::new(); + encode_start9_capability_option(&mut b); + assert!(has_start9_capability(&b)); + } + + #[test] + fn rejects_wrong_value() { + // Right code, wrong (future) version byte -> not a match. + let mut b = Vec::new(); + encode_pcp_option(&mut b, OPTION_START9_CAPABILITY, b"S9\x3b\x02"); + assert!(!has_start9_capability(&b)); + } + + #[test] + fn rejects_wrong_code() { + let mut b = Vec::new(); + encode_pcp_option(&mut b, 224, &START9_CAPABILITY_MAGIC); + assert!(!has_start9_capability(&b)); + } + + #[test] + fn skips_unknown_option_then_matches() { + let mut b = Vec::new(); + encode_pcp_option(&mut b, 200, &[1, 2, 3, 4]); + encode_start9_capability_option(&mut b); + assert!(has_start9_capability(&b)); + } + + #[test] + fn rejects_empty_and_malformed() { + assert!(!has_start9_capability(&[])); + assert!(!has_start9_capability(&[OPTION_START9_CAPABILITY, 0, 0, 10])); + } +} diff --git a/core/src/net/port_map/pcp/hostname.rs b/core/src/net/port_map/pcp/hostname.rs new file mode 100644 index 0000000000..7d2d569b9b --- /dev/null +++ b/core/src/net/port_map/pcp/hostname.rs @@ -0,0 +1,115 @@ +//! PCP `HOSTNAME` option (RFC 6887 extension): associates FQDNs with a MAP +//! request so a gateway can SNI-demultiplex inbound TLS on a shared external +//! port (e.g. 443) by the ClientHello SNI. +//! +//! The code and result codes are not IANA-assigned, so we use the PCP Private +//! Use ranges (Options 224-254, Result Codes 192-254). Shared by the +//! StartTunnel PCP server (parses/echoes) and the StartOS client (emits via +//! `crab_nat::pcp::PcpOption`). + +/// HOSTNAME option code (optional-to-process Private Use range). +pub const OPTION_HOSTNAME: u8 = 224; + +/// Hostname already bound on this external address+port (short-lifetime error). +pub const RESULT_HOSTNAME_TAKEN: u8 = 192; +/// HOSTNAME requests an unsupported feature (long-lifetime error). +pub const RESULT_UNSUPP_HOSTNAME: u8 = 193; + +/// Valid as an SNI demux key: 1-255 octets, ASCII labels of `[A-Za-z0-9-]` +/// (leading `*` label allowed), no leading/trailing dot, no empty labels. +pub fn validate_hostname(name: &str) -> bool { + if name.is_empty() || name.len() > 255 || name.starts_with('.') || name.ends_with('.') { + return false; + } + name.split('.').enumerate().all(|(i, label)| { + !label.is_empty() + && label + .bytes() + .enumerate() + .all(|(j, b)| b.is_ascii_alphanumeric() || b == b'-' || (b == b'*' && i == 0 && j == 0 && label.len() == 1)) + }) +} + +/// Append a HOSTNAME option (RFC 6887 §7.3 framing, zero-padded to 32 bits). +/// `buf` MUST already be 32-bit aligned (true after the fixed opcode payload). +pub fn encode_hostname_option(buf: &mut Vec, hostname: &str) { + super::encode_pcp_option(buf, OPTION_HOSTNAME, hostname.as_bytes()); +} + +/// Lowercased hostnames carried by HOSTNAME options in a PCP opcode's option +/// area; other options skipped. `Err` on a truncated or invalid option so the +/// caller can reply MALFORMED_OPTION. +pub fn parse_hostname_options(tail: &[u8]) -> Result, ()> { + let mut names = Vec::new(); + for opt in super::pcp_options(tail) { + let (code, value) = opt?; + if code == OPTION_HOSTNAME { + let value = std::str::from_utf8(value).map_err(|_| ())?; + if !validate_hostname(value) { + return Err(()); + } + names.push(value.to_ascii_lowercase()); + } + } + Ok(names) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate() { + assert!(validate_hostname("git.example.com")); + assert!(validate_hostname("a")); + assert!(validate_hostname("*.example.com")); + assert!(!validate_hostname("")); + assert!(!validate_hostname(".example.com")); + assert!(!validate_hostname("example.com.")); + assert!(!validate_hostname("ex ample.com")); + assert!(!validate_hostname("ex*ample.com")); + assert!(!validate_hostname("foo..bar")); + assert!(!validate_hostname(&"a".repeat(256))); + } + + #[test] + fn round_trip_single() { + let mut buf = Vec::new(); + encode_hostname_option(&mut buf, "git.example.com"); + // 4 header + 15 data = 19 -> padded to 20. + assert_eq!(buf.len(), 20); + assert_eq!(buf.len() % 4, 0); + assert_eq!(buf[0], OPTION_HOSTNAME); + assert_eq!(parse_hostname_options(&buf).unwrap(), vec!["git.example.com"]); + } + + #[test] + fn round_trip_multiple_and_lowercases() { + let mut buf = Vec::new(); + encode_hostname_option(&mut buf, "First.Example.com"); + encode_hostname_option(&mut buf, "second.example.com"); + assert_eq!( + parse_hostname_options(&buf).unwrap(), + vec!["first.example.com", "second.example.com"] + ); + } + + #[test] + fn skips_unknown_options() { + // An unknown 4-byte option (code 200, len 0) before a HOSTNAME. + let mut buf = vec![200u8, 0, 0, 0]; + encode_hostname_option(&mut buf, "host.example.com"); + assert_eq!(parse_hostname_options(&buf).unwrap(), vec!["host.example.com"]); + } + + #[test] + fn rejects_truncated() { + // code, reserved, length=10 but no data. + assert!(parse_hostname_options(&[OPTION_HOSTNAME, 0, 0, 10]).is_err()); + } + + #[test] + fn empty_tail_is_no_names() { + assert_eq!(parse_hostname_options(&[]).unwrap(), Vec::::new()); + } +} diff --git a/core/src/net/port_map/pcp/mod.rs b/core/src/net/port_map/pcp/mod.rs new file mode 100644 index 0000000000..826785ca2d --- /dev/null +++ b/core/src/net/port_map/pcp/mod.rs @@ -0,0 +1,40 @@ +//! PCP option extensions shared by client and server: HOSTNAME (SNI demux) and +//! PORT_SET (contiguous port ranges). + +pub mod capability; +pub mod hostname; +pub mod portset; + +/// Walk the PCP option area (RFC 6887 §7.3): each option is code(1), +/// reserved(1), length(2), value(length), padded to a 32-bit boundary. Yields +/// `(code, value)` per option; one `Err(())` then stops on a length overrun. +pub(super) fn pcp_options(mut tail: &[u8]) -> impl Iterator> { + std::iter::from_fn(move || { + if tail.len() < 4 { + return None; + } + let code = tail[0]; + let len = u16::from_be_bytes([tail[2], tail[3]]) as usize; + if 4 + len > tail.len() { + tail = &[]; + return Some(Err(())); + } + let value = &tail[4..4 + len]; + // A trailing option may omit final padding; clamp to the buffer. + let end = ((4 + len + 3) & !3).min(tail.len()); + tail = &tail[end..]; + Some(Ok((code, value))) + }) +} + +/// Append a PCP option (RFC 6887 §7.3): code, reserved 0, BE length, value, +/// zero-padded to a 32-bit boundary. +pub(super) fn encode_pcp_option(buf: &mut Vec, code: u8, data: &[u8]) { + buf.push(code); + buf.push(0); // reserved + buf.extend_from_slice(&(data.len() as u16).to_be_bytes()); + buf.extend_from_slice(data); + while buf.len() % 4 != 0 { + buf.push(0); + } +} diff --git a/core/src/net/port_map/pcp/portset.rs b/core/src/net/port_map/pcp/portset.rs new file mode 100644 index 0000000000..d14313bdac --- /dev/null +++ b/core/src/net/port_map/pcp/portset.rs @@ -0,0 +1,128 @@ +//! PCP PORT_SET option (RFC 7753): maps a contiguous block of external ports in +//! one MAP request. The code is optional-to-process, so a server lacking it +//! silently treats the request as single-port — the StartOS client detects the +//! missing echoed option and skips range forwarding there. +//! +//! Shared by the StartTunnel PCP server (parses/echoes) and the StartOS client +//! (emits via crab_nat `PcpOption`, reads the echo off the response). + +/// PORT_SET option code (RFC 7753 §3, optional-to-process range). +pub const OPTION_PORT_SET: u8 = 130; + +/// A parsed PORT_SET option (RFC 7753 §3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PortSet { + /// Contiguous port count (MUST NOT be zero per the RFC). + pub size: u16, + /// In a request, MUST equal the MAP opcode internal port. + pub first_internal_port: u16, + /// Request that the external set preserve the internal port's parity. + pub parity: bool, +} + +impl PortSet { + /// 5-byte payload: size (BE u16), first internal port (BE u16), then a byte + /// whose low bit is parity (upper 7 reserved). + pub fn to_payload(&self) -> Vec { + let mut d = Vec::with_capacity(5); + d.extend_from_slice(&self.size.to_be_bytes()); + d.extend_from_slice(&self.first_internal_port.to_be_bytes()); + d.push(u8::from(self.parity)); + d + } + + /// Parse a 5-byte PORT_SET payload (the `data` of a PCP option). + pub fn from_payload(data: &[u8]) -> Option { + if data.len() < 5 { + return None; + } + Some(Self { + size: u16::from_be_bytes([data[0], data[1]]), + first_internal_port: u16::from_be_bytes([data[2], data[3]]), + parity: data[4] & 1 != 0, + }) + } +} + +/// Append a framed PORT_SET option (RFC 6887 §7.3, zero-padded to 32 bits). +/// Used by the server to echo the granted set in a MAP response. +pub fn encode_port_set_option(buf: &mut Vec, ps: &PortSet) { + super::encode_pcp_option(buf, OPTION_PORT_SET, &ps.to_payload()); +} + +/// First PORT_SET option in a PCP opcode's option area. `Err` on a truncated or +/// malformed option so the caller can reply MALFORMED_OPTION. +pub fn parse_port_set_options(tail: &[u8]) -> Result, ()> { + for opt in super::pcp_options(tail) { + let (code, value) = opt?; + if code == OPTION_PORT_SET { + return PortSet::from_payload(value).ok_or(()).map(Some); + } + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_round_trip() { + let ps = PortSet { + size: 64, + first_internal_port: 8080, + parity: true, + }; + let payload = ps.to_payload(); + assert_eq!(payload.len(), 5); + assert_eq!(PortSet::from_payload(&payload), Some(ps)); + } + + #[test] + fn parity_is_low_bit_only() { + assert!(!PortSet::from_payload(&[0, 1, 0, 0, 0]).unwrap().parity); + assert!(PortSet::from_payload(&[0, 1, 0, 0, 1]).unwrap().parity); + // Upper bits reserved/ignored. + assert!(!PortSet::from_payload(&[0, 1, 0, 0, 0xfe]).unwrap().parity); + } + + #[test] + fn framed_option_round_trip() { + let ps = PortSet { + size: 10, + first_internal_port: 443, + parity: false, + }; + let mut buf = Vec::new(); + encode_port_set_option(&mut buf, &ps); + // 4 header + 5 payload = 9 -> padded to 12. + assert_eq!(buf.len(), 12); + assert!(buf.len().is_multiple_of(4)); + assert_eq!(buf[0], OPTION_PORT_SET); + assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), 5); + assert_eq!(parse_port_set_options(&buf).unwrap(), Some(ps)); + } + + #[test] + fn skips_other_options_and_handles_absent() { + // An unknown 4-byte option, then a PORT_SET. + let mut buf = vec![224u8, 0, 0, 0]; + encode_port_set_option( + &mut buf, + &PortSet { + size: 3, + first_internal_port: 1000, + parity: false, + }, + ); + assert_eq!(parse_port_set_options(&buf).unwrap().unwrap().size, 3); + assert_eq!(parse_port_set_options(&[]).unwrap(), None); + assert_eq!(parse_port_set_options(&[224, 0, 0, 0]).unwrap(), None); + } + + #[test] + fn rejects_truncated() { + // PORT_SET claiming 5 bytes but only 2 present. + assert!(parse_port_set_options(&[OPTION_PORT_SET, 0, 0, 5, 0, 64]).is_err()); + } +} diff --git a/core/src/net/port_map/server/igd.rs b/core/src/net/port_map/server/igd.rs new file mode 100644 index 0000000000..7994acc821 --- /dev/null +++ b/core/src/net/port_map/server/igd.rs @@ -0,0 +1,406 @@ +//! Reusable server-side UPnP IGD (WANIPConnection) protocol layer. +//! +//! Security model mirrors PCP: a peer can only forward to itself — the SOAP +//! body's `NewInternalClient` is ignored and the target is forced to the +//! requesting peer's own address. + +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::sync::Arc; + +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; + +use crate::net::port_map::server::GatewayBackend; + +pub const SSDP_MULTICAST: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); +pub const SSDP_PORT: u16 = 1900; +/// HTTP port serving the device description, SCPD, and SOAP control endpoint. +pub const IGD_HTTP_PORT: u16 = 49001; +pub const WANIP_SERVICE: &str = "urn:schemas-upnp-org:service:WANIPConnection:1"; +pub const IGD_DEVICE: &str = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"; +pub const SERVER_HEADER: &str = "StartOS UPnP/1.1"; +pub const ROOT_DESC_PATH: &str = "/rootDesc.xml"; +pub const SCPD_PATH: &str = "/WANIPCn.xml"; +pub const CONTROL_PATH: &str = "/ctl/IPConn"; + +/// Minimal WANIPConnection SCPD. Clients (e.g. igd-next) read its `actionList` +/// to learn each action's input argument names before issuing a request. +pub const SCPD: &str = include_str!("igd_xml/scpd.xml"); + +/// Format a 16-byte slice as a stable, well-formed UUID/UDN string. +pub fn format_uuid(bytes: &[u8]) -> String { + let mut b = [0u8; 16]; + for (i, slot) in b.iter_mut().enumerate() { + *slot = bytes.get(i).copied().unwrap_or(0); + } + // We only need a stable, well-formed UDN, so the RFC 4122 variant/version + // bits are left unset. + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] + ) +} + +/// Whether an SSDP `ST` (search target) matches this IGD. +pub fn st_matches(st: &str) -> bool { + st == "ssdp:all" + || st == "upnp:rootdevice" + || st.contains("InternetGatewayDevice") + || st.contains("WANIPConnection") + || st.contains("WANConnectionDevice") +} + +/// The SSDP M-SEARCH response advertising this IGD at `server_ip`. +pub fn ssdp_response(server_ip: Ipv4Addr, uuid: &str) -> String { + let location = format!("http://{server_ip}:{IGD_HTTP_PORT}{ROOT_DESC_PATH}"); + format!( + "HTTP/1.1 200 OK\r\n\ + CACHE-CONTROL: max-age=1800\r\n\ + EXT:\r\n\ + LOCATION: {location}\r\n\ + SERVER: {SERVER_HEADER}\r\n\ + ST: {IGD_DEVICE}\r\n\ + USN: uuid:{uuid}::{IGD_DEVICE}\r\n\ + \r\n" + ) +} + +/// Extract a case-insensitive single-line header value from a raw HTTP message. +pub fn header_value(msg: &str, name: &str) -> Option { + let name = name.to_ascii_lowercase(); + msg.lines().find_map(|line| { + let (k, v) = line.split_once(':')?; + if k.trim().to_ascii_lowercase() == name { + Some(v.trim().to_string()) + } else { + None + } + }) +} + +/// The SOAP action being invoked, from the `SOAPAction` header (`"...#Action"`) +/// or, failing that, the first element under the SOAP `Body`. +fn soap_action(headers: &HeaderMap, body: &str) -> Option { + if let Some(h) = headers.get("SOAPAction").and_then(|v| v.to_str().ok()) { + let h = h.trim().trim_matches('"'); + if let Some((_, action)) = h.rsplit_once('#') { + return Some(action.to_string()); + } + } + let root = xmltree::Element::parse(body.as_bytes()).ok()?; + let b = root.get_child("Body")?; + b.children + .iter() + .find_map(|n| n.as_element().map(|e| e.name.clone())) +} + +/// Read a `u16` argument by element name from anywhere in the SOAP body. +fn soap_u16(body: &str, arg: &str) -> Option { + let root = xmltree::Element::parse(body.as_bytes()).ok()?; + let action = root + .get_child("Body")? + .children + .iter() + .find_map(|n| n.as_element())?; + action.get_child(arg)?.get_text()?.trim().parse().ok() +} + +fn ok(action: &str, inner: &str) -> Response { + let body = format!( + include_str!("igd_xml/ok.xml"), + action = action, + service = WANIP_SERVICE, + inner = inner, + ); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], + body, + ) + .into_response() +} + +fn fault(code: u16, desc: &str) -> Response { + let body = format!(include_str!("igd_xml/fault.xml"), code = code, desc = desc); + ( + StatusCode::INTERNAL_SERVER_ERROR, + [(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], + body, + ) + .into_response() +} + +fn upnp_error_text(code: u16) -> &'static str { + match code { + 402 => "Invalid Args", + 501 => "Action Failed", + 606 => "Action not authorized", + 714 => "NoSuchEntryInArray", + 718 => "ConflictInMappingEntry", + 725 => "OnlyPermanentLeasesSupported", + _ => "Action Failed", + } +} + +/// Render the IGD root device description for `uuid`. +pub fn render_root_desc(uuid: &str) -> String { + format!( + include_str!("igd_xml/root_desc.xml"), + device_type = IGD_DEVICE, + uuid = uuid, + service = WANIP_SERVICE, + control = CONTROL_PATH, + scpd = SCPD_PATH, + ) +} + +/// Serve a static XML document with the given content type. +pub async fn serve_static(body: Arc, content_type: &'static str) -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, content_type)], + body.to_string(), + ) + .into_response() +} + +/// Dispatch a SOAP control request from `peer` to the matching IGD action. +pub async fn handle_control( + backend: &B, + peer: Ipv4Addr, + headers: &HeaderMap, + body: &str, +) -> Response { + match soap_action(headers, body).as_deref() { + Some("GetExternalIPAddress") => get_external_ip(backend, peer).await, + Some("AddPortMapping") => add_mapping(backend, peer, body, false).await, + Some("AddAnyPortMapping") => add_mapping(backend, peer, body, true).await, + Some("DeletePortMapping") => delete_mapping(backend, peer, body).await, + _ => fault(401, "Invalid Action"), + } +} + +async fn get_external_ip(backend: &B, peer: Ipv4Addr) -> Response { + match backend.external_ipv4(peer).await { + Some(ip) => ok( + "GetExternalIPAddress", + &format!("{ip}"), + ), + None => fault(501, "Action Failed"), + } +} + +async fn add_mapping( + backend: &B, + peer: Ipv4Addr, + body: &str, + any: bool, +) -> Response { + let (Some(external_port), Some(internal_port)) = ( + soap_u16(body, "NewExternalPort"), + soap_u16(body, "NewInternalPort"), + ) else { + return fault(402, "Invalid Args"); + }; + if external_port == 0 || internal_port == 0 { + return fault(402, "Invalid Args"); + } + if !backend.is_known_client(peer).await { + return fault(606, "Action not authorized"); + } + let Some(source_ip) = backend.external_ipv4(peer).await else { + return fault(501, "Action Failed"); + }; + let source = SocketAddrV4::new(source_ip, external_port); + // Secure mode: force the target to the requesting peer's own address. + let target = SocketAddrV4::new(peer, internal_port); + + match backend.add_forward(source, target, 1, peer).await { + Ok(()) if any => ok( + "AddAnyPortMapping", + &format!("{external_port}"), + ), + Ok(()) => ok("AddPortMapping", ""), + Err(code) => fault(code, upnp_error_text(code)), + } +} + +async fn delete_mapping( + backend: &B, + peer: Ipv4Addr, + body: &str, +) -> Response { + let Some(external_port) = soap_u16(body, "NewExternalPort") else { + return fault(402, "Invalid Args"); + }; + let Some(source_ip) = backend.external_ipv4(peer).await else { + return fault(714, "NoSuchEntryInArray"); + }; + let source = SocketAddrV4::new(source_ip, external_port); + + // Owner-scoped so a peer can't delete (or probe for) another's mapping. + if backend.remove_forward_by_source(source, peer).await { + ok("DeletePortMapping", "") + } else { + fault(714, "NoSuchEntryInArray") + } +} + +#[cfg(test)] +mod tests { + use xmltree::Element; + + use super::*; + + /// Recreates how an IGD client locates the WANIPConnection service: walk + /// devices/serviceLists for a matching serviceType, returning (SCPDURL, + /// controlURL). + fn find_wanip(device: &Element) -> Option<(String, String)> { + if let Some(service_list) = device.get_child("serviceList") { + for child in &service_list.children { + if let Some(svc) = child.as_element() { + if svc.name == "service" + && svc + .get_child("serviceType") + .and_then(|e| e.get_text()) + .as_deref() + == Some(WANIP_SERVICE) + { + return Some(( + svc.get_child("SCPDURL")?.get_text()?.into_owned(), + svc.get_child("controlURL")?.get_text()?.into_owned(), + )); + } + } + } + } + let device_list = device.get_child("deviceList")?; + device_list + .children + .iter() + .filter_map(|c| c.as_element()) + .filter(|c| c.name == "device") + .find_map(find_wanip) + } + + #[test] + fn root_desc_advertises_wanip_service() { + let xml = render_root_desc("abcd1234-0000-0000-0000-000000000000"); + let root = Element::parse(xml.as_bytes()).unwrap(); + let device = root.get_child("device").unwrap(); + let (scpd, control) = find_wanip(device).expect("WANIPConnection service"); + assert_eq!(scpd, SCPD_PATH); + assert_eq!(control, CONTROL_PATH); + } + + #[test] + fn scpd_lists_input_args_for_add_port_mapping() { + let scpd = Element::parse(SCPD.as_bytes()).unwrap(); + let action_list = scpd.get_child("actionList").unwrap(); + let mut actions = std::collections::HashMap::new(); + for child in &action_list.children { + if let Some(a) = child.as_element() { + let name = a.get_child("name").unwrap().get_text().unwrap().into_owned(); + let ins: Vec = a + .get_child("argumentList") + .map(|al| { + al.children + .iter() + .filter_map(|c| c.as_element()) + .filter(|arg| { + arg.get_child("direction").and_then(|d| d.get_text()).as_deref() + == Some("in") + }) + .filter_map(|arg| arg.get_child("name")?.get_text().map(|t| t.into_owned())) + .collect() + }) + .unwrap_or_default(); + actions.insert(name, ins); + } + } + assert!(actions.contains_key("GetExternalIPAddress")); + assert!(actions.contains_key("DeletePortMapping")); + let add = actions.get("AddPortMapping").expect("AddPortMapping"); + for arg in [ + "NewRemoteHost", + "NewExternalPort", + "NewProtocol", + "NewInternalPort", + "NewInternalClient", + "NewEnabled", + "NewPortMappingDescription", + "NewLeaseDuration", + ] { + assert!(add.contains(&arg.to_string()), "missing {arg}"); + } + } + + fn add_port_body() -> String { + // Shaped like igd-next's `format_add_port_mapping_message`. + r#" + + + + +443 +TCP +8443 +10.59.1.5 +1 +StartOS +0 + + +"# + .to_string() + } + + #[test] + fn parses_action_and_ports_from_soap_body() { + let body = add_port_body(); + assert_eq!(soap_action(&HeaderMap::new(), &body).as_deref(), Some("AddPortMapping")); + assert_eq!(soap_u16(&body, "NewExternalPort"), Some(443)); + assert_eq!(soap_u16(&body, "NewInternalPort"), Some(8443)); + assert_eq!(soap_u16(&body, "NoSuchArg"), None); + } + + #[test] + fn soap_action_prefers_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "SOAPAction", + r#""urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping""# + .parse() + .unwrap(), + ); + assert_eq!(soap_action(&headers, "").as_deref(), Some("DeletePortMapping")); + } + + #[test] + fn response_and_fault_are_wellformed_xml() { + let resp = ok( + "GetExternalIPAddress", + "1.2.3.4", + ); + assert_eq!(resp.status(), StatusCode::OK); + let f = fault(718, "ConflictInMappingEntry"); + assert_eq!(f.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn st_matches_igd_searches() { + assert!(st_matches(IGD_DEVICE)); + assert!(st_matches("ssdp:all")); + assert!(st_matches("upnp:rootdevice")); + assert!(st_matches(WANIP_SERVICE)); + assert!(!st_matches("urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1")); + } + + #[test] + fn uuid_is_stable_and_wellformed() { + let bytes: Vec = (0u8..32).collect(); + let uuid = format_uuid(&bytes); + assert_eq!(uuid, "00010203-0405-0607-0809-0a0b0c0d0e0f"); + assert_eq!(format_uuid(&bytes), format_uuid(&bytes)); + } +} diff --git a/core/src/net/port_map/server/igd_xml/fault.xml b/core/src/net/port_map/server/igd_xml/fault.xml new file mode 100644 index 0000000000..f045a5d61f --- /dev/null +++ b/core/src/net/port_map/server/igd_xml/fault.xml @@ -0,0 +1,15 @@ + + + + +s:Client +UPnPError + + +{code} +{desc} + + + + + \ No newline at end of file diff --git a/core/src/net/port_map/server/igd_xml/ok.xml b/core/src/net/port_map/server/igd_xml/ok.xml new file mode 100644 index 0000000000..e9039a3469 --- /dev/null +++ b/core/src/net/port_map/server/igd_xml/ok.xml @@ -0,0 +1,6 @@ + + + +{inner} + + \ No newline at end of file diff --git a/core/src/net/port_map/server/igd_xml/root_desc.xml b/core/src/net/port_map/server/igd_xml/root_desc.xml new file mode 100644 index 0000000000..e1d5bb708a --- /dev/null +++ b/core/src/net/port_map/server/igd_xml/root_desc.xml @@ -0,0 +1,42 @@ + + + 10 + + {device_type} + StartTunnel + Start9 + https://start9.com + StartTunnel Internet Gateway Device + StartTunnel + 1 + 00000000 + uuid:{uuid} + + + urn:schemas-upnp-org:device:WANDevice:1 + WANDevice + Start9 + StartTunnel + uuid:{uuid}-wan + + + urn:schemas-upnp-org:device:WANConnectionDevice:1 + WANConnectionDevice + Start9 + StartTunnel + uuid:{uuid}-wanconn + + + {service} + urn:upnp-org:serviceId:WANIPConn1 + {control} + + {scpd} + + + + + + + + \ No newline at end of file diff --git a/core/src/net/port_map/server/igd_xml/scpd.xml b/core/src/net/port_map/server/igd_xml/scpd.xml new file mode 100644 index 0000000000..0e25e59217 --- /dev/null +++ b/core/src/net/port_map/server/igd_xml/scpd.xml @@ -0,0 +1,48 @@ + + + 10 + + + GetExternalIPAddress + + + NewExternalIPAddress + out + ExternalIPAddress + + + + + AddPortMapping + + NewRemoteHostinRemoteHost + NewExternalPortinExternalPort + NewProtocolinPortMappingProtocol + NewInternalPortinInternalPort + NewInternalClientinInternalClient + NewEnabledinPortMappingEnabled + NewPortMappingDescriptioninPortMappingDescription + NewLeaseDurationinPortMappingLeaseDuration + + + + DeletePortMapping + + NewRemoteHostinRemoteHost + NewExternalPortinExternalPort + NewProtocolinPortMappingProtocol + + + + + ExternalIPAddressstring + RemoteHoststring + ExternalPortui2 + InternalPortui2 + InternalClientstring + PortMappingProtocolstringTCPUDP + PortMappingEnabledboolean + PortMappingDescriptionstring + PortMappingLeaseDurationui4 + + \ No newline at end of file diff --git a/core/src/net/port_map/server/mod.rs b/core/src/net/port_map/server/mod.rs new file mode 100644 index 0000000000..638a9b945a --- /dev/null +++ b/core/src/net/port_map/server/mod.rs @@ -0,0 +1,607 @@ +//! Reusable server-side PCP (RFC 6887 + the HOSTNAME and PORT_SET extensions). +//! +//! Security model: a MAP forces the target to the *requesting* peer's own +//! address, so a peer can only forward to itself; authorization is delegated to +//! [`GatewayBackend::is_known_client`]. + +pub mod igd; + +use std::future::Future; +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::sync::Arc; + +use crate::net::port_map::pcp::capability::encode_start9_capability_option; +use crate::net::port_map::pcp::hostname::{ + RESULT_UNSUPP_HOSTNAME, encode_hostname_option, parse_hostname_options, +}; +use crate::net::port_map::pcp::portset::{PortSet, encode_port_set_option, parse_port_set_options}; +use crate::tunnel::forward::sni::SniDemux; + +/// Standard PCP server port (RFC 6887). +pub const PCP_PORT: u16 = 5351; +const PCP_VERSION: u8 = 2; +const OPCODE_ANNOUNCE: u8 = 0; +const OPCODE_MAP: u8 = 1; +const RESPONSE_BIT: u8 = 0x80; +const MAP_REQUEST_LEN: usize = 60; +const MAP_RESPONSE_LEN: usize = 60; +const HEADER_LEN: usize = 24; +/// Cap the lease we grant; the client re-asserts well within this. +const MAX_LIFETIME_SECONDS: u32 = 3600; + +// PCP result codes (RFC 6887 §7.4). +const SUCCESS: u8 = 0; +const UNSUPP_VERSION: u8 = 1; +const NOT_AUTHORIZED: u8 = 2; +const MALFORMED_REQUEST: u8 = 3; +const UNSUPP_OPCODE: u8 = 4; +const MALFORMED_OPTION: u8 = 6; +const NO_RESOURCES: u8 = 8; +const CANNOT_PROVIDE_EXTERNAL: u8 = 11; + +/// PCP protocol field value for TCP (the only transport the SNI demux handles). +const PROTO_TCP: u8 = 6; +/// Largest PORT_SET range we will grant in one MAP (RFC 7753 lets us grant +/// fewer than requested; the client skips the range if it can't get them all). +const MAX_PORT_SET: u16 = 1024; + +/// Per-gateway I/O and forward backend for the shared PCP server. +pub trait GatewayBackend: Send + Sync { + /// Create or refresh a forward of `count` contiguous ports from `source` + /// (the external address) to `target`, on behalf of `peer`. `Err(code)` is + /// the UPnP/IGD error code (e.g. 718 ConflictInMappingEntry); PCP maps any + /// error to NO_RESOURCES. + fn add_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + count: u16, + peer: Ipv4Addr, + ) -> impl Future> + Send; + + /// Remove the peer's forward to `(peer, internal_port)`, if any (PCP + /// identifies a mapping by its target). + fn remove_forward(&self, peer: Ipv4Addr, internal_port: u16) -> impl Future + Send; + + /// Remove the forward at external address `source` if owned by `peer` (UPnP + /// IGD identifies a mapping by its external port). Returns whether a + /// peer-owned forward was removed; `false` means "no such mapping", reported + /// without revealing other peers' mappings. + fn remove_forward_by_source( + &self, + source: SocketAddrV4, + peer: Ipv4Addr, + ) -> impl Future + Send; + + /// The external (WAN) IPv4 the gateway routes `peer`'s egress out of, or + /// `None` if unknown. + fn external_ipv4(&self, peer: Ipv4Addr) -> impl Future> + Send; + + /// Whether `peer` is a client this gateway will create mappings for. + fn is_known_client(&self, peer: Ipv4Addr) -> impl Future + Send; + + /// The SNI demultiplexer used for HOSTNAME-bound shared-port mappings. + fn sni(&self) -> &Arc; + + /// Register SNI-demuxed hostname routes on `source` (the shared external + /// address) to `target`, owned by `target`. `lifetime` is `None` for a + /// permanent (DB-backed) binding. Default impl is dataplane-only; the tunnel + /// overrides it to also persist the routes. + fn add_sni_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + lifetime: Option, + ) -> impl Future> + Send { + async move { + self.sni() + .register(*source.ip(), source.port(), hostnames, target, lifetime) + } + } + + /// Remove the SNI routes for `hostnames` on `source` owned by `target`. + fn remove_sni_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + ) -> impl Future + Send { + async move { + self.sni() + .unregister(*source.ip(), source.port(), hostnames, target); + } + } +} + +fn ipv4_mapped(ip: Ipv4Addr) -> [u8; 16] { + let mut out = [0u8; 16]; + out[10] = 0xff; + out[11] = 0xff; + out[12..16].copy_from_slice(&ip.octets()); + out +} + +/// Header-only result-code response, for version/opcode errors raised before +/// the request body is trusted. +fn error_response(opcode: u8, result: u8, epoch: u32) -> Vec { + let mut r = vec![0u8; HEADER_LEN]; + r[0] = PCP_VERSION; + r[1] = RESPONSE_BIT | (opcode & 0x7f); + r[3] = result; + r[8..12].copy_from_slice(&epoch.to_be_bytes()); + r +} + +/// An ANNOUNCE response carrying the Start9 capability marker, so a client can +/// confirm this gateway speaks the HOSTNAME extension before emitting it. +fn announce_response(epoch: u32) -> Vec { + let mut r = vec![0u8; HEADER_LEN]; + r[0] = PCP_VERSION; + r[1] = RESPONSE_BIT | OPCODE_ANNOUNCE; + r[3] = SUCCESS; + r[8..12].copy_from_slice(&epoch.to_be_bytes()); + encode_start9_capability_option(&mut r); + r +} + +/// A MAP response, echoing the request's nonce/protocol/internal port. +fn map_response( + result: u8, + req: &[u8], + internal_port: u16, + external_port: u16, + external_ip: Ipv4Addr, + lifetime: u32, + epoch: u32, +) -> Vec { + let mut r = vec![0u8; MAP_RESPONSE_LEN]; + r[0] = PCP_VERSION; + r[1] = RESPONSE_BIT | OPCODE_MAP; + r[3] = result; + r[4..8].copy_from_slice(&lifetime.to_be_bytes()); + r[8..12].copy_from_slice(&epoch.to_be_bytes()); + r[24..36].copy_from_slice(&req[24..36]); + r[36] = req[36]; + r[40..42].copy_from_slice(&internal_port.to_be_bytes()); + r[42..44].copy_from_slice(&external_port.to_be_bytes()); + r[44..60].copy_from_slice(&ipv4_mapped(external_ip)); + r +} + +/// A MAP response echoing the granted HOSTNAME options. The base response is +/// 32-bit aligned, so the appended options stay aligned. +fn map_response_with_hostnames( + result: u8, + req: &[u8], + internal_port: u16, + external_port: u16, + external_ip: Ipv4Addr, + lifetime: u32, + epoch: u32, + hostnames: &[String], +) -> Vec { + let mut r = map_response( + result, + req, + internal_port, + external_port, + external_ip, + lifetime, + epoch, + ); + for name in hostnames { + encode_hostname_option(&mut r, name); + } + r +} + +/// A MAP response echoing the granted PORT_SET (RFC 7753): the opcode's +/// external port is the first port of the range; the option carries its size. +fn map_response_with_port_set( + result: u8, + req: &[u8], + internal_port: u16, + external_port: u16, + external_ip: Ipv4Addr, + lifetime: u32, + epoch: u32, + granted: u16, +) -> Vec { + let mut r = map_response( + result, + req, + internal_port, + external_port, + external_ip, + lifetime, + epoch, + ); + encode_port_set_option( + &mut r, + &PortSet { + size: granted, + first_internal_port: internal_port, + parity: false, + }, + ); + r +} + +/// Handle one PCP datagram from `peer`, returning the response bytes to send +/// back (or `None` to stay silent). `epoch` is the server's seconds-since-start. +pub async fn handle( + backend: &B, + peer: Ipv4Addr, + req: &[u8], + epoch: u32, +) -> Option> { + if req.len() < HEADER_LEN { + return None; + } + let opcode = req[1] & 0x7f; + if req[1] & RESPONSE_BIT != 0 { + return None; + } + if req[0] != PCP_VERSION { + return Some(error_response(opcode, UNSUPP_VERSION, epoch)); + } + // Answer ANNOUNCE for any peer (the marker only reveals "I speak HOSTNAME"); + // it must precede the MAP-only check, which would otherwise reject opcode 0. + if opcode == OPCODE_ANNOUNCE { + tracing::debug!("PCP ANNOUNCE from {peer}: replying with Start9 capability marker"); + return Some(announce_response(epoch)); + } + if opcode != OPCODE_MAP { + return Some(error_response(opcode, UNSUPP_OPCODE, epoch)); + } + if req.len() < MAP_REQUEST_LEN { + return Some(error_response(opcode, MALFORMED_REQUEST, epoch)); + } + + if !backend.is_known_client(peer).await { + return Some(map_response(NOT_AUTHORIZED, req, 0, 0, Ipv4Addr::UNSPECIFIED, 0, epoch)); + } + + let lifetime = u32::from_be_bytes([req[4], req[5], req[6], req[7]]); + let internal_port = u16::from_be_bytes([req[40], req[41]]); + let suggested_external_port = u16::from_be_bytes([req[42], req[43]]); + + let Some(external_ip) = backend.external_ipv4(peer).await else { + return Some(map_response( + CANNOT_PROVIDE_EXTERNAL, + req, + internal_port, + 0, + Ipv4Addr::UNSPECIFIED, + 0, + epoch, + )); + }; + let external_port = if suggested_external_port != 0 { + suggested_external_port + } else { + internal_port + }; + + if internal_port == 0 { + return Some(map_response( + MALFORMED_REQUEST, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )); + } + + // HOSTNAME options mean a SNI-demuxed binding on a shared external port, + // handled by the SNI demux dataplane rather than a forward. + let hostnames = match parse_hostname_options(req.get(MAP_REQUEST_LEN..).unwrap_or(&[])) { + Ok(h) => h, + Err(()) => { + return Some(map_response( + MALFORMED_OPTION, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )); + } + }; + if !hostnames.is_empty() { + if req[36] != PROTO_TCP { + return Some(map_response( + RESULT_UNSUPP_HOSTNAME, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )); + } + // Force the route target to the requesting peer's own address. + let target = SocketAddrV4::new(peer, internal_port); + if lifetime == 0 { + backend + .remove_sni_forward( + SocketAddrV4::new(external_ip, external_port), + target, + &hostnames, + ) + .await; + return Some(map_response_with_hostnames( + SUCCESS, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + &hostnames, + )); + } + let granted = lifetime.min(MAX_LIFETIME_SECONDS); + return match backend + .add_sni_forward( + SocketAddrV4::new(external_ip, external_port), + target, + &hostnames, + Some(granted), + ) + .await + { + Ok(()) => Some(map_response_with_hostnames( + SUCCESS, + req, + internal_port, + external_port, + external_ip, + granted, + epoch, + &hostnames, + )), + Err(code) => Some(map_response( + code, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )), + }; + } + + // PCP PORT_SET extension (RFC 7753): map a contiguous range in one request. + let port_set = match parse_port_set_options(req.get(MAP_REQUEST_LEN..).unwrap_or(&[])) { + Ok(ps) => ps, + Err(()) => { + return Some(map_response( + MALFORMED_OPTION, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )); + } + }; + if let Some(ps) = port_set { + if ps.size == 0 { + return Some(map_response( + MALFORMED_OPTION, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )); + } + if ps.size > 1 { + let granted = ps.size.min(MAX_PORT_SET); + let source = SocketAddrV4::new(external_ip, external_port); + let target = SocketAddrV4::new(peer, internal_port); + if lifetime == 0 { + backend.remove_forward(peer, internal_port).await; + return Some(map_response_with_port_set( + SUCCESS, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + granted, + )); + } + return match backend.add_forward(source, target, granted, peer).await { + Ok(()) => { + let granted_lifetime = lifetime.min(MAX_LIFETIME_SECONDS); + Some(map_response_with_port_set( + SUCCESS, + req, + internal_port, + external_port, + external_ip, + granted_lifetime, + epoch, + granted, + )) + } + Err(_) => Some(map_response( + NO_RESOURCES, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )), + }; + } + } + + // Lifetime 0 deletes the mapping (RFC 6887 §15). + if lifetime == 0 { + backend.remove_forward(peer, internal_port).await; + return Some(map_response(SUCCESS, req, internal_port, external_port, external_ip, 0, epoch)); + } + + // Secure: force the target to the requesting peer's own address. + let source = SocketAddrV4::new(external_ip, external_port); + let target = SocketAddrV4::new(peer, internal_port); + match backend.add_forward(source, target, 1, peer).await { + Ok(()) => { + let granted = lifetime.min(MAX_LIFETIME_SECONDS); + Some(map_response( + SUCCESS, + req, + internal_port, + external_port, + external_ip, + granted, + epoch, + )) + } + // The external port is taken by another mapping; the client may retry. + Err(_) => Some(map_response( + NO_RESOURCES, + req, + internal_port, + external_port, + external_ip, + 0, + epoch, + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn map_request(nonce: [u8; 12], lifetime: u32, internal: u16, external: u16) -> Vec { + let mut r = vec![0u8; MAP_REQUEST_LEN]; + r[0] = PCP_VERSION; + r[1] = OPCODE_MAP; + r[4..8].copy_from_slice(&lifetime.to_be_bytes()); + r[24..36].copy_from_slice(&nonce); + r[36] = 6; // TCP + r[40..42].copy_from_slice(&internal.to_be_bytes()); + r[42..44].copy_from_slice(&external.to_be_bytes()); + r + } + + #[test] + fn map_response_echoes_nonce_and_encodes_external() { + let nonce = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + let req = map_request(nonce, 3600, 8443, 443); + let resp = map_response(SUCCESS, &req, 8443, 443, Ipv4Addr::new(203, 0, 113, 7), 3600, 42); + assert_eq!(resp.len(), MAP_RESPONSE_LEN); + assert_eq!(resp[0], PCP_VERSION); + assert_eq!(resp[1], RESPONSE_BIT | OPCODE_MAP); + assert_eq!(resp[3], SUCCESS); + assert_eq!(u32::from_be_bytes([resp[4], resp[5], resp[6], resp[7]]), 3600); + assert_eq!(u32::from_be_bytes([resp[8], resp[9], resp[10], resp[11]]), 42); + assert_eq!(&resp[24..36], &nonce); + assert_eq!(resp[36], 6); + assert_eq!(u16::from_be_bytes([resp[40], resp[41]]), 8443); + assert_eq!(u16::from_be_bytes([resp[42], resp[43]]), 443); + assert_eq!(&resp[44..60], &ipv4_mapped(Ipv4Addr::new(203, 0, 113, 7))); + } + + #[test] + fn ipv4_mapped_is_rfc_format() { + assert_eq!( + ipv4_mapped(Ipv4Addr::new(192, 0, 2, 1)), + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 0, 2, 1] + ); + } + + #[test] + fn error_response_carries_code() { + let r = error_response(OPCODE_MAP, UNSUPP_VERSION, 7); + assert_eq!(r.len(), HEADER_LEN); + assert_eq!(r[0], PCP_VERSION); + assert_eq!(r[1], RESPONSE_BIT | OPCODE_MAP); + assert_eq!(r[3], UNSUPP_VERSION); + } + + #[test] + fn announce_response_carries_marker() { + use crate::net::port_map::pcp::capability::has_start9_capability; + let r = announce_response(42); + assert_eq!(r.len(), HEADER_LEN + 8); + assert_eq!(r[0], PCP_VERSION); + assert_eq!(r[1], RESPONSE_BIT | OPCODE_ANNOUNCE); + assert_eq!(r[3], SUCCESS); + assert_eq!(u32::from_be_bytes([r[8], r[9], r[10], r[11]]), 42); + assert!(has_start9_capability(&r[HEADER_LEN..])); + } + + struct Stub(Arc); + impl GatewayBackend for Stub { + fn add_forward( + &self, + _: SocketAddrV4, + _: SocketAddrV4, + _: u16, + _: Ipv4Addr, + ) -> impl Future> + Send { + async { Ok(()) } + } + fn remove_forward(&self, _: Ipv4Addr, _: u16) -> impl Future + Send { + async {} + } + fn remove_forward_by_source( + &self, + _: SocketAddrV4, + _: Ipv4Addr, + ) -> impl Future + Send { + async { false } + } + fn external_ipv4(&self, _: Ipv4Addr) -> impl Future> + Send { + async { Some(Ipv4Addr::new(203, 0, 113, 1)) } + } + fn is_known_client(&self, _: Ipv4Addr) -> impl Future + Send { + async { false } + } + fn sni(&self) -> &Arc { + &self.0 + } + } + + // ANNOUNCE is answered with the marker for ANY peer (is_known_client false), + // and is NOT swallowed by the MAP-only path as UNSUPP_OPCODE. + #[tokio::test] + async fn handle_announce_returns_marker_unauthed() { + use crate::net::port_map::pcp::capability::has_start9_capability; + let stub = Stub(SniDemux::new()); + let mut req = vec![0u8; HEADER_LEN]; + req[0] = PCP_VERSION; + req[1] = OPCODE_ANNOUNCE; + let resp = handle(&stub, Ipv4Addr::new(10, 59, 0, 2), &req, 7) + .await + .expect("ANNOUNCE answered"); + assert_eq!(resp[1], RESPONSE_BIT | OPCODE_ANNOUNCE); + assert_eq!(resp[3], SUCCESS); + assert!(has_start9_capability(&resp[HEADER_LEN..])); + } + + #[tokio::test] + async fn handle_rejects_unknown_opcode() { + let stub = Stub(SniDemux::new()); + let mut req = vec![0u8; HEADER_LEN]; + req[0] = PCP_VERSION; + req[1] = 3; // not ANNOUNCE(0) nor MAP(1) + let resp = handle(&stub, Ipv4Addr::LOCALHOST, &req, 0).await.unwrap(); + assert_eq!(resp[3], UNSUPP_OPCODE); + } +} diff --git a/core/src/net/port_map/upnp.rs b/core/src/net/port_map/upnp.rs new file mode 100644 index 0000000000..0f3f95989d --- /dev/null +++ b/core/src/net/port_map/upnp.rs @@ -0,0 +1,147 @@ +//! UPnP IGD client helpers — the fallback behind PCP/NAT-PMP (see +//! [`crate::net::port_map`]). Discovery binds to the gateway interface's local +//! address so the SSDP M-SEARCH leaves via that gateway, covering a home router +//! and a StartTunnel IGD over WireGuard (see [`crate::tunnel::forward::igd`]). + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use igd_next::aio::Gateway; +use igd_next::aio::tokio::{Tokio, search_gateway}; +use igd_next::{PortMappingProtocol, SearchOptions}; + +use crate::prelude::*; + +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(4); +/// IGD SOAP control calls are unbounded in igd-next; without this a gateway that +/// accepts TCP but never answers would wedge the single-threaded port-map daemon. +const CONTROL_TIMEOUT: Duration = Duration::from_secs(5); +/// `0` requests an indefinite lease; the controller re-asserts periodically. +const LEASE_DURATION: u32 = 0; +const DESCRIPTION: &str = "StartOS"; + +fn search_options(local_ip: Ipv4Addr) -> SearchOptions { + SearchOptions { + bind_addr: SocketAddr::new(IpAddr::V4(local_ip), 0), + timeout: Some(DISCOVERY_TIMEOUT), + single_search_timeout: Some(DISCOVERY_TIMEOUT), + ..Default::default() + } +} + +/// Discover the IGD reachable from `local_ip` (SSDP M-SEARCH out that interface). +pub async fn discover(local_ip: Ipv4Addr) -> Result, Error> { + search_gateway(search_options(local_ip)) + .await + .map_err(|e| Error::new(eyre!("UPnP gateway discovery failed: {e}"), ErrorKind::Network)) +} + +/// Map `external_port` -> `local_ip:internal_port` (TCP) on `gateway`. +pub async fn add_port( + gateway: &Gateway, + external_port: u16, + local_ip: Ipv4Addr, + internal_port: u16, +) -> Result<(), Error> { + let call = gateway.add_port( + PortMappingProtocol::TCP, + external_port, + SocketAddr::new(IpAddr::V4(local_ip), internal_port), + LEASE_DURATION, + DESCRIPTION, + ); + match tokio::time::timeout(CONTROL_TIMEOUT, call).await { + Ok(r) => { + r.map_err(|e| Error::new(eyre!("UPnP AddPortMapping failed: {e}"), ErrorKind::Network)) + } + Err(_) => Err(Error::new( + eyre!("UPnP AddPortMapping timed out"), + ErrorKind::Network, + )), + } +} + +/// Remove the TCP mapping for `external_port`; a missing mapping is not an error. +pub async fn remove_port(gateway: &Gateway, external_port: u16) -> Result<(), Error> { + let call = gateway.remove_port(PortMappingProtocol::TCP, external_port); + match tokio::time::timeout(CONTROL_TIMEOUT, call).await { + Ok(Ok(())) | Ok(Err(igd_next::RemovePortError::NoSuchPortMapping)) => Ok(()), + Ok(Err(e)) => Err(Error::new( + eyre!("UPnP DeletePortMapping failed: {e}"), + ErrorKind::Network, + )), + Err(_) => Err(Error::new( + eyre!("UPnP DeletePortMapping timed out"), + ErrorKind::Network, + )), + } +} + +/// Whether `ip` is a routable public IPv4 worth reporting. A gateway behind +/// CGNAT/double-NAT reports a private external IP, useless for clearnet, so the +/// caller falls back to an echoip probe. +pub(crate) fn is_wan_candidate(ip: Ipv4Addr) -> bool { + !(ip.is_unspecified() + || ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.octets()[0] == 0) +} + +/// External IPv4 of the IGD reachable from `local_ip` (UPnP +/// `GetExternalIPAddress`). `Ok(None)` means no usable public address — a +/// private/CGNAT result is discarded so the caller falls back to an echoip query. +pub async fn get_external_ipv4(local_ip: Ipv4Addr) -> Result, Error> { + let gateway = discover(local_ip).await?; + match tokio::time::timeout(CONTROL_TIMEOUT, gateway.get_external_ip()).await { + Ok(Ok(IpAddr::V4(ip))) if is_wan_candidate(ip) => Ok(Some(ip)), + Ok(Ok(_)) => Ok(None), + Ok(Err(e)) => Err(Error::new( + eyre!("UPnP GetExternalIPAddress failed: {e}"), + ErrorKind::Network, + )), + Err(_) => Err(Error::new( + eyre!("UPnP GetExternalIPAddress timed out"), + ErrorKind::Network, + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_external_ips_trigger_echoip_fallback() { + // Anything not routable on the public Internet must be rejected so the + // caller falls back to an echoip query. + for ip in [ + "10.0.0.1", + "10.255.1.2", + "172.16.0.1", + "172.31.255.254", + "192.168.1.1", + "169.254.1.1", // link-local + "127.0.0.1", // loopback + "0.0.0.0", // unspecified + "255.255.255.255", + ] { + assert!( + !is_wan_candidate(ip.parse().unwrap()), + "{ip} should be rejected as non-public" + ); + } + } + + #[test] + fn public_external_ips_are_accepted() { + for ip in ["1.2.3.4", "8.8.8.8", "1.1.1.1", "93.184.216.34"] { + assert!( + is_wan_candidate(ip.parse().unwrap()), + "{ip} should be accepted as public" + ); + } + } +} diff --git a/core/src/net/startos-base.nft b/core/src/net/startos-base.nft index 34c01fd021..fd884a95d2 100644 --- a/core/src/net/startos-base.nft +++ b/core/src/net/startos-base.nft @@ -7,3 +7,14 @@ add chain ip startos mangle_prerouting { type filter hook prerouting priority ma # 'route' (not filter) so restore-mark reroutes locally-generated replies (iptables mangle-OUTPUT parity) add chain ip startos mangle_output { type route hook output priority mangle; policy accept; } add chain ip startos mangle_forward { type filter hook forward priority mangle; policy accept; } + +# Port-mapping protocols (PCP/NAT-PMP udp/5351, UPnP SSDP udp/1900) may originate +# only from the host (startd, via the output hook); they are never forwarded +# from a package container or any other interface. inet family so this covers +# IPv4 and IPv6; flush+add keeps it idempotent across reloads. Runs before the +# main forward filter; `accept` is non-terminal so non-port-map traffic still +# falls through to the ip startos forward policy. +add table inet startos_portmap_guard +add chain inet startos_portmap_guard forward { type filter hook forward priority -10; policy accept; } +flush chain inet startos_portmap_guard forward +add rule inet startos_portmap_guard forward udp dport { 5351, 1900 } drop diff --git a/core/src/net/transparent.rs b/core/src/net/transparent.rs new file mode 100644 index 0000000000..9ffb166d77 --- /dev/null +++ b/core/src/net/transparent.rs @@ -0,0 +1,157 @@ +//! Source-preserving transparent egress for the SNI demux (RFC §4.6). +//! +//! A demux proxy reads the TLS ClientHello on a normal listener (the host is the +//! legitimate destination of the inbound flow), then originates the internal leg +//! with the *client's* source address via `IP_TRANSPARENT`, so the backend sees +//! the real peer rather than this host. Backend→client replies (addressed to the +//! client, transiting this host as the backend's gateway) are diverted back into +//! the proxy socket by policy routing. +//! +//! Datapath (all in `table ip startos` / iproute2): +//! - egress socket: `IP_TRANSPARENT`, bound to the client's `(ip, port)`. +//! - `mangle_prerouting` `sni-divert`: an inbound packet matching a local +//! `IP_TRANSPARENT` socket (i.e. a reply to such an egress) is marked with +//! [`DIVERT_MARK`]. Only the inbound/reply direction is touched, so the +//! proxy's own egress packets route to the backend normally. +//! - `ip rule fwmark DIVERT_MARK lookup DIVERT_TABLE priority 49` + `ip route add +//! local 0.0.0.0/0 dev lo table DIVERT_TABLE`: deliver the marked replies +//! locally, into the transparent socket. + +#[cfg(target_os = "linux")] +use std::net::SocketAddr; +use std::net::SocketAddrV4; + +#[cfg(target_os = "linux")] +use tokio::net::TcpSocket; +use tokio::net::TcpStream; +use tokio::process::Command; +use tokio::sync::OnceCell; + +#[cfg(target_os = "linux")] +use crate::net::utils::default_keepalive; +use crate::prelude::*; +use crate::util::Invoke; + +/// Firewall mark for transparent-egress reply diversion. Outside the gateway's +/// per-interface `1000 + ifindex` mark space and its priority-50 rule. +pub const DIVERT_MARK: u32 = 0x0054_0001; +/// Dedicated routing table holding the local-delivery default for diverted +/// replies. Outside the gateway's `1000 + ifindex` table space. +pub const DIVERT_TABLE: u32 = 1344; + +/// `mangle_prerouting` rule that marks inbound packets belonging to a local +/// `IP_TRANSPARENT` (SNI-demux) socket — the replies to a source-preserving +/// egress connection — so the priority-49 `ip rule` diverts them to the local +/// table and they reach the proxy socket instead of being forwarded back out. +/// Touches only the reply direction (the egress leg is in `output`, not here), +/// so it cannot misroute the proxy's own outbound packets. Spliced into the +/// gateway mangle reconcile so it survives that chain's flush; on hosts without +/// that reconcile (e.g. the tunnel) [`ensure_divert_infra`] adds it directly. +pub fn divert_mark_rule() -> String { + format!( + "add rule ip startos mangle_prerouting meta l4proto tcp socket transparent 1 meta mark set {DIVERT_MARK:#010x} comment \"sni-divert\"\n" + ) +} + +/// Open the internal leg of a demuxed connection from the client's own source +/// address, so the backend sees the real peer. Requires `CAP_NET_ADMIN` (startd +/// runs as root). The reply path is set up by [`ensure_divert_infra`]. +#[cfg(target_os = "linux")] +pub async fn transparent_connect( + client: SocketAddrV4, + target: SocketAddrV4, +) -> std::io::Result { + let sock = TcpSocket::new_v4()?; + { + let sref = socket2::SockRef::from(&sock); + // Must precede bind: permits binding a non-local (client) address. + sref.set_ip_transparent_v4(true)?; + sref.set_reuse_address(true)?; + if let Err(e) = sref.set_tcp_keepalive(&default_keepalive()) { + tracing::debug!("transparent egress keepalive: {e}"); + } + } + sock.bind(SocketAddr::V4(client))?; + sock.connect(SocketAddr::V4(target)).await +} + +/// `IP_TRANSPARENT` is Linux-only and the SNI demux runs only on the Linux +/// gateway, so this stub never executes off-Linux; it exists to keep the +/// cross-platform build (apple-darwin) compiling. +#[cfg(not(target_os = "linux"))] +pub async fn transparent_connect( + _client: SocketAddrV4, + _target: SocketAddrV4, +) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "IP_TRANSPARENT transparent egress is Linux-only", + )) +} + +static DIVERT_INFRA: OnceCell<()> = OnceCell::const_new(); + +/// [`ensure_divert_infra`] run at most once per process (cached only on success, +/// so a transient failure is retried on the next call). Cheap to call per +/// connection from the local passthrough path. +pub async fn ensure_divert_infra_once() { + let _ = DIVERT_INFRA + .get_or_try_init(|| async { ensure_divert_infra().await }) + .await; +} + +/// Install the reply-path divert (idempotent): the iproute2 half (rule + table) +/// always, plus the nft `sni-divert` mark rule when absent — so hosts that run the +/// SNI demux but not the gateway mangle reconcile (e.g. the tunnel) still mark and +/// divert replies. Safe to call repeatedly. +pub async fn ensure_divert_infra() -> Result<(), Error> { + let table = DIVERT_TABLE.to_string(); + + // Local-delivery default in the divert table: marked replies are delivered + // to the local transparent socket instead of being forwarded back out. + Command::new("ip") + .args(["route", "replace", "local", "0.0.0.0/0", "dev", "lo", "table", &table]) + .invoke(ErrorKind::Network) + .await?; + + // Policy rule at priority 49 — above the gateway's per-interface symmetric + // -return rules at 50 — so diverted replies win. + let rules = Command::new("ip") + .args(["rule", "list"]) + .invoke(ErrorKind::Network) + .await + .unwrap_or_default(); + if !String::from_utf8_lossy(&rules).contains(&format!("lookup {table}")) { + Command::new("ip") + .args([ + "rule", "add", "fwmark", &format!("{DIVERT_MARK:#x}"), "lookup", &table, + "priority", "49", + ]) + .invoke(ErrorKind::Network) + .await?; + } + + // nft `sni-divert` mark rule. The gateway reconcile owns (and re-adds) it on + // hosts that run it; install it directly where nothing else does (the tunnel), + // skipping if already present so we never duplicate or fight the reconcile. + let chain = Command::new("nft") + .args(["list", "chain", "ip", "startos", "mangle_prerouting"]) + .invoke(ErrorKind::Network) + .await + .unwrap_or_default(); + if !String::from_utf8_lossy(&chain).contains("sni-divert") { + Command::new("nft") + .args([ + "add", "rule", "ip", "startos", "mangle_prerouting", "meta", "l4proto", + "tcp", "socket", "transparent", "1", "meta", "mark", "set", + &format!("{DIVERT_MARK:#010x}"), "comment", "sni-divert", + ]) + .invoke(ErrorKind::Network) + .await?; + } + + // rp_filter needs no loosening: a diverted reply's source is the backend, + // routable via its own ingress interface, so even strict RPF (1) accepts it + // (verified in a netns harness under both strict and loose). + Ok(()) +} diff --git a/core/src/net/tunnel.rs b/core/src/net/tunnel.rs index 6b1d55e2da..21274d3b94 100644 --- a/core/src/net/tunnel.rs +++ b/core/src/net/tunnel.rs @@ -5,7 +5,6 @@ use imbl_value::InternedString; use patch_db::json_ptr::JsonPointer; use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; -use tokio::process::Command; use ts_rs::TS; use crate::GatewayId; @@ -15,8 +14,6 @@ use crate::db::model::public::{ }; use crate::net::host::all_hosts; use crate::prelude::*; -use crate::util::Invoke; -use crate::util::io::{TmpDir, write_file_atomic}; pub fn tunnel_api() -> ParentHandler { ParentHandler::new() @@ -33,6 +30,13 @@ pub fn tunnel_api() -> ParentHandler { .with_about("about.remove-tunnel") .with_call_remote::(), ) + .subcommand( + "update", + from_fn_async(update_tunnel) + .no_display() + .with_about("about.update-tunnel") + .with_call_remote::(), + ) } #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] @@ -51,23 +55,6 @@ pub struct AddTunnelParams { set_as_default_outbound: bool, } -fn sanitize_config(config: &str) -> String { - let mut res = String::with_capacity(config.len()); - for line in config.lines() { - if line - .trim() - .strip_prefix("AllowedIPs") - .map_or(false, |l| l.trim().starts_with("=")) - { - res.push_str("AllowedIPs = 0.0.0.0/0, ::/0"); - } else { - res.push_str(line); - } - res.push('\n'); - } - res -} - pub async fn add_tunnel( ctx: RpcContext, AddTunnelParams { @@ -126,19 +113,7 @@ pub async fn add_tunnel( ) .await; - let tmpdir = TmpDir::new().await?; - let conf = tmpdir.join(&iface).with_extension("conf"); - write_file_atomic(&conf, &sanitize_config(&config)).await?; - Command::new("nmcli") - .arg("connection") - .arg("import") - .arg("type") - .arg("wireguard") - .arg("file") - .arg(&conf) - .invoke(ErrorKind::Network) - .await?; - tmpdir.delete().await?; + crate::net::gateway::add_wireguard_config(iface.as_str(), &config).await?; sub.recv().await; @@ -305,3 +280,51 @@ pub async fn remove_tunnel( Ok(()) } + +#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[ts(export)] +pub struct UpdateTunnelParams { + #[arg(help = "help.arg.gateway-id")] + id: GatewayId, + #[arg(help = "help.arg.wireguard-config")] + config: String, +} + +/// Replace the WireGuard config behind an existing gateway interface in place, +/// keeping the gateway id and everything keyed to it (forwards, private/public +/// domains). Used to re-issue a config — e.g. one that now carries a `DNS =` +/// line. Applied via D-Bus Update2 + Device.Reapply, so the wg device is never +/// torn down and the gateway (and the request riding its tunnel) survives. +pub async fn update_tunnel( + ctx: RpcContext, + UpdateTunnelParams { id, config }: UpdateTunnelParams, +) -> Result<(), Error> { + let Some(existing) = ctx + .db + .peek() + .await + .into_public() + .into_server_info() + .into_network() + .into_gateways() + .into_idx(&id) + .and_then(|e| e.into_ip_info().transpose()) + else { + return Err(Error::new(eyre!("unknown gateway: {id}"), ErrorKind::NotFound)); + }; + + if existing.as_deref().as_device_type().de()? != Some(NetworkInterfaceType::Wireguard) { + return Err(Error::new( + eyre!("network interface {id} is not a proxy"), + ErrorKind::InvalidRequest, + )); + } + + // Apply the new config in place (Update2 + Reapply) rather than deleting and + // re-importing the connection. The wg device is never torn down, so updating + // the gateway that carries this very request doesn't drop its own transport — + // and if the handler is cancelled, the gateway is simply left on its old (or + // new) config, never in a half-deleted state. + crate::net::gateway::update_wireguard_config(id.as_str(), &config).await +} diff --git a/core/src/net/vhost.rs b/core/src/net/vhost.rs index cc31fe54f0..2265fcd4b3 100644 --- a/core/src/net/vhost.rs +++ b/core/src/net/vhost.rs @@ -1,7 +1,7 @@ use std::any::Any; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; -use std::net::{IpAddr, SocketAddr, SocketAddrV6}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV6}; use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Weak}; @@ -43,6 +43,7 @@ use crate::net::ssl::{CertBranding, CertStore, RootCaTlsHandler}; use crate::net::tls::{ ChainedHandler, TlsHandler, TlsHandlerAction, TlsListener, TlsMetadata, }; +use crate::net::port_map::PortMapController; use crate::net::utils::{bind_mio_listener, ipv6_is_link_local, is_private_ip}; use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract}; use crate::prelude::*; @@ -51,7 +52,12 @@ use crate::util::future::NonDetachingJoinHandle; use crate::util::io::ReadWriter; use crate::util::serde::{HandlerExtSerde, MaybeUtf8String, display_serializable}; use crate::util::sync::{SyncMutex, Watch}; -use crate::{GatewayId, ResultExt}; +use crate::{GatewayId, HostId, PackageId, ResultExt}; + +/// Identifies which service+host contributed a set of SNI hostname mappings, so +/// each can be reconciled independently — a service only ever adds or removes +/// *its own* hostnames on a shared external port, never another's. +type HostMapOwner = (Option, HostId); #[derive(Debug, Clone, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] @@ -255,6 +261,11 @@ pub struct VHostController { max_proxy_conns_per_target: usize, servers: SyncMutex>>, passthrough_handles: SyncMutex>, + port_map: PortMapController, + /// Per-owner set of `(ext_ip, ext_port, hostname)` SNI routes this controller + /// has asked the port-mapper to maintain. Keyed by owner so one service's + /// reconcile only adds/removes its own hostnames on a shared port. + hostname_mappings: SyncMutex>>, } impl VHostController { pub fn new( @@ -264,6 +275,7 @@ impl VHostController { branding: CertBranding, passthroughs: Vec, max_proxy_conns_per_target: usize, + port_map: PortMapController, ) -> Self { let controller = Self { db, @@ -274,6 +286,8 @@ impl VHostController { max_proxy_conns_per_target, servers: SyncMutex::new(BTreeMap::new()), passthrough_handles: SyncMutex::new(BTreeMap::new()), + port_map, + hostname_mappings: SyncMutex::new(BTreeMap::new()), }; for pt in passthroughs { if let Err(e) = controller.add_passthrough( @@ -416,6 +430,47 @@ impl VHostController { } }) } + + /// Reconcile best-effort PCP HOSTNAME port mappings for `owner`'s public + /// domain vhosts. `desired` maps `(box IP to map from, external port)` to + /// `(internal port, candidate gateways, FQDNs)`. Each hostname is mapped + /// independently — the gateway treats the hostname as part of the mapping's + /// identity and demultiplexes the shared external port by TLS SNI — so one + /// service adding or removing a hostname never disturbs another service's + /// hostnames on the same port. Like the rest of the port-map layer this is + /// best-effort: a gateway that can't honor it just leaves the user on a + /// manual forward. + pub fn sync_hostname_mappings( + &self, + owner: HostMapOwner, + desired: BTreeMap<(Ipv4Addr, u16), (u16, Vec, Vec)>, + ) { + let want: BTreeSet<(Ipv4Addr, u16, String)> = desired + .iter() + .flat_map(|((ip, port), (_, _, hostnames))| { + hostnames.iter().map(move |h| (*ip, *port, h.clone())) + }) + .collect(); + let had = self + .hostname_mappings + .peek(|owners| owners.get(&owner).cloned().unwrap_or_default()); + for (ip, port, hostname) in had.difference(&want) { + self.port_map.remove_hostname(*ip, *port, hostname.clone()); + } + for ((ip, port), (internal, gateways, hostnames)) in &desired { + for hostname in hostnames { + self.port_map + .ensure_hostname(*ip, *port, *internal, gateways.clone(), hostname.clone()); + } + } + self.hostname_mappings.mutate(|owners| { + if want.is_empty() { + owners.remove(&owner); + } else { + owners.insert(owner, want); + } + }); + } } /// Union of all ProxyTargets' bind requirements for a VHostServer. @@ -780,12 +835,26 @@ where &'a self, mut prev: ServerConfig, hello: &'a ClientHello<'a>, - _: &'a ::Metadata, + metadata: &'a ::Metadata, ) -> Option<(ServerConfig, Self::PreprocessRes)> { - let tcp_stream = TcpStream::connect(self.addr) - .await - .with_ctx(|_| (ErrorKind::Network, self.addr)) - .log_err()?; + let peer = extract::(metadata).map(|m| m.peer_addr); + let tcp_stream = match (self.passthrough, peer, self.addr) { + // Passthrough (service handles its own TLS): open the internal leg + // from the client's own source address so the backend sees the real + // peer (RFC §4.6). Non-passthrough/terminating targets keep the plain + // connect — they don't preserve source IP. + (true, Some(SocketAddr::V4(client)), SocketAddr::V4(target)) => { + crate::net::transparent::ensure_divert_infra_once().await; + crate::net::transparent::transparent_connect(client, target) + .await + .with_ctx(|_| (ErrorKind::Network, self.addr)) + .log_err()? + } + _ => TcpStream::connect(self.addr) + .await + .with_ctx(|_| (ErrorKind::Network, self.addr)) + .log_err()?, + }; if let Err(e) = socket2::SockRef::from(&tcp_stream) .set_tcp_keepalive(&crate::net::utils::default_keepalive()) { diff --git a/core/src/tunnel/api.rs b/core/src/tunnel/api.rs index 4e8f74553f..a607745f38 100644 --- a/core/src/tunnel/api.rs +++ b/core/src/tunnel/api.rs @@ -1,6 +1,8 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::str::FromStr; use clap::{Parser, ValueEnum}; +use hickory_server::proto::rr::{Name, RecordType}; use imbl_value::InternedString; use ipnet::Ipv4Net; use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; @@ -10,11 +12,13 @@ use ts_rs::TS; use crate::context::CliContext; use crate::db::model::public::NetworkInterfaceType; use crate::net::forward::nft_rule; +use crate::net::dns_update::rfc2136::InjectedRecord; use crate::prelude::*; use crate::tunnel::context::TunnelContext; -use crate::tunnel::db::PortForwardEntry; +use crate::net::port_map::server::GatewayBackend; +use crate::tunnel::db::{DnsRecordEntry, PortForward}; use crate::tunnel::wg::{ - DnsConfig, WIREGUARD_INTERFACE_NAME, WgConfig, WgSubnetClients, WgSubnetConfig, + DnsConfig, WIREGUARD_INTERFACE_NAME, WgClientKind, WgConfig, WgSubnetClients, WgSubnetConfig, }; use crate::util::serde::{HandlerExtSerde, display_serializable}; @@ -40,6 +44,10 @@ pub fn tunnel_api() -> ParentHandler { "device", device_api::().with_about("about.add-remove-or-list-devices-in-subnets"), ) + .subcommand( + "dns", + dns_api::().with_about("about.view-or-edit-injected-dns-records"), + ) .subcommand( "port-forward", ParentHandler::::new() @@ -147,6 +155,14 @@ pub fn subnet_api() -> ParentHandler { .with_about("about.set-subnet-dns") .with_call_remote::(), ) + .subcommand( + "set-wan", + from_fn_async(set_subnet_wan) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.set-subnet-wan") + .with_call_remote::(), + ) } pub fn device_api() -> ParentHandler { @@ -197,6 +213,289 @@ pub fn device_api() -> ParentHandler { .with_about("about.show-wireguard-configuration-for-device") .with_call_remote::(), ) + .subcommand( + "set-dns-injection", + from_fn_async(set_dns_injection) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.allow-or-deny-device-dns-injection") + .with_call_remote::(), + ) + .subcommand( + "set-auto-port-forward", + from_fn_async(set_auto_port_forward) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.allow-or-deny-device-auto-port-forward") + .with_call_remote::(), + ) + .subcommand( + "set-wan", + from_fn_async(set_device_wan) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.set-device-wan") + .with_call_remote::(), + ) + .subcommand( + "set-kind", + from_fn_async(set_device_kind) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.promote-or-demote-device-kind") + .with_call_remote::(), + ) +} + +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +pub struct SetDnsInjectionParams { + #[ts(type = "string")] + subnet: Ipv4Net, + #[ts(type = "string")] + ip: Ipv4Addr, + #[arg(long)] + enabled: bool, +} + +/// Allow/deny a device to inject DNS records via RFC 2136. Off by default: an +/// allowed device can add records the whole tunnel resolves, so trust only. +pub async fn set_dns_injection( + ctx: TunnelContext, + SetDnsInjectionParams { + subnet, + ip, + enabled, + }: SetDnsInjectionParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_wg_mut() + .as_subnets_mut() + .as_idx_mut(&subnet) + .or_not_found(&subnet)? + .as_clients_mut() + .as_idx_mut(&ip) + .or_not_found(&ip)? + .as_allow_dns_injection_mut() + .ser(&enabled) + }) + .await + .result?; + ctx.dns_allowed.mutate(|s| { + if enabled { + s.insert(IpAddr::V4(ip)); + } else { + s.remove(&IpAddr::V4(ip)); + } + }); + Ok(()) +} + +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +pub struct SetAutoPortForwardParams { + #[ts(type = "string")] + subnet: Ipv4Net, + #[ts(type = "string")] + ip: Ipv4Addr, + #[arg(long)] + enabled: bool, +} + +/// Allow/deny a device to auto-create port forwards via PCP/IGD. Off by +/// default; paired with DNS injection under the gateway-autoconfig toggle. +/// `is_known_client` reads this live, so no cache to update here. +pub async fn set_auto_port_forward( + ctx: TunnelContext, + SetAutoPortForwardParams { + subnet, + ip, + enabled, + }: SetAutoPortForwardParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_wg_mut() + .as_subnets_mut() + .as_idx_mut(&subnet) + .or_not_found(&subnet)? + .as_clients_mut() + .as_idx_mut(&ip) + .or_not_found(&ip)? + .as_allow_auto_port_forward_mut() + .ser(&enabled) + }) + .await + .result?; + Ok(()) +} + +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +pub struct SetDeviceKindParams { + #[ts(type = "string")] + subnet: Ipv4Net, + #[ts(type = "string")] + ip: Ipv4Addr, + #[arg(long, value_enum)] + kind: WgClientKind, +} + +/// Promote a device to Server or demote to Client. The role is sticky, but the +/// transition resets both capability flags to the kind's default (Server: both +/// on; Client: both off), since a Client has no autoconfig. +pub async fn set_device_kind( + ctx: TunnelContext, + SetDeviceKindParams { subnet, ip, kind }: SetDeviceKindParams, +) -> Result<(), Error> { + let autoconfig = matches!(kind, WgClientKind::Server); + ctx.db + .mutate(|db| { + db.as_wg_mut() + .as_subnets_mut() + .as_idx_mut(&subnet) + .or_not_found(&subnet)? + .as_clients_mut() + .as_idx_mut(&ip) + .or_not_found(&ip)? + .as_kind_mut() + .ser(&kind)?; + db.as_wg_mut() + .as_subnets_mut() + .as_idx_mut(&subnet) + .or_not_found(&subnet)? + .as_clients_mut() + .as_idx_mut(&ip) + .or_not_found(&ip)? + .as_allow_dns_injection_mut() + .ser(&autoconfig)?; + db.as_wg_mut() + .as_subnets_mut() + .as_idx_mut(&subnet) + .or_not_found(&subnet)? + .as_clients_mut() + .as_idx_mut(&ip) + .or_not_found(&ip)? + .as_allow_auto_port_forward_mut() + .ser(&autoconfig) + }) + .await + .result?; + ctx.dns_allowed.mutate(|s| { + if autoconfig { + s.insert(IpAddr::V4(ip)); + } else { + s.remove(&IpAddr::V4(ip)); + } + }); + Ok(()) +} + +pub fn dns_api() -> ParentHandler { + ParentHandler::new() + .subcommand( + "list", + from_fn_async(list_dns_records) + .with_display_serializable() + .with_about("about.list-injected-dns-records") + .with_call_remote::(), + ) + .subcommand( + "add", + from_fn_async(add_dns_record) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.add-or-replace-a-dns-record") + .with_call_remote::(), + ) + .subcommand( + "remove", + from_fn_async(remove_dns_record) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_about("about.remove-a-dns-record") + .with_call_remote::(), + ) +} + +pub async fn list_dns_records(ctx: TunnelContext) -> Result, Error> { + Ok(ctx + .dns_injector + .list() + .iter() + .map(|r| { + let (name, rtype, value, ttl, source) = r.to_parts(); + DnsRecordEntry { + name, + rtype, + value, + ttl, + source: source.map(|s| s.to_string()), + } + }) + .collect()) +} + +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +pub struct AddDnsRecordParams { + name: String, + #[serde(rename = "type")] + #[arg(long = "type")] + rtype: String, + value: String, + #[arg(long)] + ttl: Option, +} + +pub async fn add_dns_record( + ctx: TunnelContext, + AddDnsRecordParams { + name, + rtype, + value, + ttl, + }: AddDnsRecordParams, +) -> Result<(), Error> { + let record = InjectedRecord::from_parts( + &name, + &rtype, + &value, + ttl.unwrap_or(300), + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + )?; + ctx.dns_injector.upsert(record); + Ok(()) +} + +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +pub struct RemoveDnsRecordParams { + name: String, + #[serde(rename = "type")] + #[arg(long = "type")] + rtype: Option, +} + +pub async fn remove_dns_record( + ctx: TunnelContext, + RemoveDnsRecordParams { name, rtype }: RemoveDnsRecordParams, +) -> Result<(), Error> { + let mut fqdn = Name::from_utf8(&name).with_kind(ErrorKind::ParseUrl)?; + fqdn.set_fqdn(true); + let rtype = rtype + .as_deref() + .map(|s| RecordType::from_str(&s.to_ascii_uppercase())) + .transpose() + .with_kind(ErrorKind::InvalidRequest)?; + ctx.dns_injector.delete(&fqdn, rtype); + Ok(()) } #[derive(Deserialize, Serialize, Parser, TS)] @@ -245,31 +544,9 @@ pub async fn add_subnet( }) .await .result?; + // sync_network → resync_egress installs this subnet's postrouting rule. ctx.sync_network(&server).await?; - for iface in ctx.net_iface.peek(|i| { - i.iter() - .filter(|(_, info)| { - info.ip_info.as_ref().map_or(false, |i| { - i.device_type != Some(NetworkInterfaceType::Loopback) - }) - }) - .map(|(name, _)| name) - .filter(|id| id.as_str() != WIREGUARD_INTERFACE_NAME) - .cloned() - .collect::>() - }) { - let net = subnet.trunc(); - nft_rule( - "postrouting", - &format!("tunnel-masq-{net}-{iface}"), - false, - false, - &format!("ip saddr {net} oifname \"{iface}\" masquerade"), - ) - .await?; - } - Ok(()) } @@ -278,7 +555,7 @@ pub async fn remove_subnet( _: Empty, SubnetParams { subnet }: SubnetParams, ) -> Result<(), Error> { - let (server, keep) = ctx + let (server, (keep, dropped_sni)) = ctx .db .mutate(|db| { db.as_wg_mut().as_subnets_mut().remove(&subnet)?; @@ -287,7 +564,7 @@ pub async fn remove_subnet( .await .result?; ctx.sync_network(&server).await?; - ctx.gc_forwards(&keep).await?; + ctx.gc_forwards(&keep, &dropped_sni).await?; for iface in ctx.net_iface.peek(|i| { i.iter() @@ -315,8 +592,8 @@ pub async fn remove_subnet( Ok(()) } -/// Which upstream a subnet's DNS proxy forwards to. Companion fields on -/// [`SetSubnetDnsParams`] supply the data for the `Device`/`Custom` modes. +/// Which upstream a subnet's DNS proxy forwards to. `Device`/`Custom` draw +/// their data from companion fields on [`SetSubnetDnsParams`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, TS, ValueEnum)] #[serde(rename_all = "camelCase")] pub enum DnsMode { @@ -341,7 +618,7 @@ pub struct SetSubnetDnsParams { servers: Vec, } -/// Parse a custom DNS upstream entry: a bare IP (port defaults to 53) or `ip:port`. +/// Parse a DNS upstream; a bare IP defaults to port 53. fn parse_dns_server(s: &str) -> Result { if let Ok(ip) = s.parse::() { return Ok(SocketAddr::new(ip, 53)); @@ -411,10 +688,82 @@ pub async fn set_subnet_dns( .await .result?; - // The DNS line in client configs always points at the subnet's `.1`, so the - // WireGuard config is unchanged by a mode switch — only the proxy's upstreams - // change. No `server.sync()` / wg-quick bounce needed. - ctx.dns_proxy.sync(&server).await + // Client configs always point DNS at the subnet's `.1`, so a mode switch + // only changes the proxy's upstreams — no `server.sync()` / wg-quick bounce. + ctx.dns_proxy.sync(&server, ctx.dns_injector.clone()).await +} + +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +pub struct SetSubnetWanParams { + #[ts(type = "string")] + subnet: Ipv4Net, + #[arg(long)] + #[ts(type = "string | null")] + wan_ip: Option, +} + +/// Pin the WAN IP a subnet's egress SNATs to; `null` falls back to masquerade. +/// Per-device overrides still take precedence. +pub async fn set_subnet_wan( + ctx: TunnelContext, + SetSubnetWanParams { subnet, wan_ip }: SetSubnetWanParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_wg_mut() + .as_subnets_mut() + .as_idx_mut(&subnet) + .or_not_found(&subnet)? + .as_wan_ip_mut() + .ser(&wan_ip) + }) + .await + .result?; + ctx.resync_egress().await?; + ctx.resync_forward_keys().await +} + +#[derive(Deserialize, Serialize, Parser, TS)] +#[group(skip)] +#[serde(rename_all = "camelCase")] +pub struct SetDeviceWanParams { + #[ts(type = "string")] + subnet: Ipv4Net, + #[ts(type = "string")] + ip: Ipv4Addr, + #[arg(long)] + #[ts(type = "string | null")] + wan_ip: Option, +} + +/// Pin the WAN IP a device's egress SNATs to, overriding its subnet's `wan_ip`. +/// `null` falls back to the subnet rule / masquerade. +pub async fn set_device_wan( + ctx: TunnelContext, + SetDeviceWanParams { + subnet, + ip, + wan_ip, + }: SetDeviceWanParams, +) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_wg_mut() + .as_subnets_mut() + .as_idx_mut(&subnet) + .or_not_found(&subnet)? + .as_clients_mut() + .as_idx_mut(&ip) + .or_not_found(&ip)? + .as_wan_ip_mut() + .ser(&wan_ip) + }) + .await + .result?; + ctx.resync_egress().await?; + ctx.resync_forward_keys().await } #[derive(Deserialize, Serialize, Parser, TS)] @@ -426,11 +775,20 @@ pub struct AddDeviceParams { name: InternedString, #[ts(type = "string | null")] ip: Option, + /// Client (no autoconfig) or Server (gateway-autoconfig on by default). + #[serde(default)] + #[arg(long, value_enum, default_value = "client")] + kind: WgClientKind, } pub async fn add_device( ctx: TunnelContext, - AddDeviceParams { subnet, name, ip }: AddDeviceParams, + AddDeviceParams { + subnet, + name, + ip, + kind, + }: AddDeviceParams, ) -> Result<(), Error> { let server = ctx .db @@ -469,7 +827,7 @@ pub async fn add_device( } let client = clients .entry(ip) - .or_insert_with(|| WgConfig::generate(name.clone())); + .or_insert_with(|| WgConfig::generate(name.clone(), kind)); client.name = name; Ok(()) @@ -495,7 +853,7 @@ pub async fn remove_device( ctx: TunnelContext, RemoveDeviceParams { subnet, ip }: RemoveDeviceParams, ) -> Result<(), Error> { - let (server, keep) = ctx + let (server, (keep, dropped_sni)) = ctx .db .mutate(|db| { db.as_wg_mut() @@ -510,7 +868,7 @@ pub async fn remove_device( .await .result?; ctx.sync_network(&server).await?; - ctx.gc_forwards(&keep).await + ctx.gc_forwards(&keep, &dropped_sni).await } #[derive(Deserialize, Serialize, Parser, TS)] @@ -606,37 +964,48 @@ pub async fn show_config( #[group(skip)] #[serde(rename_all = "camelCase")] pub struct AddPortForwardParams { - #[ts(type = "string")] - source: SocketAddrV4, + /// External (WAN) port to forward. The external IP is fixed to the target's + /// WAN so return traffic stays symmetric. + external_port: u16, #[ts(type = "string")] target: SocketAddrV4, #[arg(long)] label: Option, + /// Hostnames to SNI-demux on the shared external port. Empty = normal DNAT. + #[arg(long = "sni")] + #[serde(default)] + sni: Vec, } pub async fn add_forward( ctx: TunnelContext, AddPortForwardParams { - source, + external_port, target, label, + sni, }: AddPortForwardParams, ) -> Result<(), Error> { - let prefix = ctx - .net_iface - .peek(|i| { - i.iter() - .find_map(|(_, i)| { - i.ip_info.as_ref().and_then(|i| { - i.subnets - .iter() - .find(|s| s.contains(&IpAddr::from(*target.ip()))) - }) - }) - .cloned() - }) - .map(|s| s.prefix_len()) - .unwrap_or(32); + let external_ip = ctx.external_ipv4(*target.ip()).await.ok_or_else(|| { + Error::new( + eyre!("no WAN IP available for device {}", target.ip()), + ErrorKind::Network, + ) + })?; + let source = SocketAddrV4::new(external_ip, external_port); + if !sni.is_empty() { + ctx.add_sni_forward(source, target, &sni, None) + .await + .map_err(|code| { + Error::new( + eyre!("SNI registration failed (code {code})"), + ErrorKind::InvalidRequest, + ) + })?; + return Ok(()); + } + + let prefix = crate::tunnel::forward::igd::prefix_for(&ctx, target.ip()).await; let rc = ctx .forward .add_forward(source, target, prefix, None) @@ -645,10 +1014,12 @@ pub async fn add_forward( m.insert(source, rc); }); - let entry = PortForwardEntry { + let entry = PortForward::Dnat { target, label, enabled: true, + count: 1, + auto: false, }; ctx.db @@ -678,21 +1049,52 @@ pub async fn add_forward( pub struct RemovePortForwardParams { #[ts(type = "string")] source: SocketAddrV4, + /// Remove a single SNI route on `source`; omit to remove the whole forward. + #[arg(long)] + #[serde(default)] + hostname: Option, } pub async fn remove_forward( ctx: TunnelContext, - RemovePortForwardParams { source, .. }: RemovePortForwardParams, + RemovePortForwardParams { source, hostname }: RemovePortForwardParams, ) -> Result<(), Error> { - ctx.db - .mutate(|db| db.as_port_forwards_mut().remove(&source)) + let entry = ctx + .db + .peek() .await - .result?; - if let Some(rc) = ctx.active_forwards.mutate(|m| m.remove(&source)) { - drop(rc); - ctx.forward.gc().await?; + .as_port_forwards() + .de()? + .0 + .get(&source) + .cloned(); + match entry { + Some(PortForward::Sni { routes }) => { + let to_remove: Vec<(String, SocketAddrV4)> = match &hostname { + Some(h) => routes + .get(h) + .map(|r| vec![(h.clone(), r.target)]) + .unwrap_or_default(), + None => routes.iter().map(|(h, r)| (h.clone(), r.target)).collect(), + }; + for (h, route_target) in to_remove { + ctx.remove_sni_forward(source, route_target, &[h]).await; + } + Ok(()) + } + Some(PortForward::Dnat { .. }) => { + ctx.db + .mutate(|db| db.as_port_forwards_mut().remove(&source)) + .await + .result?; + if let Some(rc) = ctx.active_forwards.mutate(|m| m.remove(&source)) { + drop(rc); + ctx.forward.gc().await?; + } + Ok(()) + } + None => Ok(()), } - Ok(()) } #[derive(Deserialize, Serialize, Parser, TS)] @@ -702,11 +1104,19 @@ pub struct UpdatePortForwardLabelParams { #[ts(type = "string")] source: SocketAddrV4, label: Option, + /// Label a single SNI route on `source`; omit to label the DNAT forward. + #[arg(long)] + #[serde(default)] + hostname: Option, } pub async fn update_forward_label( ctx: TunnelContext, - UpdatePortForwardLabelParams { source, label }: UpdatePortForwardLabelParams, + UpdatePortForwardLabelParams { + source, + label, + hostname, + }: UpdatePortForwardLabelParams, ) -> Result<(), Error> { ctx.db .mutate(|db| { @@ -717,8 +1127,28 @@ pub async fn update_forward_label( ErrorKind::NotFound, ) })?; - entry.label = label; - Ok(()) + match entry { + PortForward::Dnat { label: l, .. } => { + *l = label; + Ok(()) + } + PortForward::Sni { routes } => { + let hostname = hostname.ok_or_else(|| { + Error::new( + eyre!("--hostname is required to label an SNI route"), + ErrorKind::InvalidRequest, + ) + })?; + let route = routes.get_mut(&hostname).ok_or_else(|| { + Error::new( + eyre!("No SNI route for {hostname} on {source}"), + ErrorKind::NotFound, + ) + })?; + route.label = label; + Ok(()) + } + } }) }) .await @@ -733,13 +1163,27 @@ pub struct SetPortForwardEnabledParams { source: SocketAddrV4, #[arg(long)] enabled: bool, + /// Toggle a single SNI route on `source`; omit for a DNAT forward. + #[arg(long)] + #[serde(default)] + hostname: Option, +} + +/// Carries what the db.mutate selected so the dataplane action runs after it. +enum ForwardToggle { + Dnat(SocketAddrV4), + Sni { hostname: String, target: SocketAddrV4 }, } pub async fn set_forward_enabled( ctx: TunnelContext, - SetPortForwardEnabledParams { source, enabled }: SetPortForwardEnabledParams, + SetPortForwardEnabledParams { + source, + enabled, + hostname, + }: SetPortForwardEnabledParams, ) -> Result<(), Error> { - let target = ctx + let toggle = ctx .db .mutate(|db| { db.as_port_forwards_mut().mutate(|pf| { @@ -749,40 +1193,68 @@ pub async fn set_forward_enabled( ErrorKind::NotFound, ) })?; - entry.enabled = enabled; - Ok(entry.target) + match entry { + PortForward::Dnat { + enabled: e, target, .. + } => { + *e = enabled; + Ok(ForwardToggle::Dnat(*target)) + } + PortForward::Sni { routes } => { + let hostname = hostname.clone().ok_or_else(|| { + Error::new( + eyre!("--hostname is required to toggle an SNI route"), + ErrorKind::InvalidRequest, + ) + })?; + let route = routes.get_mut(&hostname).ok_or_else(|| { + Error::new( + eyre!("No SNI route for {hostname} on {source}"), + ErrorKind::NotFound, + ) + })?; + route.enabled = enabled; + Ok(ForwardToggle::Sni { + hostname, + target: route.target, + }) + } + } }) }) .await .result?; - if enabled { - let prefix = ctx - .net_iface - .peek(|i| { - i.iter() - .find_map(|(_, i)| { - i.ip_info.as_ref().and_then(|i| { - i.subnets - .iter() - .find(|s| s.contains(&IpAddr::from(*target.ip()))) - }) - }) - .cloned() - }) - .map(|s| s.prefix_len()) - .unwrap_or(32); - let rc = ctx - .forward - .add_forward(source, target, prefix, None) - .await?; - ctx.active_forwards.mutate(|m| { - m.insert(source, rc); - }); - } else { - if let Some(rc) = ctx.active_forwards.mutate(|m| m.remove(&source)) { - drop(rc); - ctx.forward.gc().await?; + match toggle { + ForwardToggle::Dnat(target) => { + if enabled { + let prefix = crate::tunnel::forward::igd::prefix_for(&ctx, target.ip()).await; + let rc = ctx + .forward + .add_forward(source, target, prefix, None) + .await?; + ctx.active_forwards.mutate(|m| { + m.insert(source, rc); + }); + } else if let Some(rc) = ctx.active_forwards.mutate(|m| m.remove(&source)) { + drop(rc); + ctx.forward.gc().await?; + } + } + ForwardToggle::Sni { hostname, target } => { + if enabled { + ctx.sni() + .register(*source.ip(), source.port(), &[hostname], target, None) + .map_err(|code| { + Error::new( + eyre!("SNI registration failed (code {code})"), + ErrorKind::InvalidRequest, + ) + })?; + } else { + ctx.sni() + .unregister(*source.ip(), source.port(), &[hostname], target); + } } } diff --git a/core/src/tunnel/context.rs b/core/src/tunnel/context.rs index f2483c201f..8bfe34b1a6 100644 --- a/core/src/tunnel/context.rs +++ b/core/src/tunnel/context.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::net::{IpAddr, SocketAddr, SocketAddrV4}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -27,13 +27,14 @@ use crate::db::model::public::{NetworkInterfaceInfo, NetworkInterfaceType}; use crate::middleware::auth::Auth; use crate::middleware::auth::local::LocalAuthContext; use crate::middleware::cors::Cors; -use crate::net::forward::{PortForwardController, nft_rule}; +use crate::net::forward::{PortForwardController, nft_comments_with_prefix, nft_rule}; use crate::net::static_server::{EMPTY_DIR, UiContext}; use crate::prelude::*; use crate::rpc_continuations::{OpenAuthedContinuations, RpcContinuations}; use crate::tunnel::TUNNEL_DEFAULT_LISTEN; use crate::tunnel::api::tunnel_api; -use crate::tunnel::db::TunnelDatabase; +use crate::net::dns_update::rfc2136::{DnsInjector, InjectedRecord}; +use crate::tunnel::db::{DnsRecordEntry, DnsRecords, PortForward, PortForwards, TunnelDatabase}; use crate::tunnel::dns::DnsProxyController; use crate::tunnel::migrations::run_migrations; use crate::tunnel::wg::{WIREGUARD_INTERFACE_NAME, WgServer}; @@ -72,6 +73,63 @@ impl TunnelConfig { } } +/// WireGuard client IPs whose `allow_dns_injection` toggle is on. +fn allowed_injectors(server: &WgServer) -> BTreeSet { + let mut out = BTreeSet::new(); + for (_, subnet) in &server.subnets.0 { + for (ip, client) in &subnet.clients.0 { + if client.allow_dns_injection { + out.insert(IpAddr::V4(*ip)); + } + } + } + out +} + +/// Allowed-injector client IPs -> their per-device TSIG key (derived from the +/// WireGuard PSK), so the injector can cryptographically verify each UPDATE. +fn injector_keys(server: &WgServer) -> BTreeMap { + let mut out = BTreeMap::new(); + for (_, subnet) in &server.subnets.0 { + for (ip, client) in &subnet.clients.0 { + if client.allow_dns_injection { + out.insert( + IpAddr::V4(*ip), + crate::net::dns_update::derive_tsig_key(&client.psk.0), + ); + } + } + } + out +} + +/// Seed the injector from persisted records, dropping any that no longer parse. +fn seed_records(records: &DnsRecords) -> Vec { + records + .0 + .iter() + .filter_map(|e| { + let src = e + .source + .as_deref() + .and_then(|s| s.parse().ok()) + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + InjectedRecord::from_parts(&e.name, &e.rtype, &e.value, e.ttl, src).ok() + }) + .collect() +} + +fn dns_entry(r: &InjectedRecord) -> DnsRecordEntry { + let (name, rtype, value, ttl, source) = r.to_parts(); + DnsRecordEntry { + name, + rtype, + value, + ttl, + source: source.map(|s| s.to_string()), + } +} + pub struct TunnelContextSeed { pub listen: SocketAddr, pub db: TypedPatchDb, @@ -82,7 +140,17 @@ pub struct TunnelContextSeed { pub net_iface: Watch>, pub forward: PortForwardController, pub dns_proxy: DnsProxyController, + pub sni: Arc, + pub dns_injector: Arc, + /// Injector authorizer reads this live, so a toggle change takes effect + /// without rebuilding the injector. + pub dns_allowed: Arc>>, + /// Per-injector TSIG keys, read live so the injector can verify UPDATEs. + pub dns_keys: Arc>>, pub active_forwards: SyncMutex>>, + /// Serializes `resync_egress`; its read-DB → install → prune isn't atomic, + /// so a concurrent reconcile could prune a rule another call just installed. + pub egress_lock: tokio::sync::Mutex<()>, pub shutdown: Sender>, } @@ -135,10 +203,9 @@ impl TunnelContext { &format!("iifname \"{WIREGUARD_INTERFACE_NAME}\" ct state new accept"), ) .await?; - // Clamp TCP MSS on forwarded SYNs to the WireGuard path MTU so large - // TLS ClientHellos (desktop Firefox/Chromium with X25519MLKEM768 - // post-quantum key shares) don't get silently dropped after - // encapsulation. See start-os#3261. + // Clamp forwarded-SYN MSS to the WireGuard path MTU, else large TLS + // ClientHellos (post-quantum key shares) get dropped after encapsulation. + // See start-os#3261. nft_rule( "mangle_forward", "wg-mss-clamp", @@ -151,58 +218,87 @@ impl TunnelContext { let dns_proxy = DnsProxyController::new(); let peek = db.peek().await; let wg = peek.as_wg().de()?; + let dns_allowed = Arc::new(SyncMutex::new(allowed_injectors(&wg))); + let dns_keys = Arc::new(SyncMutex::new(injector_keys(&wg))); + let dns_injector = { + let seed = seed_records(&peek.as_dns_records().de()?); + let allowed = dns_allowed.clone(); + let keys = dns_keys.clone(); + let persist_db = db.clone(); + DnsInjector::new( + seed, + move |src| allowed.peek(|s| s.contains(&src)), + move |src| keys.peek(|m| m.get(&src).copied()), + move |records| { + let db = persist_db.clone(); + let entries: Vec<_> = records.iter().map(dns_entry).collect(); + tokio::spawn(async move { + db.mutate(|d| d.as_dns_records_mut().ser(&DnsRecords(entries))) + .await + .result + .log_err(); + }); + }, + ) + }; wg.sync().await?; - dns_proxy.sync(&wg).await?; - - for iface in net_iface.peek(|i| { - i.iter() - .filter(|(_, info)| { - info.ip_info.as_ref().map_or(false, |i| { - i.device_type != Some(NetworkInterfaceType::Loopback) - }) - }) - .map(|(name, _)| name) - .filter(|id| id.as_str() != WIREGUARD_INTERFACE_NAME) - .cloned() - .collect::>() - }) { - for subnet in peek.as_wg().as_subnets().keys()? { - let net = subnet.trunc(); - nft_rule( - "postrouting", - &format!("tunnel-masq-{net}-{iface}"), - false, - false, - &format!("ip saddr {net} oifname \"{iface}\" masquerade"), - ) - .await?; - } - } + dns_proxy.sync(&wg, dns_injector.clone()).await?; + let sni = crate::tunnel::forward::sni::SniDemux::new(); let mut active_forwards = BTreeMap::new(); for (from, entry) in peek.as_port_forwards().de()?.0 { - if !entry.enabled { - continue; - } - let to = entry.target; - let prefix = net_iface - .peek(|i| { - i.iter() - .find_map(|(_, i)| { - i.ip_info.as_ref().and_then(|i| { - i.subnets - .iter() - .find(|s| s.contains(&IpAddr::from(*to.ip()))) - }) + match entry { + PortForward::Dnat { + target, + enabled, + count, + .. + } => { + if !enabled { + continue; + } + let to = target; + let prefix = net_iface + .peek(|i| { + i.iter() + .find_map(|(_, i)| { + i.ip_info.as_ref().and_then(|i| { + i.subnets + .iter() + .find(|s| s.contains(&IpAddr::from(*to.ip()))) + }) + }) + .cloned() }) - .cloned() - }) - .map(|s| s.prefix_len()) - .unwrap_or(32); - active_forwards.insert(from, forward.add_forward(from, to, prefix, None).await?); + .map(|s| s.prefix_len()) + .unwrap_or(32); + active_forwards.insert( + from, + forward.add_forward_range(from, to, count, prefix, None).await?, + ); + } + PortForward::Sni { routes } => { + for (hostname, route) in routes { + if !route.enabled { + continue; + } + if let Err(code) = sni.register( + *from.ip(), + from.port(), + &[hostname.clone()], + route.target, + None, + ) { + tracing::warn!( + "failed to restore SNI route {hostname} on {from}: code {code}" + ); + } + } + } + } } - Ok(Self(Arc::new(TunnelContextSeed { + let ctx = Self(Arc::new(TunnelContextSeed { listen, db, datadir, @@ -212,24 +308,246 @@ impl TunnelContext { net_iface, forward, dns_proxy, + sni, + dns_injector, + dns_allowed, + dns_keys, active_forwards: SyncMutex::new(active_forwards), + egress_lock: tokio::sync::Mutex::new(()), shutdown, - }))) + })); + + ctx.resync_egress().await?; + + // PCP (preferred) + UPnP IGD (fallback) let connected clients open their + // public ports automatically. + tokio::spawn(crate::tunnel::forward::pcp::run(ctx.clone())); + tokio::spawn(crate::tunnel::forward::igd::run(ctx.clone())); + + Ok(ctx) } - pub async fn gc_forwards(&self, keep: &BTreeSet) -> Result<(), Error> { + pub async fn gc_forwards( + &self, + keep: &BTreeSet, + dropped_sni: &[(SocketAddrV4, String, SocketAddrV4)], + ) -> Result<(), Error> { + for (source, hostname, target) in dropped_sni { + self.sni.unregister( + *source.ip(), + source.port(), + std::slice::from_ref(hostname), + *target, + ); + } self.active_forwards .mutate(|pf| pf.retain(|k, _| keep.contains(k))); self.forward.gc().await } - /// Apply a WireGuard config change to the live tunnel: re-render and reload the - /// wg interface, then rebind the per-subnet DNS proxies (which bind to the - /// interface addresses `wg-quick up` just (re)created — so this must run after - /// `server.sync()`). + /// Reload the wg interface, then rebind the per-subnet DNS proxies. The + /// proxies bind to the addresses `wg-quick up` (re)creates, so the rebind + /// must follow `server.sync()`. pub async fn sync_network(&self, server: &WgServer) -> Result<(), Error> { server.sync().await?; - self.dns_proxy.sync(server).await + self.dns_allowed.mutate(|s| *s = allowed_injectors(server)); + self.dns_keys.mutate(|m| *m = injector_keys(server)); + self.dns_proxy.sync(server, self.dns_injector.clone()).await?; + self.resync_egress().await + } + + /// Reconcile per-subnet and per-device egress NAT rules in `postrouting`: + /// `wan_ip` SNATs, else masquerade; a device `/32` rule outranks its subnet. + /// Comment tags are stable per (subnet/device, iface) so each re-run replaces + /// in place rather than leaving stale duplicates. + pub async fn resync_egress(&self) -> Result<(), Error> { + let _guard = self.egress_lock.lock().await; + let ifaces: Vec = self.net_iface.peek(|i| { + i.iter() + .filter(|(_, info)| { + info.ip_info.as_ref().map_or(false, |i| { + i.device_type != Some(NetworkInterfaceType::Loopback) + }) + }) + .map(|(name, _)| name) + .filter(|id| id.as_str() != WIREGUARD_INTERFACE_NAME) + .cloned() + .collect() + }); + let subnets = self.db.peek().await.as_wg().as_subnets().de()?; + let mut want_dev: BTreeSet = BTreeSet::new(); + for iface in &ifaces { + for (subnet, cfg) in &subnets.0 { + let net = subnet.trunc(); + let subnet_rule = match cfg.wan_ip { + Some(wan) => format!("ip saddr {net} oifname \"{iface}\" snat to {wan}"), + None => format!("ip saddr {net} oifname \"{iface}\" masquerade"), + }; + nft_rule( + "postrouting", + &format!("tunnel-masq-{net}-{iface}"), + false, + false, + &subnet_rule, + ) + .await?; + + for (client_ip, client) in &cfg.clients.0 { + if let Some(wan) = client.wan_ip { + let comment = format!("tunnel-snat-dev-{client_ip}-{iface}"); + nft_rule( + "postrouting", + &comment, + false, + true, + &format!("ip saddr {client_ip}/32 oifname \"{iface}\" snat to {wan}"), + ) + .await?; + want_dev.insert(comment); + } + } + } + } + // Prune device-override rules whose device was removed or cleared its `wan_ip`. + for comment in nft_comments_with_prefix("postrouting", "tunnel-snat-dev-").await { + if !want_dev.contains(&comment) { + nft_rule("postrouting", &comment, true, false, "").await?; + } + } + Ok(()) + } + + /// Re-key forwards to follow their target's WAN. A forward's external IP + /// equals its target's WAN, so a WAN change must re-key it old IP → new IP. + /// Idempotent: unchanged keys match in the per-key diffs and are left alone. + pub async fn resync_forward_keys(&self) -> Result<(), Error> { + let old = self.db.peek().await.as_port_forwards().de()?.0; + + let mut want: BTreeMap = BTreeMap::new(); + for (src, entry) in &old { + match entry { + PortForward::Dnat { + target, + label, + enabled, + count, + auto, + } => { + let ip = crate::tunnel::forward::igd::external_ipv4(self, *target.ip()) + .await + .unwrap_or(*src.ip()); + want.insert( + SocketAddrV4::new(ip, src.port()), + PortForward::Dnat { + target: *target, + label: label.clone(), + enabled: *enabled, + count: *count, + auto: *auto, + }, + ); + } + PortForward::Sni { routes } => { + for (host, route) in routes { + let ip = crate::tunnel::forward::igd::external_ipv4(self, *route.target.ip()) + .await + .unwrap_or(*src.ip()); + let key = SocketAddrV4::new(ip, src.port()); + match want + .entry(key) + .or_insert_with(|| PortForward::Sni { + routes: BTreeMap::new(), + }) { + PortForward::Sni { routes } => { + routes.insert(host.clone(), route.clone()); + } + PortForward::Dnat { .. } => { + tracing::warn!( + "dropping SNI route {host} on {key}: external IP now collides with a DNAT forward" + ); + } + } + } + } + } + } + + let sni_routes = |map: &BTreeMap| { + let mut out: BTreeMap<(SocketAddrV4, String), SocketAddrV4> = BTreeMap::new(); + for (src, entry) in map { + if let PortForward::Sni { routes } = entry { + for (host, route) in routes { + out.insert((*src, host.clone()), route.target); + } + } + } + out + }; + let old_sni = sni_routes(&old); + let new_sni = sni_routes(&want); + for ((src, host), target) in &old_sni { + if new_sni.get(&(*src, host.clone())) != Some(target) { + self.sni + .unregister(*src.ip(), src.port(), std::slice::from_ref(host), *target); + } + } + for ((src, host), target) in &new_sni { + if old_sni.get(&(*src, host.clone())) != Some(target) { + if let Err(code) = + self.sni + .register(*src.ip(), src.port(), std::slice::from_ref(host), *target, None) + { + tracing::warn!("failed to register SNI route {host} on {src}: code {code}"); + } + } + } + + let old_dnat: BTreeSet = old + .iter() + .filter_map(|(src, e)| matches!(e, PortForward::Dnat { .. }).then_some(*src)) + .collect(); + let new_dnat: BTreeMap = want + .iter() + .filter_map(|(src, e)| match e { + PortForward::Dnat { + target, + enabled, + count, + .. + } => Some((*src, (*target, *count, *enabled))), + _ => None, + }) + .collect(); + for src in &old_dnat { + if !new_dnat.contains_key(src) { + if let Some(rc) = self.active_forwards.mutate(|m| m.remove(src)) { + drop(rc); + } + } + } + for (src, (target, count, enabled)) in &new_dnat { + if !enabled { + continue; + } + if self.active_forwards.peek(|m| m.contains_key(src)) { + continue; + } + let prefix = crate::tunnel::forward::igd::prefix_for(self, target.ip()).await; + let rc = self + .forward + .add_forward_range(*src, *target, *count, prefix, None) + .await?; + self.active_forwards.mutate(|m| { + m.insert(*src, rc); + }); + } + self.forward.gc().await.log_err(); + + self.db + .mutate(|db| db.as_port_forwards_mut().ser(&PortForwards(want))) + .await + .result?; + Ok(()) } } impl AsRef for TunnelContext { diff --git a/core/src/tunnel/db.rs b/core/src/tunnel/db.rs index a3da5e0df8..c28d9e2562 100644 --- a/core/src/tunnel/db.rs +++ b/core/src/tunnel/db.rs @@ -47,6 +47,8 @@ pub struct TunnelDatabase { pub gateways: OrdMap, pub wg: WgServer, pub port_forwards: PortForwards, + #[serde(default)] + pub dns_records: DnsRecords, } impl TunnelDatabase { @@ -70,23 +72,135 @@ impl TunnelDatabase { } impl Model { - pub fn gc_forwards(&mut self) -> Result, Error> { + /// Prune forwards whose target is no longer a known client. Returns the + /// surviving sources and the dropped SNI routes, which the caller must + /// unregister from the in-memory demux dataplane. + pub fn gc_forwards( + &mut self, + ) -> Result<(BTreeSet, Vec<(SocketAddrV4, String, SocketAddrV4)>), Error> { let mut keep_sources = BTreeSet::new(); + let mut dropped_sni: Vec<(SocketAddrV4, String, SocketAddrV4)> = Vec::new(); let mut keep_targets = BTreeSet::new(); for (_, cfg) in self.as_wg().as_subnets().as_entries()? { keep_targets.extend(cfg.as_clients().keys()?); } self.as_port_forwards_mut().mutate(|pf| { Ok(pf.0.retain(|k, v| { - if keep_targets.contains(v.target.ip()) { + let keep = match v { + PortForward::Dnat { target, .. } => keep_targets.contains(target.ip()), + PortForward::Sni { routes } => { + for (h, r) in routes.iter() { + if !keep_targets.contains(r.target.ip()) { + dropped_sni.push((*k, h.clone(), r.target)); + } + } + routes.retain(|_, r| keep_targets.contains(r.target.ip())); + !routes.is_empty() + } + }; + if keep { keep_sources.insert(*k); - true - } else { - false } + keep })) })?; - Ok(keep_sources) + Ok((keep_sources, dropped_sni)) + } +} + +#[test] +fn sni_and_dnat_persistence_round_trip() { + use crate::tunnel::migrations::{PortForwardKind, TunnelMigration}; + + let route = SniRoute { + target: "10.59.0.2:443".parse().unwrap(), + label: None, + enabled: true, + auto: true, + }; + let mut routes = BTreeMap::new(); + routes.insert("id.thebarsonists.run".to_string(), route); + let sni = PortForward::Sni { routes }; + + let sni_json = serde_json::to_value(&sni).unwrap(); + eprintln!("SNI serialized: {sni_json}"); + assert_eq!(sni_json["kind"], serde_json::json!("sni")); + let sni_back: PortForward = serde_json::from_value(sni_json).unwrap(); + match &sni_back { + PortForward::Sni { routes } => { + let r = routes.get("id.thebarsonists.run").expect("route present"); + assert_eq!(r.target, "10.59.0.2:443".parse().unwrap()); + assert_eq!(r.label, None); + assert!(r.enabled); + } + other => panic!("expected Sni, got {other:?}"), + } + + let dnat = PortForward::Dnat { + target: "10.59.0.2:443".parse().unwrap(), + label: None, + enabled: true, + count: 1, + auto: false, + }; + let dnat_json = serde_json::to_value(&dnat).unwrap(); + eprintln!("DNAT serialized: {dnat_json}"); + assert_eq!(dnat_json["kind"], serde_json::json!("dnat")); + let dnat_back: PortForward = serde_json::from_value(dnat_json).unwrap(); + assert!(matches!(dnat_back, PortForward::Dnat { count: 1, .. })); + + // Legacy entry with no `kind` field, run through the m_01 migration. + let mut legacy: imbl_value::Value = imbl_value::json!({ + "portForwards": { + "1.2.3.4:443": { + "target": "10.59.0.2:443", + "label": null, + "enabled": true, + "count": 1 + } + } + }); + PortForwardKind.action(&mut legacy).unwrap(); + eprintln!("Migrated legacy: {legacy}"); + let migrated_entry = legacy["portForwards"]["1.2.3.4:443"].clone(); + let migrated: PortForward = + serde_json::from_value(serde_json::to_value(&migrated_entry).unwrap()).unwrap(); + assert!( + matches!(migrated, PortForward::Dnat { count: 1, .. }), + "migrated legacy entry should be Dnat, got {migrated:?}" + ); + + // Whole PortForwards map mixing a migrated dnat and a new sni entry. + let mixed = serde_json::json!({ + "1.2.3.4:443": { + "kind": "dnat", + "target": "10.59.0.2:443", + "label": null, + "enabled": true, + "count": 1 + }, + "5.6.7.8:443": { + "kind": "sni", + "routes": { + "id.thebarsonists.run": { + "target": "10.59.0.2:443", + "label": null, + "enabled": true + } + } + } + }); + let map: PortForwards = serde_json::from_value(mixed).unwrap(); + assert_eq!(map.0.len(), 2); + let dnat_e = map.0.get(&"1.2.3.4:443".parse().unwrap()).unwrap(); + assert!(matches!(dnat_e, PortForward::Dnat { .. })); + let sni_e = map.0.get(&"5.6.7.8:443".parse().unwrap()).unwrap(); + match sni_e { + PortForward::Sni { routes } => { + let r = routes.get("id.thebarsonists.run").unwrap(); + assert!(r.enabled); + } + other => panic!("expected Sni, got {other:?}"), } } @@ -107,29 +221,86 @@ fn export_bindings_tunnel_db() { RemovePortForwardParams::export_all_to("bindings/tunnel").unwrap(); UpdatePortForwardLabelParams::export_all_to("bindings/tunnel").unwrap(); SetPortForwardEnabledParams::export_all_to("bindings/tunnel").unwrap(); + SetDnsInjectionParams::export_all_to("bindings/tunnel").unwrap(); + SetAutoPortForwardParams::export_all_to("bindings/tunnel").unwrap(); + SetSubnetWanParams::export_all_to("bindings/tunnel").unwrap(); + SetDeviceWanParams::export_all_to("bindings/tunnel").unwrap(); + SetDeviceKindParams::export_all_to("bindings/tunnel").unwrap(); + AddDnsRecordParams::export_all_to("bindings/tunnel").unwrap(); + RemoveDnsRecordParams::export_all_to("bindings/tunnel").unwrap(); + DnsRecordEntry::export_all_to("bindings/tunnel").unwrap(); AddKeyParams::export_all_to("bindings/tunnel").unwrap(); RemoveKeyParams::export_all_to("bindings/tunnel").unwrap(); SetPasswordParams::export_all_to("bindings/tunnel").unwrap(); } +/// One external-port forward: an nftables DNAT or an SNI-demultiplexed shared +/// port. Mutually exclusive for a given external address. +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum PortForward { + Dnat { + target: SocketAddrV4, + label: Option, + #[serde(default = "default_true")] + enabled: bool, + /// Contiguous ports forwarded (a PCP PORT_SET range); `1` for single-port. + #[serde(default = "default_one")] + count: u16, + /// Gateway-created (PCP/UPnP) vs user-added. Drives the UI Manual/Automatic split. + #[serde(default)] + auto: bool, + }, + Sni { + /// hostname (lowercase; may be `*.suffix`) -> route. + routes: BTreeMap, + }, +} + +/// One SNI-demultiplexed hostname route on a shared external port. #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] -pub struct PortForwardEntry { +pub struct SniRoute { pub target: SocketAddrV4, pub label: Option, #[serde(default = "default_true")] pub enabled: bool, + /// Gateway-created (PCP) vs user-added. Drives the UI Manual/Automatic split. + #[serde(default)] + pub auto: bool, } fn default_true() -> bool { true } +fn default_one() -> u16 { + 1 +} + +/// A DNS record served by the tunnel (injected via RFC 2136 or added manually). +/// `value` is the rdata as text: an IP for A/AAAA, a name for CNAME, etc. +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct DnsRecordEntry { + pub name: String, + #[serde(rename = "type")] + pub rtype: String, + pub value: String, + pub ttl: u32, + /// The device IP that injected this, or `null` for a manual record. + #[serde(default)] + pub source: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] +pub struct DnsRecords(pub Vec); + #[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] -pub struct PortForwards(pub BTreeMap); +pub struct PortForwards(pub BTreeMap); impl Map for PortForwards { type Key = SocketAddrV4; - type Value = PortForwardEntry; + type Value = PortForward; fn key_str(key: &Self::Key) -> Result, Error> { Self::key_string(key) } diff --git a/core/src/tunnel/dns.rs b/core/src/tunnel/dns.rs index 3aee2b2aa1..0493e79863 100644 --- a/core/src/tunnel/dns.rs +++ b/core/src/tunnel/dns.rs @@ -15,6 +15,7 @@ use tokio::net::{TcpListener, UdpSocket}; use crate::net::dns::{ DNS_RESPONSE_BUFFER_SIZE, forward_name_server, name_server_socket_addr, parse_resolv_conf, }; +use crate::net::dns_update::rfc2136::{DnsInjector, InjectingHandler}; use crate::prelude::*; use crate::tunnel::wg::{DnsConfig, WgServer}; use crate::util::future::NonDetachingJoinHandle; @@ -42,7 +43,7 @@ impl DnsProxyController { /// rebuild keeps this race-free against the `wg-quick` down/up cycle that /// re-creates the interface addresses; callers MUST invoke this *after* /// `WgServer::sync()` so the `.1` addresses exist to bind to. - pub async fn sync(&self, server: &WgServer) -> Result<(), Error> { + pub async fn sync(&self, server: &WgServer, injector: Arc) -> Result<(), Error> { // Drop the old listeners and wait for their tasks to finish so the // sockets are released before we rebind the same addresses. let old = self.listeners.mutate(std::mem::take); @@ -64,7 +65,7 @@ impl DnsProxyController { tracing::warn!("no DNS upstreams for subnet {subnet}; proxy not started"); continue; } - match bind_proxy(subnet.addr(), upstreams).await { + match bind_proxy(subnet.addr(), upstreams, injector.clone()).await { Ok(handle) => { listeners.insert(*subnet, handle); } @@ -132,6 +133,7 @@ fn resolv_conf_upstreams(config: &ResolverConfig) -> Vec { async fn bind_proxy( addr: Ipv4Addr, upstreams: Vec, + injector: Arc, ) -> Result, Error> { let listen = SocketAddrV4::new(addr, DNS_PORT); let udp = UdpSocket::bind(listen).await.with_kind(ErrorKind::Network)?; @@ -139,7 +141,7 @@ async fn bind_proxy( .await .with_kind(ErrorKind::Network)?; - let mut server = Server::new(forwarding_catalog(upstreams)?); + let mut server = Server::new(InjectingHandler::new(injector, forwarding_catalog(upstreams)?)); server.register_socket(udp); server.register_listener(tcp, FORWARD_TIMEOUT, DNS_RESPONSE_BUFFER_SIZE); diff --git a/core/src/tunnel/forward/igd.rs b/core/src/tunnel/forward/igd.rs new file mode 100644 index 0000000000..84321091ee --- /dev/null +++ b/core/src/tunnel/forward/igd.rs @@ -0,0 +1,370 @@ +//! Server-side UPnP IGD for StartTunnel. The tunnel answers UPnP IGD requests +//! over the WireGuard interface, so a client's UPnP code path +//! ([`crate::net::port_map::upnp`]) is identical behind a router and a tunnel. +//! +//! The IGD is reachable only over WireGuard (`SO_BINDTODEVICE`-bound sockets) and +//! only honors configured peers. Unlike classic UPnP, a peer can only forward to +//! **itself**: the SOAP `NewInternalClient` is ignored and the target is forced +//! to the requesting peer's own tunnel IP. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +#[cfg(target_os = "linux")] +use std::os::unix::io::AsRawFd; +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use axum::extract::{ConnectInfo, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use nix::net::if_::if_nametoindex; +use socket2::{Domain, InterfaceIndexOrAddress, Protocol, SockAddr, Socket, Type}; +use tokio::net::{TcpListener, UdpSocket}; + +use crate::db::model::public::NetworkInterfaceType; +use crate::net::port_map::server::igd::{ + CONTROL_PATH, IGD_HTTP_PORT, ROOT_DESC_PATH, SCPD, SCPD_PATH, SSDP_MULTICAST, SSDP_PORT, + format_uuid, handle_control, header_value, render_root_desc, serve_static, ssdp_response, + st_matches, +}; +use crate::prelude::*; +use crate::tunnel::context::TunnelContext; +use crate::tunnel::db::PortForward; +use crate::tunnel::wg::WIREGUARD_INTERFACE_NAME; + +/// Run the IGD server (SSDP responder + HTTP control server) for the life of +/// the tunnel. Both halves self-restart on error. +pub async fn run(ctx: TunnelContext) { + let uuid = match device_uuid(&ctx).await { + Ok(uuid) => uuid, + Err(e) => { + tracing::error!("UPnP IGD: cannot derive device uuid: {e}"); + return; + } + }; + let root_desc: Arc = Arc::from(render_root_desc(&uuid)); + tokio::join!(http_server(ctx.clone(), root_desc), ssdp_server(ctx, uuid)); +} + +async fn device_uuid(ctx: &TunnelContext) -> Result { + let key = ctx.db.peek().await.as_wg().as_key().de()?.verifying_key(); + Ok(format_uuid(key.0.as_bytes())) +} + +async fn ssdp_server(ctx: TunnelContext, uuid: String) { + loop { + if let Err(e) = ssdp_loop(&ctx, &uuid).await { + tracing::warn!("UPnP IGD SSDP responder failed, retrying: {e}"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } +} + +fn ssdp_socket() -> Result { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) + .with_kind(ErrorKind::Network)?; + socket.set_reuse_address(true).with_kind(ErrorKind::Network)?; + bind_to_wireguard(&socket)?; + socket + .bind(&SockAddr::from(SocketAddrV4::new( + Ipv4Addr::UNSPECIFIED, + SSDP_PORT, + ))) + .with_kind(ErrorKind::Network)?; + let ifindex = if_nametoindex(WIREGUARD_INTERFACE_NAME).with_kind(ErrorKind::Network)?; + socket + .join_multicast_v4_n(&SSDP_MULTICAST, &InterfaceIndexOrAddress::Index(ifindex)) + .with_kind(ErrorKind::Network)?; + socket.set_multicast_loop_v4(false).with_kind(ErrorKind::Network)?; + socket.set_nonblocking(true).with_kind(ErrorKind::Network)?; + UdpSocket::from_std(socket.into()).with_kind(ErrorKind::Network) +} + +async fn ssdp_loop(ctx: &TunnelContext, uuid: &str) -> Result<(), Error> { + let socket = ssdp_socket()?; + tracing::info!("UPnP IGD SSDP responder listening on {WIREGUARD_INTERFACE_NAME}"); + let mut buf = [0u8; 2048]; + loop { + let (n, from) = socket.recv_from(&mut buf).await.with_kind(ErrorKind::Network)?; + let Ok(text) = std::str::from_utf8(&buf[..n]) else { + continue; + }; + if !text.starts_with("M-SEARCH") { + continue; + } + let Some(st) = header_value(text, "st") else { + continue; + }; + if !st_matches(&st) { + continue; + } + let IpAddr::V4(peer) = from.ip() else { + continue; + }; + // Only answer a configured peer, and advertise the `.1` of its subnet. + let Some(server_ip) = subnet_gateway_for(ctx, peer).await else { + continue; + }; + let resp = ssdp_response(server_ip, uuid); + if let Err(e) = socket.send_to(resp.as_bytes(), from).await { + tracing::debug!("UPnP IGD: failed to answer M-SEARCH from {from}: {e}"); + } + } +} + +/// The `.1` server address of the subnet that contains `peer`, if `peer` is a +/// configured client on it. Doubles as the peer-authorization check for SSDP. +pub(super) async fn subnet_gateway_for(ctx: &TunnelContext, peer: Ipv4Addr) -> Option { + let subnets = ctx.db.peek().await.as_wg().as_subnets().de().ok()?; + subnets.0.iter().find_map(|(subnet, cfg)| { + if cfg.clients.0.contains_key(&peer) { + Some(subnet.addr()) + } else { + None + } + }) +} + +async fn http_server(ctx: TunnelContext, root_desc: Arc) { + let app = Router::new() + .route(ROOT_DESC_PATH, get(move || serve_static(root_desc.clone(), "text/xml"))) + .route(SCPD_PATH, get(|| serve_static(Arc::from(SCPD), "text/xml"))) + .route(CONTROL_PATH, post(control)) + .with_state(ctx); + loop { + match igd_http_listener() { + Ok(listener) => { + tracing::info!("UPnP IGD control server listening on {WIREGUARD_INTERFACE_NAME}:{IGD_HTTP_PORT}"); + if let Err(e) = axum::serve( + listener, + app.clone() + .into_make_service_with_connect_info::(), + ) + .await + { + tracing::warn!("UPnP IGD control server exited, retrying: {e}"); + } + } + Err(e) => tracing::warn!("UPnP IGD control server bind failed, retrying: {e}"), + } + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +fn igd_http_listener() -> Result { + let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP)) + .with_kind(ErrorKind::Network)?; + socket.set_reuse_address(true).with_kind(ErrorKind::Network)?; + bind_to_wireguard(&socket)?; + socket + .bind(&SockAddr::from(SocketAddrV4::new( + Ipv4Addr::UNSPECIFIED, + IGD_HTTP_PORT, + ))) + .with_kind(ErrorKind::Network)?; + socket.listen(128).with_kind(ErrorKind::Network)?; + socket.set_nonblocking(true).with_kind(ErrorKind::Network)?; + TcpListener::from_std(socket.into()).with_kind(ErrorKind::Network) +} + +/// `SO_BINDTODEVICE` to the WireGuard interface so the socket never sees the +/// VPS's public interface — this is what keeps the IGD private to peers. The +/// non-linux stub below only exists to compile `core` on other CI targets +/// (apple-darwin lacks `SO_BINDTODEVICE`) and is never reached at runtime. +#[cfg(target_os = "linux")] +pub(super) fn bind_to_wireguard(socket: &Socket) -> Result<(), Error> { + let name = WIREGUARD_INTERFACE_NAME.as_bytes(); + // SAFETY: fd from `socket`; `name`/`len` describe a valid slice that SO_BINDTODEVICE copies. + let ret = unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + name.as_ptr() as *const libc::c_void, + name.len() as libc::socklen_t, + ) + }; + if ret != 0 { + return Err(Error::new( + eyre!( + "SO_BINDTODEVICE({WIREGUARD_INTERFACE_NAME}): {}", + std::io::Error::last_os_error() + ), + ErrorKind::Network, + )); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn bind_to_wireguard(_socket: &Socket) -> Result<(), Error> { + Err(Error::new( + eyre!("SO_BINDTODEVICE is only supported on Linux"), + ErrorKind::Network, + )) +} + +async fn control( + State(ctx): State, + ConnectInfo(from): ConnectInfo, + headers: HeaderMap, + body: String, +) -> Response { + let IpAddr::V4(peer) = from.ip() else { + return StatusCode::BAD_REQUEST.into_response(); + }; + handle_control(&ctx, peer, &headers, &body).await +} + +pub(super) async fn apply_peer_forward( + ctx: &TunnelContext, + source: SocketAddrV4, + target: SocketAddrV4, +) -> Result<(), u16> { + apply_peer_forward_range(ctx, source, target, 1, "UPnP").await +} + +/// Like [`apply_peer_forward`] but forwards `count` contiguous ports (a PCP +/// PORT_SET range). `protocol_label` is the DB label (e.g. "UPnP", "PCP"); the +/// requesting device is already shown by the forward's target. +pub(super) async fn apply_peer_forward_range( + ctx: &TunnelContext, + source: SocketAddrV4, + target: SocketAddrV4, + count: u16, + protocol_label: &str, +) -> Result<(), u16> { + match current_forward(ctx, source).await { + Some(PortForward::Dnat { + target: t, count: c, .. + }) if t != target || c != count => { + return Err(718); // ConflictInMappingEntry + } + // The external port is held by an SNI-demuxed forward. + Some(PortForward::Sni { .. }) => { + return Err(718); // ConflictInMappingEntry + } + Some(PortForward::Dnat { .. }) => { + // Idempotent re-assert from the client's periodic refresh: ensure the + // nft forward is actually installed. + let active = ctx.active_forwards.mutate(|m| m.contains_key(&source)); + if !active { + let prefix = prefix_for(ctx, target.ip()).await; + let rc = ctx + .forward + .add_forward_range(source, target, count, prefix, None) + .await + .map_err(|_| 501u16)?; + ctx.active_forwards.mutate(|m| { + m.insert(source, rc); + }); + } + return Ok(()); + } + None => {} + } + + let prefix = prefix_for(ctx, target.ip()).await; + let rc = ctx + .forward + .add_forward_range(source, target, count, prefix, None) + .await + .map_err(|_| 501u16)?; + ctx.active_forwards.mutate(|m| { + m.insert(source, rc); + }); + let entry = PortForward::Dnat { + target, + label: Some(protocol_label.to_string()), + enabled: true, + count, + auto: true, + }; + ctx.db + .mutate(|db| db.as_port_forwards_mut().insert(&source, &entry).map(|_| ())) + .await + .result + .map_err(|_| 501u16)?; + Ok(()) +} + +pub(super) async fn current_forward(ctx: &TunnelContext, source: SocketAddrV4) -> Option { + ctx.db + .peek() + .await + .as_port_forwards() + .de() + .ok() + .and_then(|pf| pf.0.get(&source).cloned()) +} + +/// Whether `peer` may auto-create port forwards (PCP/IGD) — the per-device +/// `allow_auto_port_forward` flag, set alongside `allow_dns_injection` by the +/// "Gateway Autoconfiguration" toggle. An untrusted client gets no forwards. +pub(super) async fn is_known_client(ctx: &TunnelContext, peer: Ipv4Addr) -> bool { + let Ok(subnets) = ctx.db.peek().await.as_wg().as_subnets().de() else { + return false; + }; + subnets.0.values().any(|cfg| { + cfg.clients + .0 + .get(&peer) + .is_some_and(|c| c.allow_auto_port_forward) + }) +} + +/// The WAN IPv4 `peer`'s egress uses: its assigned WAN if pinned, else the +/// gateway's default WAN. +pub(in crate::tunnel) async fn external_ipv4(ctx: &TunnelContext, peer: Ipv4Addr) -> Option { + assigned_wan_for(ctx, peer).await.or_else(|| default_wan(ctx)) +} + +/// First usable WAN candidate across the gateway's non-loopback, non-wg +/// interfaces — the egress when no `wan_ip` is pinned for the peer or its subnet. +fn default_wan(ctx: &TunnelContext) -> Option { + ctx.net_iface.peek(|ifaces| { + ifaces.iter().find_map(|(id, info)| { + if id.as_str() == WIREGUARD_INTERFACE_NAME { + return None; + } + let ip_info = info.ip_info.as_ref()?; + if ip_info.device_type == Some(NetworkInterfaceType::Loopback) { + return None; + } + ip_info + .wan_ip + .filter(|v4| crate::net::port_map::upnp::is_wan_candidate(*v4)) + .or_else(|| { + ip_info.subnets.iter().find_map(|s| match s.addr() { + IpAddr::V4(v4) if crate::net::port_map::upnp::is_wan_candidate(v4) => Some(v4), + _ => None, + }) + }) + }) + }) +} + +/// The WAN IP pinned for `peer`: its device override, else its subnet's `wan_ip`. +async fn assigned_wan_for(ctx: &TunnelContext, peer: Ipv4Addr) -> Option { + let subnets = ctx.db.peek().await.as_wg().as_subnets().de().ok()?; + subnets.0.values().find_map(|cfg| { + let client = cfg.clients.0.get(&peer)?; + client.wan_ip.or(cfg.wan_ip) + }) +} + +pub(in crate::tunnel) async fn prefix_for(ctx: &TunnelContext, target_ip: &Ipv4Addr) -> u8 { + ctx.net_iface + .peek(|ifaces| { + ifaces.iter().find_map(|(_, info)| { + info.ip_info.as_ref().and_then(|i| { + i.subnets + .iter() + .find(|s| s.contains(&IpAddr::V4(*target_ip))) + .map(|s| s.prefix_len()) + }) + }) + }) + .unwrap_or(32) +} diff --git a/core/src/tunnel/forward/mod.rs b/core/src/tunnel/forward/mod.rs new file mode 100644 index 0000000000..78d039b688 --- /dev/null +++ b/core/src/tunnel/forward/mod.rs @@ -0,0 +1,7 @@ +//! The tunnel's gateway-side inbound forwarding: nft DNAT + external-IP +//! resolution ([`igd`]), the PCP [`GatewayBackend`](crate::net::port_map::server::GatewayBackend) +//! implementation ([`pcp`]), and the SNI demultiplexer ([`sni`]). + +pub mod igd; +pub mod pcp; +pub mod sni; diff --git a/core/src/tunnel/forward/pcp.rs b/core/src/tunnel/forward/pcp.rs new file mode 100644 index 0000000000..81ca2fd78b --- /dev/null +++ b/core/src/tunnel/forward/pcp.rs @@ -0,0 +1,244 @@ +//! Server-side PCP for StartTunnel: the WireGuard-bound socket + serve loop and +//! the [`GatewayBackend`] impl mapping PCP forwards onto nftables + PatchDb. The +//! protocol core (RFC 6887 + HOSTNAME/PORT_SET extensions) lives in +//! [`crate::net::port_map::server`]. +//! +//! The socket is `SO_BINDTODEVICE`-bound to the WireGuard interface, so the PCP +//! server is never reachable from the VPS's public interface. + +use std::net::{IpAddr, Ipv4Addr, SocketAddrV4}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use socket2::{Domain, Protocol, SockAddr, Socket, Type}; +use tokio::net::UdpSocket; + +use crate::net::port_map::server::{GatewayBackend, PCP_PORT, handle}; +use crate::prelude::*; +use crate::tunnel::context::TunnelContext; +use crate::tunnel::db::PortForward; +use crate::tunnel::forward::igd::{ + apply_peer_forward_range, bind_to_wireguard, external_ipv4, is_known_client, +}; +use crate::tunnel::forward::sni::SniDemux; +use crate::tunnel::wg::WIREGUARD_INTERFACE_NAME; + +/// Run the PCP server for the life of the tunnel, self-restarting on error. +pub async fn run(ctx: TunnelContext) { + let started = Instant::now(); + loop { + if let Err(e) = serve(&ctx, started).await { + tracing::warn!("PCP server failed, retrying: {e}"); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } +} + +fn socket() -> Result { + let socket = + Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).with_kind(ErrorKind::Network)?; + socket.set_reuse_address(true).with_kind(ErrorKind::Network)?; + bind_to_wireguard(&socket)?; + socket + .bind(&SockAddr::from(SocketAddrV4::new( + Ipv4Addr::UNSPECIFIED, + PCP_PORT, + ))) + .with_kind(ErrorKind::Network)?; + socket.set_nonblocking(true).with_kind(ErrorKind::Network)?; + UdpSocket::from_std(socket.into()).with_kind(ErrorKind::Network) +} + +async fn serve(ctx: &TunnelContext, started: Instant) -> Result<(), Error> { + let socket = socket()?; + tracing::info!("PCP server listening on {WIREGUARD_INTERFACE_NAME}:{PCP_PORT}"); + let mut buf = [0u8; 1100]; + loop { + let (n, from) = socket.recv_from(&mut buf).await.with_kind(ErrorKind::Network)?; + let IpAddr::V4(peer) = from.ip() else { + continue; + }; + let epoch = started.elapsed().as_secs() as u32; + if let Some(resp) = handle(ctx, peer, &buf[..n], epoch).await { + socket.send_to(&resp, from).await.ok(); + } + } +} + +/// Maps PCP forward operations onto the tunnel's nftables forwards + PatchDb. A +/// peer can only forward to its own tunnel IP (caller passes `target = peer`). +impl GatewayBackend for TunnelContext { + async fn add_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + count: u16, + _peer: Ipv4Addr, + ) -> Result<(), u16> { + apply_peer_forward_range(self, source, target, count, "PCP").await + } + + async fn remove_forward(&self, peer: Ipv4Addr, internal_port: u16) { + remove_peer_forward(self, peer, internal_port).await + } + + async fn remove_forward_by_source(&self, source: SocketAddrV4, peer: Ipv4Addr) -> bool { + let owned = crate::tunnel::forward::igd::current_forward(self, source) + .await + .is_some_and(|e| matches!(e, PortForward::Dnat { target, .. } if *target.ip() == peer)); + if !owned { + return false; + } + if self + .db + .mutate(|db| db.as_port_forwards_mut().remove(&source).map(|_| ())) + .await + .result + .is_err() + { + return false; + } + if let Some(rc) = self.active_forwards.mutate(|m| m.remove(&source)) { + drop(rc); + self.forward.gc().await.log_err(); + } + true + } + + async fn external_ipv4(&self, peer: Ipv4Addr) -> Option { + external_ipv4(self, peer).await + } + + async fn is_known_client(&self, peer: Ipv4Addr) -> bool { + is_known_client(self, peer).await + } + + fn sni(&self) -> &Arc { + &self.sni + } + + async fn add_sni_forward( + &self, + source: SocketAddrV4, + target: SocketAddrV4, + hostnames: &[String], + _lifetime: Option, + ) -> Result<(), u8> { + // Persist first (DB is source of truth): reject a DNAT-occupied port or a + // foreign-owned hostname before touching the dataplane. Registering first + // risked a rollback on a transient DB error tearing down a valid binding. + let hostnames_owned = hostnames.to_vec(); + let persisted = self + .db + .mutate(|db| { + db.as_port_forwards_mut().mutate(|pf| { + use crate::tunnel::db::{PortForward, SniRoute}; + let entry = pf.0.entry(source).or_insert_with(|| PortForward::Sni { + routes: std::collections::BTreeMap::new(), + }); + match entry { + PortForward::Sni { routes } => { + for h in &hostnames_owned { + if routes.get(h).is_some_and(|r| r.target != target) { + return Err(Error::new( + eyre!("SNI hostname {h} on {source} is held by another client"), + ErrorKind::InvalidRequest, + )); + } + } + for h in &hostnames_owned { + // A renewal must not clobber a user's edits, so + // keep any existing label/enabled; a brand-new + // route gets a default label marking it PCP-added. + let (label, enabled) = match routes.get(h) { + Some(r) => ( + r.label.clone().or_else(|| Some("PCP".into())), + r.enabled, + ), + None => (Some("PCP".into()), true), + }; + routes.insert(h.clone(), SniRoute { target, label, enabled, auto: true }); + } + Ok(()) + } + // external port already used by a DNAT forward + PortForward::Dnat { .. } => Err(Error::new( + eyre!("{source} is already a DNAT forward"), + ErrorKind::InvalidRequest, + )), + } + }) + }) + .await + .result; + if persisted.is_err() { + return Err(crate::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN); + } + // Mirror into the dataplane; on the unexpected register failure undo the + // DB routes we just added. + if self + .sni() + .register(*source.ip(), source.port(), hostnames, target, None) + .is_err() + { + self.remove_sni_forward(source, target, hostnames).await; + return Err(crate::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN); + } + Ok(()) + } + + async fn remove_sni_forward(&self, source: SocketAddrV4, target: SocketAddrV4, hostnames: &[String]) { + self.sni().unregister(*source.ip(), source.port(), hostnames, target); + let hostnames = hostnames.to_vec(); + self.db + .mutate(|db| { + db.as_port_forwards_mut().mutate(|pf| { + use crate::tunnel::db::PortForward; + let mut now_empty = false; + if let Some(PortForward::Sni { routes }) = pf.0.get_mut(&source) { + routes.retain(|h, r| !(r.target == target && hostnames.contains(h))); + now_empty = routes.is_empty(); + } + if now_empty { + pf.0.remove(&source); + } + Ok(()) + }) + }) + .await + .result + .log_err(); + } +} + +/// Remove the peer's forward to `(peer, internal_port)`, if any. We forward both +/// protocols on one entry, so match by target rather than PCP's (proto, port, client). +async fn remove_peer_forward(ctx: &TunnelContext, peer: Ipv4Addr, internal_port: u16) { + let target = SocketAddrV4::new(peer, internal_port); + let source = ctx + .db + .peek() + .await + .as_port_forwards() + .de() + .ok() + .and_then(|pf| { + pf.0.iter() + .find(|(_, entry)| { + matches!(entry, PortForward::Dnat { target: t, .. } if *t == target) + }) + .map(|(source, _)| *source) + }); + let Some(source) = source else { + return; + }; + ctx.db + .mutate(|db| db.as_port_forwards_mut().remove(&source).map(|_| ())) + .await + .result + .log_err(); + if let Some(rc) = ctx.active_forwards.mutate(|m| m.remove(&source)) { + drop(rc); + ctx.forward.gc().await.log_err(); + } +} diff --git a/core/src/tunnel/forward/sni.rs b/core/src/tunnel/forward/sni.rs new file mode 100644 index 0000000000..241ba65075 --- /dev/null +++ b/core/src/tunnel/forward/sni.rs @@ -0,0 +1,363 @@ +//! SNI demultiplexer for the PCP HOSTNAME extension: a per-port TCP listener +//! reads the TLS ClientHello, selects a binding (exact → wildcard → fallback), +//! and splices to the internal host. TLS is never terminated; the ClientHello +//! bytes are forwarded verbatim. The internal leg is opened from the client's +//! own source address (source-address preservation, RFC §4.6) via +//! [`crate::net::transparent`]. +//! +//! QUIC (§4.5) and wildcards beyond a single leading `*` label are out of scope. + +use std::collections::BTreeMap; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt, copy_bidirectional}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::time::timeout; + +use crate::net::port_map::pcp::hostname::RESULT_HOSTNAME_TAKEN; +use crate::prelude::*; +use crate::util::future::NonDetachingJoinHandle; +use crate::util::sync::SyncMutex; + +/// (external IP, external port). +type PortKey = (Ipv4Addr, u16); + +const CLIENTHELLO_CAP: usize = 16384; +const CLIENTHELLO_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone)] +struct Binding { + target: SocketAddrV4, + /// `None` for a permanent (DB-backed/manual) binding that never expires. + expiry: Option, +} + +#[derive(Default)] +struct PortBindings { + /// hostname (lowercase) -> binding; a `*.suffix` key is a wildcard. + hostnames: BTreeMap, + fallback: Option, +} + +impl PortBindings { + fn prune(&mut self, now: Instant) { + self.hostnames.retain(|_, b| b.expiry.is_none_or(|e| e > now)); + } + fn is_empty(&self) -> bool { + self.hostnames.is_empty() && self.fallback.is_none() + } + /// exact match, then a `*.suffix` wildcard on the parent, then fallback. + fn select(&self, sni: Option<&str>) -> Option { + if let Some(name) = sni { + if let Some(b) = self.hostnames.get(name) { + return Some(b.target); + } + if let Some((_, rest)) = name.split_once('.') { + if let Some(b) = self.hostnames.get(&format!("*.{rest}")) { + return Some(b.target); + } + } + } + self.fallback + } +} + +/// Called `(ext_port, active)` when a port's listener starts/stops, so a gateway +/// can open/close inbound access (e.g. a StartWRT firewall ACCEPT rule). +type OnChange = Box; + +pub struct SniDemux { + ports: Arc>>, + listeners: SyncMutex>>, + on_change: Option, +} + +impl SniDemux { + pub fn new() -> Arc { + Self::build(None) + } + + /// Like [`new`](Self::new) but invokes `on_change` on listener create/teardown. + pub fn with_on_change(on_change: impl Fn(u16, bool) + Send + Sync + 'static) -> Arc { + Self::build(Some(Box::new(on_change))) + } + + fn build(on_change: Option) -> Arc { + let this = Arc::new(Self { + ports: Arc::new(SyncMutex::new(BTreeMap::new())), + listeners: SyncMutex::new(BTreeMap::new()), + on_change, + }); + let weak = Arc::downgrade(&this); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + let Some(this) = weak.upgrade() else { break }; + this.prune(); + } + }); + this + } + + /// Register hostname bindings for `(ext_ip, ext_port) -> target` and ensure + /// the listener runs. `Err(RESULT_HOSTNAME_TAKEN)` if any name is held by a + /// different target — all-or-nothing; the same target reclaims. + pub fn register( + self: &Arc, + ext_ip: Ipv4Addr, + ext_port: u16, + hostnames: &[String], + target: SocketAddrV4, + lifetime_secs: Option, + ) -> Result<(), u8> { + let now = Instant::now(); + let expiry = lifetime_secs.map(|s| now + Duration::from_secs(s as u64)); + let key = (ext_ip, ext_port); + self.ports.mutate(|ports| { + let entry = ports.entry(key).or_default(); + entry.prune(now); + for name in hostnames { + if let Some(b) = entry.hostnames.get(name) { + if b.target != target { + return Err(RESULT_HOSTNAME_TAKEN); + } + } + } + for name in hostnames { + entry + .hostnames + .insert(name.clone(), Binding { target, expiry }); + } + Ok(()) + })?; + self.ensure_listener(key); + Ok(()) + } + + /// Delete the named bindings (lifetime-0 MAP), only those held by `target`. + pub fn unregister( + &self, + ext_ip: Ipv4Addr, + ext_port: u16, + hostnames: &[String], + target: SocketAddrV4, + ) { + let key = (ext_ip, ext_port); + self.ports.mutate(|ports| { + if let Some(entry) = ports.get_mut(&key) { + for name in hostnames { + if entry.hostnames.get(name).is_some_and(|b| b.target == target) { + entry.hostnames.remove(name); + } + } + } + }); + self.reap_if_empty(key); + } + + fn prune(&self) { + let now = Instant::now(); + let empty: Vec = self.ports.mutate(|ports| { + for entry in ports.values_mut() { + entry.prune(now); + } + ports + .iter() + .filter(|(_, e)| e.is_empty()) + .map(|(k, _)| *k) + .collect() + }); + for key in empty { + self.reap_if_empty(key); + } + } + + fn reap_if_empty(&self, key: PortKey) { + let empty = self + .ports + .mutate(|ports| ports.get(&key).is_none_or(|e| e.is_empty())); + if empty { + self.ports.mutate(|ports| { + ports.remove(&key); + }); + if let Some(handle) = self.listeners.mutate(|l| l.remove(&key)) { + drop(handle); // aborts the listener task + if let Some(cb) = &self.on_change { + cb(key.1, false); + } + } + } + } + + fn ensure_listener(self: &Arc, key: PortKey) { + let already = self.listeners.mutate(|l| l.contains_key(&key)); + if already { + return; + } + let ports = self.ports.clone(); + let handle = NonDetachingJoinHandle::from(tokio::spawn(async move { + if let Err(e) = run_listener(key, ports).await { + tracing::warn!("SNI demux listener on {}:{} exited: {e}", key.0, key.1); + } + })); + self.listeners.mutate(|l| { + l.insert(key, handle); + }); + if let Some(cb) = &self.on_change { + cb(key.1, true); + } + } +} + +async fn run_listener( + key: PortKey, + ports: Arc>>, +) -> Result<(), Error> { + if let Err(e) = crate::net::transparent::ensure_divert_infra().await { + tracing::warn!("SNI demux reply-path divert setup failed (source preservation may be degraded): {e}"); + } + let listener = TcpListener::bind(SocketAddrV4::new(key.0, key.1)) + .await + .with_kind(ErrorKind::Network)?; + tracing::info!("SNI demux listening on {}:{}", key.0, key.1); + loop { + let (conn, peer) = listener.accept().await.with_kind(ErrorKind::Network)?; + let ports = ports.clone(); + tokio::spawn(async move { + handle_conn(conn, peer, key, ports).await; + }); + } +} + +async fn handle_conn( + mut conn: TcpStream, + peer: SocketAddr, + key: PortKey, + ports: Arc>>, +) { + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + let sni = loop { + match timeout(CLIENTHELLO_TIMEOUT, conn.read(&mut tmp)).await { + Ok(Ok(0)) => break extract_sni(&buf), + Ok(Ok(n)) => { + buf.extend_from_slice(&tmp[..n]); + if let Some(name) = extract_sni(&buf) { + break Some(name); + } + // Complete-but-SNI-less, non-TLS, or capped: stop and use fallback. + if record_complete(&buf) || buf.len() >= CLIENTHELLO_CAP { + break extract_sni(&buf); + } + } + _ => break extract_sni(&buf), + } + }; + + let target = ports.peek(|p| p.get(&key).and_then(|e| e.select(sni.as_deref()))); + let Some(target) = target else { + return; // no match and no fallback: close + }; + let SocketAddr::V4(peer) = peer else { + return; // IPv4-only listener; should not occur + }; + // Open the internal leg from the client's own source address (RFC §4.6). + let Ok(mut upstream) = crate::net::transparent::transparent_connect(peer, target).await else { + return; + }; + if upstream.write_all(&buf).await.is_err() { + return; + } + let _ = copy_bidirectional(&mut conn, &mut upstream).await; +} + +/// Whether `buf` holds at least one complete TLS handshake record. +fn record_complete(buf: &[u8]) -> bool { + buf.len() >= 5 && buf.len() >= 5 + u16::from_be_bytes([buf[3], buf[4]]) as usize +} + +/// Extract the (lowercased) SNI host_name from a buffered TLS ClientHello via +/// rustls, or `None` if absent / not yet complete / not TLS. The ClientHello is +/// only parsed, never answered — `buf` is still forwarded verbatim to the peer. +fn extract_sni(buf: &[u8]) -> Option { + let mut acceptor = tokio_rustls::rustls::server::Acceptor::default(); + let mut cursor = std::io::Cursor::new(buf); + while let Ok(n) = acceptor.read_tls(&mut cursor) { + if n == 0 { + break; + } + } + match acceptor.accept() { + Ok(Some(accepted)) => accepted + .client_hello() + .server_name() + .map(|s| s.to_ascii_lowercase()), + _ => None, + } +} + +impl Default for SniDemux { + fn default() -> Self { + Self { + ports: Arc::new(SyncMutex::new(BTreeMap::new())), + listeners: SyncMutex::new(BTreeMap::new()), + on_change: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real ClientHello produced by rustls, carrying `sni` in the SNI + /// extension — so the parser is exercised against genuine wire bytes. + fn real_client_hello(sni: &str) -> Vec { + use tokio_rustls::rustls::pki_types::ServerName; + use tokio_rustls::rustls::{ClientConfig, ClientConnection, RootCertStore}; + + let provider = std::sync::Arc::new(tokio_rustls::rustls::crypto::ring::default_provider()); + let config = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(RootCertStore::empty()) + .with_no_client_auth(); + let name = ServerName::try_from(sni.to_owned()).unwrap(); + let mut conn = ClientConnection::new(std::sync::Arc::new(config), name).unwrap(); + let mut buf = Vec::new(); + while conn.wants_write() { + conn.write_tls(&mut buf).unwrap(); + } + buf + } + + #[test] + fn parses_sni() { + let hello = real_client_hello("git.example.com"); + assert_eq!(extract_sni(&hello).as_deref(), Some("git.example.com")); + } + + #[test] + fn non_tls_is_none() { + assert_eq!(extract_sni(b"GET / HTTP/1.1\r\n"), None); + } + + #[test] + fn select_exact_wildcard_fallback() { + let mut pb = PortBindings::default(); + let exp = Instant::now() + Duration::from_secs(60); + let mk = |o: u8| Binding { + target: SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, o), 443), + expiry: Some(exp), + }; + pb.hostnames.insert("a.example.com".into(), mk(1)); + pb.hostnames.insert("*.example.com".into(), mk(2)); + pb.fallback = Some(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 9), 443)); + assert_eq!(pb.select(Some("a.example.com")).unwrap().ip().octets()[3], 1); + assert_eq!(pb.select(Some("b.example.com")).unwrap().ip().octets()[3], 2); + assert_eq!(pb.select(Some("other.org")).unwrap().ip().octets()[3], 9); + assert_eq!(pb.select(None).unwrap().ip().octets()[3], 9); + } +} diff --git a/core/src/tunnel/migrations/m_01_port_forward_kind.rs b/core/src/tunnel/migrations/m_01_port_forward_kind.rs new file mode 100644 index 0000000000..953c10e685 --- /dev/null +++ b/core/src/tunnel/migrations/m_01_port_forward_kind.rs @@ -0,0 +1,18 @@ +use imbl_value::InternedString; + +use super::TunnelMigration; +use crate::prelude::*; + +pub struct PortForwardKind; +impl TunnelMigration for PortForwardKind { + fn action(&self, db: &mut Value) -> Result<(), Error> { + for (_, value) in db["portForwards"].as_object_mut().unwrap().iter_mut() { + if let Some(obj) = value.as_object_mut() { + if !obj.contains_key(&InternedString::intern("kind")) { + obj.insert(InternedString::intern("kind"), "dnat".into()); + } + } + } + Ok(()) + } +} diff --git a/core/src/tunnel/migrations/m_02_wg_client_kind.rs b/core/src/tunnel/migrations/m_02_wg_client_kind.rs new file mode 100644 index 0000000000..fa636660b1 --- /dev/null +++ b/core/src/tunnel/migrations/m_02_wg_client_kind.rs @@ -0,0 +1,42 @@ +use imbl_value::InternedString; + +use super::TunnelMigration; +use crate::prelude::*; + +/// Backfill the `kind` field on existing WireGuard clients: a client that +/// already has both autoconfig flags on was a Server; everything else a Client. +/// Never touches the flag values. +pub struct WgClientKind; +impl TunnelMigration for WgClientKind { + fn action(&self, db: &mut Value) -> Result<(), Error> { + let kind_key = InternedString::intern("kind"); + let Some(subnets) = db["wg"]["subnets"].as_object_mut() else { + return Ok(()); + }; + for (_, subnet) in subnets.iter_mut() { + let Some(clients) = subnet["clients"].as_object_mut() else { + continue; + }; + for (_, client) in clients.iter_mut() { + let Some(obj) = client.as_object_mut() else { + continue; + }; + if obj.contains_key(&kind_key) { + continue; + } + let flag = |k: &str| { + obj.get(&InternedString::intern(k)) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + }; + let kind = if flag("allowDnsInjection") && flag("allowAutoPortForward") { + "server" + } else { + "client" + }; + obj.insert(kind_key.clone(), kind.into()); + } + } + Ok(()) + } +} diff --git a/core/src/tunnel/migrations/m_03_port_forward_auto.rs b/core/src/tunnel/migrations/m_03_port_forward_auto.rs new file mode 100644 index 0000000000..cf24c32068 --- /dev/null +++ b/core/src/tunnel/migrations/m_03_port_forward_auto.rs @@ -0,0 +1,55 @@ +use imbl_value::InternedString; + +use super::TunnelMigration; +use crate::prelude::*; + +/// Backfill the `auto` field on existing forwards/SNI routes: an entry labeled +/// `PCP`/`UPnP` was gateway-created, everything else user-added. +pub struct PortForwardAuto; +impl TunnelMigration for PortForwardAuto { + fn action(&self, db: &mut Value) -> Result<(), Error> { + let auto_key = InternedString::intern("auto"); + let label_key = InternedString::intern("label"); + let is_auto = |v: Option<&Value>| { + v.and_then(|v| v.as_str()) + .is_some_and(|l| l == "PCP" || l == "UPnP") + }; + let Some(forwards) = db["portForwards"].as_object_mut() else { + return Ok(()); + }; + for (_, value) in forwards.iter_mut() { + let Some(obj) = value.as_object_mut() else { + continue; + }; + match obj + .get(&InternedString::intern("kind")) + .and_then(|v| v.as_str()) + { + Some("dnat") => { + if !obj.contains_key(&auto_key) { + let auto = is_auto(obj.get(&label_key)); + obj.insert(auto_key.clone(), auto.into()); + } + } + Some("sni") => { + let Some(routes) = obj + .get_mut(&InternedString::intern("routes")) + .and_then(|v| v.as_object_mut()) + else { + continue; + }; + for (_, route) in routes.iter_mut() { + if let Some(r) = route.as_object_mut() { + if !r.contains_key(&auto_key) { + let auto = is_auto(r.get(&label_key)); + r.insert(auto_key.clone(), auto.into()); + } + } + } + } + _ => {} + } + } + Ok(()) + } +} diff --git a/core/src/tunnel/migrations/mod.rs b/core/src/tunnel/migrations/mod.rs index d007dd80f2..66c06ae3c1 100644 --- a/core/src/tunnel/migrations/mod.rs +++ b/core/src/tunnel/migrations/mod.rs @@ -4,6 +4,12 @@ use crate::prelude::*; use crate::tunnel::db::TunnelDatabase; mod m_00_port_forward_entry; +mod m_01_port_forward_kind; +mod m_02_wg_client_kind; +mod m_03_port_forward_auto; + +#[cfg(test)] +pub use m_01_port_forward_kind::PortForwardKind; pub trait TunnelMigration { fn name(&self) -> &'static str { @@ -13,7 +19,12 @@ pub trait TunnelMigration { fn action(&self, db: &mut Value) -> Result<(), Error>; } -pub const MIGRATIONS: &[&dyn TunnelMigration] = &[&m_00_port_forward_entry::PortForwardEntry]; +pub const MIGRATIONS: &[&dyn TunnelMigration] = &[ + &m_00_port_forward_entry::PortForwardEntry, + &m_01_port_forward_kind::PortForwardKind, + &m_02_wg_client_kind::WgClientKind, + &m_03_port_forward_auto::PortForwardAuto, +]; #[instrument(skip_all)] pub fn run_migrations(db: &mut Model) -> Result<(), Error> { diff --git a/core/src/tunnel/mod.rs b/core/src/tunnel/mod.rs index 7fe0d71d9f..ba60dd5009 100644 --- a/core/src/tunnel/mod.rs +++ b/core/src/tunnel/mod.rs @@ -10,6 +10,7 @@ pub mod auth; pub mod context; pub mod db; pub mod dns; +pub mod forward; pub(crate) mod migrations; pub mod update; pub mod web; diff --git a/core/src/tunnel/wg.rs b/core/src/tunnel/wg.rs index 7859e95ed8..cc7443319d 100644 --- a/core/src/tunnel/wg.rs +++ b/core/src/tunnel/wg.rs @@ -120,6 +120,11 @@ pub struct WgSubnetConfig { pub clients: WgSubnetClients, #[serde(default)] pub dns: DnsConfig, + /// SNAT this subnet's egress to this WAN IP instead of `masquerade`. `None` + /// keeps the default masquerade; a per-device `wan_ip` overrides this. + #[serde(default)] + #[ts(type = "string | null")] + pub wan_ip: Option, } impl WgSubnetConfig { pub fn new(name: InternedString) -> Self { @@ -179,6 +184,18 @@ impl Base64 { } } +/// A WireGuard client's role. A `Server` is a StartOS box that may configure the +/// gateway (DNS injection / auto port-forward); a `Client` is a plain peer with +/// no autoconfig. Stored and sticky — toggling the capability flags never changes +/// it; the migration backfills it from those flags. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, TS, clap::ValueEnum)] +#[serde(rename_all = "camelCase")] +pub enum WgClientKind { + #[default] + Client, + Server, +} + #[derive(Clone, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] #[model = "Model"] @@ -186,13 +203,39 @@ pub struct WgConfig { pub name: InternedString, pub key: Base64, pub psk: Base64<[u8; 32]>, + /// Client (no autoconfig) vs Server (a StartOS box). Sticky; defaulted by the + /// migration from the capability flags. + #[serde(default)] + pub kind: WgClientKind, + /// Whether this device may inject DNS records via RFC 2136. Off by default + /// — only enable for devices you trust (it lets the device add records to + /// the tunnel's DNS for every peer to resolve). + #[serde(default)] + pub allow_dns_injection: bool, + /// Whether this device may auto-create port forwards via PCP/IGD. Off by + /// default — paired with `allow_dns_injection` under one "Gateway + /// Autoconfiguration" toggle, but tracked separately so each capability is + /// gated on its own. + #[serde(default)] + pub allow_auto_port_forward: bool, + /// SNAT this device's egress to this WAN IP, overriding the subnet's + /// `wan_ip` / the default masquerade. `None` falls back to the subnet rule. + #[serde(default)] + #[ts(type = "string | null")] + pub wan_ip: Option, } impl WgConfig { - pub fn generate(name: InternedString) -> Self { + pub fn generate(name: InternedString, kind: WgClientKind) -> Self { + // A Server gets gateway-autoconfig on by default; a Client gets nothing. + let autoconfig = matches!(kind, WgClientKind::Server); Self { name, key: Base64(WgKey::generate()), psk: Base64(rand::random()), + kind, + allow_dns_injection: autoconfig, + allow_auto_port_forward: autoconfig, + wan_ip: None, } } pub fn server_peer_config<'a>(&'a self, addr: Ipv4Addr) -> ServerPeerConfig<'a> { diff --git a/docs/draft-start9-pcp-hostname.md b/docs/draft-start9-pcp-hostname.md new file mode 100644 index 0000000000..526e51ab14 --- /dev/null +++ b/docs/draft-start9-pcp-hostname.md @@ -0,0 +1,500 @@ +--- +v: 3 +title: PCP Hostname Extension for SNI-Demultiplexed Port Mappings +abbrev: PCP Hostname Extension +docname: draft-start9-pcp-hostname-00 +category: std +submissiontype: IETF +consensus: true +ipr: trust200902 +area: Internet +workgroup: Network Working Group +keyword: + - PCP + - SNI + - NAT + - port mapping + - QUIC +date: 2026-06-23 +author: + - ins: A. McClelland + name: Aiden McClelland + org: Start9 + email: me@drbonez.dev +normative: + RFC6887: + RFC6066: + RFC5890: + RFC8999: + RFC9000: + RFC9001: + RFC7753: +informative: + RFC7652: + I-D.ietf-tls-esni: + I-D.ietf-quic-load-balancers: + +--- abstract + +This document defines a Port Control Protocol (PCP) option, HOSTNAME, that +associates one or more fully qualified domain names with a MAP request. A +PCP server implementing this option demultiplexes inbound connections on a +shared external port by inspecting the Server Name Indication presented in +the TLS ClientHello and forwards each connection to the internal host that +holds the binding for the presented name. This allows multiple internal +hosts behind a single external IP address to share well-known ports +(e.g. 443) provided their hostnames differ, while each host retains the +ability to self-provision its mappings via ordinary PCP semantics. +Demultiplexing is defined for TCP-carried TLS, with optional support for +QUIC. + +--- middle + +# Introduction {#intro} + +## Motivation {#motivation} + +A NAT gateway exposes one external IP address, or at most a few. A +conventional PCP MAP grants an entire (protocol, external IP, external +port) tuple to a single internal host, so the number of hosts that can +serve HTTPS on port 443 is limited to the number of external addresses, +typically one. Operators work around this with a manually configured +reverse proxy or SNI proxy on the gateway, which: + +1. requires central, out-of-band configuration rather than client + self-provisioning, and +2. typically replaces the client source address with the proxy address, + hiding the real peer from the backend. + +Since hosts behind a NAT typically serve public Internet domains (the +names are registered in public DNS and resolve to the gateway's external +address), the hostname is a natural demultiplexing key that the internal +host already knows and can assert itself. This extension moves the SNI +routing table into PCP, so each internal host registers its own names, and +the gateway, which is already on the return path for all LAN traffic, +forwards with the original client source address preserved. + +## Requirements Language {#conventions} + +{::boilerplate bcp14-tagged} + +## Terminology {#terminology} + +This document uses the terminology of {{RFC6887}} (PCP client, PCP server, +internal host, external address, mapping, mapping nonce). In addition: + +Hostname binding: +: an association, held by the PCP server, from (protocol, external IP + address, external port, hostname) to an internal address and internal + port, created by a MAP request carrying a HOSTNAME option. + +Fallback mapping: +: a conventional mapping (created by a MAP request with no HOSTNAME + option) on a port that also has hostname bindings; it receives + connections that match no hostname binding (see {{validity}}). + +Demultiplexed port: +: a (protocol, external IP address, external port) tuple holding at least + one hostname binding. Inbound connections to a demultiplexed port are + routed per {{tcp-demux}} or {{quic-demux}} rather than by conventional + destination NAT. + +# Overview of Operation {#overview} + +This section is informative; normative requirements appear in +{{hostname-option}} through {{security}}. + +1. An internal host sends a PCP MAP request for protocol TCP, suggesting + an external port (e.g. 443), and includes one or more HOSTNAME options, + each carrying an FQDN such as `git.example.com`. +2. The PCP server checks each requested name against its hostname bindings + for that (external IP, external port). If no conflicting binding + exists, it creates bindings with the requested lifetime and returns + success, echoing the HOSTNAME options. +3. Other internal hosts may map the same external port with different + hostnames; the port is shared because the mapping key is extended to + (protocol, external IP, external port, hostname). +4. For each inbound TCP connection to a demultiplexed port, the server + accepts the connection, reads the TLS ClientHello, extracts the + server_name extension, selects the matching binding, and splices the + connection to the bound internal host. +5. Because the PCP server is the default gateway for the internal network, + it originates the internal leg with the original client's source + address and port (transparent mode, {{source-pres}}); return packets + necessarily traverse the gateway, which steers them back into the + spliced connection. + +A host requests forwards for multiple hostnames either by including +multiple HOSTNAME options in a single MAP request or by issuing multiple +MAP requests; see {{client}}. + +# The HOSTNAME Option {#hostname-option} + +## Format {#format} + +The option follows the PCP option format ({{Section 7.3 of RFC6887}}): + +~~~ + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Option Code | Reserved | Option Length | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | +: hostname (variable length) : +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +~~~ +{: #fig-format title="HOSTNAME Option Format"} + +Option Code: +: TBD1, in the optional-to-process range. + +Option Length: +: length of the hostname field in octets (1 to 255). The option data is + zero-padded to a 32-bit boundary per {{RFC6887}}; padding is not + included in Option Length. + +hostname: +: the name to bind, encoded exactly as it appears on the wire in the SNI + HostName field ({{Section 3 of RFC6066}}): ASCII, internationalized + names as A-labels {{RFC5890}}, no trailing dot, no NUL octets, total + length 1 to 255 octets. Literal IPv4 and IPv6 addresses MUST NOT be used + ({{Section 3 of RFC6066}}). Comparison is case-insensitive; servers + SHOULD normalize names to lowercase on receipt. A syntactically invalid + hostname MUST be rejected with MALFORMED_OPTION. + +Because the option code is in the optional-to-process range, a server that +does not implement this option ignores it and processes the request as a +conventional MAP, granting an unrestricted port forward. This degrades +gracefully: if the requesting host is the only one using the port, +observable behavior is identical, and a second host receives +CANNOT_PROVIDE_EXTERNAL (or an alternate port) per standard MAP +processing. Clients detect whether hostname scoping was applied by +checking for the echoed HOSTNAME option(s) in the response; see +{{client}}. + +## Validity and the Fallback Mapping {#validity} + +The HOSTNAME option is valid only for the MAP opcode. A server +implementing this option MUST reject any other opcode carrying it with +MALFORMED_OPTION. + +The option is valid for protocol TCP, and for protocol UDP only on servers +implementing QUIC demultiplexing ({{quic-demux}}). A server implementing +this option MUST reject a HOSTNAME option for any other protocol, or for +UDP when QUIC demultiplexing is not implemented, with UNSUPP_HOSTNAME +({{result-codes}}). Clients MUST NOT request hostname bindings for +protocols whose servers send application data before the client +(e.g. SMTP, IMAP); a server cannot detect this misuse, and such +connections will stall waiting for a ClientHello and then be handled as in +{{tcp-demux}}, step 4. + +A HOSTNAME option MAY appear multiple times in a single MAP request, each +occurrence creating one binding, subject to the atomic failure semantics +of {{conflicts}}. + +There is no in-option fallback syntax. A conventional MAP request (no +HOSTNAME option) for the same (protocol, external IP, external port) +creates the fallback mapping: connections whose ClientHello carries no +server_name extension, presents a name with no matching binding, or whose +initial bytes do not parse as TLS (or as QUIC per {{quic-demux}}), are +forwarded to its holder. At most one fallback mapping exists per +(protocol, external IP, external port), enforced by normal MAP +exclusivity. + +## Wildcards {#wildcards} + +A hostname whose first label is `*` (e.g. `*.example.com`) requests a +wildcard binding matching exactly one left-most label, with the same +semantics as certificate wildcard matching. Wildcard support is OPTIONAL; +servers not implementing it respond with UNSUPP_HOSTNAME +({{result-codes}}). An exact binding takes precedence over a wildcard +binding. + +# Server Behavior {#server} + +## Mapping Table {#mapping-table} + +The server maintains, per (protocol, external IP, external port), a set of +hostname bindings, each comprising: hostname, internal address, internal +port, lifetime, and the mapping nonce of the creating client. All standard +MAP processing of {{RFC6887}} applies (lifetime decay, renewal by matching +nonce, ANNOUNCE/epoch recovery). + +## Conflicts {#conflicts} + +If a requested hostname is already bound on that (protocol, external IP, +external port) under a different mapping nonce, the server MUST NOT create +or alter the binding and MUST return result code HOSTNAME_TAKEN +({{result-codes}}). + +If any HOSTNAME option in a request fails validation or conflicts, the +entire request fails and no bindings from that request are created or +modified; PCP responses are atomic per request. + +A conventional MAP for a (protocol, external IP, external port) that has +hostname bindings is permitted and serves as the fallback mapping +({{validity}}). It is subject to ordinary MAP exclusivity. Hostname +bindings and the fallback mapping have independent lifetimes; expiry of +one does not affect the others. + +## Deletion {#deletion} + +A MAP request with requested lifetime 0 carrying HOSTNAME options deletes +only the named bindings, subject to the nonce-matching rules of +{{Section 15 of RFC6887}}. A lifetime-0 request without HOSTNAME options +deletes only the requester's conventional (fallback) mapping, if any. +Other bindings on the port are unaffected in both cases. + +## TCP Demultiplexing {#tcp-demux} + +For TCP connections arriving at a demultiplexed port, the server: + +1. Completes the TCP handshake with the external client. +2. Reads until it has the full ClientHello or a parse failure, subject to + a buffer cap (16384 octets, the maximum TLS plaintext record fragment, + is RECOMMENDED) and a timeout (5 seconds is RECOMMENDED). ClientHellos + can span multiple TCP segments and, rarely, multiple TLS records; + servers SHOULD handle multi-record ClientHellos. +3. Extracts server_name, selects the binding (exact match, then wildcard, + then the fallback mapping if present), and connects to the bound + internal host and port, replaying the buffered bytes before splicing. +4. If no binding matches and no fallback mapping exists, the server SHOULD + close the connection. Whether to reset or send a TLS alert is + implementation defined; the server MUST NOT forward the connection to + an arbitrary binding. + +Demultiplexing engages only on demultiplexed ports. A port holding only a +conventional mapping is forwarded as plain destination NAT per {{RFC6887}}, +with no ClientHello inspection. When the first hostname binding is added to +a port with an existing conventional mapping, the port transitions to +demultiplexed mode; established connections are unaffected, and the +conventional mapping's holder begins receiving only fallback traffic for +new connections. The reverse transition occurs when the last hostname +binding expires. The server does not terminate TLS; it requires no keys +and forwards the ClientHello bytes verbatim. + +## QUIC Demultiplexing (OPTIONAL) {#quic-demux} + +A server MAY additionally implement demultiplexing of QUIC {{RFC9000}} for +hostname bindings with protocol UDP. Servers implementing this section +accept HOSTNAME options with protocol UDP; servers that do not MUST reject +them per {{validity}}, so a client can discover support by the result +code. + +For UDP datagrams arriving at a demultiplexed port: + +1. Initial packets. + A datagram beginning with a QUIC long header ({{RFC8999}}) of a version + the server supports is processed as a candidate Initial. The server + derives the Initial secrets from the client's Destination Connection ID + and the version-specific salt ({{Section 5.2 of RFC9001}}), decrypts + the packet, and reassembles the CRYPTO stream to obtain the TLS + ClientHello. The ClientHello can span multiple Initial packets; the + server MUST buffer and reassemble across datagrams, subject to a cap + (10 datagrams or 65535 octets of CRYPTO data is RECOMMENDED) and a + timeout (5 seconds is RECOMMENDED). +2. Binding selection. + The server extracts server_name and selects a binding exactly as in + {{tcp-demux}}, step 3. It then creates flow state keyed on the + connection's 4-tuple, forwards the buffered datagrams verbatim to the + bound internal host, and forwards subsequent datagrams matching the + flow in both directions. No decryption is performed after binding + selection, and the server never holds handshake or 1-RTT keys. +3. Flow lifetime. + Flow state is removed after an idle period consistent with the + gateway's UDP mapping timeout. Version Negotiation and Retry exchanges + initiated by the internal host traverse the established flow and require + no special handling. +4. Non-matching datagrams. + Datagrams that are not parseable Initials of a supported version + (including short-header packets matching no flow, long headers of + unknown versions, and non-QUIC UDP payloads) are forwarded to the + fallback mapping if one exists and otherwise dropped. + +Two consequences follow from routing on flow state. First, active +connection migration and NAT rebinding both move the connection to a +4-tuple the server has no state for; such packets carry connection IDs +opaque to the server and arrive as non-matching datagrams. Internal hosts +serving QUIC behind a demultiplexing gateway SHOULD send the +disable_active_migration transport parameter ({{Section 18.2 of RFC9000}}) +to suppress client-initiated migration; this does not prevent NAT +rebinding ({{Section 9.3 of RFC9000}}), which can still strand a connection +on an unknown 4-tuple unless coordinated connection-ID routing is in use. +Coordinated connection-ID routing (e.g. QUIC-LB +{{I-D.ietf-quic-load-balancers}}) could lift this restriction and is out of +scope. Second, a client whose first flight uses a QUIC version unknown to +the server cannot be demultiplexed; servers SHOULD support all current +standard versions and treat unknown-version long headers as non-matching +rather than attempting Version Negotiation themselves. + +## Source Address Preservation {#source-pres} + +Servers SHOULD originate the internal leg of demultiplexed TCP connections +using the external client's source address and port (e.g. via +IP_TRANSPARENT on Linux), relying on their position as the internal +network's gateway to capture return traffic. When operating in this mode +the forwarded connection is, from the internal host's perspective, +indistinguishable from a direct destination-NAT forward. A server that +cannot operate transparently uses its own address on the internal leg; +this behavior is visible to internal hosts and operators should document +it. QUIC demultiplexing ({{quic-demux}}) forwards datagrams at the IP layer +and preserves the source address inherently. + +# Client Behavior {#client} + +- Clients include one HOSTNAME option per name. Given the atomic failure + semantics of {{conflicts}}, clients SHOULD use one MAP request per name + when independent success matters, and MAY batch multiple options in one + request when the names succeed or fail together (e.g. a certificate SAN + set). +- Clients MUST examine the response for echoed HOSTNAME options. Absence + means the server did not process the option and the granted mapping is + unrestricted; clients that require hostname scoping MAY delete such a + mapping. Against a server that ignores the option, both request patterns + above degrade safely: a batched request yields one unrestricted mapping + covering all names, and per-name requests for the same internal port + resolve to that same mapping ({{RFC6887}} keys mappings on the internal + tuple), acting as refreshes rather than conflicts. +- On gateways with multiple external addresses, hostname bindings are + scoped per external address: the same name may be bound on two external + IPs to different internal hosts. Clients SHOULD set the Suggested + External IP Address field ({{Section 11.1 of RFC6887}}) to the address + their public DNS resolves to and SHOULD include PREFER_FAILURE so the + server fails rather than assigning a different address. The Assigned + External IP Address in the response is authoritative; clients publishing + DNS records from PCP state SHOULD use it rather than the suggested + address. +- Clients renew exactly as in {{RFC6887}}, re-sending the same MAP request + (same nonce, same options) before lifetime expiry. +- Clients MUST be prepared to receive HOSTNAME_TAKEN at any renewal + (e.g. after server state loss and a race with another host) and SHOULD + surface the condition rather than silently retrying. + +# Interaction with Other PCP Options {#interactions} + +FILTER ({{RFC6887}}): +: composes normally; filters restrict which remote peers may connect and + are evaluated before demultiplexing. + +PREFER_FAILURE ({{RFC6887}}): +: composes normally; see {{client}} for its use on multihomed gateways. + +THIRD_PARTY ({{RFC6887}}): +: a request combining THIRD_PARTY and HOSTNAME options creates hostname + bindings on behalf of the third party, subject to the server's + THIRD_PARTY authorization policy; the conflict rules of {{conflicts}} + apply to the third party's bindings identically. + +PORT_SET ({{RFC7753}}): +: a request combining PORT_SET and HOSTNAME options is not meaningful (a + hostname binding targets a single internal port) and MUST be rejected + with MALFORMED_OPTION. + +# Interaction with Encrypted ClientHello {#ech} + +ECH {{I-D.ietf-tls-esni}} encrypts the true server name; the outer +ClientHello carries the public name of the client-facing server. A +demultiplexing server sees only the outer SNI, for both TLS over TCP and +QUIC. Deployments using this extension with ECH MUST bind the public name +(the name published in the HTTPS/SVCB ech parameter), and the internal +host performs inner-name routing itself if needed. Names hidden by ECH +cannot serve as demultiplexing keys by design; this is a property of ECH, +not a defect of this extension. + +# New Result Codes {#result-codes} + +HOSTNAME_TAKEN (TBD2): +: the requested hostname is already bound on this external IP address and + port by another client. This is a short lifetime error; the condition + clears when the conflicting binding expires or is deleted. + +UNSUPP_HOSTNAME (TBD3): +: the hostname is syntactically valid but the request uses a feature this + server does not support (e.g. wildcard bindings, or protocol UDP without + QUIC demultiplexing support). This is a long lifetime error. + +Syntactically invalid hostnames are rejected with the existing +MALFORMED_OPTION ({{RFC6887}}). + +# Security Considerations {#security} + +The security model is that of {{RFC6887}}: any host on the internal +network may create mappings, so any host may claim any hostname, first come +first served. This is equivalent in power to the existing ability of any +internal host to claim an entire external port; the granularity is finer +but the trust assumption is unchanged. Deployments requiring stronger +guarantees SHOULD deploy PCP authentication {{RFC7652}} or restrict the +option via policy (e.g. per-host hostname allowlists). + +A server MAY verify, at binding time, that the requested name resolves in +public DNS to one of its external addresses, rejecting names that do not. +DNS can change after binding and split-horizon deployments may legitimately +fail such a check, so it MUST be possible to disable; it is a policy +mechanism, not a protocol requirement. + +ClientHello collection ({{tcp-demux}} and {{quic-demux}}) creates state per +pending connection. Servers MUST cap buffered bytes and the number of +pending connections to bound memory under handshake-and-stall attacks, and +SHOULD subject pending connections to the same limits as their existing +connection tracking. + +The hostname presented in SNI is attacker controlled and unauthenticated. +The server uses it only to select among pre-registered bindings; it MUST +NOT be used to construct file paths or commands, and MUST be sanitized +before inclusion in log output. + +For QUIC demultiplexing, an attacker can cheaply elicit Initial-secret +derivation and decryption work with spoofed datagrams. The per-version key +derivation uses no secret state and the cost is bounded per datagram, but +servers SHOULD rate-limit Initial processing per source address. + +# Applicability and Limitations {#applicability} + +Demultiplexing requires a client-first protocol carrying SNI: TLS over TCP +({{tcp-demux}}) and QUIC ({{quic-demux}}). Non-TLS protocols with a +cleartext hostname early in the stream (e.g. HTTP/1.1 Host) are out of +scope; the fallback mapping covers them coarsely. Server-first protocols +cannot be demultiplexed at all ({{validity}}). + +# IANA Considerations {#iana} + +## PCP Option Code {#iana-option} + +IANA is requested to assign the following option code in the "PCP +Options" registry of the "Port Control Protocol (PCP) Parameters" group, +from the Specification Required range (192-223); values in this range are +optional-to-process: + +| Field | Value | +| ----- | ----- | +| Value | TBD1 | +| Name | HOSTNAME | +| Purpose | Associates a fully qualified domain name with a MAP request; inbound connections on the mapped external port are forwarded to the internal host whose binding matches the SNI presented in the TLS or QUIC ClientHello. | +| Valid for Opcodes | MAP | +| Length | variable; 1 to 255 octets | +| May Appear in | Request. May appear in response only if it appeared in the associated request. | +| Maximum Occurrences | As many as fit within maximum PCP message size. | +| Reference | This document | + +## PCP Result Codes {#iana-results} + +IANA is requested to assign two result codes in the "PCP Result Codes" +registry of the same group, from the Specification Required range +(128-191): + +| Value | Name | Description | Reference | +| ----- | ---- | ----------- | --------- | +| TBD2 | HOSTNAME_TAKEN | The requested hostname is already bound on the assigned external address and port by another client. This is a short lifetime error. | This document | +| TBD3 | UNSUPP_HOSTNAME | The HOSTNAME option requests a feature not supported by this server. This is a long lifetime error. | This document | + +--- back + +# Implementation Status {#impl-status} + +An open-source implementation exists in StartOS and its StartTunnel +gateway. Pending IANA assignment, it uses values from the PCP Private Use +ranges: option code 224, and result codes 192 (HOSTNAME_TAKEN) and 193 +(UNSUPP_HOSTNAME). These are placeholders and are expected to be replaced +by the IANA-assigned values (TBD1, TBD2, TBD3) once allocated. diff --git a/docs/draft-start9-pcp-hostname.txt b/docs/draft-start9-pcp-hostname.txt new file mode 100644 index 0000000000..85ea85fbd5 --- /dev/null +++ b/docs/draft-start9-pcp-hostname.txt @@ -0,0 +1,896 @@ + + + + +Network Working Group A. McClelland +Internet-Draft Start9 +Intended status: Standards Track 23 June 2026 +Expires: 25 December 2026 + + + PCP Hostname Extension for SNI-Demultiplexed Port Mappings + draft-start9-pcp-hostname-00 + +Abstract + + This document defines a Port Control Protocol (PCP) option, HOSTNAME, + that associates one or more fully qualified domain names with a MAP + request. A PCP server implementing this option demultiplexes inbound + connections on a shared external port by inspecting the Server Name + Indication presented in the TLS ClientHello and forwards each + connection to the internal host that holds the binding for the + presented name. This allows multiple internal hosts behind a single + external IP address to share well-known ports (e.g. 443) provided + their hostnames differ, while each host retains the ability to self- + provision its mappings via ordinary PCP semantics. Demultiplexing is + defined for TCP-carried TLS, with optional support for QUIC. + +Status of This Memo + + This Internet-Draft is submitted in full conformance with the + provisions of BCP 78 and BCP 79. + + Internet-Drafts are working documents of the Internet Engineering + Task Force (IETF). Note that other groups may also distribute + working documents as Internet-Drafts. The list of current Internet- + Drafts is at https://datatracker.ietf.org/drafts/current/. + + Internet-Drafts are draft documents valid for a maximum of six months + and may be updated, replaced, or obsoleted by other documents at any + time. It is inappropriate to use Internet-Drafts as reference + material or to cite them other than as "work in progress." + + This Internet-Draft will expire on 25 December 2026. + +Copyright Notice + + Copyright (c) 2026 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents (https://trustee.ietf.org/ + license-info) in effect on the date of publication of this document. + + + +McClelland Expires 25 December 2026 [Page 1] + +Internet-Draft PCP Hostname Extension June 2026 + + + Please review these documents carefully, as they describe your rights + and restrictions with respect to this document. Code Components + extracted from this document must include Revised BSD License text as + described in Section 4.e of the Trust Legal Provisions and are + provided without warranty as described in the Revised BSD License. + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . 2 + 1.1. Motivation . . . . . . . . . . . . . . . . . . . . . . . 3 + 1.2. Requirements Language . . . . . . . . . . . . . . . . . . 3 + 1.3. Terminology . . . . . . . . . . . . . . . . . . . . . . . 3 + 2. Overview of Operation . . . . . . . . . . . . . . . . . . . . 4 + 3. The HOSTNAME Option . . . . . . . . . . . . . . . . . . . . . 4 + 3.1. Format . . . . . . . . . . . . . . . . . . . . . . . . . 4 + 3.2. Validity and the Fallback Mapping . . . . . . . . . . . . 5 + 3.3. Wildcards . . . . . . . . . . . . . . . . . . . . . . . . 6 + 4. Server Behavior . . . . . . . . . . . . . . . . . . . . . . . 6 + 4.1. Mapping Table . . . . . . . . . . . . . . . . . . . . . . 6 + 4.2. Conflicts . . . . . . . . . . . . . . . . . . . . . . . . 6 + 4.3. Deletion . . . . . . . . . . . . . . . . . . . . . . . . 7 + 4.4. TCP Demultiplexing . . . . . . . . . . . . . . . . . . . 7 + 4.5. QUIC Demultiplexing (OPTIONAL) . . . . . . . . . . . . . 8 + 4.6. Source Address Preservation . . . . . . . . . . . . . . . 9 + 5. Client Behavior . . . . . . . . . . . . . . . . . . . . . . . 9 + 6. Interaction with Other PCP Options . . . . . . . . . . . . . 10 + 7. Interaction with Encrypted ClientHello . . . . . . . . . . . 10 + 8. New Result Codes . . . . . . . . . . . . . . . . . . . . . . 11 + 9. Security Considerations . . . . . . . . . . . . . . . . . . . 11 + 10. Applicability and Limitations . . . . . . . . . . . . . . . . 12 + 11. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 12 + 11.1. PCP Option Code . . . . . . . . . . . . . . . . . . . . 12 + 11.2. PCP Result Codes . . . . . . . . . . . . . . . . . . . . 13 + 12. References . . . . . . . . . . . . . . . . . . . . . . . . . 14 + 12.1. Normative References . . . . . . . . . . . . . . . . . . 14 + 12.2. Informative References . . . . . . . . . . . . . . . . . 15 + Appendix A. Implementation Status . . . . . . . . . . . . . . . 15 + Author's Address . . . . . . . . . . . . . . . . . . . . . . . . 16 + +1. Introduction + + + + + + + + + + + +McClelland Expires 25 December 2026 [Page 2] + +Internet-Draft PCP Hostname Extension June 2026 + + +1.1. Motivation + + A NAT gateway exposes one external IP address, or at most a few. A + conventional PCP MAP grants an entire (protocol, external IP, + external port) tuple to a single internal host, so the number of + hosts that can serve HTTPS on port 443 is limited to the number of + external addresses, typically one. Operators work around this with a + manually configured reverse proxy or SNI proxy on the gateway, which: + + 1. requires central, out-of-band configuration rather than client + self-provisioning, and + + 2. typically replaces the client source address with the proxy + address, hiding the real peer from the backend. + + Since hosts behind a NAT typically serve public Internet domains (the + names are registered in public DNS and resolve to the gateway's + external address), the hostname is a natural demultiplexing key that + the internal host already knows and can assert itself. This + extension moves the SNI routing table into PCP, so each internal host + registers its own names, and the gateway, which is already on the + return path for all LAN traffic, forwards with the original client + source address preserved. + +1.2. Requirements Language + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and + "OPTIONAL" in this document are to be interpreted as described in + BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all + capitals, as shown here. + +1.3. Terminology + + This document uses the terminology of [RFC6887] (PCP client, PCP + server, internal host, external address, mapping, mapping nonce). In + addition: + + Hostname binding: an association, held by the PCP server, from + (protocol, external IP address, external port, hostname) to an + internal address and internal port, created by a MAP request + carrying a HOSTNAME option. + + Fallback mapping: a conventional mapping (created by a MAP request + with no HOSTNAME option) on a port that also has hostname + bindings; it receives connections that match no hostname binding + (see Section 3.2). + + + + +McClelland Expires 25 December 2026 [Page 3] + +Internet-Draft PCP Hostname Extension June 2026 + + + Demultiplexed port: a (protocol, external IP address, external port) + tuple holding at least one hostname binding. Inbound connections + to a demultiplexed port are routed per Section 4.4 or Section 4.5 + rather than by conventional destination NAT. + +2. Overview of Operation + + This section is informative; normative requirements appear in + Section 3 through Section 9. + + 1. An internal host sends a PCP MAP request for protocol TCP, + suggesting an external port (e.g. 443), and includes one or more + HOSTNAME options, each carrying an FQDN such as git.example.com. + + 2. The PCP server checks each requested name against its hostname + bindings for that (external IP, external port). If no + conflicting binding exists, it creates bindings with the + requested lifetime and returns success, echoing the HOSTNAME + options. + + 3. Other internal hosts may map the same external port with + different hostnames; the port is shared because the mapping key + is extended to (protocol, external IP, external port, hostname). + + 4. For each inbound TCP connection to a demultiplexed port, the + server accepts the connection, reads the TLS ClientHello, + extracts the server_name extension, selects the matching binding, + and splices the connection to the bound internal host. + + 5. Because the PCP server is the default gateway for the internal + network, it originates the internal leg with the original + client's source address and port (transparent mode, Section 4.6); + return packets necessarily traverse the gateway, which steers + them back into the spliced connection. + + A host requests forwards for multiple hostnames either by including + multiple HOSTNAME options in a single MAP request or by issuing + multiple MAP requests; see Section 5. + +3. The HOSTNAME Option + +3.1. Format + + The option follows the PCP option format (Section 7.3 of [RFC6887]): + + + + + + + +McClelland Expires 25 December 2026 [Page 4] + +Internet-Draft PCP Hostname Extension June 2026 + + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Option Code | Reserved | Option Length | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + : hostname (variable length) : + | | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + Figure 1: HOSTNAME Option Format + + Option Code: TBD1, in the optional-to-process range. + + Option Length: length of the hostname field in octets (1 to 255). + The option data is zero-padded to a 32-bit boundary per [RFC6887]; + padding is not included in Option Length. + + hostname: the name to bind, encoded exactly as it appears on the + wire in the SNI HostName field (Section 3 of [RFC6066]): ASCII, + internationalized names as A-labels [RFC5890], no trailing dot, no + NUL octets, total length 1 to 255 octets. Literal IPv4 and IPv6 + addresses MUST NOT be used (Section 3 of [RFC6066]). Comparison + is case-insensitive; servers SHOULD normalize names to lowercase + on receipt. A syntactically invalid hostname MUST be rejected + with MALFORMED_OPTION. + + Because the option code is in the optional-to-process range, a server + that does not implement this option ignores it and processes the + request as a conventional MAP, granting an unrestricted port forward. + This degrades gracefully: if the requesting host is the only one + using the port, observable behavior is identical, and a second host + receives CANNOT_PROVIDE_EXTERNAL (or an alternate port) per standard + MAP processing. Clients detect whether hostname scoping was applied + by checking for the echoed HOSTNAME option(s) in the response; see + Section 5. + +3.2. Validity and the Fallback Mapping + + The HOSTNAME option is valid only for the MAP opcode. A server + implementing this option MUST reject any other opcode carrying it + with MALFORMED_OPTION. + + The option is valid for protocol TCP, and for protocol UDP only on + servers implementing QUIC demultiplexing (Section 4.5). A server + implementing this option MUST reject a HOSTNAME option for any other + protocol, or for UDP when QUIC demultiplexing is not implemented, + with UNSUPP_HOSTNAME (Section 8). Clients MUST NOT request hostname + + + +McClelland Expires 25 December 2026 [Page 5] + +Internet-Draft PCP Hostname Extension June 2026 + + + bindings for protocols whose servers send application data before the + client (e.g. SMTP, IMAP); a server cannot detect this misuse, and + such connections will stall waiting for a ClientHello and then be + handled as in Section 4.4, step 4. + + A HOSTNAME option MAY appear multiple times in a single MAP request, + each occurrence creating one binding, subject to the atomic failure + semantics of Section 4.2. + + There is no in-option fallback syntax. A conventional MAP request + (no HOSTNAME option) for the same (protocol, external IP, external + port) creates the fallback mapping: connections whose ClientHello + carries no server_name extension, presents a name with no matching + binding, or whose initial bytes do not parse as TLS (or as QUIC per + Section 4.5), are forwarded to its holder. At most one fallback + mapping exists per (protocol, external IP, external port), enforced + by normal MAP exclusivity. + +3.3. Wildcards + + A hostname whose first label is * (e.g. *.example.com) requests a + wildcard binding matching exactly one left-most label, with the same + semantics as certificate wildcard matching. Wildcard support is + OPTIONAL; servers not implementing it respond with UNSUPP_HOSTNAME + (Section 8). An exact binding takes precedence over a wildcard + binding. + +4. Server Behavior + +4.1. Mapping Table + + The server maintains, per (protocol, external IP, external port), a + set of hostname bindings, each comprising: hostname, internal + address, internal port, lifetime, and the mapping nonce of the + creating client. All standard MAP processing of [RFC6887] applies + (lifetime decay, renewal by matching nonce, ANNOUNCE/epoch recovery). + +4.2. Conflicts + + If a requested hostname is already bound on that (protocol, external + IP, external port) under a different mapping nonce, the server MUST + NOT create or alter the binding and MUST return result code + HOSTNAME_TAKEN (Section 8). + + If any HOSTNAME option in a request fails validation or conflicts, + the entire request fails and no bindings from that request are + created or modified; PCP responses are atomic per request. + + + + +McClelland Expires 25 December 2026 [Page 6] + +Internet-Draft PCP Hostname Extension June 2026 + + + A conventional MAP for a (protocol, external IP, external port) that + has hostname bindings is permitted and serves as the fallback mapping + (Section 3.2). It is subject to ordinary MAP exclusivity. Hostname + bindings and the fallback mapping have independent lifetimes; expiry + of one does not affect the others. + +4.3. Deletion + + A MAP request with requested lifetime 0 carrying HOSTNAME options + deletes only the named bindings, subject to the nonce-matching rules + of Section 15 of [RFC6887]. A lifetime-0 request without HOSTNAME + options deletes only the requester's conventional (fallback) mapping, + if any. Other bindings on the port are unaffected in both cases. + +4.4. TCP Demultiplexing + + For TCP connections arriving at a demultiplexed port, the server: + + 1. Completes the TCP handshake with the external client. + + 2. Reads until it has the full ClientHello or a parse failure, + subject to a buffer cap (16384 octets, the maximum TLS plaintext + record fragment, is RECOMMENDED) and a timeout (5 seconds is + RECOMMENDED). ClientHellos can span multiple TCP segments and, + rarely, multiple TLS records; servers SHOULD handle multi-record + ClientHellos. + + 3. Extracts server_name, selects the binding (exact match, then + wildcard, then the fallback mapping if present), and connects to + the bound internal host and port, replaying the buffered bytes + before splicing. + + 4. If no binding matches and no fallback mapping exists, the server + SHOULD close the connection. Whether to reset or send a TLS + alert is implementation defined; the server MUST NOT forward the + connection to an arbitrary binding. + + Demultiplexing engages only on demultiplexed ports. A port holding + only a conventional mapping is forwarded as plain destination NAT per + [RFC6887], with no ClientHello inspection. When the first hostname + binding is added to a port with an existing conventional mapping, the + port transitions to demultiplexed mode; established connections are + unaffected, and the conventional mapping's holder begins receiving + only fallback traffic for new connections. The reverse transition + occurs when the last hostname binding expires. The server does not + terminate TLS; it requires no keys and forwards the ClientHello bytes + verbatim. + + + + +McClelland Expires 25 December 2026 [Page 7] + +Internet-Draft PCP Hostname Extension June 2026 + + +4.5. QUIC Demultiplexing (OPTIONAL) + + A server MAY additionally implement demultiplexing of QUIC [RFC9000] + for hostname bindings with protocol UDP. Servers implementing this + section accept HOSTNAME options with protocol UDP; servers that do + not MUST reject them per Section 3.2, so a client can discover + support by the result code. + + For UDP datagrams arriving at a demultiplexed port: + + 1. Initial packets. A datagram beginning with a QUIC long header + ([RFC8999]) of a version the server supports is processed as a + candidate Initial. The server derives the Initial secrets from + the client's Destination Connection ID and the version-specific + salt (Section 5.2 of [RFC9001]), decrypts the packet, and + reassembles the CRYPTO stream to obtain the TLS ClientHello. The + ClientHello can span multiple Initial packets; the server MUST + buffer and reassemble across datagrams, subject to a cap (10 + datagrams or 65535 octets of CRYPTO data is RECOMMENDED) and a + timeout (5 seconds is RECOMMENDED). + + 2. Binding selection. The server extracts server_name and selects a + binding exactly as in Section 4.4, step 3. It then creates flow + state keyed on the connection's 4-tuple, forwards the buffered + datagrams verbatim to the bound internal host, and forwards + subsequent datagrams matching the flow in both directions. No + decryption is performed after binding selection, and the server + never holds handshake or 1-RTT keys. + + 3. Flow lifetime. Flow state is removed after an idle period + consistent with the gateway's UDP mapping timeout. Version + Negotiation and Retry exchanges initiated by the internal host + traverse the established flow and require no special handling. + + 4. Non-matching datagrams. Datagrams that are not parseable + Initials of a supported version (including short-header packets + matching no flow, long headers of unknown versions, and non-QUIC + UDP payloads) are forwarded to the fallback mapping if one exists + and otherwise dropped. + + Two consequences follow from routing on flow state. First, active + connection migration and NAT rebinding both move the connection to a + 4-tuple the server has no state for; such packets carry connection + IDs opaque to the server and arrive as non-matching datagrams. + Internal hosts serving QUIC behind a demultiplexing gateway SHOULD + send the disable_active_migration transport parameter (Section 18.2 + of [RFC9000]) to suppress client-initiated migration; this does not + prevent NAT rebinding (Section 9.3 of [RFC9000]), which can still + + + +McClelland Expires 25 December 2026 [Page 8] + +Internet-Draft PCP Hostname Extension June 2026 + + + strand a connection on an unknown 4-tuple unless coordinated + connection-ID routing is in use. Coordinated connection-ID routing + (e.g. QUIC-LB [I-D.ietf-quic-load-balancers]) could lift this + restriction and is out of scope. Second, a client whose first flight + uses a QUIC version unknown to the server cannot be demultiplexed; + servers SHOULD support all current standard versions and treat + unknown-version long headers as non-matching rather than attempting + Version Negotiation themselves. + +4.6. Source Address Preservation + + Servers SHOULD originate the internal leg of demultiplexed TCP + connections using the external client's source address and port (e.g. + via IP_TRANSPARENT on Linux), relying on their position as the + internal network's gateway to capture return traffic. When operating + in this mode the forwarded connection is, from the internal host's + perspective, indistinguishable from a direct destination-NAT forward. + A server that cannot operate transparently uses its own address on + the internal leg; this behavior is visible to internal hosts and + operators should document it. QUIC demultiplexing (Section 4.5) + forwards datagrams at the IP layer and preserves the source address + inherently. + +5. Client Behavior + + * Clients include one HOSTNAME option per name. Given the atomic + failure semantics of Section 4.2, clients SHOULD use one MAP + request per name when independent success matters, and MAY batch + multiple options in one request when the names succeed or fail + together (e.g. a certificate SAN set). + + * Clients MUST examine the response for echoed HOSTNAME options. + Absence means the server did not process the option and the + granted mapping is unrestricted; clients that require hostname + scoping MAY delete such a mapping. Against a server that ignores + the option, both request patterns above degrade safely: a batched + request yields one unrestricted mapping covering all names, and + per-name requests for the same internal port resolve to that same + mapping ([RFC6887] keys mappings on the internal tuple), acting as + refreshes rather than conflicts. + + + + + + + + + + + +McClelland Expires 25 December 2026 [Page 9] + +Internet-Draft PCP Hostname Extension June 2026 + + + * On gateways with multiple external addresses, hostname bindings + are scoped per external address: the same name may be bound on two + external IPs to different internal hosts. Clients SHOULD set the + Suggested External IP Address field (Section 11.1 of [RFC6887]) to + the address their public DNS resolves to and SHOULD include + PREFER_FAILURE so the server fails rather than assigning a + different address. The Assigned External IP Address in the + response is authoritative; clients publishing DNS records from PCP + state SHOULD use it rather than the suggested address. + + * Clients renew exactly as in [RFC6887], re-sending the same MAP + request (same nonce, same options) before lifetime expiry. + + * Clients MUST be prepared to receive HOSTNAME_TAKEN at any renewal + (e.g. after server state loss and a race with another host) and + SHOULD surface the condition rather than silently retrying. + +6. Interaction with Other PCP Options + + FILTER ([RFC6887]): composes normally; filters restrict which remote + peers may connect and are evaluated before demultiplexing. + + PREFER_FAILURE ([RFC6887]): composes normally; see Section 5 for its + use on multihomed gateways. + + THIRD_PARTY ([RFC6887]): a request combining THIRD_PARTY and + HOSTNAME options creates hostname bindings on behalf of the third + party, subject to the server's THIRD_PARTY authorization policy; + the conflict rules of Section 4.2 apply to the third party's + bindings identically. + + PORT_SET ([RFC7753]): a request combining PORT_SET and HOSTNAME + options is not meaningful (a hostname binding targets a single + internal port) and MUST be rejected with MALFORMED_OPTION. + +7. Interaction with Encrypted ClientHello + + ECH [I-D.ietf-tls-esni] encrypts the true server name; the outer + ClientHello carries the public name of the client-facing server. A + demultiplexing server sees only the outer SNI, for both TLS over TCP + and QUIC. Deployments using this extension with ECH MUST bind the + public name (the name published in the HTTPS/SVCB ech parameter), and + the internal host performs inner-name routing itself if needed. + Names hidden by ECH cannot serve as demultiplexing keys by design; + this is a property of ECH, not a defect of this extension. + + + + + + +McClelland Expires 25 December 2026 [Page 10] + +Internet-Draft PCP Hostname Extension June 2026 + + +8. New Result Codes + + HOSTNAME_TAKEN (TBD2): the requested hostname is already bound on + this external IP address and port by another client. This is a + short lifetime error; the condition clears when the conflicting + binding expires or is deleted. + + UNSUPP_HOSTNAME (TBD3): the hostname is syntactically valid but the + request uses a feature this server does not support (e.g. wildcard + bindings, or protocol UDP without QUIC demultiplexing support). + This is a long lifetime error. + + Syntactically invalid hostnames are rejected with the existing + MALFORMED_OPTION ([RFC6887]). + +9. Security Considerations + + The security model is that of [RFC6887]: any host on the internal + network may create mappings, so any host may claim any hostname, + first come first served. This is equivalent in power to the existing + ability of any internal host to claim an entire external port; the + granularity is finer but the trust assumption is unchanged. + Deployments requiring stronger guarantees SHOULD deploy PCP + authentication [RFC7652] or restrict the option via policy (e.g. per- + host hostname allowlists). + + A server MAY verify, at binding time, that the requested name + resolves in public DNS to one of its external addresses, rejecting + names that do not. DNS can change after binding and split-horizon + deployments may legitimately fail such a check, so it MUST be + possible to disable; it is a policy mechanism, not a protocol + requirement. + + ClientHello collection (Section 4.4 and Section 4.5) creates state + per pending connection. Servers MUST cap buffered bytes and the + number of pending connections to bound memory under handshake-and- + stall attacks, and SHOULD subject pending connections to the same + limits as their existing connection tracking. + + The hostname presented in SNI is attacker controlled and + unauthenticated. The server uses it only to select among pre- + registered bindings; it MUST NOT be used to construct file paths or + commands, and MUST be sanitized before inclusion in log output. + + + + + + + + +McClelland Expires 25 December 2026 [Page 11] + +Internet-Draft PCP Hostname Extension June 2026 + + + For QUIC demultiplexing, an attacker can cheaply elicit Initial- + secret derivation and decryption work with spoofed datagrams. The + per-version key derivation uses no secret state and the cost is + bounded per datagram, but servers SHOULD rate-limit Initial + processing per source address. + +10. Applicability and Limitations + + Demultiplexing requires a client-first protocol carrying SNI: TLS + over TCP (Section 4.4) and QUIC (Section 4.5). Non-TLS protocols + with a cleartext hostname early in the stream (e.g. HTTP/1.1 Host) + are out of scope; the fallback mapping covers them coarsely. Server- + first protocols cannot be demultiplexed at all (Section 3.2). + +11. IANA Considerations + +11.1. PCP Option Code + + IANA is requested to assign the following option code in the "PCP + Options" registry of the "Port Control Protocol (PCP) Parameters" + group, from the Specification Required range (192-223); values in + this range are optional-to-process: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +McClelland Expires 25 December 2026 [Page 12] + +Internet-Draft PCP Hostname Extension June 2026 + + + +=============+===============================================+ + | Field | Value | + +=============+===============================================+ + | Value | TBD1 | + +-------------+-----------------------------------------------+ + | Name | HOSTNAME | + +-------------+-----------------------------------------------+ + | Purpose | Associates a fully qualified domain name with | + | | a MAP request; inbound connections on the | + | | mapped external port are forwarded to the | + | | internal host whose binding matches the SNI | + | | presented in the TLS or QUIC ClientHello. | + +-------------+-----------------------------------------------+ + | Valid for | MAP | + | Opcodes | | + +-------------+-----------------------------------------------+ + | Length | variable; 1 to 255 octets | + +-------------+-----------------------------------------------+ + | May Appear | Request. May appear in response only if it | + | in | appeared in the associated request. | + +-------------+-----------------------------------------------+ + | Maximum | As many as fit within maximum PCP message | + | Occurrences | size. | + +-------------+-----------------------------------------------+ + | Reference | This document | + +-------------+-----------------------------------------------+ + + Table 1 + +11.2. PCP Result Codes + + IANA is requested to assign two result codes in the "PCP Result + Codes" registry of the same group, from the Specification Required + range (128-191): + + + + + + + + + + + + + + + + + +McClelland Expires 25 December 2026 [Page 13] + +Internet-Draft PCP Hostname Extension June 2026 + + + +=======+=================+==========================+===========+ + | Value | Name | Description | Reference | + +=======+=================+==========================+===========+ + | TBD2 | HOSTNAME_TAKEN | The requested hostname | This | + | | | is already bound on the | document | + | | | assigned external | | + | | | address and port by | | + | | | another client. This is | | + | | | a short lifetime error. | | + +-------+-----------------+--------------------------+-----------+ + | TBD3 | UNSUPP_HOSTNAME | The HOSTNAME option | This | + | | | requests a feature not | document | + | | | supported by this | | + | | | server. This is a long | | + | | | lifetime error. | | + +-------+-----------------+--------------------------+-----------+ + + Table 2 + +12. References + +12.1. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, + DOI 10.17487/RFC2119, March 1997, + . + + [RFC5890] Klensin, J., "Internationalized Domain Names for + Applications (IDNA): Definitions and Document Framework", + RFC 5890, DOI 10.17487/RFC5890, August 2010, + . + + [RFC6066] Eastlake 3rd, D., "Transport Layer Security (TLS) + Extensions: Extension Definitions", RFC 6066, + DOI 10.17487/RFC6066, January 2011, + . + + [RFC6887] Wing, D., Ed., Cheshire, S., Boucadair, M., Penno, R., and + P. Selkirk, "Port Control Protocol (PCP)", RFC 6887, + DOI 10.17487/RFC6887, April 2013, + . + + [RFC7753] Sun, Q., Boucadair, M., Sivakumar, S., Zhou, C., Tsou, T., + and S. Perreault, "Port Control Protocol (PCP) Extension + for Port-Set Allocation", RFC 7753, DOI 10.17487/RFC7753, + February 2016, . + + + + +McClelland Expires 25 December 2026 [Page 14] + +Internet-Draft PCP Hostname Extension June 2026 + + + [RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC + 2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174, + May 2017, . + + [RFC8999] Thomson, M., "Version-Independent Properties of QUIC", + RFC 8999, DOI 10.17487/RFC8999, May 2021, + . + + [RFC9000] Iyengar, J., Ed. and M. Thomson, Ed., "QUIC: A UDP-Based + Multiplexed and Secure Transport", RFC 9000, + DOI 10.17487/RFC9000, May 2021, + . + + [RFC9001] Thomson, M., Ed. and S. Turner, Ed., "Using TLS to Secure + QUIC", RFC 9001, DOI 10.17487/RFC9001, May 2021, + . + +12.2. Informative References + + [I-D.ietf-quic-load-balancers] + Duke, M., Banks, N., and C. Huitema, "QUIC-LB: Generating + Routable QUIC Connection IDs", Work in Progress, Internet- + Draft, draft-ietf-quic-load-balancers-21, 27 August 2025, + . + + [I-D.ietf-tls-esni] + Rescorla, E., Oku, K., Sullivan, N., and C. A. Wood, "TLS + Encrypted Client Hello", Work in Progress, Internet-Draft, + draft-ietf-tls-esni-25, 14 June 2025, + . + + [RFC7652] Cullen, M., Hartman, S., Zhang, D., and T. Reddy, "Port + Control Protocol (PCP) Authentication Mechanism", + RFC 7652, DOI 10.17487/RFC7652, September 2015, + . + +Appendix A. Implementation Status + + An open-source implementation exists in StartOS and its StartTunnel + gateway. Pending IANA assignment, it uses values from the PCP + Private Use ranges: option code 224, and result codes 192 + (HOSTNAME_TAKEN) and 193 (UNSUPP_HOSTNAME). These are placeholders + and are expected to be replaced by the IANA-assigned values (TBD1, + TBD2, TBD3) once allocated. + + + + + +McClelland Expires 25 December 2026 [Page 15] + +Internet-Draft PCP Hostname Extension June 2026 + + +Author's Address + + Aiden McClelland + Start9 + Email: me@drbonez.dev + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +McClelland Expires 25 December 2026 [Page 16] diff --git a/sdk/base/lib/osBindings/CheckDnsParams.ts b/sdk/base/lib/osBindings/CheckDnsParams.ts index 992d0706a4..8a59b2aeaa 100644 --- a/sdk/base/lib/osBindings/CheckDnsParams.ts +++ b/sdk/base/lib/osBindings/CheckDnsParams.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { GatewayId } from './GatewayId' -export type CheckDnsParams = { gateway: GatewayId } +export type CheckDnsParams = { gateway: GatewayId; fqdn: string } diff --git a/sdk/base/lib/osBindings/SetupInfo.ts b/sdk/base/lib/osBindings/SetupInfo.ts index 7c9fe69eec..6a3f8c6ccc 100644 --- a/sdk/base/lib/osBindings/SetupInfo.ts +++ b/sdk/base/lib/osBindings/SetupInfo.ts @@ -5,9 +5,10 @@ export type SetupInfo = { attach: boolean mokEnrolled: boolean /** - * The whole disk the running OS booted from. Set when the device is - * pre-installed (setup-after-install), so the wizard fixes it as the OS - * drive and asks only for a data drive. + * The whole disk the OS is installed on, recorded by whatever installed it + * (os_install, or init_resize for a pre-installed image). When the device + * is pre-installed the wizard fixes this as the OS drive and asks only for + * a data drive. */ osDrive: string | null } diff --git a/sdk/base/lib/osBindings/UpdateTunnelParams.ts b/sdk/base/lib/osBindings/UpdateTunnelParams.ts new file mode 100644 index 0000000000..f25ea0b625 --- /dev/null +++ b/sdk/base/lib/osBindings/UpdateTunnelParams.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GatewayId } from './GatewayId' + +export type UpdateTunnelParams = { id: GatewayId; config: string } diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index f15ba96869..d1751b3986 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -5,8 +5,8 @@ export { ActionAccess } from './ActionAccess' export { ActionId } from './ActionId' export { ActionInput } from './ActionInput' export { ActionMetadata } from './ActionMetadata' -export { ActionResultMember } from './ActionResultMember' export { ActionResult } from './ActionResult' +export { ActionResultMember } from './ActionResultMember' export { ActionResultV0 } from './ActionResultV0' export { ActionResultV1 } from './ActionResultV1' export { ActionResultValue } from './ActionResultValue' @@ -21,13 +21,13 @@ export { AddPackageToCategoryParams } from './AddPackageToCategoryParams' export { AddPrivateDomainParams } from './AddPrivateDomainParams' export { AddPublicDomainParams } from './AddPublicDomainParams' export { AddPublicDomainRes } from './AddPublicDomainRes' -export { AddressInfo } from './AddressInfo' export { AddSslOptions } from './AddSslOptions' export { AddTunnelParams } from './AddTunnelParams' export { AddVersionParams } from './AddVersionParams' +export { AddressInfo } from './AddressInfo' export { Algorithm } from './Algorithm' -export { AllowedStatuses } from './AllowedStatuses' export { AllPackageData } from './AllPackageData' +export { AllowedStatuses } from './AllowedStatuses' export { AlpnInfo } from './AlpnInfo' export { AnySignature } from './AnySignature' export { AnySigningKey } from './AnySigningKey' @@ -37,20 +37,20 @@ export { AttachParams } from './AttachParams' export { BackupInfo } from './BackupInfo' export { BackupParams } from './BackupParams' export { BackupReport } from './BackupReport' +export { BackupTarget } from './BackupTarget' export { BackupTargetFS } from './BackupTargetFS' export { BackupTargetId } from './BackupTargetId' -export { BackupTarget } from './BackupTarget' export { Base64 } from './Base64' export { BasicCredential } from './BasicCredential' export { BindId } from './BindId' export { BindInfo } from './BindInfo' +export { BindOptions } from './BindOptions' +export { BindParams } from './BindParams' +export { BindRangeParams } from './BindRangeParams' export { BindingRanges } from './BindingRanges' export { BindingSetAddressEnabledParams } from './BindingSetAddressEnabledParams' export { BindingSetRangeGatewayAccessParams } from './BindingSetRangeGatewayAccessParams' export { Bindings } from './Bindings' -export { BindOptions } from './BindOptions' -export { BindParams } from './BindParams' -export { BindRangeParams } from './BindRangeParams' export { Blake3Commitment } from './Blake3Commitment' export { BlockDev } from './BlockDev' export { BuildArg } from './BuildArg' @@ -63,10 +63,10 @@ export { CheckDependenciesResult } from './CheckDependenciesResult' export { CheckDnsParams } from './CheckDnsParams' export { CheckPortParams } from './CheckPortParams' export { CheckPortRes } from './CheckPortRes' +export { Cifs } from './Cifs' export { CifsAddParams } from './CifsAddParams' export { CifsBackupTarget } from './CifsBackupTarget' export { CifsRemoveParams } from './CifsRemoveParams' -export { Cifs } from './Cifs' export { CifsUpdateParams } from './CifsUpdateParams' export { ClearActionsParams } from './ClearActionsParams' export { ClearBindingsParams } from './ClearBindingsParams' @@ -84,10 +84,10 @@ export { CreateTaskParams } from './CreateTaskParams' export { CurrentDependencies } from './CurrentDependencies' export { CurrentDependencyInfo } from './CurrentDependencyInfo' export { DataUrl } from './DataUrl' +export { DepInfo } from './DepInfo' export { Dependencies } from './Dependencies' export { DependencyMetadata } from './DependencyMetadata' export { DependencyRequirement } from './DependencyRequirement' -export { DepInfo } from './DepInfo' export { DerivedAddressInfo } from './DerivedAddressInfo' export { Description } from './Description' export { DesiredStatus } from './DesiredStatus' @@ -122,8 +122,8 @@ export { GetOsAssetParams } from './GetOsAssetParams' export { GetOsVersionParams } from './GetOsVersionParams' export { GetOutboundGatewayParams } from './GetOutboundGatewayParams' export { GetPackageParams } from './GetPackageParams' -export { GetPackageResponseFull } from './GetPackageResponseFull' export { GetPackageResponse } from './GetPackageResponse' +export { GetPackageResponseFull } from './GetPackageResponseFull' export { GetServiceInterfaceParams } from './GetServiceInterfaceParams' export { GetServiceManifestParams } from './GetServiceManifestParams' export { GetServicePortForwardParams } from './GetServicePortForwardParams' @@ -138,11 +138,11 @@ export { Governor } from './Governor' export { Guid } from './Guid' export { HardwareRequirements } from './HardwareRequirements' export { HealthCheckId } from './HealthCheckId' +export { Host } from './Host' export { HostId } from './HostId' export { HostnameInfo } from './HostnameInfo' export { HostnameMetadata } from './HostnameMetadata' export { Hosts } from './Hosts' -export { Host } from './Host' export { ImageConfig } from './ImageConfig' export { ImageId } from './ImageId' export { ImageMetadata } from './ImageMetadata' @@ -150,11 +150,11 @@ export { ImageSource } from './ImageSource' export { InfoParams } from './InfoParams' export { InitAcmeParams } from './InitAcmeParams' export { InitProgressRes } from './InitProgressRes' +export { InstallParams } from './InstallParams' export { InstalledState } from './InstalledState' export { InstalledVersionParams } from './InstalledVersionParams' export { InstallingInfo } from './InstallingInfo' export { InstallingState } from './InstallingState' -export { InstallParams } from './InstallParams' export { IpInfo } from './IpInfo' export { KeyboardOptions } from './KeyboardOptions' export { KillParams } from './KillParams' @@ -166,8 +166,8 @@ export { ListVersionSignersParams } from './ListVersionSignersParams' export { LocaleString } from './LocaleString' export { LogEntry } from './LogEntry' export { LogFollowResponse } from './LogFollowResponse' -export { LoginParams } from './LoginParams' export { LogResponse } from './LogResponse' +export { LoginParams } from './LoginParams' export { LogsParams } from './LogsParams' export { LshwDevice } from './LshwDevice' export { LshwDisplay } from './LshwDisplay' @@ -176,15 +176,15 @@ export { Manifest } from './Manifest' export { MaybeUtf8String } from './MaybeUtf8String' export { MebiBytes } from './MebiBytes' export { MerkleArchiveCommitment } from './MerkleArchiveCommitment' -export { MetadataSrc } from './MetadataSrc' export { Metadata } from './Metadata' +export { MetadataSrc } from './MetadataSrc' +export { Metrics } from './Metrics' export { MetricsCpu } from './MetricsCpu' export { MetricsDisk } from './MetricsDisk' export { MetricsFollowResponse } from './MetricsFollowResponse' export { MetricsGeneral } from './MetricsGeneral' export { MetricsMemory } from './MetricsMemory' export { MetricsSummary } from './MetricsSummary' -export { Metrics } from './Metrics' export { ModifyNotificationBeforeParams } from './ModifyNotificationBeforeParams' export { ModifyNotificationParams } from './ModifyNotificationParams' export { MountParams } from './MountParams' @@ -195,20 +195,20 @@ export { NetInfo } from './NetInfo' export { NetworkInfo } from './NetworkInfo' export { NetworkInterfaceInfo } from './NetworkInterfaceInfo' export { NetworkInterfaceType } from './NetworkInterfaceType' -export { NotificationLevel } from './NotificationLevel' export { Notification } from './Notification' +export { NotificationLevel } from './NotificationLevel' export { NotificationWithId } from './NotificationWithId' export { OsIndex } from './OsIndex' -export { OsVersionInfoMap } from './OsVersionInfoMap' export { OsVersionInfo } from './OsVersionInfo' +export { OsVersionInfoMap } from './OsVersionInfoMap' export { PackageBackupInfo } from './PackageBackupInfo' export { PackageBackupReport } from './PackageBackupReport' export { PackageDataEntry } from './PackageDataEntry' export { PackageDetailLevel } from './PackageDetailLevel' export { PackageId } from './PackageId' export { PackageIndex } from './PackageIndex' -export { PackageInfoShort } from './PackageInfoShort' export { PackageInfo } from './PackageInfo' +export { PackageInfoShort } from './PackageInfoShort' export { PackagePlugin } from './PackagePlugin' export { PackageState } from './PackageState' export { PackageVersionCount } from './PackageVersionCount' @@ -225,8 +225,8 @@ export { PortForward } from './PortForward' export { Progress } from './Progress' export { ProgressUnits } from './ProgressUnits' export { ProxyAuth } from './ProxyAuth' -export { PublicDomainConfig } from './PublicDomainConfig' export { Public } from './Public' +export { PublicDomainConfig } from './PublicDomainConfig' export { QueryDnsParams } from './QueryDnsParams' export { RangeBindInfo } from './RangeBindInfo' export { RangeGatewayAccess } from './RangeGatewayAccess' @@ -259,12 +259,12 @@ export { ServerHostname } from './ServerHostname' export { ServerInfo } from './ServerInfo' export { ServerSpecs } from './ServerSpecs' export { ServerStatus } from './ServerStatus' -export { ServiceInterfaceId } from './ServiceInterfaceId' export { ServiceInterface } from './ServiceInterface' +export { ServiceInterfaceId } from './ServiceInterfaceId' export { ServiceInterfaceType } from './ServiceInterfaceType' +export { Session } from './Session' export { SessionList } from './SessionList' export { Sessions } from './Sessions' -export { Session } from './Session' export { SetBackupProgress } from './SetBackupProgress' export { SetCountryParams } from './SetCountryParams' export { SetDataVersionParams } from './SetDataVersionParams' @@ -274,23 +274,23 @@ export { SetHealth } from './SetHealth' export { SetIconParams } from './SetIconParams' export { SetInitProgress } from './SetInitProgress' export { SetLanguageParams } from './SetLanguageParams' -export { SetMainStatusStatus } from './SetMainStatusStatus' export { SetMainStatus } from './SetMainStatus' +export { SetMainStatusStatus } from './SetMainStatusStatus' export { SetNameParams } from './SetNameParams' export { SetOutboundGatewayParams } from './SetOutboundGatewayParams' export { SetServerHostnameParams } from './SetServerHostnameParams' export { SetStaticDnsParams } from './SetStaticDnsParams' +export { SetWifiEnabledParams } from './SetWifiEnabledParams' export { SetupExecuteParams } from './SetupExecuteParams' export { SetupInfo } from './SetupInfo' export { SetupProgress } from './SetupProgress' export { SetupResult } from './SetupResult' export { SetupStatusRes } from './SetupStatusRes' -export { SetWifiEnabledParams } from './SetWifiEnabledParams' export { ShutdownParams } from './ShutdownParams' export { SideloadParams } from './SideloadParams' export { SideloadResponse } from './SideloadResponse' -export { SignalStrength } from './SignalStrength' export { SignAssetParams } from './SignAssetParams' +export { SignalStrength } from './SignalStrength' export { SignerInfo } from './SignerInfo' export { SmtpSecurity } from './SmtpSecurity' export { SmtpValue } from './SmtpValue' @@ -302,16 +302,17 @@ export { Ssid } from './Ssid' export { StartOsRecoveryInfo } from './StartOsRecoveryInfo' export { StartStop } from './StartStop' export { StatusInfo } from './StatusInfo' +export { Task } from './Task' export { TaskCondition } from './TaskCondition' export { TaskEntry } from './TaskEntry' export { TaskInput } from './TaskInput' export { TaskSeverity } from './TaskSeverity' export { TaskTrigger } from './TaskTrigger' -export { Task } from './Task' export { TestSmtpParams } from './TestSmtpParams' export { TimeInfo } from './TimeInfo' export { UmountParams } from './UmountParams' export { UninstallParams } from './UninstallParams' +export { UpdateTunnelParams } from './UpdateTunnelParams' export { UpdatingState } from './UpdatingState' export { UrlPluginClearUrlsParams } from './UrlPluginClearUrlsParams' export { UrlPluginExportUrlParams } from './UrlPluginExportUrlParams' @@ -319,8 +320,8 @@ export { UrlPluginRegisterParams } from './UrlPluginRegisterParams' export { UrlPluginRegistration } from './UrlPluginRegistration' export { UsersResponse } from './UsersResponse' export { VerifyCifsParams } from './VerifyCifsParams' -export { VersionSignerParams } from './VersionSignerParams' export { Version } from './Version' +export { VersionSignerParams } from './VersionSignerParams' export { VolumeId } from './VolumeId' export { WifiAddParams } from './WifiAddParams' export { WifiInfo } from './WifiInfo' diff --git a/sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts b/sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts index c5ff2738d8..e605860de5 100644 --- a/sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts +++ b/sdk/base/lib/osBindings/tunnel/AddDeviceParams.ts @@ -1,7 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WgClientKind } from './WgClientKind' export type AddDeviceParams = { subnet: string name: string ip: string | null + /** + * Client (no autoconfig) or Server (gateway-autoconfig on by default). + */ + kind: WgClientKind } diff --git a/sdk/base/lib/osBindings/tunnel/AddDnsRecordParams.ts b/sdk/base/lib/osBindings/tunnel/AddDnsRecordParams.ts new file mode 100644 index 0000000000..d368c013dc --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/AddDnsRecordParams.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AddDnsRecordParams = { + name: string + type: string + value: string + ttl: number | null +} diff --git a/sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts b/sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts index ea50dca511..75cec0f0f0 100644 --- a/sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts +++ b/sdk/base/lib/osBindings/tunnel/AddPortForwardParams.ts @@ -1,7 +1,15 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type AddPortForwardParams = { - source: string + /** + * External (WAN) port to forward. The external IP is fixed to the target's + * WAN so return traffic stays symmetric. + */ + externalPort: number target: string label: string | null + /** + * Hostnames to SNI-demux on the shared external port. Empty = normal DNAT. + */ + sni: Array } diff --git a/sdk/base/lib/osBindings/tunnel/DnsMode.ts b/sdk/base/lib/osBindings/tunnel/DnsMode.ts index 7a8a3cb6c5..c7cd43348d 100644 --- a/sdk/base/lib/osBindings/tunnel/DnsMode.ts +++ b/sdk/base/lib/osBindings/tunnel/DnsMode.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Which upstream a subnet's DNS proxy forwards to. Companion fields on - * [`SetSubnetDnsParams`] supply the data for the `Device`/`Custom` modes. + * Which upstream a subnet's DNS proxy forwards to. `Device`/`Custom` draw + * their data from companion fields on [`SetSubnetDnsParams`]. */ export type DnsMode = 'default' | 'device' | 'custom' diff --git a/sdk/base/lib/osBindings/tunnel/DnsRecordEntry.ts b/sdk/base/lib/osBindings/tunnel/DnsRecordEntry.ts new file mode 100644 index 0000000000..0e4ae1a173 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/DnsRecordEntry.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A DNS record served by the tunnel (injected via RFC 2136 or added manually). + * `value` is the rdata as text: an IP for A/AAAA, a name for CNAME, etc. + */ +export type DnsRecordEntry = { + name: string + type: string + value: string + ttl: number + /** + * The device IP that injected this, or `null` for a manual record. + */ + source: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/DnsRecords.ts b/sdk/base/lib/osBindings/tunnel/DnsRecords.ts new file mode 100644 index 0000000000..d13c73c332 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/DnsRecords.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DnsRecordEntry } from './DnsRecordEntry' + +export type DnsRecords = Array diff --git a/sdk/base/lib/osBindings/tunnel/PortForward.ts b/sdk/base/lib/osBindings/tunnel/PortForward.ts new file mode 100644 index 0000000000..0e278398b2 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/PortForward.ts @@ -0,0 +1,29 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SniRoute } from './SniRoute' + +/** + * One external-port forward: an nftables DNAT or an SNI-demultiplexed shared + * port. Mutually exclusive for a given external address. + */ +export type PortForward = + | { + kind: 'dnat' + target: string + label: string | null + enabled: boolean + /** + * Contiguous ports forwarded (a PCP PORT_SET range); `1` for single-port. + */ + count: number + /** + * Gateway-created (PCP/UPnP) vs user-added. Drives the UI Manual/Automatic split. + */ + auto: boolean + } + | { + kind: 'sni' + /** + * hostname (lowercase; may be `*.suffix`) -> route. + */ + routes: { [key: string]: SniRoute } + } diff --git a/sdk/base/lib/osBindings/tunnel/PortForwards.ts b/sdk/base/lib/osBindings/tunnel/PortForwards.ts index f2d249dd74..b3dd71ebcb 100644 --- a/sdk/base/lib/osBindings/tunnel/PortForwards.ts +++ b/sdk/base/lib/osBindings/tunnel/PortForwards.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PortForwardEntry } from './PortForwardEntry' +import type { PortForward } from './PortForward' -export type PortForwards = { [key: string]: PortForwardEntry } +export type PortForwards = { [key: string]: PortForward } diff --git a/sdk/base/lib/osBindings/tunnel/RemoveDnsRecordParams.ts b/sdk/base/lib/osBindings/tunnel/RemoveDnsRecordParams.ts new file mode 100644 index 0000000000..97ce691394 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/RemoveDnsRecordParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RemoveDnsRecordParams = { name: string; type: string | null } diff --git a/sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts b/sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts index 2e85f5e77a..fc9fe06f68 100644 --- a/sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts +++ b/sdk/base/lib/osBindings/tunnel/RemovePortForwardParams.ts @@ -1,3 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type RemovePortForwardParams = { source: string } +export type RemovePortForwardParams = { + source: string + /** + * Remove a single SNI route on `source`; omit to remove the whole forward. + */ + hostname: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/PortForwardEntry.ts b/sdk/base/lib/osBindings/tunnel/SetAutoPortForwardParams.ts similarity index 64% rename from sdk/base/lib/osBindings/tunnel/PortForwardEntry.ts rename to sdk/base/lib/osBindings/tunnel/SetAutoPortForwardParams.ts index 1619d3f409..e9274d12cb 100644 --- a/sdk/base/lib/osBindings/tunnel/PortForwardEntry.ts +++ b/sdk/base/lib/osBindings/tunnel/SetAutoPortForwardParams.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PortForwardEntry = { - target: string - label: string | null +export type SetAutoPortForwardParams = { + subnet: string + ip: string enabled: boolean } diff --git a/sdk/base/lib/osBindings/tunnel/SetDeviceKindParams.ts b/sdk/base/lib/osBindings/tunnel/SetDeviceKindParams.ts new file mode 100644 index 0000000000..88e656cb26 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SetDeviceKindParams.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WgClientKind } from './WgClientKind' + +export type SetDeviceKindParams = { + subnet: string + ip: string + kind: WgClientKind +} diff --git a/sdk/base/lib/osBindings/tunnel/SetDeviceWanParams.ts b/sdk/base/lib/osBindings/tunnel/SetDeviceWanParams.ts new file mode 100644 index 0000000000..18dc6675e3 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SetDeviceWanParams.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetDeviceWanParams = { + subnet: string + ip: string + wanIp: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/SetDnsInjectionParams.ts b/sdk/base/lib/osBindings/tunnel/SetDnsInjectionParams.ts new file mode 100644 index 0000000000..90fbef0a39 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SetDnsInjectionParams.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetDnsInjectionParams = { + subnet: string + ip: string + enabled: boolean +} diff --git a/sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts b/sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts index 51f9234363..7e6434d027 100644 --- a/sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts +++ b/sdk/base/lib/osBindings/tunnel/SetPortForwardEnabledParams.ts @@ -1,3 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SetPortForwardEnabledParams = { source: string; enabled: boolean } +export type SetPortForwardEnabledParams = { + source: string + enabled: boolean + /** + * Toggle a single SNI route on `source`; omit for a DNAT forward. + */ + hostname: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/SetSubnetWanParams.ts b/sdk/base/lib/osBindings/tunnel/SetSubnetWanParams.ts new file mode 100644 index 0000000000..faf8fc2e1f --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SetSubnetWanParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetSubnetWanParams = { subnet: string; wanIp: string | null } diff --git a/sdk/base/lib/osBindings/tunnel/SniRoute.ts b/sdk/base/lib/osBindings/tunnel/SniRoute.ts new file mode 100644 index 0000000000..6ca91aa2fc --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/SniRoute.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One SNI-demultiplexed hostname route on a shared external port. + */ +export type SniRoute = { + target: string + label: string | null + enabled: boolean + /** + * Gateway-created (PCP) vs user-added. Drives the UI Manual/Automatic split. + */ + auto: boolean +} diff --git a/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts b/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts index 74b8eacd92..0995bc0dab 100644 --- a/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts +++ b/sdk/base/lib/osBindings/tunnel/TunnelDatabase.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AnyVerifyingKey } from './AnyVerifyingKey' +import type { DnsRecords } from './DnsRecords' import type { GatewayId } from './GatewayId' import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo' import type { PortForwards } from './PortForwards' @@ -16,4 +17,5 @@ export type TunnelDatabase = { gateways: { [key: GatewayId]: NetworkInterfaceInfo } wg: WgServer portForwards: PortForwards + dnsRecords: DnsRecords } diff --git a/sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts b/sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts index 1697a12504..c924b5ee7a 100644 --- a/sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts +++ b/sdk/base/lib/osBindings/tunnel/UpdatePortForwardLabelParams.ts @@ -3,4 +3,8 @@ export type UpdatePortForwardLabelParams = { source: string label: string | null + /** + * Label a single SNI route on `source`; omit to label the DNAT forward. + */ + hostname: string | null } diff --git a/sdk/base/lib/osBindings/tunnel/WgClientKind.ts b/sdk/base/lib/osBindings/tunnel/WgClientKind.ts new file mode 100644 index 0000000000..6b1479fa90 --- /dev/null +++ b/sdk/base/lib/osBindings/tunnel/WgClientKind.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A WireGuard client's role. A `Server` is a StartOS box that may configure the + * gateway (DNS injection / auto port-forward); a `Client` is a plain peer with + * no autoconfig. Stored and sticky — toggling the capability flags never changes + * it; the migration backfills it from those flags. + */ +export type WgClientKind = 'client' | 'server' diff --git a/sdk/base/lib/osBindings/tunnel/WgConfig.ts b/sdk/base/lib/osBindings/tunnel/WgConfig.ts index 4ca18e900f..15be336a9d 100644 --- a/sdk/base/lib/osBindings/tunnel/WgConfig.ts +++ b/sdk/base/lib/osBindings/tunnel/WgConfig.ts @@ -1,4 +1,32 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Base64 } from './Base64' +import type { WgClientKind } from './WgClientKind' -export type WgConfig = { name: string; key: Base64; psk: Base64 } +export type WgConfig = { + name: string + key: Base64 + psk: Base64 + /** + * Client (no autoconfig) vs Server (a StartOS box). Sticky; defaulted by the + * migration from the capability flags. + */ + kind: WgClientKind + /** + * Whether this device may inject DNS records via RFC 2136. Off by default + * — only enable for devices you trust (it lets the device add records to + * the tunnel's DNS for every peer to resolve). + */ + allowDnsInjection: boolean + /** + * Whether this device may auto-create port forwards via PCP/IGD. Off by + * default — paired with `allow_dns_injection` under one "Gateway + * Autoconfiguration" toggle, but tracked separately so each capability is + * gated on its own. + */ + allowAutoPortForward: boolean + /** + * SNAT this device's egress to this WAN IP, overriding the subnet's + * `wan_ip` / the default masquerade. `None` falls back to the subnet rule. + */ + wanIp: string | null +} diff --git a/sdk/base/lib/osBindings/tunnel/WgSubnetConfig.ts b/sdk/base/lib/osBindings/tunnel/WgSubnetConfig.ts index b10e212e11..dd60905a0d 100644 --- a/sdk/base/lib/osBindings/tunnel/WgSubnetConfig.ts +++ b/sdk/base/lib/osBindings/tunnel/WgSubnetConfig.ts @@ -6,4 +6,9 @@ export type WgSubnetConfig = { name: string clients: WgSubnetClients dns: DnsConfig + /** + * SNAT this subnet's egress to this WAN IP instead of `masquerade`. `None` + * keeps the default masquerade; a per-device `wan_ip` overrides this. + */ + wanIp: string | null } diff --git a/sdk/base/lib/osBindings/tunnel/index.ts b/sdk/base/lib/osBindings/tunnel/index.ts index 53df797bd5..efb144b483 100644 --- a/sdk/base/lib/osBindings/tunnel/index.ts +++ b/sdk/base/lib/osBindings/tunnel/index.ts @@ -1,4 +1,5 @@ export { AddDeviceParams } from './AddDeviceParams' +export { AddDnsRecordParams } from './AddDnsRecordParams' export { AddKeyParams } from './AddKeyParams' export { AddPortForwardParams } from './AddPortForwardParams' export { AddSubnetParams } from './AddSubnetParams' @@ -6,6 +7,8 @@ export { AnyVerifyingKey } from './AnyVerifyingKey' export { Base64 } from './Base64' export { DnsConfig } from './DnsConfig' export { DnsMode } from './DnsMode' +export { DnsRecordEntry } from './DnsRecordEntry' +export { DnsRecords } from './DnsRecords' export { GatewayId } from './GatewayId' export { GatewayType } from './GatewayType' export { IpInfo } from './IpInfo' @@ -13,24 +16,32 @@ export { ListDevicesParams } from './ListDevicesParams' export { NetworkInterfaceInfo } from './NetworkInterfaceInfo' export { NetworkInterfaceType } from './NetworkInterfaceType' export { Pem } from './Pem' -export { PortForwardEntry } from './PortForwardEntry' +export { PortForward } from './PortForward' export { PortForwards } from './PortForwards' export { RemoveDeviceParams } from './RemoveDeviceParams' +export { RemoveDnsRecordParams } from './RemoveDnsRecordParams' export { RemoveKeyParams } from './RemoveKeyParams' export { RemovePortForwardParams } from './RemovePortForwardParams' export { Session } from './Session' export { Sessions } from './Sessions' +export { SetAutoPortForwardParams } from './SetAutoPortForwardParams' +export { SetDeviceKindParams } from './SetDeviceKindParams' +export { SetDeviceWanParams } from './SetDeviceWanParams' +export { SetDnsInjectionParams } from './SetDnsInjectionParams' export { SetPasswordParams } from './SetPasswordParams' export { SetPortForwardEnabledParams } from './SetPortForwardEnabledParams' export { SetSubnetDnsParams } from './SetSubnetDnsParams' +export { SetSubnetWanParams } from './SetSubnetWanParams' export { ShowConfigParams } from './ShowConfigParams' export { SignerInfo } from './SignerInfo' +export { SniRoute } from './SniRoute' export { SubnetParams } from './SubnetParams' export { TunnelCertData } from './TunnelCertData' export { TunnelDatabase } from './TunnelDatabase' export { TunnelUpdateResult } from './TunnelUpdateResult' export { UpdatePortForwardLabelParams } from './UpdatePortForwardLabelParams' export { WebserverInfo } from './WebserverInfo' +export { WgClientKind } from './WgClientKind' export { WgConfig } from './WgConfig' export { WgServer } from './WgServer' export { WgSubnetClients } from './WgSubnetClients' diff --git a/web/package-lock.json b/web/package-lock.json index 739fc16018..319c0b5efa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2826,9 +2826,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2841,9 +2838,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6575,9 +6569,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6592,9 +6583,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6609,9 +6597,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6626,9 +6611,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6643,9 +6625,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6660,9 +6639,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6677,9 +6653,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6694,9 +6667,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6711,9 +6681,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6728,9 +6695,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6745,9 +6709,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6760,9 +6721,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6775,9 +6733,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9019,9 +8974,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9039,9 +8991,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9059,9 +9008,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9079,9 +9025,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9099,9 +9042,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9117,9 +9057,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9135,9 +9072,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9634,9 +9568,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9658,9 +9589,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9682,9 +9610,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9706,9 +9631,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9728,9 +9650,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9750,9 +9669,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9972,9 +9888,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9989,9 +9902,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10006,9 +9916,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10023,9 +9930,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10040,9 +9944,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10057,9 +9958,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10074,9 +9972,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10091,9 +9986,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10108,9 +10000,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10125,9 +10014,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10142,9 +10028,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10157,9 +10040,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -10172,9 +10052,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10750,7 +10627,7 @@ }, "node_modules/@types/node": { "version": "22.19.15", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -16762,7 +16639,7 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/union": { diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 32c6040853..5b991afbea 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -785,4 +785,7 @@ export default { 876: 'Einen Start9-Server holen', 877: 'Register konnte nicht erreicht werden', 878: 'Dies müssen Sie auf jedem Gerät wiederholen, mit dem Sie eine Verbindung zum Server herstellen.', + 879: 'Konfiguration aktualisieren', + 880: 'Oder aktivieren Sie DNS Injection für dieses Gerät auf dem Gateway.', + 881: 'Oder aktivieren Sie die automatische Portweiterleitung (UPnP / NAT-PMP / PCP) auf dem Gateway.', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 9065b1e51e..69566691f1 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -672,6 +672,7 @@ export const ENGLISH: Record = { 'as its DNS server': 752, 'DNS Server': 753, 'View port forwards': 754, + 'Update config': 879, 'Interface(s)': 755, 'No port forwarding rules': 756, 'Port forwarding rules required on gateway': 757, @@ -786,4 +787,6 @@ export const ENGLISH: Record = { 'Get a Start9 server': 876, 'Could not reach registry': 877, 'You will need to repeat this on every device you use to connect to your server.': 878, + 'Or enable DNS Injection for this device on the gateway.': 880, + 'Or enable automatic port forwarding (UPnP / NAT-PMP / PCP) on the gateway.': 881, } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index aeb45ed7ba..cd33b3df55 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -785,4 +785,7 @@ export default { 876: 'Consigue un servidor Start9', 877: 'No se pudo acceder al registro', 878: 'Tendrás que repetir esto en cada dispositivo que uses para conectarte a tu servidor.', + 879: 'Actualizar configuración', + 880: 'O habilite DNS Injection para este dispositivo en la puerta de enlace.', + 881: 'O habilite el reenvío de puertos automático (UPnP / NAT-PMP / PCP) en la puerta de enlace.', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index cd1486b15f..9705c8179c 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -785,4 +785,7 @@ export default { 876: 'Obtenez un serveur Start9', 877: 'Impossible de joindre la bibliothèque', 878: 'Vous devrez répéter cette opération sur chaque appareil utilisé pour vous connecter à votre serveur.', + 879: 'Mettre à jour la configuration', + 880: 'Ou activez DNS Injection pour cet appareil sur la passerelle.', + 881: 'Ou activez la redirection de port automatique (UPnP / NAT-PMP / PCP) sur la passerelle.', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 95cc5a0c88..233d5f41fe 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -785,4 +785,7 @@ export default { 876: 'Zdobądź serwer Start9', 877: 'Nie można połączyć z katalogiem', 878: 'Będziesz musiał powtórzyć tę czynność na każdym urządzeniu, którego używasz do łączenia się z serwerem.', + 879: 'Zaktualizuj konfigurację', + 880: 'Lub włącz DNS Injection dla tego urządzenia w bramie.', + 881: 'Lub włącz automatyczne przekierowanie portów (UPnP / NAT-PMP / PCP) w bramie.', } satisfies i18n diff --git a/web/projects/start-tunnel/src/app/routes/home/components/outlet.ts b/web/projects/start-tunnel/src/app/routes/home/components/outlet.ts index b7e9a26423..b0a7d4fe1a 100644 --- a/web/projects/start-tunnel/src/app/routes/home/components/outlet.ts +++ b/web/projects/start-tunnel/src/app/routes/home/components/outlet.ts @@ -155,6 +155,11 @@ export class Outlet { icon: '@tui.globe', link: 'port-forwards', }, + { + name: 'DNS', + icon: '@tui.list', + link: 'dns', + }, ] as const protected readonly title = toSignal( diff --git a/web/projects/start-tunnel/src/app/routes/home/components/wan.ts b/web/projects/start-tunnel/src/app/routes/home/components/wan.ts new file mode 100644 index 0000000000..7ba5707dbb --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/components/wan.ts @@ -0,0 +1,67 @@ +import { utils } from '@start9labs/start-sdk' +import { TunnelData } from 'src/app/services/patch-db/data-model' + +type Gateways = TunnelData['gateways'] + +// Ranges core's is_wan_candidate (core/src/net/upnp.rs) rejects: 0/8 + +// unspecified, loopback, RFC1918, link-local, the TEST-NET docs ranges, and +// broadcast. CGNAT (100.64/10) is intentionally absent — the backend accepts it +// as a valid WAN, so the UI must too. Keep this list in sync with that fn. +const NON_WAN_RANGES = [ + '0.0.0.0/8', + '127.0.0.0/8', + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '169.254.0.0/16', + '192.0.2.0/24', + '198.51.100.0/24', + '203.0.113.0/24', + '255.255.255.255/32', +].map(r => utils.IpNet.parse(r)) + +function isWanCandidate(address: string): boolean { + const ip = utils.IpAddress.parse(address) + return ip.isIpv4() && !NON_WAN_RANGES.some(r => r.contains(ip)) +} + +// Public WAN IPv4 addresses the gateway can egress from — the explicit choices. +export function wanOptions(gateways: Gateways): readonly string[] { + return Object.values(gateways) + .flatMap( + g => g.ipInfo?.subnets.map(s => utils.IpNet.parse(s).address) ?? [], + ) + .filter(isWanCandidate) +} + +// Mirrors core's default_wan (core/src/tunnel/igd.rs): the first gateway's +// detected WAN IP if it is a candidate, else its first candidate subnet address. +export function defaultWanIp(gateways: Gateways): string | null { + for (const { ipInfo } of Object.values(gateways)) { + if (!ipInfo) continue + if (ipInfo.wanIp && isWanCandidate(ipInfo.wanIp)) return ipInfo.wanIp + const subnet = ipInfo.subnets + .map(s => utils.IpNet.parse(s).address) + .find(isWanCandidate) + if (subnet) return subnet + } + return null +} + +// tuiSelect skips a bare `null` item, so the "default" choice is wrapped in an +// object to keep it selectable. +export interface WanItem { + readonly ip: string | null +} + +export function toWanItems(options: readonly string[]): readonly WanItem[] { + return [{ ip: null }, ...options.map(ip => ({ ip }))] +} + +export const matchWan = (a: WanItem, b: WanItem) => a.ip === b.ip + +// `defaultLabel` names what the null/default option inherits from, e.g. +// "Use System Default" (subnet) or "Use Subnet Default" (device). +export function wanLabel(ip: string | null, defaultLabel: string): string { + return ip ?? defaultLabel +} diff --git a/web/projects/start-tunnel/src/app/routes/home/index.ts b/web/projects/start-tunnel/src/app/routes/home/index.ts index cd52425688..9087231523 100644 --- a/web/projects/start-tunnel/src/app/routes/home/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/index.ts @@ -21,6 +21,11 @@ export default [ loadComponent: () => import('./routes/port-forwards'), title: 'Port forwards', }, + { + path: 'dns', + loadComponent: () => import('./routes/dns'), + title: 'DNS', + }, { path: 'settings', loadComponent: () => import('./routes/settings'), diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts index efae0a0cff..60e69f9376 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/add.ts @@ -6,17 +6,32 @@ import { } from '@angular/forms' import { WA_IS_MOBILE } from '@ng-web-apis/platform' import { ErrorService } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk' -import { TuiButton, TuiDialogContext, TuiError, TuiInput } from '@taiga-ui/core' +import { + TuiButton, + TuiCheckbox, + TuiDialogContext, + TuiError, + TuiIcon, + TuiInput, +} from '@taiga-ui/core' import { TuiChevron, TuiDataListWrapper, TuiNotificationMiddleService, TuiSelect, + TuiTooltip, } from '@taiga-ui/kit' import { TuiElasticContainer, TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { + matchWan, + toWanItems, + WanItem, + wanLabel, +} from 'src/app/routes/home/components/wan' import { ApiService } from 'src/app/services/api/api.service' import { DEVICES_CONFIG } from './config' @@ -76,6 +91,55 @@ import { } } + + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + @if (!context.data.device && kind === 'server') { + + + } + +
@@ -85,9 +149,12 @@ import { ReactiveFormsModule, TuiAutoFocus, TuiButton, + TuiCheckbox, TuiDataListWrapper, TuiError, TuiForm, + TuiTooltip, + TuiIcon, TuiSelect, TuiInput, TuiChevron, @@ -104,12 +171,14 @@ export class DevicesAdd { protected readonly context = injectContext>() + private readonly fb = inject(NonNullableFormBuilder) + private readonly autoSubnet = !this.context.data.device && this.context.data.subnets().length === 1 ? this.context.data.subnets().at(0) : undefined - protected readonly form = inject(NonNullableFormBuilder).group({ + protected readonly form = this.fb.group({ name: [this.context.data.device?.name || '', Validators.required], subnet: [ this.context.data.device?.subnet ?? this.autoSubnet, @@ -122,10 +191,29 @@ export class DevicesAdd { ? [Validators.required, ipInSubnetValidator(this.autoSubnet.range)] : [], ], + wanIp: this.fb.control({ + ip: this.context.data.device?.wanIp ?? null, + }), + dnsInjection: [this.context.data.device?.allowDnsInjection ?? true], + autoPortForward: [this.context.data.device?.allowAutoPortForward ?? true], }) + // Inferred from which "Add" button opened the dialog, not user-selectable. + protected readonly kind: T.Tunnel.WgClientKind = + this.context.data.kind ?? this.context.data.device?.kind ?? 'client' + + protected readonly dnsInjectionHint = + 'The device can add/update the DNS records the tunnel serves for every peer to resolve. Only enable for devices you trust.' + protected readonly autoPortForwardHint = + 'The device can request port forwards on the gateway (via PCP). Only enable for devices you trust.' + + protected readonly wanItems = toWanItems(this.context.data.wanOptions) + protected readonly stringify = ({ range, name }: MappedSubnet) => range ? `${name} (${range})` : '' + protected readonly stringifyWan = ({ ip }: WanItem) => + wanLabel(ip, 'Use Subnet Default') + protected readonly matchWan = matchWan protected onSubnet(subnet: MappedSubnet) { this.form.controls.ip.clearValidators() @@ -152,15 +240,46 @@ export class DevicesAdd { } const loader = this.loading.open('').subscribe() - const { ip, name, subnet } = this.form.getRawValue() + const { ip, name, subnet, wanIp, dnsInjection, autoPortForward } = + this.form.getRawValue() const data = { ip, name, subnet: subnet?.range || '' } + const device = this.context.data.device + const kind = this.kind try { - if (this.context.data.device) { - await this.api.editDevice(data) + if (device) { + await this.api.editDevice({ ...data, kind: device.kind }) } else { - await this.api.addDevice(data) + await this.api.addDevice({ ...data, kind }) + } + + if (wanIp.ip !== (device?.wanIp ?? null)) { + await this.api.setDeviceWan({ + subnet: data.subnet, + ip, + wanIp: wanIp.ip, + }) + } + + // addDevice sets both flags on for a server; only sync the ones unchecked. + if (!device && kind === 'server') { + if (!dnsInjection) { + await this.api.setDnsInjection({ + subnet: data.subnet, + ip, + enabled: false, + }) + } + if (!autoPortForward) { + await this.api.setAutoPortForward({ + subnet: data.subnet, + ip, + enabled: false, + }) + } + } + if (!device) { const config = await this.api.showDeviceConfig({ subnet: data.subnet, ip, diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts index 243fd62895..c9ddcf7f58 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/index.ts @@ -1,16 +1,31 @@ -import { Component, computed, inject } from '@angular/core' +import { Component, computed, inject, signal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' +import { FormsModule } from '@angular/forms' import { ErrorService } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' -import { TuiButton, TuiDataList, TuiDropdown } from '@taiga-ui/core' +import { + TuiButton, + TuiDataList, + TuiDropdown, + TuiLoader, + TuiTitle, +} from '@taiga-ui/core' import { TUI_CONFIRM, TuiNotificationMiddleService, TuiSkeleton, + TuiSwitch, } from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { filter, map } from 'rxjs' import { PlaceholderComponent } from 'src/app/routes/home/components/placeholder' +import { + defaultWanIp, + wanLabel, + wanOptions, +} from 'src/app/routes/home/components/wan' import { ApiService } from 'src/app/services/api/api.service' import { TunnelData } from 'src/app/services/patch-db/data-model' import { DEVICES_ADD } from './add' @@ -19,86 +34,229 @@ import { MappedDevice } from './utils' @Component({ template: ` - - - - - - - - - - - @for (device of devices(); track $index) { +
+
+

Servers

+ +
+
NameSubnetLAN IP - -
+ - - - - + + + + + + + + + @for (device of servers(); track $index) { + + + + + + + + - - } @empty { + + + + + + + + + } @empty { + + + + } + +
{{ device.name }}{{ device.subnet.name }}{{ device.ip }} - SubnetLAN IPDNS InjectionAuto Port ForwardWAN
{{ device.name }}{{ device.subnet.name }}{{ device.ip }} + - - - + + + + {{ wanLabel(device.wanIp, 'Use Subnet Default') }} + - - -
+ No servers +
+ + +
+
+

Clients

+ +
+ + - + + + + + - } - -
- No devices - NameSubnetLAN IPWAN
+ + + @for (device of clients(); track $index) { + + {{ device.name }} + {{ device.subnet.name }} + {{ device.ip }} + {{ wanLabel(device.wanIp, 'Use Subnet Default') }} + + + + + + + + + + } @empty { + + + No clients + + + } + + +
`, styles: ` :host { - max-inline-size: 50rem; + display: flex; + flex-direction: column; + gap: 1rem; } `, imports: [ + FormsModule, TuiButton, + TuiCardLarge, TuiDropdown, TuiDataList, + TuiLoader, + TuiSwitch, PlaceholderComponent, TuiSkeleton, + TuiHeader, + TuiTitle, ], }) export default class Devices { @@ -106,40 +264,76 @@ export default class Devices { private readonly api = inject(ApiService) private readonly loading = inject(TuiNotificationMiddleService) private readonly errorService = inject(ErrorService) + private readonly patch = inject>(PatchDB) + + protected readonly wanLabel = wanLabel + + protected readonly togglingDns = signal(null) + protected readonly togglingPf = signal(null) + + private readonly wans = toSignal( + this.patch.watch$('gateways').pipe(map(wanOptions)), + { initialValue: [] }, + ) + + protected readonly defaultWan = toSignal( + this.patch.watch$('gateways').pipe(map(defaultWanIp)), + { initialValue: null }, + ) protected readonly subnets = toSignal( - inject>(PatchDB) - .watch$('wg', 'subnets') - .pipe( - map(subnets => - Object.entries(subnets).map(([range, { name, clients }]) => ({ - range, - name, - clients, - })), - ), + this.patch.watch$('wg', 'subnets').pipe( + map(subnets => + Object.entries(subnets).map(([range, { name, clients }]) => ({ + range, + name, + clients, + })), ), + ), { initialValue: null }, ) protected readonly devices = computed(() => this.subnets()?.flatMap(subnet => - Object.entries(subnet.clients).map(([ip, { name }]) => ({ - subnet: { - name: subnet.name, - range: subnet.range, - }, - ip, - name, - })), + Object.entries(subnet.clients).map( + ([ + ip, + { name, kind, allowDnsInjection, allowAutoPortForward, wanIp }, + ]) => ({ + subnet: { + name: subnet.name, + range: subnet.range, + }, + ip, + name, + kind, + allowDnsInjection, + allowAutoPortForward, + wanIp, + }), + ), ), ) - protected onAdd() { + protected readonly servers = computed(() => + this.devices()?.filter(d => d.kind === 'server'), + ) + + protected readonly clients = computed(() => + this.devices()?.filter(d => d.kind === 'client'), + ) + + protected onAdd(kind: T.Tunnel.WgClientKind) { this.dialogs .open(DEVICES_ADD, { - label: 'Add device', - data: { subnets: this.subnets }, + label: kind === 'server' ? 'Add server' : 'Add client', + data: { + kind, + subnets: this.subnets, + wanOptions: this.wans(), + defaultWan: this.defaultWan(), + }, }) .subscribe() } @@ -147,8 +341,13 @@ export default class Devices { protected onEdit(device: MappedDevice) { this.dialogs .open(DEVICES_ADD, { - label: 'Rename device', - data: { device, subnets: this.subnets }, + label: 'Edit device', + data: { + device, + subnets: this.subnets, + wanOptions: this.wans(), + defaultWan: this.defaultWan(), + }, }) .subscribe() } @@ -185,4 +384,62 @@ export default class Devices { } }) } + + protected async onDnsInjection({ + subnet, + ip, + allowDnsInjection, + }: MappedDevice) { + this.togglingDns.set(ip) + try { + await this.api.setDnsInjection({ + subnet: subnet.range, + ip, + enabled: !allowDnsInjection, + }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.togglingDns.set(null) + } + } + + protected async onAutoPortForward({ + subnet, + ip, + allowAutoPortForward, + }: MappedDevice) { + this.togglingPf.set(ip) + try { + await this.api.setAutoPortForward({ + subnet: subnet.range, + ip, + enabled: !allowAutoPortForward, + }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.togglingPf.set(null) + } + } + + protected onSetKind( + { subnet, ip }: MappedDevice, + kind: T.Tunnel.WgClientKind, + ): void { + this.dialogs + .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loading.open('').subscribe() + try { + await this.api.setDeviceKind({ subnet: subnet.range, ip, kind }) + } catch (e: any) { + this.errorService.handleError(e) + console.log(e) + } finally { + loader.unsubscribe() + } + }) + } } diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts b/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts index 4ef7191919..d839e7ed7b 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/devices/utils.ts @@ -10,6 +10,10 @@ export interface MappedDevice { } readonly ip: string readonly name: string + readonly kind: T.Tunnel.WgClientKind + readonly allowDnsInjection: boolean + readonly allowAutoPortForward: boolean + readonly wanIp: string | null } export interface MappedSubnet { @@ -21,6 +25,9 @@ export interface MappedSubnet { export interface DeviceData { readonly subnets: Signal readonly device?: MappedDevice + readonly kind?: T.Tunnel.WgClientKind + readonly wanOptions: readonly string[] + readonly defaultWan: string | null } export function subnetValidator({ value }: AbstractControl) { diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/dns/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/dns/add.ts new file mode 100644 index 0000000000..cc64360e70 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/dns/add.ts @@ -0,0 +1,250 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + Signal, +} from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { + AbstractControl, + NonNullableFormBuilder, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms' +import { WA_IS_MOBILE } from '@ng-web-apis/platform' +import { ErrorService } from '@start9labs/shared' +import { utils } from '@start9labs/start-sdk' +import { tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk' +import { + TuiButton, + TuiDialogContext, + TuiError, + TuiInput, + TuiNumberFormat, +} from '@taiga-ui/core' +import { + TuiChevron, + TuiDataListWrapper, + TuiInputNumber, + TuiNotificationMiddleService, + TuiSelect, +} from '@taiga-ui/kit' +import { TuiForm } from '@taiga-ui/layout' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { MappedDevice } from 'src/app/routes/home/routes/port-forwards/utils' +import { ApiService } from 'src/app/services/api/api.service' + +const TYPES = ['A', 'AAAA', 'CNAME', 'TXT'] as const + +interface DnsAddData { + readonly devices: Signal +} + +// Sentinel "device" for manual entry; ip === '' marks the Other branch. +const OTHER: MappedDevice = { ip: '', name: 'Other (custom)' } + +function ipValidator(v6: boolean): ValidatorFn { + return ({ value }: AbstractControl): ValidationErrors | null => { + if (!value) return null + try { + const net = utils.IpNet.parse(`${value}/${v6 ? 128 : 32}`) + return (v6 ? net.isIpv6() : net.isIpv4()) ? null : { ip: true } + } catch { + return { ip: true } + } + } +} + +function configure( + ctrl: AbstractControl, + on: boolean, + validators: ValidatorFn[], +) { + if (on) { + ctrl.setValidators(validators) + ctrl.enable({ emitEvent: false }) + } else { + ctrl.clearValidators() + ctrl.disable({ emitEvent: false }) + } + ctrl.updateValueAndValidity({ emitEvent: false }) +} + +@Component({ + template: ` +
+ + + + + + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + @if (isAddr()) { + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + @if (isOther()) { + + + + + + } + } @else { + + + + + + } + + + + +
+ +
+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ReactiveFormsModule, + TuiButton, + TuiChevron, + TuiDataListWrapper, + TuiError, + TuiInput, + TuiInputNumber, + TuiNumberFormat, + TuiSelect, + TuiForm, + ], +}) +export class DnsAdd { + private readonly api = inject(ApiService) + private readonly loading = inject(TuiNotificationMiddleService) + private readonly errorService = inject(ErrorService) + + protected readonly mobile = inject(WA_IS_MOBILE) + protected readonly context = + injectContext>() + protected readonly types = TYPES + + protected readonly form = inject(NonNullableFormBuilder).group({ + name: ['', Validators.required], + type: ['A' as (typeof TYPES)[number], Validators.required], + device: [null as MappedDevice | null], + custom: [''], + value: [''], + ttl: [300 as number | null], + }) + + protected readonly type = toSignal(this.form.controls.type.valueChanges, { + initialValue: this.form.controls.type.value, + }) + private readonly device = toSignal(this.form.controls.device.valueChanges, { + initialValue: this.form.controls.device.value, + }) + + protected readonly isAddr = computed( + () => this.type() === 'A' || this.type() === 'AAAA', + ) + protected readonly isOther = computed( + () => this.isAddr() && this.device()?.ip === '', + ) + protected readonly deviceItems = computed(() => [ + ...this.context.data.devices(), + OTHER, + ]) + + protected readonly stringifyDevice = ({ name, ip }: MappedDevice) => + ip ? `${name} (${ip})` : name + + // Keep only the controls relevant to the current type/branch enabled, so + // form.invalid reflects exactly what the user must fill in. + private readonly reconcile = effect(() => { + const c = this.form.controls + const addr = this.isAddr() + configure(c.device, addr, [Validators.required]) + configure(c.custom, this.isOther(), [ + Validators.required, + ipValidator(this.type() === 'AAAA'), + ]) + configure(c.value, !addr, [Validators.required]) + }) + + protected async onSave() { + if (this.form.invalid) { + tuiMarkControlAsTouchedAndValidate(this.form) + return + } + + const loader = this.loading.open('').subscribe() + const { name, type, device, custom, value, ttl } = this.form.getRawValue() + const finalValue = + type === 'A' || type === 'AAAA' + ? device?.ip === '' + ? custom.trim() + : (device?.ip ?? '') + : value.trim() + + try { + await this.api.addDnsRecord({ name, type, value: finalValue, ttl }) + this.context.$implicit.complete() + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } +} + +export const DNS_ADD = new PolymorpheusComponent(DnsAdd) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/dns/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/dns/index.ts new file mode 100644 index 0000000000..7efaaf12f4 --- /dev/null +++ b/web/projects/start-tunnel/src/app/routes/home/routes/dns/index.ts @@ -0,0 +1,235 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + Signal, +} from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { ErrorService } from '@start9labs/shared' +import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { TuiButton, TuiDataList, TuiDropdown, TuiTitle } from '@taiga-ui/core' +import { + TUI_CONFIRM, + TuiNotificationMiddleService, + TuiSkeleton, +} from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { PatchDB } from 'patch-db-client' +import { filter, map } from 'rxjs' +import { PlaceholderComponent } from 'src/app/routes/home/components/placeholder' +import { MappedDevice } from 'src/app/routes/home/routes/port-forwards/utils' +import { ApiService } from 'src/app/services/api/api.service' +import { TunnelData } from 'src/app/services/patch-db/data-model' + +import { DNS_ADD } from './add' + +@Component({ + template: ` +
+
+

Manual

+ +
+ + + + + + + + + + + + @for (record of manual(); track $index) { + + + + + + + + } @empty { + + + + } + +
NameTypeValueTTL
{{ record.name }}{{ record.type }}{{ record.value }}{{ record.ttl }} + + + +
+ + No manual DNS records. Add one to get started. + +
+
+ +
+
+

Automatic

+
+ + + + + + + + + + + + + @for (record of automatic(); track $index) { + + + + + + + + + } @empty { + + + + } + +
NameTypeValueTTLSource
{{ record.name }}{{ record.type }}{{ record.value }}{{ record.ttl }}{{ sourceName(record.source) }} + + + +
+ + No automatic DNS records. Devices you trust can add their own + via RFC 2136. + +
+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + gap: 1rem; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiButton, + TuiCardLarge, + TuiDropdown, + TuiDataList, + PlaceholderComponent, + TuiSkeleton, + TuiHeader, + TuiTitle, + ], +}) +export default class Dns { + private readonly dialogs = inject(TuiResponsiveDialogService) + private readonly api = inject(ApiService) + private readonly loading = inject(TuiNotificationMiddleService) + private readonly patch = inject>(PatchDB) + private readonly errorService = inject(ErrorService) + + protected readonly records = toSignal(this.patch.watch$('dnsRecords')) + protected readonly manual = computed(() => + (this.records() || []).filter(r => r.source === null), + ) + protected readonly automatic = computed(() => + (this.records() || []).filter(r => r.source !== null), + ) + + private readonly devices: Signal = toSignal( + this.patch + .watch$('wg', 'subnets') + .pipe( + map(subnets => + Object.values(subnets).flatMap(({ clients }) => + Object.entries(clients).map(([ip, { name }]) => ({ ip, name })), + ), + ), + ), + { initialValue: [] }, + ) + + // Records carry the injecting device's IP; show its friendly name when known. + protected sourceName(source: string | null): string { + if (!source) return '—' + return this.devices().find(d => d.ip === source)?.name ?? source + } + + protected onAdd(): void { + this.dialogs + .open(DNS_ADD, { + label: 'Add DNS record', + data: { devices: this.devices }, + }) + .subscribe() + } + + protected onDelete(record: { name: string; type: string }): void { + this.dialogs + .open(TUI_CONFIRM, { label: 'Are you sure?' }) + .pipe(filter(Boolean)) + .subscribe(async () => { + const loader = this.loading.open('').subscribe() + try { + await this.api.removeDnsRecord({ + name: record.name, + type: record.type, + }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + }) + } +} diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts index 34efe466ed..33fe3b9ed8 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/port-forwards/add.ts @@ -15,6 +15,7 @@ import { TuiCheckbox, TuiDialogContext, TuiError, + TuiIcon, TuiInput, TuiNumberFormat, } from '@taiga-ui/core' @@ -24,6 +25,7 @@ import { TuiInputNumber, TuiNotificationMiddleService, TuiSelect, + TuiTooltip, } from '@taiga-ui/kit' import { TuiElasticContainer, TuiForm } from '@taiga-ui/layout' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' @@ -39,23 +41,6 @@ import { MappedDevice, PortForwardsData } from './utils' - - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - - + + + + + @if (show80) {