Skip to main content

max / alloy

Ship the firewall: firewalld, a zone per profile, tailscale exempt The pick is alloy@fcd829a and docs/STACK.md `## Firewall`; this is the implementation. Before it the image had no host firewall at all, so every `enable` line in 50-alloy.preset stated an exposure that nothing bounded -- the sshd comment there describes a listener reachable from any network the machine joins, and that was literally true. firewalld on both profiles, enabled in the preset. The client keeps Fedora's stock `public` zone, which allows ssh, mdns and dhcpv6-client and is exactly the set the preset enables, so there is no Alloy-authored policy that can drift from the listeners it describes. The server takes `alloy-server`, the same set minus mdns, because that profile also runs on a public address. `tailscale0` is pinned to the `trusted` zone on both, and the ruling had only inferred that this was necessary. It is measured now: two nftables tables in a network namespace, one with an accepting base chain at priority -100 and one with `policy drop` at 10, and the packet dies. Each base chain returns its own verdict, so firewalld's drop is final regardless of what tailscale accepted in its own table. Without the pin, enabling tailscaled would kill every inbound tailnet connection on a machine whose whole tree is addressed by tailnet name. THE REGRESSION THIS ALMOST SHIPPED, and the reason there is a third 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 `alloy.installer` like 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. Four assertions, on the same principle as the polkit grants and the hardening drop-ins. The default zone matches the profile, the server zone has ssh and does not have mdns, tailscale0 is bound and the zone it is bound to still accepts, and the installer unit carries the right gate, no `--permanent`, and an ordering after firewalld. Two of them earned their keep during this commit: the zone check caught a server zone that did not parse, because a double hyphen is not legal inside an XML comment and the file had `firewall-cmd --permanent --add-service=mdns` written into one, and the unit check caught its own `--permanent` grep matching the word in a comment rather than in the command. Both profiles built and read back. Client: default zone public, tailscale0 in trusted, no server zone installed. Server: default zone alloy-server, services ssh and dhcpv6-client, mdns absent, tailscale0 in trusted, and avahi still installed so the installer unit has something to answer with. Documented where a person meets it rather than only where it is configured: chapter 12 gains the firewall section, the Syncthing entry says enabling it needs a port and that tailnet peers need nothing, and chapter 14 gains the case where a service is unreachable from another machine. Not done here, and it is the last open piece of this: nobody has watched a real tailnet connection survive firewalld on a booted Alloy machine, because none exists yet. The netns measurement covers why the pin is there; it does not cover the whole path.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 14:19 UTC
Signed with PGP, not checked
Commit: 7cf375db11c134d2c22327904ea52c6f78343426
Parent: fcd829a
8 files changed, +335 insertions, -0 deletions
M Containerfile +114
@@ -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>