max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+335 insertions,
-0 deletions
| @@ -1208,6 +1208,21 @@ | |||
| 1208 | 1208 | # generation, since `nmcli` imports a config but will not mint one. | |
| 1209 | 1209 | # wireguard-tools carries `wg` and `wg-quick`. | |
| 1210 | 1210 | wireguard-tools \ | |
| 1211 | + | # The host firewall, ruled 2026-08-22 (docs/STACK.md `## Firewall`). | |
| 1212 | + | # Before it the image had none at all: firewalld absent, no nftables | |
| 1213 | + | # ruleset, every listener the preset enables reachable from whatever | |
| 1214 | + | # network the machine was on. | |
| 1215 | + | # | |
| 1216 | + | # 2.0 MiB, and the dependency closure is 7.3 MiB because python3 and | |
| 1217 | + | # nftables are already here. The Python daemon that gets cited as | |
| 1218 | + | # firewalld's cost is a library load on this image, not an interpreter. | |
| 1219 | + | # | |
| 1220 | + | # In the base rather than on `server` alone, and the pick says why: a | |
| 1221 | + | # laptop is on more untrusted networks than a server is. The two | |
| 1222 | + | # profiles differ by their default zone, not by whether they have one, | |
| 1223 | + | # and the zone assignment is further down beside the other config-tree | |
| 1224 | + | # assertions. | |
| 1225 | + | firewalld \ | |
| 1211 | 1226 | # Hardware health. The base ships nvme-cli, so NVMe wear and SMART | |
| 1212 | 1227 | # were already readable, and nothing else was: no way to read ECC | |
| 1213 | 1228 | # corrected-error counts, no SMART for SATA, no path to a BMC. That | |
| @@ -2410,6 +2425,34 @@ | |||
| 2410 | 2425 | || { echo "$conf must end with 'Match all' or its block leaks into every later sshd drop-in" >&2; exit 1; }; \ | |
| 2411 | 2426 | echo "installer ssh: gated on alloy.installer, scope closed" | |
| 2412 | 2427 | ||
| 2428 | + | # ===================================================================== | |
| 2429 | + | # The installer's mDNS unit, gated by the same flag as the two above. | |
| 2430 | + | # | |
| 2431 | + | # The headless flow is `ssh installer@<hostname>.local`, and the server | |
| 2432 | + | # profile's firewall zone does not allow mDNS on purpose, because that | |
| 2433 | + | # profile also runs on a public address. The live medium is where that | |
| 2434 | + | # reasoning inverts: it is on a LAN, it has joined no mesh, and it exists | |
| 2435 | + | # for the length of an install. | |
| 2436 | + | # | |
| 2437 | + | # Three ways this unit could ship and do nothing, all silent, and each is | |
| 2438 | + | # checked: the wrong gate (so it would run on installed machines, which is | |
| 2439 | + | # the direction that matters), `--permanent` (which would write the opening | |
| 2440 | + | # into /etc and hand it to the machine being installed), and an ordering | |
| 2441 | + | # that lets it run before firewalld is up, where firewall-cmd fails and the | |
| 2442 | + | # medium is unreachable by name for reasons nobody can see from outside. | |
| 2443 | + | # ===================================================================== | |
| 2444 | + | RUN set -eu; \ | |
| 2445 | + | unit=/etc/systemd/system/alloy-installer-firewall.service; \ | |
| 2446 | + | test -f "$unit" \ | |
| 2447 | + | || { echo "$unit did not land; a server-profile medium would boot undiscoverable" >&2; exit 1; }; \ | |
| 2448 | + | grep -q '^ConditionKernelCommandLine=alloy.installer$' "$unit" \ | |
| 2449 | + | || { echo "$unit does not gate on alloy.installer, so installed machines would open mDNS too" >&2; exit 1; }; \ | |
| 2450 | + | ! grep '^ExecStart=' "$unit" | grep -q -- '--permanent' \ | |
| 2451 | + | || { echo "$unit uses --permanent; the opening would survive into the installed system" >&2; exit 1; }; \ | |
| 2452 | + | grep -q '^After=firewalld.service$' "$unit" \ | |
| 2453 | + | || { echo "$unit is not ordered after firewalld; firewall-cmd would fail with no daemon" >&2; exit 1; }; \ | |
| 2454 | + | echo "installer mdns: gated on alloy.installer, runtime only" | |
| 2455 | + | ||
| 2413 | 2456 | # ===================================================================== | |
| 2414 | 2457 | # The encrypted install path — assert bootc will actually permit it. | |
| 2415 | 2458 | # | |
| @@ -3172,6 +3215,77 @@ | |||
| 3172 | 3215 | || { echo "profile=client picked up the server preset overlay" >&2; exit 1; }; \ | |
| 3173 | 3216 | fi | |
| 3174 | 3217 | ||
| 3218 | + | # ===================================================================== | |
| 3219 | + | # The firewall's default zone, which is where the two profiles differ. | |
| 3220 | + | # | |
| 3221 | + | # The pick and its rejected alternatives are docs/STACK.md `## Firewall`. | |
| 3222 | + | # Both profiles run firewalld; only the zone changes, and the zone is the | |
| 3223 | + | # whole of the policy. | |
| 3224 | + | # | |
| 3225 | + | # CLIENT: Fedora's stock `public`, untouched. It allows ssh, mdns and | |
| 3226 | + | # dhcpv6-client, which is exactly what 50-alloy.preset enables, so there is | |
| 3227 | + | # no Alloy-authored policy to drift from the preset. `FedoraWorkstation` is | |
| 3228 | + | # the zone this is NOT: it opens 1025-65535 on tcp and udp, and it is the | |
| 3229 | + | # desktop spin's default, so leaving the zone unstated would have been a | |
| 3230 | + | # plausible way to ship almost nothing. The client branch asserts the | |
| 3231 | + | # default is still `public` rather than assuming Fedora keeps it there. | |
| 3232 | + | # | |
| 3233 | + | # SERVER: `alloy-server`, installed from usr/share/alloy/firewalld/. Same | |
| 3234 | + | # set minus mdns, because this profile also runs on a public address. | |
| 3235 | + | # | |
| 3236 | + | # `firewall-offline-cmd` rather than `firewall-cmd`: there is no daemon in | |
| 3237 | + | # a build, and the offline tool writes /etc/firewalld/firewalld.conf | |
| 3238 | + | # directly. It also refuses a zone that does not parse, which is what makes | |
| 3239 | + | # this a check on the shipped XML rather than a file copy. | |
| 3240 | + | # ===================================================================== | |
| 3241 | + | RUN set -eu; \ | |
| 3242 | + | if [ "$PROFILE" = server ]; then \ | |
| 3243 | + | src=/usr/share/alloy/firewalld/alloy-server.xml; \ | |
| 3244 | + | test -f "$src" \ | |
| 3245 | + | || { echo "$src did not land from the config tree" >&2; exit 1; }; \ | |
| 3246 | + | install -m 0644 "$src" /etc/firewalld/zones/alloy-server.xml; \ | |
| 3247 | + | firewall-offline-cmd --set-default-zone=alloy-server >/dev/null; \ | |
| 3248 | + | [ "$(firewall-offline-cmd --get-default-zone)" = alloy-server ] \ | |
| 3249 | + | || { echo "the default zone did not take; the server would run the client's policy" >&2; exit 1; }; \ | |
| 3250 | + | firewall-offline-cmd --zone=alloy-server --query-service=ssh >/dev/null \ | |
| 3251 | + | || { echo "the server zone does not permit ssh; the profile with no console would be unreachable" >&2; exit 1; }; \ | |
| 3252 | + | ! firewall-offline-cmd --zone=alloy-server --query-service=mdns >/dev/null \ | |
| 3253 | + | || { echo "the server zone permits mdns; that subtraction is the reason this zone exists" >&2; exit 1; }; \ | |
| 3254 | + | echo "firewall: default zone alloy-server (ssh, dhcpv6-client)"; \ | |
| 3255 | + | else \ | |
| 3256 | + | test ! -e /etc/firewalld/zones/alloy-server.xml \ | |
| 3257 | + | || { echo "profile=client installed the server zone" >&2; exit 1; }; \ | |
| 3258 | + | [ "$(firewall-offline-cmd --get-default-zone)" = public ] \ | |
| 3259 | + | || { echo "the default zone is not public; Fedora moved it and the client policy is now whatever it moved to" >&2; exit 1; }; \ | |
| 3260 | + | firewall-offline-cmd --zone=public --query-service=mdns >/dev/null \ | |
| 3261 | + | || { echo "the public zone no longer permits mdns; the .local install flow would break" >&2; exit 1; }; \ | |
| 3262 | + | echo "firewall: default zone public (ssh, mdns, dhcpv6-client)"; \ | |
| 3263 | + | fi | |
| 3264 | + | ||
| 3265 | + | # ===================================================================== | |
| 3266 | + | # The firewall's one exempt interface, asserted on both profiles. | |
| 3267 | + | # | |
| 3268 | + | # etc/firewalld/zones/trusted.xml overrides the package's copy and adds | |
| 3269 | + | # `<interface name="tailscale0"/>`. Without it, firewalld drops every | |
| 3270 | + | # inbound tailnet connection the moment it starts: tailscaled writes its own | |
| 3271 | + | # nftables tables, and a base chain's accept in one table does not survive | |
| 3272 | + | # another table's drop on the same hook. That is measured, in a network | |
| 3273 | + | # namespace, and the file carries the detail. | |
| 3274 | + | # | |
| 3275 | + | # The failure this asserts against is silent and specific: a zone file that | |
| 3276 | + | # is present, parses, and binds nothing. Reading the file back through | |
| 3277 | + | # firewalld is the only way to tell that from a working one. | |
| 3278 | + | # ===================================================================== | |
| 3279 | + | RUN set -eu; \ | |
| 3280 | + | zone=/etc/firewalld/zones/trusted.xml; \ | |
| 3281 | + | test -f "$zone" \ | |
| 3282 | + | || { echo "$zone did not land from the config tree" >&2; exit 1; }; \ | |
| 3283 | + | firewall-offline-cmd --zone=trusted --query-interface=tailscale0 >/dev/null \ | |
| 3284 | + | || { echo "tailscale0 is not bound to the trusted zone; enabling tailscaled would kill inbound tailnet traffic" >&2; exit 1; }; \ | |
| 3285 | + | [ "$(firewall-offline-cmd --zone=trusted --get-target)" = ACCEPT ] \ | |
| 3286 | + | || { echo "the trusted zone no longer accepts; the binding above would bind to a zone that drops" >&2; exit 1; }; \ | |
| 3287 | + | echo "firewall: tailscale0 bound to the trusted zone" | |
| 3288 | + | ||
| 3175 | 3289 | # ===================================================================== | |
| 3176 | 3290 | # Machine identity, from the builder: hostname and ssh pubkey. | |
| 3177 | 3291 | # ===================================================================== |
| @@ -387,6 +387,8 @@ | |||
| 387 | 387 | ||
| 388 | 388 | **`tailscale0` is pinned to the `trusted` zone**, on both profiles, and this is the part that would silently break if it were left out. tailscaled writes its own nftables tables (`ts-input`, `ts-forward`, `ts-postrouting`) and has no firewalld awareness whatsoever: zero matching strings in the 1.102.3 binary in this image. Under nftables every base chain returns its own verdict, so firewalld rejecting a packet in its table is final no matter what tailscale accepted in its own, and the whole tree is addressed by tailnet name. Trusting the tailnet is not a new position: Tailscale SSH already bypasses `authorized_keys` on these hosts, so the ACL is the access control and has been for as long as the tailnet has existed. | |
| 389 | 389 | ||
| 390 | + | **The installer medium is the one exception to the server zone, and it needs a unit.** The headless install flow is `ssh installer@<hostname>.local`, so a server-profile ISO whose zone refuses mDNS boots a machine that is waiting to be installed and cannot be found. `alloy-installer-firewall.service` opens mDNS at runtime, gated on the same `alloy.installer` kernel argument as the installer account and the tty1 wizard, so it is inert on every installed system. Runtime rather than permanent: the medium does not survive a reboot and the machine being installed must not inherit the opening. | |
| 391 | + | ||
| 390 | 392 | Rejected: **a hand-written nftables ruleset in the image.** Smaller and more legible on the page, and it owns every interaction firewalld already handles. tailscale and netavark both write rules; NetworkManager knows about firewalld zones and knows nothing about a file; and every future service that needs a port becomes an image rebuild instead of a command. Alloy ships no hand-written ruleset anywhere else either, so this would be a bespoke mechanism with exactly one user. | |
| 391 | 393 | ||
| 392 | 394 | Rejected: **a firewall on the server profile only.** It is the cheaper half of the work and it defends the machine that is already the least exposed to the case a firewall is for, which is an untrusted LAN. A laptop is on more networks than a server is. |
| @@ -21,6 +21,49 @@ | |||
| 21 | 21 | ||
| 22 | 22 | Once it is saved, `alloy net` connects to it like any other. | |
| 23 | 23 | ||
| 24 | + | ## The firewall | |
| 25 | + | ||
| 26 | + | Alloy runs firewalld, on every install and both profiles. Inbound connections | |
| 27 | + | are refused unless something allows them; outbound traffic is untouched. | |
| 28 | + | ||
| 29 | + | What is allowed by default on a desktop install is `ssh`, mDNS, and DHCPv6 | |
| 30 | + | replies. That is the same list as the services Alloy actually runs, which is | |
| 31 | + | the point: the firewall is the enforcement of the defaults rather than a | |
| 32 | + | separate policy to keep in step with them. | |
| 33 | + | ||
| 34 | + | Printing needs nothing here. CUPS listens on a unix socket and on localhost, | |
| 35 | + | and loopback is not filtered. | |
| 36 | + | ||
| 37 | + | To open something you installed yourself: | |
| 38 | + | ||
| 39 | + | run0 firewall-cmd --permanent --add-port=8080/tcp | |
| 40 | + | run0 firewall-cmd --reload | |
| 41 | + | ||
| 42 | + | `--add-service=<name>` takes a name from `firewall-cmd --get-services` and is | |
| 43 | + | better when one exists, because it carries every port the service needs. | |
| 44 | + | ||
| 45 | + | To see what is currently allowed: | |
| 46 | + | ||
| 47 | + | firewall-cmd --list-all | |
| 48 | + | ||
| 49 | + | **A server image is stricter by one entry.** It allows `ssh` and DHCPv6 and not | |
| 50 | + | mDNS, because that profile is also what runs on a machine with a public | |
| 51 | + | address, and answering mDNS to the internet buys nothing. If your server is on | |
| 52 | + | a LAN and you want to reach it at `<name>.local`: | |
| 53 | + | ||
| 54 | + | run0 firewall-cmd --permanent --add-service=mdns | |
| 55 | + | run0 firewall-cmd --reload | |
| 56 | + | ||
| 57 | + | Without it the machine is still findable: it sends its hostname over DHCP, so | |
| 58 | + | it appears in the router's lease table under the name it was minted with. | |
| 59 | + | ||
| 60 | + | **The tailnet is exempt.** The `tailscale0` interface is in firewalld's trusted | |
| 61 | + | zone, so anything reaching this machine over Tailscale is accepted on any port | |
| 62 | + | regardless of the rules above. That is deliberate, and it is worth knowing | |
| 63 | + | rather than discovering: a service you start for yourself is reachable by every | |
| 64 | + | machine on your tailnet, without opening a port. Access there is controlled by | |
| 65 | + | your Tailscale ACL rather than by this firewall. | |
| 66 | + | ||
| 24 | 67 | ## Mesh: Tailscale | |
| 25 | 68 | ||
| 26 | 69 | alloy mesh | |
| @@ -61,6 +104,15 @@ | |||
| 61 | 104 | ||
| 62 | 105 | systemctl --user enable --now syncthing.service | |
| 63 | 106 | ||
| 107 | + | Syncing over the LAN also needs the firewall opened, because Syncthing is off | |
| 108 | + | by default and Alloy does not open ports for services nobody is running: | |
| 109 | + | ||
| 110 | + | run0 firewall-cmd --permanent --add-service=syncthing | |
| 111 | + | run0 firewall-cmd --reload | |
| 112 | + | ||
| 113 | + | Two machines on the same tailnet need none of that. The tailnet is exempt from | |
| 114 | + | the firewall, so they find each other with no rule at all. | |
| 115 | + | ||
| 64 | 116 | One asymmetry to know about: you can accept a pending device but not decline | |
| 65 | 117 | it. The command-line client Syncthing ships has no verb for dropping a pending | |
| 66 | 118 | entry, so an unaccepted device stays listed. The view says so when you press |
| @@ -188,6 +188,25 @@ | |||
| 188 | 188 | ||
| 189 | 189 | ## Network access | |
| 190 | 190 | ||
| 191 | + | ### A service I started is unreachable from another machine | |
| 192 | + | ||
| 193 | + | Alloy refuses inbound connections it was not told to allow, so a server you | |
| 194 | + | started on a port is reachable from the machine itself and from nowhere else. | |
| 195 | + | See what is open: | |
| 196 | + | ||
| 197 | + | firewall-cmd --list-all | |
| 198 | + | ||
| 199 | + | Then allow yours, permanently: | |
| 200 | + | ||
| 201 | + | run0 firewall-cmd --permanent --add-port=8080/tcp | |
| 202 | + | run0 firewall-cmd --reload | |
| 203 | + | ||
| 204 | + | Two things that are not the firewall, checked before reaching for it. If the | |
| 205 | + | other machine is on your tailnet, the tailnet is exempt already, so a failure | |
| 206 | + | there is the service binding to localhost rather than to every interface. And a | |
| 207 | + | server profile does not answer mDNS, so `<name>.local` failing while the IP | |
| 208 | + | address works is chapter 12 rather than a firewall rule. | |
| 209 | + | ||
| 191 | 210 | ### SSH says permission denied, and I know the password is right | |
| 192 | 211 | ||
| 193 | 212 | The password is not the problem. Alloy accepts a key and nothing else: |
| @@ -36,6 +36,11 @@ | |||
| 36 | 36 | # but a line that read `enable` on a unit missing its condition would be the | |
| 37 | 37 | # other direction, which is why the condition lives in the unit and not here. | |
| 38 | 38 | enable alloy-installer-ssh.service | |
| 39 | + | # The third piece of the same flow, gated by the same flag, added with the | |
| 40 | + | # firewall on 2026-08-22. A server-profile medium's zone does not allow mDNS, | |
| 41 | + | # so without this a headless install medium boots and cannot be found at | |
| 42 | + | # `<name>.local`. It opens mDNS at runtime only; see the unit. | |
| 43 | + | enable alloy-installer-firewall.service | |
| 39 | 44 | # Lays the console and shop down as layered packages on a freshly installed | |
| 40 | 45 | # machine, from the repo that rides inside the image at /usr/share/alloy/rpm, | |
| 41 | 46 | # so it needs no network and nothing copied off the medium. They cannot ship | |
| @@ -116,6 +121,26 @@ | |||
| 116 | 121 | # and is an access question, and they do not resolve the same way. | |
| 117 | 122 | enable sshd.service | |
| 118 | 123 | ||
| 124 | + | # firewalld, and it is the enforcement of every exposure comment in this file | |
| 125 | + | # rather than a new capability. Until 2026-08-22 there was no host firewall at | |
| 126 | + | # all, so each `enable` line above stated an exposure that nothing bounded: the | |
| 127 | + | # sshd note two paragraphs up describes a listener reachable from any network | |
| 128 | + | # the machine joins, and that was literally true. | |
| 129 | + | # | |
| 130 | + | # The zone is what the policy is, and the two profiles differ there rather than | |
| 131 | + | # here: `public` on a client (ssh, mdns, dhcpv6-client, which is exactly what | |
| 132 | + | # this file enables), Alloy's own `alloy-server` zone on a server, which is the | |
| 133 | + | # same minus mdns. Both are asserted at build time; see the Containerfile. | |
| 134 | + | # | |
| 135 | + | # The exposure this line changes is inbound only. Outbound is untouched, and | |
| 136 | + | # firewalld is not an application firewall. | |
| 137 | + | # | |
| 138 | + | # One interface is exempt and it is deliberate: tailscale0 is bound to the | |
| 139 | + | # `trusted` zone (etc/firewalld/zones/trusted.xml), because tailscaled writes | |
| 140 | + | # its own nftables tables and firewalld would otherwise drop every inbound | |
| 141 | + | # tailnet connection. That file carries the measurement and the cost. | |
| 142 | + | enable firewalld.service | |
| 143 | + | ||
| 119 | 144 | disable tailscaled.service | |
| 120 | 145 | ||
| 121 | 146 | # bluetooth was on before this line existed, and that was the problem: Fedora's |
| @@ -1,0 +1,40 @@ | |||
| 1 | + | <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + | <!-- | |
| 3 | + | Alloy's copy of firewalld's `trusted` zone, which exists for one line: the | |
| 4 | + | interface binding at the bottom. A file here overrides the package's copy in | |
| 5 | + | /usr/lib/firewalld/zones/, so the rest of this is that file verbatim. | |
| 6 | + | ||
| 7 | + | WHY tailscale0 IS PINNED HERE. tailscaled writes its own nftables tables | |
| 8 | + | (ts-input, ts-forward, ts-postrouting) and has no firewalld awareness at all: | |
| 9 | + | zero matching strings in the 1.102.3 binary this image ships. Under nftables | |
| 10 | + | every base chain on a hook returns its own verdict, so firewalld dropping a | |
| 11 | + | packet in its table is final no matter what tailscale accepted in its own. | |
| 12 | + | ||
| 13 | + | That is measured, not reasoned: two tables in a network namespace, one with | |
| 14 | + | an accepting base chain at priority -100 and one with `policy drop` at | |
| 15 | + | priority 10, and the packet dies. Without this file, bringing firewalld up on | |
| 16 | + | a machine running tailscaled kills every inbound tailnet connection, and the | |
| 17 | + | whole tree is addressed by tailnet name. | |
| 18 | + | ||
| 19 | + | WHAT IT COSTS, stated because `trusted` means what it says: anything reaching | |
| 20 | + | this machine over the tailnet is accepted, on any port, including ports the | |
| 21 | + | default zone refuses from the LAN. That is not a new position. Tailscale SSH | |
| 22 | + | already bypasses authorized_keys on these hosts, so the tailnet ACL has been | |
| 23 | + | the access control here for as long as the tailnet has existed. If that ever | |
| 24 | + | stops being true, this file is where the change goes. | |
| 25 | + | ||
| 26 | + | The binding is declarative rather than a `firewall-cmd` at first boot: a | |
| 27 | + | command that has to run is a command that can fail to run, and on a headless | |
| 28 | + | box its failure is invisible until someone cannot reach it. | |
| 29 | + | ||
| 30 | + | tailscaled is disabled by default (etc/systemd/system-preset/50-alloy.preset). | |
| 31 | + | An interface that does not exist binds nothing, so this file is inert until | |
| 32 | + | someone turns the mesh on, which is the point at which it has to already be | |
| 33 | + | correct. | |
| 34 | + | --> | |
| 35 | + | <zone target="ACCEPT"> | |
| 36 | + | <short>Trusted</short> | |
| 37 | + | <description>All network connections are accepted.</description> | |
| 38 | + | <forward/> | |
| 39 | + | <interface name="tailscale0"/> | |
| 40 | + | </zone> |
| @@ -1,0 +1,44 @@ | |||
| 1 | + | # Opens mDNS on the installer medium only, so a headless install stays findable. | |
| 2 | + | # | |
| 3 | + | # WHY THIS EXISTS. The server profile's firewall zone deliberately does not | |
| 4 | + | # allow mDNS: that profile also runs on machines with a public address, and | |
| 5 | + | # answering 5353 to the internet is not what buys discovery on a LAN | |
| 6 | + | # (usr/share/alloy/firewalld/alloy-server.xml says it at length). | |
| 7 | + | # | |
| 8 | + | # The live installer medium is the one place that reasoning inverts. The | |
| 9 | + | # headless flow IS `ssh installer@<hostname>.local` (docs/manual/03-installing.md), | |
| 10 | + | # the medium is on a LAN and has joined no mesh, and it exists for the length of | |
| 11 | + | # an install. A server-profile ISO without this unit boots a machine that is | |
| 12 | + | # waiting to be installed and cannot be found, which is the exact failure the | |
| 13 | + | # baked hostname exists to prevent. | |
| 14 | + | # | |
| 15 | + | # Gated on `alloy.installer`, the same kernel argument as the installer account | |
| 16 | + | # and the tty1 wizard, and written by the ISO build's GRUB entries only. So it | |
| 17 | + | # is inert on an installed system, which is the same image. | |
| 18 | + | # | |
| 19 | + | # RUNTIME, NOT PERMANENT. `--add-service` without `--permanent` writes nothing | |
| 20 | + | # to /etc and does not survive a reboot. The live medium does not survive a | |
| 21 | + | # reboot either, and an installed system must never inherit this. | |
| 22 | + | # | |
| 23 | + | # Fails safe in the direction that matters. If the condition does not hold, or | |
| 24 | + | # firewalld is not up, or this unit fails outright, the medium is merely not | |
| 25 | + | # discoverable by name and the installer is still reachable by IP address from | |
| 26 | + | # the router's lease table. Nothing is opened that should have stayed shut. | |
| 27 | + | # | |
| 28 | + | # No-op on a client-profile medium, where the default zone already allows mDNS. | |
| 29 | + | # Stated rather than conditional: a unit that asks for what is already true is | |
| 30 | + | # cheaper than one that has to work out which profile it is on. | |
| 31 | + | [Unit] | |
| 32 | + | Description=Alloy installer mDNS | |
| 33 | + | Documentation=https://git.sr.ht/~maxmj/alloy | |
| 34 | + | ConditionKernelCommandLine=alloy.installer | |
| 35 | + | After=firewalld.service | |
| 36 | + | Requires=firewalld.service | |
| 37 | + | ||
| 38 | + | [Service] | |
| 39 | + | Type=oneshot | |
| 40 | + | RemainAfterExit=yes | |
| 41 | + | ExecStart=/usr/bin/firewall-cmd --add-service=mdns | |
| 42 | + | ||
| 43 | + | [Install] | |
| 44 | + | WantedBy=multi-user.target |
| @@ -1,0 +1,39 @@ | |||
| 1 | + | <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + | <!-- | |
| 3 | + | The server profile's default zone. NOT active from here: this path is the | |
| 4 | + | image's copy, and the server branch of the Containerfile installs it into | |
| 5 | + | /etc/firewalld/zones/ and makes it the default. On a client it ships and does | |
| 6 | + | nothing, the same arrangement as usr/share/alloy/flatpak/global. | |
| 7 | + | ||
| 8 | + | `public` minus mdns, and that one subtraction is the whole reason this zone | |
| 9 | + | exists rather than the stock one. | |
| 10 | + | ||
| 11 | + | WHY mdns COMES OUT. avahi runs on this profile deliberately: a headless box | |
| 12 | + | minted with a baked hostname is reached at `<name>.local`, and the install | |
| 13 | + | flow depends on it. But `server` is also the profile the Hetzner boxes get | |
| 14 | + | (wiki `host-base-images`), and those have a public address with nothing in | |
| 15 | + | front of it. Answering 5353 to the internet is not what buys LAN discovery. | |
| 16 | + | ||
| 17 | + | WHAT IT COSTS: a fresh headless install on a LAN is not discoverable by name | |
| 18 | + | until someone grants it. The fallback is real and already in place, which is | |
| 19 | + | why this trade is affordable — NetworkManager sends the same baked hostname | |
| 20 | + | in DHCP option 12, so the machine appears in the router's lease table, and | |
| 21 | + | the name was known at mint time anyway. | |
| 22 | + | ||
| 23 | + | GRANTING IT BACK, on a LAN server, is a permanent add-service of mdns | |
| 24 | + | through firewall-cmd, followed by a reload. The manual gives the exact | |
| 25 | + | commands (chapter 12); they cannot be written here, because a double hyphen | |
| 26 | + | is not legal inside an XML comment and firewalld rejects the whole file for | |
| 27 | + | it. That is not a hypothetical: this file was written with the commands in | |
| 28 | + | it, and the build-time zone check is what caught it. | |
| 29 | + | ||
| 30 | + | ssh is here for the obvious reason and dhcpv6-client because a machine that | |
| 31 | + | cannot accept its own DHCPv6 reply has no IPv6 address. | |
| 32 | + | --> | |
| 33 | + | <zone> | |
| 34 | + | <short>Alloy Server</short> | |
| 35 | + | <description>Alloy's server profile: ssh and DHCPv6 only. mDNS is deliberately absent, because this profile is also what runs on a public address.</description> | |
| 36 | + | <service name="ssh"/> | |
| 37 | + | <service name="dhcpv6-client"/> | |
| 38 | + | <forward/> | |
| 39 | + | </zone> |