max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
9 files changed,
+967 insertions,
-1 deletion
| @@ -3853,6 +3853,11 @@ | |||
| 3853 | 3853 | dns=/etc/systemd/resolved.conf.d/10-alloy-dns.conf; \ | |
| 3854 | 3854 | grep -q '^DNSOverTLS=opportunistic' "$dns" \ | |
| 3855 | 3855 | || { echo "$dns does not set DNSOverTLS, or sets 'yes', which breaks every name off the tailnet on an uplink with no DoT (measured, see the file)" >&2; exit 1; }; \ | |
| 3856 | + | dnshelper=/usr/bin/alloy-dns; \ | |
| 3857 | + | [ -x "$dnshelper" ] \ | |
| 3858 | + | || { echo "$dnshelper is missing or not executable; alloy settings' resolver row runs it through run0 and would fail with nothing to say" >&2; exit 1; }; \ | |
| 3859 | + | sh -n "$dnshelper" \ | |
| 3860 | + | || { echo "$dnshelper is not valid shell" >&2; exit 1; }; \ | |
| 3856 | 3861 | echo "hardening drop-ins: sysctl, modprobe, kargs, sway, flatpak, resolved — all present and non-inert" | |
| 3857 | 3862 | ||
| 3858 | 3863 | # ===================================================================== |
| @@ -292,6 +292,8 @@ | |||
| 292 | 292 | - **System**, first, because it is the one a user goes looking for. General settings held as live state: time (zone, NTP), hostname, locale and keymap, theme, and whether gopass has an age identity. Time, hostname and locale are built, fronting `timedatectl`, `hostnamectl` and `localectl`; theme is the one left. Each writing row runs exactly one command, and every one of those is in the five actions the shipped polkit rule grants, so none of them prompts and none can reach an action deliberately left prompting. The grant is for an active *local* session in `wheel`, which an ssh login is not, so those same rows do prompt over ssh — correctly, since "the person sitting at the machine" is the whole of the argument for granting them. The console answers that with polkit's own text agent rather than by widening the grant: a setter that comes back saying interactive authentication is required is run again with `pkttyagent` alive beside it, which means the screen tears down, polkit asks in the terminal's own colors, and the console rebuilds. It is the honest fallback and looks like one. A row whose front did not answer, or whose vocabulary came back empty, is shown and not settable and says why: hiding it would answer "where do I set this" with silence, and offering it would promise a command that is not there. A vocabulary of one closes the row on the same grounds, since the single entry is whatever is already in force and picking it changes nothing. The locale row is the case that reaches users: the image carries no glibc langpacks, so `localectl` has one locale to list, and the row names it and says that adding a langpack adds a choice. The gate is the count rather than a langpack probe, so the row reopens by itself once one is layered in. The secrets row applies the same idiom to something absent rather than to a front that stayed quiet: Alloy ships gopass and provisions no age identity for it, so the row names the directory gopass will look in and says the store will not decrypt until a key is placed there. It is shown and closed in both states, because writing an identity from a settings form is exactly what the provisioning decision ruled out, and it reports whether a file is there without ever reading it. Its rows commit as they are edited rather than at Ctrl-S, and the form reads that from the bind rather than from which tab it is on. Each is a handful of rows over one front with machine-readable output, which is the bar `net` (`nmcli`) and `audio` (`pactl -f json`) were already held to. | |
| 293 | 293 | - **Applications**, the adopted stack's config files, grouped behind the app each one configures rather than presented as file paths. The user picks **rio**, not `~/.config/rio/config.toml`; left pane lists the apps a schema ships for, right pane is the form for the selected one, reusing `audio`'s two-pane routing layout. An app with no *form* (sway, whose i3-style syntax is neither TOML nor KDL) still appears in the list and opens the text-edit fallback, because someone looking for "where do I configure sway" should find an answer rather than an absence. It reaches the list through a header-only schema declaring its syntax, so there is one catalog and one search path rather than a second registry for the apps the first one cannot describe. | |
| 294 | 294 | ||
| 295 | + | The System tab's last two rows are DNS, and they are a pair rather than one setting. **Who answers** is a picker over the network's own resolver and a handful of public ones; Alloy recommends none of them, the list is alphabetical by operator so the order ranks nobody, and a row carries the operator and only what this machine measured about it, because a privacy policy is a claim rather than a fact and an operating system repeating one is endorsing it. The default is the network's own, which is the only option that names no company. **Whether queries travel encrypted** is not a setting and sits beside it read-only, because `resolvectl status` reports the setting and a machine can be configured to encrypt while sending everything in the clear: measured on fw12, it said `DNSOverTLS=opportunistic` while port 853 was closed on both uplinks. The row answers what is happening rather than what was asked for. Picking a resolver is the one row here whose command is Alloy's own rather than a systemd tool, since there is no `resolvectl` verb that persists across a reboot; `alloy-dns` writes the drop-in under `run0` and is handed the addresses on its command line rather than keeping a second copy of them in shell. | |
| 296 | + | ||
| 295 | 297 | Display and power/idle are deliberately not here. `alloy display` is a verb of its own and `alloy power` is another; idle behaviour belongs with power rather than with display. Folding any of them into settings would duplicate a screen rather than unify one. | |
| 296 | 298 | ||
| 297 | 299 | `alloy config <path>` stays as the direct-open escape hatch: one file's form, no tab chrome, which is what a script or a `helix` sidecar wants. Same view, entered with a path instead of a tab, and same code under the chrome — a second render path for one file is how the two would start to disagree. The schema is found by the path it declares as its target rather than by the file's name, since two tools can both keep a `config.toml`, and a file no schema targets is refused by name instead of opening as an empty form. |
| @@ -14,6 +14,7 @@ | |||
| 14 | 14 | mod credits; | |
| 15 | 15 | mod disk; | |
| 16 | 16 | mod display; | |
| 17 | + | mod dns; | |
| 17 | 18 | mod editor; | |
| 18 | 19 | mod image; | |
| 19 | 20 | mod install; |
| @@ -93,6 +93,7 @@ | |||
| 93 | 93 | ||
| 94 | 94 | use crate::bind::Bind; | |
| 95 | 95 | use crate::cli::{CommandLog, Effect, Invocation}; | |
| 96 | + | use crate::dns; | |
| 96 | 97 | use crate::install::validate_hostname; | |
| 97 | 98 | use crate::schema::{EnumValue, Field, FieldKind, Section, check_value}; | |
| 98 | 99 | ||
| @@ -110,6 +111,14 @@ | |||
| 110 | 111 | const AGE: &str = "secrets.age"; | |
| 111 | 112 | /// Shown, never set. See [`SecureBoot`]. | |
| 112 | 113 | const SECURE_BOOT: &str = "security.secure_boot"; | |
| 114 | + | /// Who answers DNS. Settable; the only row here whose command is an Alloy | |
| 115 | + | /// helper rather than a systemd tool, because the setting is a file in | |
| 116 | + | /// `resolved.conf.d` and there is no `resolvectl` verb that persists. | |
| 117 | + | const RESOLVER: &str = "dns.resolver"; | |
| 118 | + | /// Whether queries are actually encrypted, which is not what `resolvectl` | |
| 119 | + | /// reports. See [`crate::dns`]. Not a setting: it is the consequence of the | |
| 120 | + | /// row above and of the network the machine is on. | |
| 121 | + | const ENCRYPTION: &str = "dns.encryption"; | |
| 113 | 122 | ||
| 114 | 123 | /// Where the firmware publishes whether it is verifying the boot chain. | |
| 115 | 124 | /// | |
| @@ -127,6 +136,7 @@ | |||
| 127 | 136 | locale: Locale, | |
| 128 | 137 | age: Age, | |
| 129 | 138 | secure_boot: SecureBoot, | |
| 139 | + | dns: Dns, | |
| 130 | 140 | } | |
| 131 | 141 | ||
| 132 | 142 | /// What `hostnamectl` reported. | |
| @@ -298,12 +308,13 @@ | |||
| 298 | 308 | ||
| 299 | 309 | let mut bind = Self { | |
| 300 | 310 | sections: sections(), | |
| 301 | - | fields: rows(zones, locales, keymaps, theme_choices()), | |
| 311 | + | fields: rows(zones, locales, keymaps, theme_choices(), resolver_choices()), | |
| 302 | 312 | time, | |
| 303 | 313 | host: read_host(log), | |
| 304 | 314 | locale: read_locale(log), | |
| 305 | 315 | age: read_age(), | |
| 306 | 316 | secure_boot: secure_boot(), | |
| 317 | + | dns: read_dns(log), | |
| 307 | 318 | }; | |
| 308 | 319 | bind.annotate(); | |
| 309 | 320 | Some(bind) | |
| @@ -521,6 +532,12 @@ | |||
| 521 | 532 | path: "security".to_string(), | |
| 522 | 533 | description: Some("What the firmware is enforcing under all of this.".to_string()), | |
| 523 | 534 | }, | |
| 535 | + | Section { | |
| 536 | + | path: "dns".to_string(), | |
| 537 | + | description: Some( | |
| 538 | + | "Who answers name lookups, and whether they travel encrypted.".to_string(), | |
| 539 | + | ), | |
| 540 | + | }, | |
| 524 | 541 | ] | |
| 525 | 542 | } | |
| 526 | 543 | ||
| @@ -530,6 +547,7 @@ | |||
| 530 | 547 | locales: Vec<EnumValue>, | |
| 531 | 548 | keymaps: Vec<EnumValue>, | |
| 532 | 549 | themes: Vec<EnumValue>, | |
| 550 | + | resolvers: Vec<EnumValue>, | |
| 533 | 551 | ) -> Vec<Field> { | |
| 534 | 552 | let enumerated = |path: &str, values: Vec<EnumValue>| Field { | |
| 535 | 553 | path: path.to_string(), | |
| @@ -574,6 +592,10 @@ | |||
| 574 | 592 | // lives in the firmware, and the only way to change it is the setup | |
| 575 | 593 | // utility before this operating system starts. | |
| 576 | 594 | text(SECURE_BOOT, true), | |
| 595 | + | enumerated(RESOLVER, resolvers), | |
| 596 | + | // Not a setting: it is what the row above plus this network add up to, | |
| 597 | + | // and a machine can be set to encrypt and be sending plaintext. | |
| 598 | + | text(ENCRYPTION, true), | |
| 577 | 599 | ] | |
| 578 | 600 | } | |
| 579 | 601 | ||
| @@ -584,6 +606,73 @@ | |||
| 584 | 606 | /// "Akari Night" is the thing a person picked. The variant rides along as the | |
| 585 | 607 | /// description, so a list of a dozen themes says which are dark without being | |
| 586 | 608 | /// opened. | |
| 609 | + | /// What DNS is set to and what it is actually doing. | |
| 610 | + | /// | |
| 611 | + | /// Two separate facts, and the whole reason [`crate::dns`] exists: the setting | |
| 612 | + | /// says what was asked for and the second says what the network permits. | |
| 613 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 614 | + | pub(crate) struct Dns { | |
| 615 | + | /// The id in the drop-in, or `None` where nothing has been chosen and the | |
| 616 | + | /// network's own resolver is in use. | |
| 617 | + | choice: Option<dns::Choice>, | |
| 618 | + | encryption: dns::Encryption, | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | /// Read the DNS posture off the running machine. | |
| 622 | + | /// | |
| 623 | + | /// The probe runs here rather than on a keypress, unlike `stale.rs`'s: that | |
| 624 | + | /// one is an HTTP metadata refresh over the internet and this is a TCP | |
| 625 | + | /// handshake to servers already configured on this link. Measured on fw12: a | |
| 626 | + | /// closed 853 answers immediately, so the millisecond budget only ever runs | |
| 627 | + | /// out on a network dropping the packets outright, and there are one or two | |
| 628 | + | /// servers rather than a repository index. | |
| 629 | + | fn read_dns(log: &mut CommandLog) -> Dns { | |
| 630 | + | let chosen = std::fs::read_to_string(dns::DROP_IN) | |
| 631 | + | .ok() | |
| 632 | + | .and_then(|text| dns::chosen_id(&text).and_then(dns::Choice::parse)); | |
| 633 | + | ||
| 634 | + | let status = log | |
| 635 | + | .quiet(|log| Invocation::new("resolvectl").arg("status").run(log)) | |
| 636 | + | .unwrap_or_default(); | |
| 637 | + | let (mode, servers) = dns::parse_status(&status); | |
| 638 | + | ||
| 639 | + | // Probe what was chosen where something was, and what resolved reports | |
| 640 | + | // otherwise. The two agree once resolved has restarted; before that the | |
| 641 | + | // chosen resolver is the intent and the honest thing to answer about. | |
| 642 | + | let reachable = match &chosen { | |
| 643 | + | Some(dns::Choice::Named(resolver)) => resolver.addresses().any(dns::probe), | |
| 644 | + | _ => servers.iter().any(|address| dns::probe(address)), | |
| 645 | + | }; | |
| 646 | + | ||
| 647 | + | Dns { | |
| 648 | + | choice: chosen, | |
| 649 | + | encryption: dns::encryption(&mode, servers.len(), reachable), | |
| 650 | + | } | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | /// The resolver rows, the one that names nobody first. | |
| 654 | + | /// | |
| 655 | + | /// Mirrors [`theme_choices`], and for the same reason: the entry a machine | |
| 656 | + | /// arrives at without anyone choosing leads the list. `crate::dns` carries the | |
| 657 | + | /// argument for the order and for what a row is allowed to say. | |
| 658 | + | fn resolver_choices() -> Vec<EnumValue> { | |
| 659 | + | dns::Choice::all() | |
| 660 | + | .into_iter() | |
| 661 | + | .map(|choice| match choice { | |
| 662 | + | dns::Choice::Network => EnumValue { | |
| 663 | + | value: choice.id().to_string(), | |
| 664 | + | label: "This network's own".to_string(), | |
| 665 | + | description: Some("Encrypted only where the network offers it".to_string()), | |
| 666 | + | }, | |
| 667 | + | dns::Choice::Named(resolver) => EnumValue { | |
| 668 | + | value: resolver.id.to_string(), | |
| 669 | + | label: resolver.operator.to_string(), | |
| 670 | + | description: resolver.caveat.map(str::to_string), | |
| 671 | + | }, | |
| 672 | + | }) | |
| 673 | + | .collect() | |
| 674 | + | } | |
| 675 | + | ||
| 587 | 676 | fn theme_choices() -> Vec<EnumValue> { | |
| 588 | 677 | // Follow first, and not only because it is the default. A console that has | |
| 589 | 678 | // never been told anything tracks the terminal; pinning whatever it guessed | |
| @@ -758,6 +847,13 @@ | |||
| 758 | 847 | .as_ref() | |
| 759 | 848 | .map(|dir| Value::String(dir.display().to_string())), | |
| 760 | 849 | SECURE_BOOT => Some(Value::String(self.secure_boot.label().to_string())), | |
| 850 | + | // The operator, not the id: the id is what the file holds and the | |
| 851 | + | // operator is who a person picked. | |
| 852 | + | RESOLVER => Some(Value::String(match &self.dns.choice { | |
| 853 | + | Some(dns::Choice::Named(resolver)) => resolver.operator.to_string(), | |
| 854 | + | Some(dns::Choice::Network) | None => "This network's own".to_string(), | |
| 855 | + | })), | |
| 856 | + | ENCRYPTION => Some(Value::String(self.dns.encryption.message().to_string())), | |
| 761 | 857 | _ => None, | |
| 762 | 858 | } | |
| 763 | 859 | } | |
| @@ -804,6 +900,20 @@ | |||
| 804 | 900 | (KEYMAP, Value::String(keymap)) => { | |
| 805 | 901 | Invocation::new("localectl").args(["set-keymap", keymap]) | |
| 806 | 902 | } | |
| 903 | + | // The one row whose command is Alloy's own. There is no resolvectl | |
| 904 | + | // verb that persists across a reboot, and the setting is a file in | |
| 905 | + | // resolved.conf.d, so the helper writes it and restarts resolved. | |
| 906 | + | // `run0` because writing there is root's, and it is the escalation | |
| 907 | + | // the console already teaches (see `alloy-mesh-up`). | |
| 908 | + | (RESOLVER, Value::String(id)) => { | |
| 909 | + | let choice = dns::Choice::parse(id) | |
| 910 | + | .ok_or_else(|| anyhow::anyhow!("no resolver called {id}"))?; | |
| 911 | + | let mut invocation = Invocation::new("run0").args(["alloy-dns", choice.id()]); | |
| 912 | + | if let dns::Choice::Named(resolver) = choice { | |
| 913 | + | invocation = invocation.args(resolver.servers.iter().copied()); | |
| 914 | + | } | |
| 915 | + | invocation | |
| 916 | + | } | |
| 807 | 917 | // A config-store set and a file write, and the only row here that | |
| 808 | 918 | // needs no privilege at all. | |
| 809 | 919 | // | |
| @@ -889,12 +999,17 @@ | |||
| 889 | 999 | parse_list("en_US.UTF-8\nen_GB.UTF-8\n"), | |
| 890 | 1000 | parse_list("us\nuk\n"), | |
| 891 | 1001 | theme_choices(), | |
| 1002 | + | resolver_choices(), | |
| 892 | 1003 | ), | |
| 893 | 1004 | time: parse_show(tests::SHOW), | |
| 894 | 1005 | host: parse_host(tests::HOST), | |
| 895 | 1006 | locale: parse_locale(tests::LOCALE_STATUS), | |
| 896 | 1007 | age, | |
| 897 | 1008 | secure_boot: SecureBoot::Enforcing, | |
| 1009 | + | dns: Dns { | |
| 1010 | + | choice: None, | |
| 1011 | + | encryption: dns::Encryption::Unknown, | |
| 1012 | + | }, | |
| 898 | 1013 | }; | |
| 899 | 1014 | bind.annotate(); | |
| 900 | 1015 | bind |
| @@ -1079,6 +1079,9 @@ | |||
| 1079 | 1079 | "secrets.age", | |
| 1080 | 1080 | "[security]", | |
| 1081 | 1081 | "security.secure_boot", | |
| 1082 | + | "[dns]", | |
| 1083 | + | "dns.resolver", | |
| 1084 | + | "dns.encryption", | |
| 1082 | 1085 | ], | |
| 1083 | 1086 | "three fronts, the console's own preference, a file check and a firmware read, one form", | |
| 1084 | 1087 | ); |
| @@ -360,12 +360,17 @@ | |||
| 360 | 360 | parse_list("en_US.UTF-8\nen_GB.UTF-8\n"), | |
| 361 | 361 | Vec::new(), | |
| 362 | 362 | theme_choices(), | |
| 363 | + | resolver_choices(), | |
| 363 | 364 | ), | |
| 364 | 365 | time: parse_show(SHOW), | |
| 365 | 366 | host: parse_host(HOST), | |
| 366 | 367 | locale: parse_locale(LOCALE_STATUS), | |
| 367 | 368 | age: Age::default(), | |
| 368 | 369 | secure_boot: SecureBoot::Enforcing, | |
| 370 | + | dns: Dns { | |
| 371 | + | choice: None, | |
| 372 | + | encryption: dns::Encryption::Unknown, | |
| 373 | + | }, | |
| 369 | 374 | }; | |
| 370 | 375 | bind.annotate(); | |
| 371 | 376 | ||
| @@ -386,12 +391,17 @@ | |||
| 386 | 391 | parse_list(locales), | |
| 387 | 392 | parse_list("us\n"), | |
| 388 | 393 | theme_choices(), | |
| 394 | + | resolver_choices(), | |
| 389 | 395 | ), | |
| 390 | 396 | time: parse_show(SHOW), | |
| 391 | 397 | host: parse_host(HOST), | |
| 392 | 398 | locale: parse_locale(LOCALE_STATUS), | |
| 393 | 399 | age: Age::default(), | |
| 394 | 400 | secure_boot: SecureBoot::Enforcing, | |
| 401 | + | dns: Dns { | |
| 402 | + | choice: None, | |
| 403 | + | encryption: dns::Encryption::Unknown, | |
| 404 | + | }, | |
| 395 | 405 | }; | |
| 396 | 406 | bind.annotate(); | |
| 397 | 407 | bind | |
| @@ -643,3 +653,62 @@ | |||
| 643 | 653 | assert_eq!(zones[0].label, "America/Denver"); | |
| 644 | 654 | assert!(zones[0].description.is_none()); | |
| 645 | 655 | } | |
| 656 | + | ||
| 657 | + | // ---- dns ---- | |
| 658 | + | ||
| 659 | + | // The argv the log pane shows, and the reason the resolver table lives in | |
| 660 | + | // `dns` alone: the helper is handed what to write rather than keeping a second | |
| 661 | + | // copy of the addresses in shell, where the two would drift. | |
| 662 | + | #[test] | |
| 663 | + | fn choosing_a_resolver_hands_the_helper_its_servers() { | |
| 664 | + | let mut bind = bind(); | |
| 665 | + | let effects = bind | |
| 666 | + | .commit(RESOLVER, Value::String("quad9".into())) | |
| 667 | + | .unwrap(); | |
| 668 | + | let [Effect::Run(invocation)] = effects.as_slice() else { | |
| 669 | + | panic!("expected one command, got {effects:?}"); | |
| 670 | + | }; | |
| 671 | + | let shown = invocation.display(); | |
| 672 | + | assert!(shown.starts_with("run0 alloy-dns quad9"), "{shown}"); | |
| 673 | + | assert!(shown.contains("9.9.9.9#dns.quad9.net"), "{shown}"); | |
| 674 | + | // The certificate name travels with every address, or DNSOverTLS=yes has | |
| 675 | + | // nothing to check. | |
| 676 | + | assert!( | |
| 677 | + | !shown.split_whitespace().skip(3).any(|s| !s.contains('#')), | |
| 678 | + | "every server must carry its #hostname: {shown}" | |
| 679 | + | ); | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | // Going back is a choice with a command, not the absence of one. | |
| 683 | + | #[test] | |
| 684 | + | fn the_networks_own_resolver_takes_no_servers() { | |
| 685 | + | let mut bind = bind(); | |
| 686 | + | let effects = bind | |
| 687 | + | .commit(RESOLVER, Value::String("network".into())) | |
| 688 | + | .unwrap(); | |
| 689 | + | let [Effect::Run(invocation)] = effects.as_slice() else { | |
| 690 | + | panic!("expected one command, got {effects:?}"); | |
| 691 | + | }; | |
| 692 | + | assert_eq!(invocation.display(), "run0 alloy-dns network"); | |
| 693 | + | } | |
| 694 | + | ||
| 695 | + | #[test] | |
| 696 | + | fn a_resolver_this_build_does_not_know_is_refused() { | |
| 697 | + | let mut bind = bind(); | |
| 698 | + | let error = message(bind.commit(RESOLVER, Value::String("nonesuch".into()))); | |
| 699 | + | assert!(error.contains("nonesuch"), "{error}"); | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | // The row exists to answer the question `resolvectl status` cannot: it reports | |
| 703 | + | // the setting, and a machine can be set to encrypt while sending plaintext. | |
| 704 | + | #[test] | |
| 705 | + | fn the_encryption_row_reports_a_state_and_not_a_setting() { | |
| 706 | + | let bind = bind(); | |
| 707 | + | let Some(Value::String(shown)) = bind.read(ENCRYPTION) else { | |
| 708 | + | panic!("the encryption row reads nothing"); | |
| 709 | + | }; | |
| 710 | + | assert!( | |
| 711 | + | !shown.contains("opportunistic") && !shown.contains("DNSOverTLS"), | |
| 712 | + | "this row must not repeat the setting: {shown}" | |
| 713 | + | ); | |
| 714 | + | } |
| @@ -1,0 +1,344 @@ | |||
| 1 | + | //! Who answers DNS for this machine, and whether the answer is encrypted. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: alloy-hardening-posture --> | |
| 4 | + | //! | |
| 5 | + | //! ## Alloy recommends none of these | |
| 6 | + | //! | |
| 7 | + | //! Ruled 2026-09-08 (GoingsOn alloy `ffe3b139`): a no-recommendation choice | |
| 8 | + | //! with the most reasonable default. The default is [`Choice::Network`], which | |
| 9 | + | //! names no provider at all, and the named resolvers below are listed in one | |
| 10 | + | //! fixed order with nothing marking one out. A row carries the operator and | |
| 11 | + | //! little else: a privacy policy is a claim rather than a fact, and an | |
| 12 | + | //! operating system repeating one is endorsing it. Who runs the resolver is | |
| 13 | + | //! the thing a person actually chooses on, and it is checkable. | |
| 14 | + | //! | |
| 15 | + | //! Order is alphabetical by operator. That is not a ranking and is chosen | |
| 16 | + | //! because every other order is one: fastest depends on where you are, | |
| 17 | + | //! most-private is the argument the list exists to avoid having. | |
| 18 | + | //! | |
| 19 | + | //! ## Why the default names nobody | |
| 20 | + | //! | |
| 21 | + | //! Measured on fw12 2026-09-08 (GO alloy `f4806a35`): `DNSOverTLS=yes` against | |
| 22 | + | //! a network that does not offer DoT breaks every name off the tailnet, and | |
| 23 | + | //! port 853 was closed on both the LAN router and the ISP resolver behind it. | |
| 24 | + | //! A router with no DoT is the ordinary case. So the default keeps the | |
| 25 | + | //! network's own resolver on `opportunistic`, which encrypts where it can, | |
| 26 | + | //! never overrides a resolver the network legitimately runs, and cannot leave | |
| 27 | + | //! a machine unable to resolve anything. | |
| 28 | + | //! | |
| 29 | + | //! ## The half that is not a choice | |
| 30 | + | //! | |
| 31 | + | //! `resolvectl status` reports the *setting* and not whether anything is | |
| 32 | + | //! encrypted: on that same machine it said `DNSOverTLS=opportunistic` while | |
| 33 | + | //! every lookup went out in plaintext. [`Encryption`] answers the question the | |
| 34 | + | //! status line does not, so a screen can say which of the two is happening | |
| 35 | + | //! rather than letting the setting read as a guarantee. | |
| 36 | + | ||
| 37 | + | use std::net::{IpAddr, SocketAddr, TcpStream}; | |
| 38 | + | use std::time::Duration; | |
| 39 | + | ||
| 40 | + | /// The DoT port. Named because 853 on its own reads as a magic number, and the | |
| 41 | + | /// probe below is the only thing in the tree that opens it. | |
| 42 | + | const DOT_PORT: u16 = 853; | |
| 43 | + | ||
| 44 | + | /// How long to wait for 853 before calling a resolver unreachable. | |
| 45 | + | /// | |
| 46 | + | /// Short: this runs on a keypress in a synchronous TUI, and a resolver that | |
| 47 | + | /// has not answered a TCP handshake in a second on a working network is one | |
| 48 | + | /// this machine cannot use anyway. A closed port answers immediately; only a | |
| 49 | + | /// dropped packet costs the whole budget. | |
| 50 | + | const PROBE_TIMEOUT: Duration = Duration::from_secs(1); | |
| 51 | + | ||
| 52 | + | /// A resolver the user can choose, or the network's own. | |
| 53 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 54 | + | pub(crate) enum Choice { | |
| 55 | + | /// Whatever DHCP handed out, with `DNSOverTLS=opportunistic`. | |
| 56 | + | /// | |
| 57 | + | /// An entry rather than the absence of one. A list of providers with no | |
| 58 | + | /// way back to the network's own resolver would be a recommendation by | |
| 59 | + | /// omission, and this is the only option that names no company. | |
| 60 | + | Network, | |
| 61 | + | /// One of [`RESOLVERS`], by its `id`. | |
| 62 | + | Named(&'static Resolver), | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | impl Choice { | |
| 66 | + | /// The id written to the drop-in and read back from it. | |
| 67 | + | pub(crate) fn id(&self) -> &'static str { | |
| 68 | + | match self { | |
| 69 | + | Choice::Network => "network", | |
| 70 | + | Choice::Named(r) => r.id, | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Every choice, the network's own first. | |
| 75 | + | pub(crate) fn all() -> Vec<Choice> { | |
| 76 | + | std::iter::once(Choice::Network) | |
| 77 | + | .chain(RESOLVERS.iter().map(Choice::Named)) | |
| 78 | + | .collect() | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Parse an id, as `alloy-dns` writes it. Unknown ids read as `None` | |
| 82 | + | /// rather than falling back to the default: a drop-in naming a resolver | |
| 83 | + | /// this build does not know is worth reporting, not silently overriding. | |
| 84 | + | pub(crate) fn parse(id: &str) -> Option<Choice> { | |
| 85 | + | if id == "network" { | |
| 86 | + | return Some(Choice::Network); | |
| 87 | + | } | |
| 88 | + | RESOLVERS.iter().find(|r| r.id == id).map(Choice::Named) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// A public resolver that speaks DNS over TLS. | |
| 93 | + | /// | |
| 94 | + | /// The operator, where to reach it, and nothing else except what this machine | |
| 95 | + | /// measured. Deliberately thin: privacy policies are claims rather than facts, | |
| 96 | + | /// and a row repeating one is endorsing it in the voice of the operating | |
| 97 | + | /// system. Who runs the resolver is the fact a person actually chooses on. | |
| 98 | + | /// | |
| 99 | + | /// `servers` carries systemd-resolved's `DNS=` syntax, `address#hostname`, | |
| 100 | + | /// where the hostname is what the certificate is checked against. Without it | |
| 101 | + | /// `DNSOverTLS=yes` has nothing to validate and the encryption proves only | |
| 102 | + | /// that something answered. | |
| 103 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 104 | + | pub(crate) struct Resolver { | |
| 105 | + | pub id: &'static str, | |
| 106 | + | /// Who runs it. The operator, not a product name. | |
| 107 | + | pub operator: &'static str, | |
| 108 | + | /// `DNS=` values, IPv4 then IPv6. | |
| 109 | + | pub servers: &'static [&'static str], | |
| 110 | + | /// Something measured on this machine that changes what the computer does. | |
| 111 | + | /// | |
| 112 | + | /// `None` means nothing was observed, which is not the same as nothing | |
| 113 | + | /// happening: the measurement below is four domains from one network, and | |
| 114 | + | /// a resolver's own documentation may describe behaviour it did not show. | |
| 115 | + | /// Saying nothing is the honest reading of an absence. | |
| 116 | + | pub caveat: Option<&'static str>, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | impl Resolver { | |
| 120 | + | /// The bare addresses, with the `#hostname` cut off, for probing. | |
| 121 | + | pub(crate) fn addresses(&self) -> impl Iterator<Item = &'static str> { | |
| 122 | + | self.servers | |
| 123 | + | .iter() | |
| 124 | + | .map(|s| s.split('#').next().unwrap_or(*s)) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// The offered resolvers, alphabetical by operator. | |
| 129 | + | /// | |
| 130 | + | /// ## What was measured, on fw12, 2026-09-08 | |
| 131 | + | /// | |
| 132 | + | /// Every **IPv4** address here took a TCP connection on 853. The **IPv6** | |
| 133 | + | /// addresses did not, and that result means nothing: the machine has a global | |
| 134 | + | /// v6 address and no v6 default route, so it has no v6 egress to test with. | |
| 135 | + | /// They are unverified rather than known good, and checking them wants a host | |
| 136 | + | /// that can reach v6 at all. | |
| 137 | + | /// | |
| 138 | + | /// The caveats come from asking each resolver for `flurry.com`, | |
| 139 | + | /// `doubleclick.net`, `scorecardresearch.com` and `example.com` and comparing | |
| 140 | + | /// against an unfiltered answer. AdGuard returned `0.0.0.0` for the first two | |
| 141 | + | /// and resolved the rest, which is the filtering it carries. Mullvad returned | |
| 142 | + | /// NXDOMAIN for all four including `example.com`, which is not filtering but a | |
| 143 | + | /// resolver that does not serve plaintext at all. Cloudflare, Google and Quad9 | |
| 144 | + | /// filtered none of the four; Quad9 documents a malware feed that these four | |
| 145 | + | /// domains do not exercise, which is why its row says nothing rather than | |
| 146 | + | /// "blocks nothing". | |
| 147 | + | /// | |
| 148 | + | /// Adding a resolver means running that again, not reading a website. | |
| 149 | + | pub(crate) static RESOLVERS: &[Resolver] = &[ | |
| 150 | + | Resolver { | |
| 151 | + | id: "adguard", | |
| 152 | + | operator: "AdGuard", | |
| 153 | + | servers: &[ | |
| 154 | + | "94.140.14.14#dns.adguard-dns.com", | |
| 155 | + | "94.140.15.15#dns.adguard-dns.com", | |
| 156 | + | "2a10:50c0::ad1:ff#dns.adguard-dns.com", | |
| 157 | + | "2a10:50c0::ad2:ff#dns.adguard-dns.com", | |
| 158 | + | ], | |
| 159 | + | caveat: Some("blocks advertising and tracker domains"), | |
| 160 | + | }, | |
| 161 | + | Resolver { | |
| 162 | + | id: "cloudflare", | |
| 163 | + | operator: "Cloudflare", | |
| 164 | + | servers: &[ | |
| 165 | + | "1.1.1.1#cloudflare-dns.com", | |
| 166 | + | "1.0.0.1#cloudflare-dns.com", | |
| 167 | + | "2606:4700:4700::1111#cloudflare-dns.com", | |
| 168 | + | "2606:4700:4700::1001#cloudflare-dns.com", | |
| 169 | + | ], | |
| 170 | + | caveat: None, | |
| 171 | + | }, | |
| 172 | + | Resolver { | |
| 173 | + | id: "google", | |
| 174 | + | operator: "Google", | |
| 175 | + | servers: &[ | |
| 176 | + | "8.8.8.8#dns.google", | |
| 177 | + | "8.8.4.4#dns.google", | |
| 178 | + | "2001:4860:4860::8888#dns.google", | |
| 179 | + | "2001:4860:4860::8844#dns.google", | |
| 180 | + | ], | |
| 181 | + | caveat: None, | |
| 182 | + | }, | |
| 183 | + | Resolver { | |
| 184 | + | id: "mullvad", | |
| 185 | + | operator: "Mullvad", | |
| 186 | + | servers: &[ | |
| 187 | + | "194.242.2.2#dns.mullvad.net", | |
| 188 | + | "2a07:e340::2#dns.mullvad.net", | |
| 189 | + | ], | |
| 190 | + | caveat: Some("answers over TLS only, so a plaintext fallback gets nothing"), | |
| 191 | + | }, | |
| 192 | + | Resolver { | |
| 193 | + | id: "quad9", | |
| 194 | + | operator: "Quad9", | |
| 195 | + | servers: &[ | |
| 196 | + | "9.9.9.9#dns.quad9.net", | |
| 197 | + | "149.112.112.112#dns.quad9.net", | |
| 198 | + | "2620:fe::fe#dns.quad9.net", | |
| 199 | + | "2620:fe::9#dns.quad9.net", | |
| 200 | + | ], | |
| 201 | + | caveat: None, | |
| 202 | + | }, | |
| 203 | + | ]; | |
| 204 | + | ||
| 205 | + | /// Whether DNS is actually encrypted, as opposed to configured to be. | |
| 206 | + | /// | |
| 207 | + | /// The distinction this whole module exists for. `opportunistic` falls back to | |
| 208 | + | /// plaintext without saying so, so a machine can report DoT and use none. | |
| 209 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 210 | + | pub(crate) enum Encryption { | |
| 211 | + | /// The configured servers answer on 853, so lookups can be encrypted. | |
| 212 | + | Encrypted, | |
| 213 | + | /// Configured `opportunistic` and no server answers on 853, which means | |
| 214 | + | /// every lookup is in the clear. The state fw12 was measured in. | |
| 215 | + | Plaintext, | |
| 216 | + | /// `DNSOverTLS=no`. Nothing is claimed and nothing is encrypted. | |
| 217 | + | Off, | |
| 218 | + | /// No server could be read, so there is nothing to say. A container with | |
| 219 | + | /// no resolved, or a machine mid-reconfiguration. | |
| 220 | + | Unknown, | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | impl Encryption { | |
| 224 | + | /// The line a screen shows. Says what is true, never what is configured. | |
| 225 | + | pub(crate) fn message(self) -> &'static str { | |
| 226 | + | match self { | |
| 227 | + | Encryption::Encrypted => "queries are encrypted to the resolver", | |
| 228 | + | Encryption::Plaintext => { | |
| 229 | + | "queries are in the clear: this network's resolver does not offer DNS over TLS" | |
| 230 | + | } | |
| 231 | + | Encryption::Off => "queries are in the clear: DNS over TLS is off", | |
| 232 | + | Encryption::Unknown => "cannot tell whether queries are encrypted", | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// Where the choice is recorded. | |
| 238 | + | /// | |
| 239 | + | /// A second file rather than an edit to `10-alloy-dns.conf`: that one is | |
| 240 | + | /// shipped in the image and carries the posture argument, and a machine whose | |
| 241 | + | /// user picked a resolver should still get the shipped file's updates. Later | |
| 242 | + | /// number, so it wins on the keys it sets. | |
| 243 | + | pub(crate) const DROP_IN: &str = "/etc/systemd/resolved.conf.d/20-alloy-resolver.conf"; | |
| 244 | + | ||
| 245 | + | /// The marker `alloy-dns` writes so the choice can be read back. | |
| 246 | + | /// | |
| 247 | + | /// A comment rather than a key: resolved would reject an unknown key, and the | |
| 248 | + | /// id is Alloy's bookkeeping rather than something resolved needs. `DNS=` alone | |
| 249 | + | /// could not answer it, since two resolvers could in principle share an | |
| 250 | + | /// address and the operator is what was chosen. | |
| 251 | + | const MARKER: &str = "# alloy-resolver: "; | |
| 252 | + | ||
| 253 | + | /// Read the chosen id out of a drop-in this module wrote. | |
| 254 | + | pub(crate) fn chosen_id(text: &str) -> Option<&str> { | |
| 255 | + | text.lines() | |
| 256 | + | .find_map(|line| line.trim().strip_prefix(MARKER)) | |
| 257 | + | .map(str::trim) | |
| 258 | + | .filter(|id| !id.is_empty()) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// The global DoT mode and the servers on the default route, from | |
| 262 | + | /// `resolvectl status`. | |
| 263 | + | /// | |
| 264 | + | /// The default-route link and not every link, which is the distinction the | |
| 265 | + | /// whole measurement turned on: Tailscale's link carries `-DNSOverTLS` and the | |
| 266 | + | /// MagicDNS stub, and counting it would report the tailnet's encryption for | |
| 267 | + | /// the network's queries. | |
| 268 | + | /// | |
| 269 | + | /// Three spellings, all of which `resolvectl` emits: `DNSOverTLS=opportunistic` | |
| 270 | + | /// for the middle setting, and the bare `+DNSOverTLS` / `-DNSOverTLS` flags for | |
| 271 | + | /// yes and no. | |
| 272 | + | pub(crate) fn parse_status(raw: &str) -> (String, Vec<String>) { | |
| 273 | + | let mut mode = String::new(); | |
| 274 | + | let mut servers = Vec::new(); | |
| 275 | + | let mut default_route = false; | |
| 276 | + | let mut in_global = true; | |
| 277 | + | ||
| 278 | + | for line in raw.lines() { | |
| 279 | + | let trimmed = line.trim(); | |
| 280 | + | if trimmed.starts_with("Link ") { | |
| 281 | + | in_global = false; | |
| 282 | + | default_route = false; | |
| 283 | + | } | |
| 284 | + | if in_global && trimmed.contains("DNSOverTLS") { | |
| 285 | + | mode = read_mode(trimmed); | |
| 286 | + | } | |
| 287 | + | if trimmed.contains("+DefaultRoute") { | |
| 288 | + | default_route = true; | |
| 289 | + | } | |
| 290 | + | if default_route && let Some(list) = trimmed.strip_prefix("DNS Servers:") { | |
| 291 | + | servers.extend(list.split_whitespace().map(str::to_string)); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | (mode, servers) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /// Read one `Protocols:` line's DoT setting. | |
| 298 | + | fn read_mode(line: &str) -> String { | |
| 299 | + | for field in line.split_whitespace() { | |
| 300 | + | if let Some(value) = field.strip_prefix("DNSOverTLS=") { | |
| 301 | + | return value.to_string(); | |
| 302 | + | } | |
| 303 | + | if field == "+DNSOverTLS" { | |
| 304 | + | return "yes".to_string(); | |
| 305 | + | } | |
| 306 | + | if field == "-DNSOverTLS" { | |
| 307 | + | return "no".to_string(); | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | String::new() | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | /// Can this address take a DoT connection? | |
| 314 | + | /// | |
| 315 | + | /// A TCP handshake on 853 and nothing more. It does not check the certificate | |
| 316 | + | /// or send a query, so it answers "this port is open" rather than "this is a | |
| 317 | + | /// working resolver"; resolved does the rest and reports its own failures. The | |
| 318 | + | /// weaker check is deliberate: the question on screen is whether the network | |
| 319 | + | /// permits DoT at all, which is what a closed port answers. | |
| 320 | + | pub(crate) fn probe(address: &str) -> bool { | |
| 321 | + | let Ok(ip) = address.parse::<IpAddr>() else { | |
| 322 | + | return false; | |
| 323 | + | }; | |
| 324 | + | TcpStream::connect_timeout(&SocketAddr::new(ip, DOT_PORT), PROBE_TIMEOUT).is_ok() | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// Decide what to report, given the configured mode and the servers in use. | |
| 328 | + | /// | |
| 329 | + | /// Pure, so the reporting is testable without a network. `mode` is | |
| 330 | + | /// resolved's `DNSOverTLS` value; `reachable` is whether any configured server | |
| 331 | + | /// answered [`probe`]. | |
| 332 | + | pub(crate) fn encryption(mode: &str, servers: usize, reachable: bool) -> Encryption { | |
| 333 | + | match mode { | |
| 334 | + | "no" => Encryption::Off, | |
| 335 | + | _ if servers == 0 => Encryption::Unknown, | |
| 336 | + | "yes" => Encryption::Encrypted, | |
| 337 | + | "opportunistic" if reachable => Encryption::Encrypted, | |
| 338 | + | "opportunistic" => Encryption::Plaintext, | |
| 339 | + | _ => Encryption::Unknown, | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[cfg(test)] | |
| 344 | + | mod tests; |
| @@ -1,0 +1,299 @@ | |||
| 1 | + | //! Tests for [`super`]. | |
| 2 | + | ||
| 3 | + | use super::*; | |
| 4 | + | ||
| 5 | + | // The property the whole module exists to hold. If a later edit sorts this | |
| 6 | + | // list by anything else, or puts one operator first, that is a recommendation | |
| 7 | + | // however it is worded, and this fails. | |
| 8 | + | #[test] | |
| 9 | + | fn the_list_recommends_nobody() { | |
| 10 | + | let operators: Vec<&str> = RESOLVERS.iter().map(|r| r.operator).collect(); | |
| 11 | + | let mut sorted = operators.clone(); | |
| 12 | + | sorted.sort_unstable(); | |
| 13 | + | assert_eq!( | |
| 14 | + | operators, sorted, | |
| 15 | + | "resolvers must stay alphabetical by operator; any other order ranks them" | |
| 16 | + | ); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // The network's own resolver is a choice a person can pick, not the gap left | |
| 20 | + | // when they pick nothing. A list of five companies with no way back would | |
| 21 | + | // recommend by omission. | |
| 22 | + | #[test] | |
| 23 | + | fn the_networks_own_resolver_is_the_first_option() { | |
| 24 | + | let all = Choice::all(); | |
| 25 | + | assert_eq!(all.first(), Some(&Choice::Network)); | |
| 26 | + | assert_eq!(all.len(), RESOLVERS.len() + 1); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | #[test] | |
| 30 | + | fn every_id_round_trips() { | |
| 31 | + | for choice in Choice::all() { | |
| 32 | + | let id = choice.id(); | |
| 33 | + | assert_eq!( | |
| 34 | + | Choice::parse(id), | |
| 35 | + | Some(choice.clone()), | |
| 36 | + | "{id} did not survive the round trip" | |
| 37 | + | ); | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // A drop-in naming a resolver this build has never heard of is worth saying | |
| 42 | + | // out loud. Falling back to the default would silently answer a question the | |
| 43 | + | // user thought they had answered. | |
| 44 | + | #[test] | |
| 45 | + | fn an_unknown_id_is_not_quietly_the_default() { | |
| 46 | + | assert_eq!(Choice::parse("nonesuch"), None); | |
| 47 | + | assert_eq!(Choice::parse(""), None); | |
| 48 | + | assert_eq!(Choice::parse("NETWORK"), None, "ids are matched exactly"); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | #[test] | |
| 52 | + | fn ids_are_unique_and_spellable() { | |
| 53 | + | let mut seen = std::collections::BTreeSet::new(); | |
| 54 | + | for r in RESOLVERS { | |
| 55 | + | assert!(seen.insert(r.id), "duplicate id {}", r.id); | |
| 56 | + | assert!( | |
| 57 | + | !r.id.is_empty() | |
| 58 | + | && r.id | |
| 59 | + | .chars() | |
| 60 | + | .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), | |
| 61 | + | "{} is not a bare lowercase id, and it is written into a config file", | |
| 62 | + | r.id | |
| 63 | + | ); | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Two things at once, and both matter to `DNSOverTLS=yes`. The address has to | |
| 68 | + | // parse or resolved cannot dial it, and the `#hostname` has to be there or | |
| 69 | + | // there is no certificate name to check, which turns the encryption into | |
| 70 | + | // proof that *something* answered. | |
| 71 | + | #[test] | |
| 72 | + | fn every_server_carries_an_address_and_a_certificate_name() { | |
| 73 | + | for r in RESOLVERS { | |
| 74 | + | assert!(!r.servers.is_empty(), "{} offers no servers", r.id); | |
| 75 | + | for server in r.servers { | |
| 76 | + | let (address, host) = server | |
| 77 | + | .split_once('#') | |
| 78 | + | .unwrap_or_else(|| panic!("{server} has no #hostname for the certificate")); | |
| 79 | + | assert!( | |
| 80 | + | address.parse::<std::net::IpAddr>().is_ok(), | |
| 81 | + | "{address} is not an address resolved can dial" | |
| 82 | + | ); | |
| 83 | + | assert!( | |
| 84 | + | host.contains('.') && !host.is_empty(), | |
| 85 | + | "{host} is not a hostname a certificate could carry" | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Both families, so a v6-only network is not left with a list it cannot use. | |
| 92 | + | // The v6 addresses are unverified rather than measured; see the note on | |
| 93 | + | // RESOLVERS for why, and what would check them. | |
| 94 | + | #[test] | |
| 95 | + | fn every_resolver_offers_both_address_families() { | |
| 96 | + | for r in RESOLVERS { | |
| 97 | + | let (v6, v4): (Vec<_>, Vec<_>) = r | |
| 98 | + | .addresses() | |
| 99 | + | .filter_map(|a| a.parse::<std::net::IpAddr>().ok()) | |
| 100 | + | .partition(std::net::IpAddr::is_ipv6); | |
| 101 | + | assert!(!v4.is_empty(), "{} has no IPv4 address", r.id); | |
| 102 | + | assert!(!v6.is_empty(), "{} has no IPv6 address", r.id); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[test] | |
| 107 | + | fn the_hostname_is_cut_off_for_probing() { | |
| 108 | + | let quad9 = RESOLVERS | |
| 109 | + | .iter() | |
| 110 | + | .find(|r| r.id == "quad9") | |
| 111 | + | .expect("quad9 is in the list"); | |
| 112 | + | let addresses: Vec<&str> = quad9.addresses().collect(); | |
| 113 | + | assert!(addresses.contains(&"9.9.9.9")); | |
| 114 | + | assert!( | |
| 115 | + | addresses.iter().all(|a| !a.contains('#')), | |
| 116 | + | "a probe target must be an address alone: {addresses:?}" | |
| 117 | + | ); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // A caveat is something this machine watched happen. Prose that reads like a | |
| 121 | + | // policy summary is the thing the record deliberately does not carry. | |
| 122 | + | #[test] | |
| 123 | + | fn a_caveat_says_what_the_resolver_does_not_what_it_promises() { | |
| 124 | + | for r in RESOLVERS { | |
| 125 | + | let Some(caveat) = r.caveat else { continue }; | |
| 126 | + | for banned in ["privacy", "log", "policy", "trust", "secure", "safe"] { | |
| 127 | + | assert!( | |
| 128 | + | !caveat.to_lowercase().contains(banned), | |
| 129 | + | "{}'s caveat reads as a claim rather than an observation: {caveat}", | |
| 130 | + | r.id | |
| 131 | + | ); | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // ---- what is actually happening, as opposed to what is configured ---- | |
| 137 | + | ||
| 138 | + | #[test] | |
| 139 | + | fn strict_dot_is_reported_as_encrypted() { | |
| 140 | + | assert_eq!(encryption("yes", 2, true), Encryption::Encrypted); | |
| 141 | + | // `yes` refuses a server that cannot do DoT, so resolved either has an | |
| 142 | + | // encrypted answer or no answer. Reachability does not change the reading. | |
| 143 | + | assert_eq!(encryption("yes", 2, false), Encryption::Encrypted); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // The state fw12 was measured in, and the reason this enum exists: the setting | |
| 147 | + | // says opportunistic, nothing answers on 853, and every lookup is in the clear. | |
| 148 | + | #[test] | |
| 149 | + | fn opportunistic_with_nothing_listening_is_plaintext() { | |
| 150 | + | assert_eq!(encryption("opportunistic", 2, false), Encryption::Plaintext); | |
| 151 | + | assert_eq!(encryption("opportunistic", 2, true), Encryption::Encrypted); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[test] | |
| 155 | + | fn off_is_off_and_no_servers_is_unknown() { | |
| 156 | + | assert_eq!(encryption("no", 2, true), Encryption::Off); | |
| 157 | + | assert_eq!(encryption("opportunistic", 0, false), Encryption::Unknown); | |
| 158 | + | assert_eq!(encryption("no", 0, false), Encryption::Off); | |
| 159 | + | // A value resolved grows later reads as unknown rather than as encrypted. | |
| 160 | + | assert_eq!(encryption("something-new", 2, true), Encryption::Unknown); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // The copy constraint, held by a test because it is a property of the strings | |
| 164 | + | // rather than of the rendering. A screen that says "encrypted" on a machine | |
| 165 | + | // sending plaintext is worse than one that says nothing, and this is the same | |
| 166 | + | // shape as `stale.rs`'s rule against implying a security check. | |
| 167 | + | #[test] | |
| 168 | + | fn nothing_claims_encryption_that_is_not_happening() { | |
| 169 | + | for state in [Encryption::Plaintext, Encryption::Off, Encryption::Unknown] { | |
| 170 | + | let message = state.message().to_lowercase(); | |
| 171 | + | assert!( | |
| 172 | + | !message.contains("encrypted to"), | |
| 173 | + | "{state:?} must not read as an assurance: {message}" | |
| 174 | + | ); | |
| 175 | + | } | |
| 176 | + | assert!( | |
| 177 | + | Encryption::Plaintext.message().contains("in the clear"), | |
| 178 | + | "the plaintext case has to say so in words a person reads" | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Probe this machine's own resolvers. | |
| 183 | + | /// | |
| 184 | + | /// Ignored by default: it opens TCP connections to whatever this network's | |
| 185 | + | /// DNS servers are, and what it finds depends on the network. Run it when | |
| 186 | + | /// touching the probe. | |
| 187 | + | #[test] | |
| 188 | + | #[ignore = "reaches the network"] | |
| 189 | + | fn probes_this_machines_reachable_resolvers() { | |
| 190 | + | for r in RESOLVERS { | |
| 191 | + | for address in r.addresses() { | |
| 192 | + | println!( | |
| 193 | + | "{:<12} {:<34} 853 {}", | |
| 194 | + | r.id, | |
| 195 | + | address, | |
| 196 | + | if probe(address) { "open" } else { "closed" } | |
| 197 | + | ); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // ---- resolvectl status, against a real capture ---- | |
| 203 | + | ||
| 204 | + | /// Captured verbatim from `resolvectl status` on fw12, 2026-09-08, trimmed to | |
| 205 | + | /// the three blocks the parser reads. The tailscale0 link is the half that | |
| 206 | + | /// matters: it carries `-DNSOverTLS` and the MagicDNS stub, and a parser that | |
| 207 | + | /// counted it would report the tailnet's encryption for the network's queries. | |
| 208 | + | const STATUS: &str = "\ | |
| 209 | + | Global | |
| 210 | + | Protocols: LLMNR=resolve -mDNS DNSOverTLS=opportunistic | |
| 211 | + | DNSSEC=allow-downgrade/unsupported | |
| 212 | + | resolv.conf mode: stub | |
| 213 | + | ||
| 214 | + | Link 2 (wlp0s20f3) | |
| 215 | + | Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6 | |
| 216 | + | Protocols: +DefaultRoute LLMNR=resolve -mDNS DNSOverTLS=opportunistic | |
| 217 | + | DNSSEC=allow-downgrade/supported | |
| 218 | + | Current DNS Server: 192.168.0.1 | |
| 219 | + | DNS Servers: 192.168.0.1 205.171.2.65 | |
| 220 | + | Default Route: yes | |
| 221 | + | ||
| 222 | + | Link 3 (tailscale0) | |
| 223 | + | Current Scopes: DNS | |
| 224 | + | Protocols: -DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported | |
| 225 | + | Current DNS Server: 100.100.100.100 | |
| 226 | + | DNS Servers: 100.100.100.100 fd7a:115c:a1e0::53 | |
| 227 | + | DNS Domain: tailc6b3e1.ts.net ~ts.net | |
| 228 | + | Default Route: no | |
| 229 | + | "; | |
| 230 | + | ||
| 231 | + | #[test] | |
| 232 | + | fn the_global_mode_and_the_default_route_servers_are_read() { | |
| 233 | + | let (mode, servers) = parse_status(STATUS); | |
| 234 | + | assert_eq!(mode, "opportunistic"); | |
| 235 | + | assert_eq!(servers, vec!["192.168.0.1", "205.171.2.65"]); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // The whole reason the parser looks for +DefaultRoute. Tailscale's servers | |
| 239 | + | // answer the tailnet and are not what "is this network encrypting" is about. | |
| 240 | + | #[test] | |
| 241 | + | fn the_tailnet_stub_is_not_mistaken_for_the_networks_resolver() { | |
| 242 | + | let (_, servers) = parse_status(STATUS); | |
| 243 | + | assert!( | |
| 244 | + | !servers.iter().any(|s| s.starts_with("100.100.100")), | |
| 245 | + | "MagicDNS leaked into the default-route servers: {servers:?}" | |
| 246 | + | ); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // The three spellings resolvectl actually emits. `yes` and `no` come through | |
| 250 | + | // as bare flags and only the middle setting is written out with an `=`. | |
| 251 | + | #[test] | |
| 252 | + | fn all_three_spellings_of_the_setting_are_understood() { | |
| 253 | + | let with = |protocols: &str| { | |
| 254 | + | let raw = format!("Global\n Protocols: {protocols}\n"); | |
| 255 | + | parse_status(&raw).0 | |
| 256 | + | }; | |
| 257 | + | assert_eq!( | |
| 258 | + | with("LLMNR=resolve -mDNS DNSOverTLS=opportunistic"), | |
| 259 | + | "opportunistic" | |
| 260 | + | ); | |
| 261 | + | assert_eq!(with("LLMNR=resolve -mDNS +DNSOverTLS"), "yes"); | |
| 262 | + | assert_eq!(with("LLMNR=resolve -mDNS -DNSOverTLS"), "no"); | |
| 263 | + | assert_eq!(with("LLMNR=resolve -mDNS"), ""); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn a_machine_with_no_resolved_reads_as_unknown_rather_than_off() { | |
| 268 | + | let (mode, servers) = parse_status(""); | |
| 269 | + | assert_eq!(mode, ""); | |
| 270 | + | assert!(servers.is_empty()); | |
| 271 | + | assert_eq!(encryption(&mode, servers.len(), false), Encryption::Unknown); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // ---- the drop-in the helper writes ---- | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn the_chosen_id_is_read_back_out_of_the_drop_in() { | |
| 278 | + | let written = "\ | |
| 279 | + | # Written by alloy-dns. Chosen in `alloy settings`, System tab. | |
| 280 | + | # | |
| 281 | + | # alloy-resolver: quad9 | |
| 282 | + | # | |
| 283 | + | [Resolve] | |
| 284 | + | DNS=9.9.9.9#dns.quad9.net | |
| 285 | + | DNSOverTLS=yes | |
| 286 | + | "; | |
| 287 | + | assert_eq!(chosen_id(written), Some("quad9")); | |
| 288 | + | assert_eq!( | |
| 289 | + | Choice::parse(chosen_id(written).unwrap()).unwrap().id(), | |
| 290 | + | "quad9" | |
| 291 | + | ); | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | #[test] | |
| 295 | + | fn a_drop_in_with_no_marker_names_nothing() { | |
| 296 | + | assert_eq!(chosen_id("[Resolve]\nDNS=9.9.9.9#dns.quad9.net\n"), None); | |
| 297 | + | assert_eq!(chosen_id(""), None); | |
| 298 | + | assert_eq!(chosen_id("# alloy-resolver: \n"), None); | |
| 299 | + | } |
| @@ -1,0 +1,128 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # | |
| 3 | + | # alloy-dns - point systemd-resolved at a chosen resolver, or back at the | |
| 4 | + | # network's own. | |
| 5 | + | # | |
| 6 | + | # Run by `alloy settings`' System tab through `run0`, because the setting is a | |
| 7 | + | # file in /etc/systemd/resolved.conf.d and writing there is root's. There is no | |
| 8 | + | # `resolvectl` verb that persists across a reboot, and a DNS setting that | |
| 9 | + | # quietly reverts is worse than one that never applied. | |
| 10 | + | # | |
| 11 | + | # THE RESOLVER TABLE IS NOT HERE, ON PURPOSE. The console passes the servers on | |
| 12 | + | # the command line and this writes what it is given. Keeping a second copy of | |
| 13 | + | # the addresses in shell is how the two would start to disagree, and the log | |
| 14 | + | # pane showing the whole argv is the console's promise about every action it | |
| 15 | + | # takes: what is written is what is on screen. | |
| 16 | + | # | |
| 17 | + | # Usage: | |
| 18 | + | # alloy-dns network back to whatever the network hands out | |
| 19 | + | # alloy-dns <id> <server> [server...] name a resolver | |
| 20 | + | # alloy-dns --report <id> [server...] say what would happen, write nothing | |
| 21 | + | # | |
| 22 | + | # A server is systemd-resolved's `DNS=` syntax, `address#hostname`, where the | |
| 23 | + | # hostname is the certificate name. Without it `DNSOverTLS=yes` has nothing to | |
| 24 | + | # validate and the encryption only proves something answered. | |
| 25 | + | ||
| 26 | + | set -eu | |
| 27 | + | ||
| 28 | + | DROP_IN=/etc/systemd/resolved.conf.d/20-alloy-resolver.conf | |
| 29 | + | SHIPPED=/etc/systemd/resolved.conf.d/10-alloy-dns.conf | |
| 30 | + | REPORT=0 | |
| 31 | + | ||
| 32 | + | die() { printf 'alloy-dns: %s\n' "$*" >&2; exit 1; } | |
| 33 | + | ||
| 34 | + | [ $# -ge 1 ] || die "usage: alloy-dns [--report] <id|network> [server...]" | |
| 35 | + | case "$1" in | |
| 36 | + | --report|-n|--dry-run) REPORT=1; shift ;; | |
| 37 | + | esac | |
| 38 | + | [ $# -ge 1 ] || die "no resolver named" | |
| 39 | + | ||
| 40 | + | id="$1"; shift | |
| 41 | + | ||
| 42 | + | # The id is written into a file and read back, so it is bookkeeping rather than | |
| 43 | + | # free text. Refusing anything else keeps a hostile value out of the drop-in. | |
| 44 | + | case "$id" in | |
| 45 | + | *[!a-z0-9-]*|'') die "not a resolver id: $id" ;; | |
| 46 | + | esac | |
| 47 | + | ||
| 48 | + | if [ "$id" = network ]; then | |
| 49 | + | [ $# -eq 0 ] || die "the network's own resolver takes no servers" | |
| 50 | + | if [ "$REPORT" = 1 ]; then | |
| 51 | + | if [ -e "$DROP_IN" ]; then | |
| 52 | + | echo "would remove $DROP_IN, leaving the shipped $SHIPPED" | |
| 53 | + | echo "would restart systemd-resolved" | |
| 54 | + | else | |
| 55 | + | echo "nothing to do: no resolver is set, so the network's own is in use" | |
| 56 | + | fi | |
| 57 | + | exit 0 | |
| 58 | + | fi | |
| 59 | + | if [ -e "$DROP_IN" ]; then | |
| 60 | + | rm -f "$DROP_IN" | |
| 61 | + | echo "removed $DROP_IN" | |
| 62 | + | else | |
| 63 | + | echo "no resolver was set; the network's own was already in use" | |
| 64 | + | fi | |
| 65 | + | systemctl restart systemd-resolved | |
| 66 | + | echo "restarted systemd-resolved" | |
| 67 | + | exit 0 | |
| 68 | + | fi | |
| 69 | + | ||
| 70 | + | [ $# -ge 1 ] || die "$id needs at least one server, as address#hostname" | |
| 71 | + | ||
| 72 | + | # Validate every server before writing any of them. A half-written drop-in | |
| 73 | + | # naming one good server and one malformed line is a resolver that half works, | |
| 74 | + | # which is harder to diagnose than one that was refused. | |
| 75 | + | for server in "$@"; do | |
| 76 | + | case "$server" in | |
| 77 | + | *'#'*) ;; | |
| 78 | + | *) die "$server has no #hostname, so there is no certificate name to check" ;; | |
| 79 | + | esac | |
| 80 | + | case "$server" in | |
| 81 | + | *[!0-9a-fA-F.:#a-z-]*) die "$server is not an address#hostname" ;; | |
| 82 | + | esac | |
| 83 | + | done | |
| 84 | + | ||
| 85 | + | if [ "$REPORT" = 1 ]; then | |
| 86 | + | echo "would write $DROP_IN naming $id:" | |
| 87 | + | for server in "$@"; do echo " DNS=$server"; done | |
| 88 | + | echo " DNSOverTLS=yes" | |
| 89 | + | echo "would restart systemd-resolved" | |
| 90 | + | exit 0 | |
| 91 | + | fi | |
| 92 | + | ||
| 93 | + | # Written beside the file rather than in /tmp, so the rename is within one | |
| 94 | + | # filesystem and resolved never reads a partial drop-in. | |
| 95 | + | tmp="$(mktemp "${DROP_IN}.XXXXXX")" | |
| 96 | + | trap 'rm -f "$tmp"' EXIT | |
| 97 | + | ||
| 98 | + | { | |
| 99 | + | printf '# Written by alloy-dns. Chosen in `alloy settings`, System tab.\n' | |
| 100 | + | printf '#\n' | |
| 101 | + | printf '# The line below is how the choice is read back; it is a comment because\n' | |
| 102 | + | printf '# resolved would reject an unknown key.\n' | |
| 103 | + | printf '# alloy-resolver: %s\n' "$id" | |
| 104 | + | printf '#\n' | |
| 105 | + | printf '# DNSOverTLS=yes rather than opportunistic, and only because a resolver\n' | |
| 106 | + | printf '# was named: these servers are known to speak it, so falling back to\n' | |
| 107 | + | printf '# plaintext would give away the whole point of choosing one. The shipped\n' | |
| 108 | + | printf '# %s stays opportunistic for the network default,\n' "$SHIPPED" | |
| 109 | + | printf '# where the uplink usually cannot.\n' | |
| 110 | + | printf '[Resolve]\n' | |
| 111 | + | for server in "$@"; do printf 'DNS=%s\n' "$server"; done | |
| 112 | + | printf 'DNSOverTLS=yes\n' | |
| 113 | + | } > "$tmp" | |
| 114 | + | ||
| 115 | + | chmod 0644 "$tmp" | |
| 116 | + | mv -f "$tmp" "$DROP_IN" | |
| 117 | + | trap - EXIT | |
| 118 | + | ||
| 119 | + | # /etc is SELinux-labelled and enforcing on an installed machine, and mv | |
| 120 | + | # carries the temp file's label rather than the one the policy wants. Same | |
| 121 | + | # correction build/check-installed.sh exists to catch elsewhere in /etc. | |
| 122 | + | if command -v restorecon >/dev/null 2>&1; then | |
| 123 | + | restorecon -F "$DROP_IN" 2>/dev/null || true | |
| 124 | + | fi | |
| 125 | + | ||
| 126 | + | echo "wrote $DROP_IN naming $id" | |
| 127 | + | systemctl restart systemd-resolved | |
| 128 | + | echo "restarted systemd-resolved" |