Resolve overlay members by name
`--dns` serves a zone for the network's members, built from signed state, so a member that is switched off still resolves — its claim outlived the session. IPv4 only, as agreed: the IPv6 overlay address derives from a key that travels in live announcements, so it cannot be answered for an absent member, and answering for some and not others depending on who is online is worse than not answering. On Linux the agent tells systemd-resolved where to ask, over D-Bus. SetLinkDNSEx carries a port, which is why the server needs neither port 53 nor CAP_NET_BIND_SERVICE; the suffix goes in as a routing domain and the link's default route is cleared, so this never becomes the resolver for anything else. The setting is keyed to the overlay interface, which goes with the agent, so it cleans itself up. That step needs permission CAP_NET_ADMIN does not give — resolved asks polkit, and polkit decides by user, not by capability — so it is reported as its own kind of failure with its own remedy. The server runs regardless and status prints the exact dig line: the automatic part is what is missing, not the feature. The zone name is the user's to choose. One shadowing a real public domain is reported and then used, because that is a decision; the warning knows the IANA list, says something different about `.local` where the clash is with mDNS, and stays quiet for names reserved for private use. Two bugs found by running it, both in the supervisor and neither reachable from a unit test, so tests/dns_service.rs drives the real binary. It bound to the allocated overlay address without checking that address was on an interface — with --no-tun it never is — and left the feature silently dead; it now tries the overlay first and falls back to loopback. And it compared the address it got against the address it wanted, which never matched when the preferred one could not be bound, so it tore the listener down every two seconds; it now compares what it tried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+293
-8
@@ -126,6 +126,18 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
@@ -138,12 +150,34 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async_io_stream"
|
||||
version = "0.3.3"
|
||||
@@ -825,6 +859,12 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-assoc"
|
||||
version = "1.4.1"
|
||||
@@ -836,6 +876,27 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1538,7 +1599,7 @@ dependencies = [
|
||||
"papaya",
|
||||
"pin-project",
|
||||
"portable-atomic",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"reqwest",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1568,7 +1629,7 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.4.3",
|
||||
"n0-error",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"serde",
|
||||
"url",
|
||||
"zeroize",
|
||||
@@ -1588,7 +1649,7 @@ dependencies = [
|
||||
"n0-error",
|
||||
"n0-future",
|
||||
"portable-atomic",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rustls",
|
||||
"simple-dns",
|
||||
"strum",
|
||||
@@ -1651,7 +1712,7 @@ dependencies = [
|
||||
"num_enum",
|
||||
"pin-project",
|
||||
"postcard",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
@@ -1883,6 +1944,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
@@ -1907,7 +1977,7 @@ dependencies = [
|
||||
"n0-error",
|
||||
"n0-future",
|
||||
"ndk-context",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"reqwest",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -2222,7 +2292,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"identity-hash",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
@@ -2415,6 +2485,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "papaya"
|
||||
version = "0.2.5"
|
||||
@@ -2485,6 +2565,44 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2664,6 +2782,15 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
@@ -3068,6 +3195,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serdect"
|
||||
version = "0.4.3"
|
||||
@@ -3154,6 +3292,12 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3432,6 +3576,16 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tld"
|
||||
version = "2.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9e03760f0f8fe48c198c550d1c5bf832ada07f337e0c18125c8328885752165"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -3445,6 +3599,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -3509,7 +3664,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"http",
|
||||
"httparse",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"sha1_smol",
|
||||
@@ -3683,7 +3838,7 @@ dependencies = [
|
||||
"iroh",
|
||||
"netwatch",
|
||||
"postcard",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rtnetlink",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
@@ -3692,10 +3847,12 @@ dependencies = [
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tld",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tun",
|
||||
"zbus",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3727,6 +3884,17 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -3786,6 +3954,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -4339,6 +4518,71 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zcheapstr"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
@@ -4438,3 +4682,44 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 3.0.6",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
+11
-1
@@ -13,11 +13,14 @@ categories = ["network-programming"]
|
||||
[features]
|
||||
default = ["cli"]
|
||||
# The `tsunagi` command line binary. Library users can opt out.
|
||||
cli = ["dep:clap", "dep:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device"]
|
||||
cli = ["dep:clap", "dep:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device", "dns-publish"]
|
||||
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
|
||||
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
|
||||
# in-memory device can be used for tests.
|
||||
tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
|
||||
# Telling the operating system where to send its questions. Linux only for
|
||||
# now; the zone and the server work without it.
|
||||
dns-publish = ["dep:zbus"]
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi"
|
||||
@@ -47,6 +50,10 @@ postcard = { version = "1.1", default-features = false, features = ["use-std"] }
|
||||
# zone logic is ours and a full server framework would be a large dependency
|
||||
# for answering A records from memory.
|
||||
simple-dns = "0.12"
|
||||
# The IANA top-level domain list, compiled in: one function, no
|
||||
# dependencies, no network. Used only to warn that a zone name shadows a
|
||||
# real public domain, never to refuse one.
|
||||
tld = "2.40"
|
||||
data-encoding = "2.11"
|
||||
hex = "0.4"
|
||||
thiserror = "2.0"
|
||||
@@ -66,6 +73,9 @@ tun = { version = "0.8", features = ["async"], optional = true }
|
||||
# out of the effective set except during the moments it is used.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = { version = "0.23", optional = true }
|
||||
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
|
||||
# default reactor, because the agent brings its own.
|
||||
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
|
||||
caps = { version = "0.5", optional = true }
|
||||
futures-util = { version = "0.3", default-features = false, optional = true }
|
||||
|
||||
|
||||
@@ -149,6 +149,45 @@ Because the address is allocated at run time rather than derived, it is not
|
||||
known until the agent has started and agreed with its peers. The agent then
|
||||
assigns it to the interface itself.
|
||||
|
||||
## Names
|
||||
|
||||
`--dns` serves a local DNS zone for the network's members, so they can be
|
||||
reached by name instead of by address:
|
||||
|
||||
```bash
|
||||
tsunagi up --network lab --secret "$SECRET" --wireguard --dns
|
||||
dig @10.13.37.69 -p 5354 music.lab
|
||||
```
|
||||
|
||||
Names come from signed state, which is the point: **a member that is
|
||||
switched off still resolves**, because its claim outlived the session. IPv4
|
||||
only — the IPv6 overlay address derives from a key that travels in live
|
||||
announcements, so it cannot be answered for a member that is away, and
|
||||
answering for some members and not others depending on who is online is
|
||||
worse than not answering.
|
||||
|
||||
The zone is the network name unless `--dns-zone` says otherwise. It is
|
||||
yours to choose, so a name that shadows a real public domain is reported and
|
||||
then used: `--dns-zone ru` warns that every public `.ru` name becomes
|
||||
unreachable from this host, and then does it. `.internal` is reserved for
|
||||
exactly this and is never mentioned.
|
||||
|
||||
On Linux the agent tells systemd-resolved to send questions for that suffix
|
||||
here, over D-Bus, scoped to the overlay interface and as a *routing* domain
|
||||
so it never becomes the resolver for anything else. resolved drops the whole
|
||||
setting when the interface goes, and the interface goes with the agent.
|
||||
|
||||
That last step needs permission that `CAP_NET_ADMIN` does not give:
|
||||
systemd-resolved asks polkit, and polkit decides by user. Running as a
|
||||
system service is enough; so is a polkit rule granting this user the
|
||||
`org.freedesktop.resolve1.set-*` actions. **Without it the server still
|
||||
runs** — `tsunagi status` prints where it is listening and the exact `dig`
|
||||
line — so the automatic part is missing, not the feature.
|
||||
|
||||
The server is authoritative for its zone and nothing else. No recursion, no
|
||||
forwarding, no cache: pointing a resolver at it can never make it a route to
|
||||
the outside.
|
||||
|
||||
## Privileges
|
||||
|
||||
On Linux the agent **manages its own overlay interface**. It creates the TUN
|
||||
|
||||
+341
-3
@@ -252,6 +252,21 @@ struct UpArgs {
|
||||
#[arg(long, value_name = "CIDR")]
|
||||
ipv4_range: Option<String>,
|
||||
|
||||
/// Serve a local DNS zone for this network's members.
|
||||
///
|
||||
/// Members resolve as `<hostname>.<zone>`, from signed state, so a
|
||||
/// member that is switched off still resolves. IPv4 only.
|
||||
#[arg(long)]
|
||||
dns: bool,
|
||||
|
||||
/// The zone to answer for. Defaults to the network name.
|
||||
#[arg(long, value_name = "NAME")]
|
||||
dns_zone: Option<String>,
|
||||
|
||||
/// Port for the local DNS server.
|
||||
#[arg(long, default_value_t = 5354)]
|
||||
dns_port: u16,
|
||||
|
||||
/// How often to print a status summary, in seconds. Zero disables it.
|
||||
#[arg(long, default_value_t = 15)]
|
||||
status_interval: u64,
|
||||
@@ -475,6 +490,215 @@ fn configured_networks_section(paths: &StoragePaths) -> report::Section {
|
||||
section
|
||||
}
|
||||
|
||||
/// What the local DNS service is doing, for `status` to report.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct DnsState {
|
||||
zone: String,
|
||||
listening: Option<SocketAddr>,
|
||||
bind_error: Option<String>,
|
||||
publish_error: Option<String>,
|
||||
publish_remedy: Option<String>,
|
||||
zone_warning: Option<String>,
|
||||
names: u32,
|
||||
}
|
||||
|
||||
/// The local DNS service: a server, and an attempt to tell the OS about it.
|
||||
///
|
||||
/// The two are deliberately independent. The server comes up whether or not
|
||||
/// the resolver can be configured, because a resolver the user can point at
|
||||
/// by hand is worth more than nothing, and the reason it was not configured
|
||||
/// is reported rather than swallowed.
|
||||
struct DnsService {
|
||||
state: Arc<std::sync::Mutex<DnsState>>,
|
||||
publisher: Arc<dyn tsunagi::dns::DnsPublisher>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl DnsService {
|
||||
/// Stops answering and undoes what was told to the resolver.
|
||||
async fn shutdown(self) {
|
||||
self.task.abort();
|
||||
if let Err(err) = self.publisher.revert().await {
|
||||
tracing::debug!(%err, "cannot undo the resolver setting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the publisher for this platform.
|
||||
fn dns_publisher() -> Arc<dyn tsunagi::dns::DnsPublisher> {
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
{
|
||||
Arc::new(tsunagi::dns::publish::ResolvedPublisher::new())
|
||||
}
|
||||
#[cfg(not(all(feature = "dns-publish", target_os = "linux")))]
|
||||
{
|
||||
Arc::new(tsunagi::dns::publish::UnsupportedPublisher::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the DNS service for one network and keeps it in step with state.
|
||||
fn spawn_dns(
|
||||
agent: Agent,
|
||||
wireguard: Option<Arc<WireguardPlugin>>,
|
||||
network: NetworkId,
|
||||
zone: tsunagi::dns::ZoneName,
|
||||
port: u16,
|
||||
) -> DnsService {
|
||||
use tsunagi::dns::{DnsServer, SharedZone, Zone, listen_addresses};
|
||||
|
||||
let state = Arc::new(std::sync::Mutex::new(DnsState {
|
||||
zone: zone.as_str().to_string(),
|
||||
zone_warning: zone.collision(),
|
||||
..DnsState::default()
|
||||
}));
|
||||
let publisher = dns_publisher();
|
||||
|
||||
let task = {
|
||||
let state = Arc::clone(&state);
|
||||
let publisher = Arc::clone(&publisher);
|
||||
tokio::spawn(async move {
|
||||
let shared = SharedZone::new(Zone::new(zone.clone(), []));
|
||||
// Held for its `Drop`, which stops the server: the value is
|
||||
// never read, but letting it go is what closes the socket.
|
||||
let mut _server: Option<DnsServer> = None;
|
||||
let mut bound: Option<SocketAddr> = None;
|
||||
// What was tried last time, not what was got. Comparing against
|
||||
// what was got would rebind on every tick whenever the preferred
|
||||
// address is one that cannot be bound, closing the port each
|
||||
// time for no reason.
|
||||
let mut attempted: Vec<SocketAddr> = Vec::new();
|
||||
let mut published: Option<tsunagi::dns::Published> = None;
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(2));
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let Ok(status) = agent.network_status(network).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Names come from signed state, so a member that is away is
|
||||
// in here too.
|
||||
let members = status.members.iter().filter_map(|member| {
|
||||
Some((member.hostname.clone()?, member.overlay_address_v4?))
|
||||
});
|
||||
let fresh = Zone::new(zone.clone(), members);
|
||||
let names = fresh.len() as u32;
|
||||
shared.set(fresh);
|
||||
|
||||
// Listen where the resolver will be told to ask, which is an
|
||||
// address on the overlay interface when there is one.
|
||||
let own = agent.endpoint_id();
|
||||
let overlay = status
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == own)
|
||||
.and_then(|member| member.overlay_address_v4);
|
||||
let interface = wireguard
|
||||
.as_ref()
|
||||
.and_then(|plugin| plugin.overview(network))
|
||||
.map(|view| view.interface)
|
||||
.filter(|name| !name.is_empty());
|
||||
let wanted = listen_addresses(overlay, port);
|
||||
if attempted != wanted {
|
||||
attempted = wanted.clone();
|
||||
// Dropping the old one first releases the port, so the
|
||||
// rebind is not racing itself.
|
||||
_server = None;
|
||||
let mut last: Option<std::io::Error> = None;
|
||||
bound = None;
|
||||
for candidate in &wanted {
|
||||
match DnsServer::bind(*candidate, shared.clone()).await {
|
||||
Ok(fresh) => {
|
||||
tracing::info!(
|
||||
address = %fresh.local_addr(),
|
||||
zone = %zone.as_str(),
|
||||
"dns listening"
|
||||
);
|
||||
bound = Some(fresh.local_addr());
|
||||
_server = Some(fresh);
|
||||
break;
|
||||
}
|
||||
Err(err) => last = Some(err),
|
||||
}
|
||||
}
|
||||
let bind_error = bound.is_none().then(|| {
|
||||
last.map_or_else(
|
||||
|| "no address to listen on".to_string(),
|
||||
|err| err.to_string(),
|
||||
)
|
||||
});
|
||||
update(&state, |state| {
|
||||
state.listening = bound;
|
||||
state.bind_error = bind_error;
|
||||
});
|
||||
// The address moved, so whatever the resolver was told
|
||||
// is now wrong.
|
||||
published = None;
|
||||
}
|
||||
|
||||
let Some(address) = bound else { continue };
|
||||
let Some(interface) = interface else {
|
||||
update(&state, |state| {
|
||||
state.publish_error = Some(
|
||||
"there is no overlay interface to attach the resolver setting to"
|
||||
.to_string(),
|
||||
);
|
||||
state.publish_remedy = None;
|
||||
});
|
||||
update(&state, |state| state.names = names);
|
||||
continue;
|
||||
};
|
||||
|
||||
let want_published = tsunagi::dns::Published {
|
||||
interface,
|
||||
server: address,
|
||||
domains: vec![zone.as_str().to_string()],
|
||||
};
|
||||
if published.as_ref() != Some(&want_published) {
|
||||
match publisher.apply(&want_published).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
zone = %zone.as_str(),
|
||||
interface = %want_published.interface,
|
||||
"the system resolver was told where to ask"
|
||||
);
|
||||
published = Some(want_published);
|
||||
update(&state, |state| {
|
||||
state.publish_error = None;
|
||||
state.publish_remedy = None;
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
// Not fatal, by design: the server keeps
|
||||
// answering and the user is told what is missing.
|
||||
tracing::warn!(%err, "cannot configure the system resolver");
|
||||
let remedy = err.remedy().map(str::to_string);
|
||||
update(&state, |state| {
|
||||
state.publish_error = Some(err.to_string());
|
||||
state.publish_remedy = remedy;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
update(&state, |state| state.names = names);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
DnsService {
|
||||
state,
|
||||
publisher,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(state: &Arc<std::sync::Mutex<DnsState>>, edit: impl FnOnce(&mut DnsState)) {
|
||||
match state.lock() {
|
||||
Ok(mut guard) => edit(&mut guard),
|
||||
Err(poisoned) => edit(&mut poisoned.into_inner()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the local control socket from the running agent.
|
||||
///
|
||||
/// A struct rather than a closure because this end both answers questions and
|
||||
@@ -484,11 +708,18 @@ fn configured_networks_section(paths: &StoragePaths) -> report::Section {
|
||||
struct AgentControl {
|
||||
agent: Agent,
|
||||
plugin: Option<Arc<WireguardPlugin>>,
|
||||
dns: Option<Arc<std::sync::Mutex<DnsState>>>,
|
||||
}
|
||||
|
||||
impl tsunagi::ipc::unix::ReportSource for AgentControl {
|
||||
fn report(&self) -> tsunagi::BoxFuture<'_, tsunagi::ipc::StatusReport> {
|
||||
Box::pin(async move { build_report(&self.agent, self.plugin.as_deref()).await })
|
||||
Box::pin(async move {
|
||||
let dns = self.dns.as_ref().map(|state| match state.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
});
|
||||
build_report(&self.agent, self.plugin.as_deref(), dns).await
|
||||
})
|
||||
}
|
||||
|
||||
fn set_hostname(&self, hostname: String) -> tsunagi::BoxFuture<'_, Result<String, String>> {
|
||||
@@ -757,11 +988,71 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Observed::Stored { .. } => out.push(configured_networks_section(&paths)),
|
||||
}
|
||||
|
||||
if let Observed::Agent(report) = &observed
|
||||
&& let Some(dns) = &report.dns
|
||||
{
|
||||
out.push(dns_section(dns));
|
||||
}
|
||||
|
||||
out.push(host_section());
|
||||
out.push(addresses_section().await);
|
||||
print_report("tsunagi status", &out)
|
||||
}
|
||||
|
||||
/// The local resolver: whether it answers, and whether the system asks it.
|
||||
fn dns_section(dns: &tsunagi::ipc::DnsReport) -> report::Section {
|
||||
use report::{Health, Row, Section};
|
||||
|
||||
let mut section = Section::new("dns");
|
||||
section.push(Row::new(
|
||||
Health::Info,
|
||||
"zone",
|
||||
format!("{} · {} name(s)", dns.zone, dns.names),
|
||||
));
|
||||
if let Some(warning) = &dns.zone_warning {
|
||||
section.push(Row::new(Health::Degraded, "zone name", warning.clone()));
|
||||
}
|
||||
|
||||
match (&dns.listening, &dns.bind_error) {
|
||||
(Some(address), _) => {
|
||||
section.push(Row::new(Health::Good, "listening", address.clone()));
|
||||
}
|
||||
(None, Some(err)) => {
|
||||
section.push(Row::new(Health::Broken, "listening", err.clone()));
|
||||
}
|
||||
(None, None) => {
|
||||
section.push(Row::new(Health::Degraded, "listening", "not yet"));
|
||||
}
|
||||
}
|
||||
|
||||
match &dns.publish_error {
|
||||
None if dns.listening.is_some() => {
|
||||
section.push(Row::new(
|
||||
Health::Good,
|
||||
"system resolver",
|
||||
"asking this server for the zone",
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
Some(err) => {
|
||||
// The server still answers, so this is a degraded overlay and
|
||||
// not a broken one; what is missing is the automatic part.
|
||||
let row = Row::new(Health::Degraded, "system resolver", err.clone());
|
||||
section.push(match (&dns.publish_remedy, &dns.listening) {
|
||||
(Some(remedy), _) => row.with_note(remedy.clone()),
|
||||
(None, Some(address)) => row.with_note(format!(
|
||||
"resolve names yourself with `dig @{} -p {} <name>.{}`",
|
||||
address.rsplit_once(':').map_or("", |(host, _)| host),
|
||||
address.rsplit_once(':').map_or("", |(_, port)| port),
|
||||
dns.zone
|
||||
)),
|
||||
(None, None) => row,
|
||||
});
|
||||
}
|
||||
}
|
||||
section
|
||||
}
|
||||
|
||||
/// One member of a network, from every source that knows something about it.
|
||||
///
|
||||
/// The three sources answer different questions and none of them answers the
|
||||
@@ -1611,13 +1902,42 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
agent.endpoint_id()
|
||||
);
|
||||
}
|
||||
// A local resolver for this network's members. The zone name is the
|
||||
// user's to choose; a name that shadows a public one is reported and
|
||||
// then used, because that is a decision and not a mistake.
|
||||
let dns = if args.dns {
|
||||
let raw = args.dns_zone.clone().unwrap_or_else(|| name.to_string());
|
||||
match tsunagi::dns::ZoneName::new(&raw) {
|
||||
Ok(zone) => {
|
||||
if let Some(warning) = zone.collision() {
|
||||
tracing::warn!("{warning}");
|
||||
}
|
||||
println!(" dns zone {}", zone.as_str());
|
||||
Some(spawn_dns(
|
||||
agent.clone(),
|
||||
wireguard.clone(),
|
||||
network,
|
||||
zone,
|
||||
args.dns_port,
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
agent.shutdown().await;
|
||||
return Err(format!("--dns-zone {raw}: {err}").into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Serve `tsunagi status` for as long as this agent runs. Failing to bind
|
||||
// is not fatal: the agent itself works fine without it.
|
||||
let control = {
|
||||
let agent = agent.clone();
|
||||
let plugin = wireguard.clone();
|
||||
let dns = dns.as_ref().map(|service| Arc::clone(&service.state));
|
||||
let source: Arc<dyn tsunagi::ipc::unix::ReportSource> =
|
||||
Arc::new(AgentControl { agent, plugin });
|
||||
Arc::new(AgentControl { agent, plugin, dns });
|
||||
let path = control_socket(&paths, args.control_socket.as_ref());
|
||||
match tsunagi::ipc::unix::ControlSocket::bind(path, source).await {
|
||||
Ok(socket) => {
|
||||
@@ -1664,6 +1984,11 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(control) = control {
|
||||
control.shutdown().await;
|
||||
}
|
||||
// Before the agent, so the resolver stops being pointed at a server
|
||||
// that is about to stop answering.
|
||||
if let Some(dns) = dns {
|
||||
dns.shutdown().await;
|
||||
}
|
||||
agent.shutdown().await;
|
||||
println!("stopped.");
|
||||
Ok(())
|
||||
@@ -1675,11 +2000,23 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn build_report(
|
||||
agent: &Agent,
|
||||
wireguard: Option<&WireguardPlugin>,
|
||||
dns: Option<DnsState>,
|
||||
) -> tsunagi::ipc::StatusReport {
|
||||
use tsunagi::ipc::{
|
||||
MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport,
|
||||
DnsReport, MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport,
|
||||
StatusReport,
|
||||
};
|
||||
|
||||
let dns = dns.map(|dns| DnsReport {
|
||||
zone: dns.zone,
|
||||
listening: dns.listening.map(|address| address.to_string()),
|
||||
bind_error: dns.bind_error,
|
||||
publish_error: dns.publish_error,
|
||||
publish_remedy: dns.publish_remedy,
|
||||
zone_warning: dns.zone_warning,
|
||||
names: dns.names,
|
||||
});
|
||||
|
||||
let Ok(status) = agent.status().await else {
|
||||
return StatusReport::default();
|
||||
};
|
||||
@@ -1789,6 +2126,7 @@ async fn build_report(
|
||||
.collect(),
|
||||
cache_healthy: status.cache_healthy,
|
||||
networks,
|
||||
dns,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,59 @@
|
||||
//! * `publish` tells the operating system where to send its questions,
|
||||
//! which is the only part that differs between platforms.
|
||||
|
||||
pub mod publish;
|
||||
pub mod server;
|
||||
pub mod zone;
|
||||
|
||||
pub use publish::{DnsPublisher, PublishError, Published};
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
/// Where the server should try to listen, best first.
|
||||
///
|
||||
/// The overlay address comes first: that is what the system resolver is
|
||||
/// pointed at, and it is reachable only over the overlay interface, so a
|
||||
/// question for these names cannot arrive from anywhere else.
|
||||
///
|
||||
/// Loopback second, and it is not merely a fallback for having no overlay
|
||||
/// address. An address this agent has been *allocated* is not necessarily an
|
||||
/// address that is *on an interface* — with no privileges, with `--no-tun`,
|
||||
/// or in the moment before the interface is configured, it is not — and
|
||||
/// binding to one that is not there fails. Trying loopback afterwards is
|
||||
/// what keeps the promise that the port comes up regardless.
|
||||
pub fn listen_addresses(overlay: Option<Ipv4Addr>, port: u16) -> Vec<SocketAddr> {
|
||||
let mut candidates = Vec::with_capacity(2);
|
||||
if let Some(overlay) = overlay {
|
||||
candidates.push(SocketAddr::from((overlay, port)));
|
||||
}
|
||||
candidates.push(SocketAddr::from((Ipv4Addr::LOCALHOST, port)));
|
||||
candidates
|
||||
}
|
||||
|
||||
pub use server::{DnsServer, SharedZone};
|
||||
pub use zone::{Answer, Query, Zone, ZoneError, ZoneName};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_overlay_is_preferred_and_loopback_is_always_offered() {
|
||||
// Loopback is in the list even when there is an overlay address,
|
||||
// because being allocated one is not the same as it being on an
|
||||
// interface — and binding to one that is not there fails.
|
||||
assert_eq!(
|
||||
listen_addresses(Some(Ipv4Addr::new(10, 13, 37, 69)), 5354),
|
||||
vec![
|
||||
"10.13.37.69:5354".parse::<SocketAddr>().unwrap(),
|
||||
"127.0.0.1:5354".parse::<SocketAddr>().unwrap(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listen_addresses(None, 5354),
|
||||
vec!["127.0.0.1:5354".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! A pretend resolver, so the wiring is tested without touching this host's.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Records what it was asked to do, and can be told to refuse.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockPublisher {
|
||||
applied: Arc<Mutex<Option<Published>>>,
|
||||
failure: Option<PublishError>,
|
||||
}
|
||||
|
||||
impl MockPublisher {
|
||||
/// A publisher that accepts everything.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A publisher that refuses everything, with this reason.
|
||||
pub fn failing(error: PublishError) -> Self {
|
||||
Self {
|
||||
applied: Arc::new(Mutex::new(None)),
|
||||
failure: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is currently applied, if anything.
|
||||
pub fn applied(&self) -> Option<Published> {
|
||||
match self.applied.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for MockPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
if let Some(failure) = &self.failure {
|
||||
return Err(failure.clone());
|
||||
}
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(published.clone()),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(published.clone()),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = None,
|
||||
Err(poisoned) => *poisoned.into_inner() = None,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn published() -> Published {
|
||||
Published {
|
||||
interface: "tsundemo".into(),
|
||||
server: "10.13.37.69:5354".parse().unwrap(),
|
||||
domains: vec!["lab".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applying_and_reverting_are_both_recorded() {
|
||||
let publisher = MockPublisher::new();
|
||||
assert!(publisher.applied().is_none());
|
||||
|
||||
publisher.apply(&published()).await.unwrap();
|
||||
assert_eq!(publisher.applied(), Some(published()));
|
||||
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(publisher.applied().is_none());
|
||||
// Reverting twice is not an error: shutdown must not fail here.
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refusal_leaves_nothing_applied() {
|
||||
let publisher = MockPublisher::failing(PublishError::Refused("polkit said no".into()));
|
||||
assert!(publisher.apply(&published()).await.is_err());
|
||||
assert!(publisher.applied().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Telling the operating system where to send its questions.
|
||||
//!
|
||||
//! The server answers whether or not this works. That is the whole reason it
|
||||
//! is a separate thing: if the resolver cannot be configured — no
|
||||
//! systemd-resolved, an unwilling polkit, a platform nobody has written this
|
||||
//! for — the port is still up and the user can point something at it by
|
||||
//! hand. A failure here is a degraded overlay, not a broken one.
|
||||
//!
|
||||
//! Only the *mechanics* differ between systems. What has to be arranged is
|
||||
//! the same everywhere: send questions for these suffixes to this address,
|
||||
//! through this interface, and do not make it the resolver for anything
|
||||
//! else. That is [`Published`]; the rest is behind [`DnsPublisher`].
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
mod mock;
|
||||
pub use mock::MockPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
mod resolved;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
pub use resolved::ResolvedPublisher;
|
||||
|
||||
mod unsupported;
|
||||
pub use unsupported::UnsupportedPublisher;
|
||||
|
||||
/// What the operating system is asked to do.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Published {
|
||||
/// The interface questions should be sent through.
|
||||
///
|
||||
/// The server listens on an overlay address, which is only reachable
|
||||
/// over the overlay interface, so the two travel together.
|
||||
pub interface: String,
|
||||
/// Where the server is listening.
|
||||
pub server: SocketAddr,
|
||||
/// The suffixes that belong to this server.
|
||||
///
|
||||
/// Routing suffixes only: they say *which questions* come here, never
|
||||
/// that this is the resolver for anything else.
|
||||
pub domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why the resolver could not be told.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PublishError {
|
||||
/// There is nothing here that can be configured this way.
|
||||
#[error("{0}")]
|
||||
Unavailable(String),
|
||||
/// Something is there, and it declined.
|
||||
///
|
||||
/// Separate from a plain failure because the answer is different: this
|
||||
/// one is about who the agent is running as, not about whether the thing
|
||||
/// works.
|
||||
#[error("{0}")]
|
||||
Refused(String),
|
||||
/// It was there, it accepted the request, and it went wrong anyway.
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl PublishError {
|
||||
/// What the user can do about it, when there is something.
|
||||
pub fn remedy(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
PublishError::Refused(_) => Some(
|
||||
"systemd-resolved asks polkit before accepting this, and polkit \
|
||||
decides by user. Run the agent as a system service, or install a \
|
||||
polkit rule allowing this user the `org.freedesktop.resolve1.set-*` \
|
||||
actions.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arranges for the operating system to ask this server.
|
||||
pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Applies the setting, replacing whatever this publisher set before.
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>>;
|
||||
|
||||
/// Undoes it.
|
||||
///
|
||||
/// Reverting something that was never applied succeeds: this runs on the
|
||||
/// shutdown path, where the setting being gone is the point.
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>>;
|
||||
}
|
||||
|
||||
/// The kernel's index for an interface.
|
||||
///
|
||||
/// From sysfs, which needs no privileges and no netlink round trip.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn interface_index(name: &str) -> Option<u32> {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{name}/ifindex"))
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn interface_index(_name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_a_refusal_suggests_what_to_do_about_it() {
|
||||
// The other two are conditions of the host, not of the user.
|
||||
assert!(PublishError::Refused("no".into()).remedy().is_some());
|
||||
assert!(PublishError::Unavailable("none".into()).remedy().is_none());
|
||||
assert!(PublishError::Failed("bang".into()).remedy().is_none());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_interface_index_is_read_from_the_running_kernel() {
|
||||
assert_eq!(interface_index("lo"), Some(1));
|
||||
assert_eq!(interface_index("tsunagi-no-such-interface"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Telling systemd-resolved to send some questions here.
|
||||
//!
|
||||
//! Three calls on `org.freedesktop.resolve1.Manager`, all scoped to the
|
||||
//! overlay interface:
|
||||
//!
|
||||
//! * `SetLinkDNSEx` — where to send them. The `Ex` form carries a **port**,
|
||||
//! which is why this server does not have to sit on 53 and the agent needs
|
||||
//! no `CAP_NET_BIND_SERVICE`. Systems before systemd 247 have only
|
||||
//! `SetLinkDNS`, which has no port; there the fallback only works if the
|
||||
//! server did get port 53, and it says so rather than appearing to work.
|
||||
//! * `SetLinkDomains` with `routing_only` set — a *routing* suffix, the
|
||||
//! `~domain` form. It says which questions come here and claims nothing
|
||||
//! else.
|
||||
//! * `SetLinkDefaultRoute(false)` — so this never becomes the resolver for
|
||||
//! anything outside those suffixes. Without it resolved may fall back to
|
||||
//! this link for ordinary names, and this server refuses those.
|
||||
//!
|
||||
//! # It cleans up by itself
|
||||
//!
|
||||
//! resolved keys all of this to the interface, and drops it when the
|
||||
//! interface goes. The overlay interface belongs to a file descriptor the
|
||||
//! agent holds, so it goes when the agent does — however the agent goes. The
|
||||
//! explicit `RevertLink` on shutdown only makes that immediate.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! resolved asks polkit, and polkit decides by user id, not by capability.
|
||||
//! So `CAP_NET_ADMIN` does not help here: an ordinary user is prompted or
|
||||
//! refused, while a system service running as root is not. That refusal is
|
||||
//! reported as its own kind of error, because the answer to it is different
|
||||
//! from the answer to "resolved is not installed".
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published, interface_index};
|
||||
|
||||
/// `AF_INET`, as resolved wants it.
|
||||
const AF_INET: i32 = 2;
|
||||
/// `AF_INET6`.
|
||||
const AF_INET6: i32 = 10;
|
||||
|
||||
#[zbus::proxy(
|
||||
interface = "org.freedesktop.resolve1.Manager",
|
||||
default_service = "org.freedesktop.resolve1",
|
||||
default_path = "/org/freedesktop/resolve1"
|
||||
)]
|
||||
trait Resolved {
|
||||
/// Servers for a link, with a port and a name. systemd 247 and later.
|
||||
#[zbus(name = "SetLinkDNSEx")]
|
||||
fn set_link_dns_ex(
|
||||
&self,
|
||||
ifindex: i32,
|
||||
addresses: &[(i32, Vec<u8>, u16, String)],
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
/// Servers for a link, without a port. Always port 53.
|
||||
#[zbus(name = "SetLinkDNS")]
|
||||
fn set_link_dns(&self, ifindex: i32, addresses: &[(i32, Vec<u8>)]) -> zbus::Result<()>;
|
||||
|
||||
/// Suffixes for a link. The flag makes one routing-only.
|
||||
#[zbus(name = "SetLinkDomains")]
|
||||
fn set_link_domains(&self, ifindex: i32, domains: &[(String, bool)]) -> zbus::Result<()>;
|
||||
|
||||
/// Whether this link may answer for names outside its suffixes.
|
||||
#[zbus(name = "SetLinkDefaultRoute")]
|
||||
fn set_link_default_route(&self, ifindex: i32, enable: bool) -> zbus::Result<()>;
|
||||
|
||||
/// Forgets everything set for a link.
|
||||
#[zbus(name = "RevertLink")]
|
||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
/// Configures systemd-resolved over D-Bus.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResolvedPublisher {
|
||||
/// The link last configured, so shutdown knows what to undo.
|
||||
applied: std::sync::Mutex<Option<u32>>,
|
||||
}
|
||||
|
||||
impl ResolvedPublisher {
|
||||
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn proxy() -> Result<ResolvedProxy<'static>, PublishError> {
|
||||
let connection = zbus::Connection::system().await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("no system D-Bus to talk to resolved on: {err}"))
|
||||
})?;
|
||||
ResolvedProxy::new(&connection).await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("systemd-resolved is not answering: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a D-Bus failure into the kind of failure it actually is.
|
||||
fn classify(err: zbus::Error, what: &str) -> PublishError {
|
||||
let name = match &err {
|
||||
zbus::Error::MethodError(name, _, _) => name.as_str().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
match name.as_str() {
|
||||
"org.freedesktop.DBus.Error.InteractiveAuthorizationRequired"
|
||||
| "org.freedesktop.DBus.Error.AccessDenied" => {
|
||||
PublishError::Refused(format!("systemd-resolved refused {what}: {err}"))
|
||||
}
|
||||
"org.freedesktop.DBus.Error.UnknownMethod"
|
||||
| "org.freedesktop.DBus.Error.ServiceUnknown" => {
|
||||
PublishError::Unavailable(format!("systemd-resolved cannot do {what}: {err}"))
|
||||
}
|
||||
_ => PublishError::Failed(format!("systemd-resolved failed {what}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address in the shape resolved wants: a family and raw octets.
|
||||
fn wire_address(address: IpAddr) -> (i32, Vec<u8>) {
|
||||
match address {
|
||||
IpAddr::V4(address) => (AF_INET, address.octets().to_vec()),
|
||||
IpAddr::V6(address) => (AF_INET6, address.octets().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for ResolvedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"systemd-resolved"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let ifindex = interface_index(&published.interface).ok_or_else(|| {
|
||||
PublishError::Unavailable(format!(
|
||||
"interface `{}` is not on this host, so there is no link to configure",
|
||||
published.interface
|
||||
))
|
||||
})?;
|
||||
let proxy = Self::proxy().await?;
|
||||
let index = ifindex as i32;
|
||||
let (family, octets) = wire_address(published.server.ip());
|
||||
let port = published.server.port();
|
||||
|
||||
match proxy
|
||||
.set_link_dns_ex(index, &[(family, octets.clone(), port, String::new())])
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
let classified = classify(err, "setting the link's DNS server");
|
||||
// Older systemd has no `Ex` form, and the plain one is
|
||||
// always port 53. Falling back to it when the server is
|
||||
// somewhere else would point resolved at nothing.
|
||||
if !matches!(classified, PublishError::Unavailable(_)) {
|
||||
return Err(classified);
|
||||
}
|
||||
if port != 53 {
|
||||
return Err(PublishError::Unavailable(format!(
|
||||
"this systemd-resolved cannot be given a port, and the server is on \
|
||||
{port}. Run the server on port 53, or point your resolver at \
|
||||
{} yourself.",
|
||||
published.server
|
||||
)));
|
||||
}
|
||||
proxy
|
||||
.set_link_dns(index, &[(family, octets)])
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's DNS server"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let domains: Vec<(String, bool)> = published
|
||||
.domains
|
||||
.iter()
|
||||
.map(|domain| (domain.clone(), true))
|
||||
.collect();
|
||||
proxy
|
||||
.set_link_domains(index, &domains)
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's search domains"))?;
|
||||
|
||||
// Last, and deliberately: until this is off, resolved may send
|
||||
// ordinary names here, and this server refuses them.
|
||||
proxy
|
||||
.set_link_default_route(index, false)
|
||||
.await
|
||||
.map_err(|err| classify(err, "clearing the link's default route"))?;
|
||||
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(ifindex),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(ifindex),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let applied = match self.applied.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(poisoned) => poisoned.into_inner().take(),
|
||||
};
|
||||
let Some(ifindex) = applied else {
|
||||
return Ok(());
|
||||
};
|
||||
let proxy = Self::proxy().await?;
|
||||
match proxy.revert_link(ifindex as i32).await {
|
||||
Ok(()) => Ok(()),
|
||||
// The link going away takes the setting with it, so this is
|
||||
// the outcome asked for rather than a failure.
|
||||
Err(zbus::Error::MethodError(name, _, _))
|
||||
if name.as_str().contains("NoSuchLink") =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(classify(err, "reverting the link")),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
#[test]
|
||||
fn an_address_is_encoded_the_way_resolved_expects() {
|
||||
assert_eq!(
|
||||
wire_address(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69))),
|
||||
(AF_INET, vec![10, 13, 37, 69])
|
||||
);
|
||||
let (family, octets) = wire_address("fd55::1".parse().unwrap());
|
||||
assert_eq!(family, AF_INET6);
|
||||
assert_eq!(octets.len(), 16);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_that_is_not_here_is_unavailable_not_a_failure() {
|
||||
// Nothing is contacted: there is no link to configure, and saying so
|
||||
// is the honest answer without bothering the bus.
|
||||
let publisher = ResolvedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsunagi-no-such-interface".into(),
|
||||
server: SocketAddr::from(([10, 13, 37, 69], 5354)),
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
|
||||
// The shutdown path must not fail because there was nothing to undo.
|
||||
ResolvedPublisher::new().revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The publisher for systems nobody has written one for.
|
||||
//!
|
||||
//! macOS needs `SystemConfiguration` and Windows the IP Helper API, and
|
||||
//! neither is written. Saying so is more use than appearing to work: the
|
||||
//! server is already answering, so all that is missing is the last step, and
|
||||
//! the user can take it by hand once they know that is what is needed.
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Refuses to configure anything, with an explanation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsupportedPublisher {
|
||||
platform: &'static str,
|
||||
}
|
||||
|
||||
impl Default for UnsupportedPublisher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UnsupportedPublisher {
|
||||
/// A publisher naming the platform it stands in for.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
platform: std::env::consts::OS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for UnsupportedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"unsupported"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, _published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
Err(PublishError::Unavailable(format!(
|
||||
"configuring the system resolver is not implemented on {} yet",
|
||||
self.platform
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
// Nothing was set, so there is nothing to undo and no reason to fail
|
||||
// a shutdown over it.
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_names_the_platform_and_still_reverts_quietly() {
|
||||
let publisher = UnsupportedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsundemo".into(),
|
||||
server: "10.0.0.1:5354".parse().unwrap(),
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)));
|
||||
assert!(err.to_string().contains(std::env::consts::OS));
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,49 @@ impl ZoneName {
|
||||
self.0.rsplit('.').next().unwrap_or(&self.0)
|
||||
}
|
||||
|
||||
/// What is worrying about this zone name, if anything.
|
||||
///
|
||||
/// A warning and never a refusal: the name is the user's to choose, and
|
||||
/// a private zone that shadows a public one is a decision, not a
|
||||
/// mistake. Saying nothing would let it be an accident.
|
||||
pub fn collision(&self) -> Option<String> {
|
||||
let top = self.top_label();
|
||||
// Reserved for exactly this use and never delegated, so nothing to
|
||||
// say. See RFC 6761 and RFC 8375.
|
||||
const RESERVED: &[&str] = &[
|
||||
"internal",
|
||||
"home",
|
||||
"test",
|
||||
"example",
|
||||
"invalid",
|
||||
"localhost",
|
||||
];
|
||||
if RESERVED.contains(&top) {
|
||||
return None;
|
||||
}
|
||||
if top == "local" {
|
||||
return Some(
|
||||
"`.local` belongs to multicast DNS: on a host running Avahi or \
|
||||
systemd-resolved's mDNS, names under it are resolved by that and \
|
||||
not by this agent"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if tld::exist_case_insensitive(top) {
|
||||
return Some(format!(
|
||||
"`.{top}` is a real top-level domain, so every public name under it \
|
||||
becomes unreachable from this host while the overlay is up"
|
||||
));
|
||||
}
|
||||
// Not delegated today is not a promise about tomorrow.
|
||||
(!self.0.contains('.')).then(|| {
|
||||
format!(
|
||||
"`.{top}` is not a delegated top-level domain today, but it could \
|
||||
become one; `.internal` is reserved for private use and never will"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `name` is this zone or sits under it.
|
||||
///
|
||||
/// Compared label-wise, so `evilzone` does not count as being under
|
||||
@@ -374,6 +417,32 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_name_that_shadows_a_public_one_is_flagged_but_allowed() {
|
||||
// Flagged, never refused: shadowing is the user's decision to make.
|
||||
let ru = ZoneName::new("ru").unwrap();
|
||||
let warning = ru.collision().expect("a real TLD is worth mentioning");
|
||||
assert!(warning.contains("real top-level domain"), "{warning}");
|
||||
|
||||
assert!(ZoneName::new("com").unwrap().collision().is_some());
|
||||
assert!(ZoneName::new("lab.com").unwrap().collision().is_some());
|
||||
|
||||
// Reserved for private use, so nothing to say.
|
||||
for quiet in ["internal", "lab.internal", "home", "test", "invalid"] {
|
||||
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
|
||||
}
|
||||
|
||||
// `.local` is not a delegated TLD, but it is not free either.
|
||||
let local = ZoneName::new("local").unwrap().collision().unwrap();
|
||||
assert!(local.contains("multicast DNS"), "{local}");
|
||||
|
||||
// An undelegated single label is a maybe, not a yes.
|
||||
let lab = ZoneName::new("lab").unwrap().collision().unwrap();
|
||||
assert!(lab.contains("could"), "{lab}");
|
||||
// A multi-label name under something undelegated is not worth a word.
|
||||
assert_eq!(ZoneName::new("a.lab").unwrap().collision(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neighbouring_name_is_not_inside_the_zone() {
|
||||
// `evillab` ends with `lab`, and a suffix comparison that forgot the
|
||||
|
||||
@@ -99,6 +99,30 @@ pub struct StatusReport {
|
||||
pub cache_healthy: bool,
|
||||
/// One entry per configured network.
|
||||
pub networks: Vec<NetworkReport>,
|
||||
/// The local DNS service, when one was asked for.
|
||||
pub dns: Option<DnsReport>,
|
||||
}
|
||||
|
||||
/// The local DNS service.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DnsReport {
|
||||
/// The zone it answers for.
|
||||
pub zone: String,
|
||||
/// Where it is listening, if it managed to bind.
|
||||
pub listening: Option<String>,
|
||||
/// Why it could not bind, if it did not.
|
||||
pub bind_error: Option<String>,
|
||||
/// Why the system resolver was not told, if it was not.
|
||||
///
|
||||
/// `None` means it was told. The server answers either way, so this is a
|
||||
/// degraded overlay rather than a broken one.
|
||||
pub publish_error: Option<String>,
|
||||
/// What to do about that, when there is something.
|
||||
pub publish_remedy: Option<String>,
|
||||
/// Something worth saying about the zone name itself.
|
||||
pub zone_warning: Option<String>,
|
||||
/// How many names it answers for.
|
||||
pub names: u32,
|
||||
}
|
||||
|
||||
/// One network.
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@ pub async fn set_hostname(path: impl AsRef<Path>, hostname: &str) -> Result<Stri
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 3]);
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 4]);
|
||||
|
||||
async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> Result<()> {
|
||||
let encoded = postcard::to_stdvec(value)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The DNS service as the binary actually runs it.
|
||||
//!
|
||||
//! The zone and the server have their own tests. What this covers is the
|
||||
//! wiring between them and the agent, which is where the interesting
|
||||
//! mistakes live: choosing an address to listen on, and deciding when to
|
||||
//! rebuild the listener. Both were wrong once, and neither was reachable
|
||||
//! from a unit test, so this runs the real binary.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use simple_dns::{Name, Packet, QCLASS, RCODE, TYPE, rdata::RData};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// A port per test, high enough to need no privileges and fixed so the query
|
||||
// knows where to look. Distinct because these tests run in parallel and each
|
||||
// starts its own agent.
|
||||
const PORT_BINDS: u16 = 15361;
|
||||
const PORT_REBIND: u16 = 15362;
|
||||
const PORT_REFUSE: u16 = 15363;
|
||||
|
||||
/// Asks, and returns the raw reply. Raw because a parsed packet borrows
|
||||
/// from the bytes it came out of.
|
||||
fn query(server: SocketAddr, name: &str, qtype: TYPE) -> Option<Vec<u8>> {
|
||||
let mut packet = Packet::new_query(0x2468);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new(name).unwrap(),
|
||||
qtype.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::IN),
|
||||
false,
|
||||
));
|
||||
let bytes = packet.build_bytes_vec().unwrap();
|
||||
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").ok()?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(500)))
|
||||
.ok()?;
|
||||
socket.send_to(&bytes, server).ok()?;
|
||||
let mut buffer = vec![0u8; 4096];
|
||||
let read = socket.recv(&mut buffer).ok()?;
|
||||
buffer.truncate(read);
|
||||
Packet::parse(&buffer).ok()?;
|
||||
Some(buffer)
|
||||
}
|
||||
|
||||
/// Whether a reply carries at least one answer record.
|
||||
fn has_answer(reply: &[u8]) -> bool {
|
||||
Packet::parse(reply).is_ok_and(|packet| !packet.answers.is_empty())
|
||||
}
|
||||
|
||||
/// Blocks until the server answers, or gives up.
|
||||
fn wait_for_answer(server: SocketAddr, name: &str) -> Vec<u8> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(reply) = query(server, name, TYPE::A)
|
||||
&& has_answer(&reply)
|
||||
{
|
||||
return reply;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the dns server never answered");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent, running as a real process with its DNS service on.
|
||||
struct Running {
|
||||
child: std::process::Child,
|
||||
_dir: TempDir,
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn start(zone: &str, port: u16) -> Running {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args([
|
||||
"up",
|
||||
"--network",
|
||||
"dnswiring",
|
||||
"--secret",
|
||||
"a-secret-for-the-dns-test",
|
||||
])
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
// No real interface and no internet: this is about the wiring.
|
||||
.args(["--transport", "local", "--no-tun", "--wireguard", "--dns"])
|
||||
.args(["--dns-zone", zone])
|
||||
.args(["--dns-port", &port.to_string()])
|
||||
.args(["--log", "error", "--status-interval", "0"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("the agent binary starts");
|
||||
Running { child, _dir: dir }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_resolver_comes_up_even_with_no_overlay_interface_to_put_it_on() {
|
||||
// The promise is that the port is served whatever else fails. With
|
||||
// `--no-tun` the allocated overlay address is on no interface, so
|
||||
// binding to it cannot work and loopback is the answer — getting this
|
||||
// wrong left the feature silently dead.
|
||||
let _agent = start("lab.internal", PORT_BINDS);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_BINDS}").parse().unwrap();
|
||||
|
||||
let reply = wait_for_answer(server, &format!("{}.lab.internal", hostname()));
|
||||
let answer = Packet::parse(&reply).unwrap();
|
||||
assert_eq!(answer.rcode(), RCODE::NoError);
|
||||
match &answer.answers[0].rdata {
|
||||
RData::A(_) => {}
|
||||
other => panic!("expected an A record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listener_is_not_rebuilt_on_every_pass() {
|
||||
// The supervisor compares what it tried last time, not what it got. The
|
||||
// other way round it rebound on every tick, because the preferred
|
||||
// address is one that never binds here — and the port was shut for a
|
||||
// moment each time.
|
||||
let _agent = start("rebind.internal", PORT_REBIND);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_REBIND}").parse().unwrap();
|
||||
let name = format!("{}.rebind.internal", hostname());
|
||||
wait_for_answer(server, &name);
|
||||
|
||||
// Long enough to cross several of the supervisor's passes.
|
||||
for round in 0..6 {
|
||||
std::thread::sleep(Duration::from_millis(900));
|
||||
assert!(
|
||||
query(server, &name, TYPE::A).is_some_and(|reply| has_answer(&reply)),
|
||||
"the server stopped answering on round {round}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_outside_the_zone_is_refused_and_never_forwarded() {
|
||||
let _agent = start("refuse.internal", PORT_REFUSE);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_REFUSE}").parse().unwrap();
|
||||
wait_for_answer(server, &format!("{}.refuse.internal", hostname()));
|
||||
|
||||
let reply = query(server, "example.com", TYPE::A).expect("an answer");
|
||||
let answer = Packet::parse(&reply).unwrap();
|
||||
assert_eq!(answer.rcode(), RCODE::Refused);
|
||||
assert!(answer.answers.is_empty());
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
@@ -81,6 +81,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::u
|
||||
.collect(),
|
||||
cache_healthy: status.cache_healthy,
|
||||
networks,
|
||||
dns: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user