Skip to main content

max / alloy

Let a person choose who answers DNS, and say whether it is encrypted Ruled on GO alloy ffe3b139: a no-recommendation choice with the most reasonable default. The default does not move, because the only option that names no company is the network's own resolver on opportunistic. A row carries the operator and nothing else except what this machine watched happen. Measured on fw12 rather than read off websites: every IPv4 address took a connection on 853; AdGuard returned 0.0.0.0 for flurry.com and doubleclick.net and resolved the other two probes, which is the filtering it carries; Mullvad returned NXDOMAIN for all four including example.com, which is not filtering but a resolver that serves no plaintext at all. Cloudflare, Google and Quad9 filtered none of the four, so their rows say nothing rather than "blocks nothing". The IPv6 addresses are unverified and say so: fw12 has a global v6 address and no v6 default route, so the probe proves nothing. A privacy policy is a claim rather than a fact and is not carried at all. The second row is the one that is not a choice. resolvectl reports the setting, not whether anything is encrypted, and on that machine it said DNSOverTLS=opportunistic while 853 was closed on both uplinks and every lookup went out in the clear. dns::Encryption answers that instead, and a test holds the copy to it the way stale.rs holds its own. alloy-dns writes the drop-in under run0, since the setting is a file in resolved.conf.d and no resolvectl verb persists. It is handed the addresses on its command line rather than keeping a second copy of the table in shell, which is also what keeps the log pane's promise that what is written is what is on screen.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 18:40 UTC
Signed with PGP, not checked
Commit: 0839b7f61b5126e7b69d258ae6d2c1d426155601
Parent: 3fc5628
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"