Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
44 KiB
The installer: contract
Date: 2026-09-07 · Measured with: dev/installer-probe.sh against
technitium/dns-server:latest (v15.4.0.0) · Ratified 2026-09-07 after two
readings: F4, W3, A1, A7 and S2 rewritten in the first, two of them because
they contradicted other clauses, and A2 in the second, because it promised
something no installer can deliver, and implemented the same day.
Read this before touching
install.sh. The installer is not missing: it was written on 2026-09-01 (b20842d) and it already does most of what it should. What is missing is the contract it has to meet, and a way to say whether it meets it. This document is the first;dev/installer-probe.shis the second.
Where it stands: all twenty-one clauses are met, and all twenty-one probe
cases have been seen passing. Nineteen of them against the official image, and
the two mode B cases against a server built from the fork branch that carries the
variable: because until that branch is merged, that is the only server there is
that honours it. Building one took four commands; they are in the history of
dev/README.md. Those numbers come out of the probe, not out of this paragraph.
2026-09-30. The security audit of that day found three ways the installer could remove what is not a console. They are now clauses W6, W7 and A8, with a fourth, I1, for the download itself: twenty-five clauses and twenty-five cases. Measured against
technitium/dns-server:latest(v15.5.1, which honours the variable): 24 met, 0 not met, 1 not applicable (C15). C22 to C25 were first seen failing against theinstall.shofHEAD.
2026-10-01. Docker gets an install path of its own: an init image that copies the console into a volume the official server mounts (README.md, "Docker"). Four clauses for it, D1 to D4; amendments to W6, A6 and I1, W4 giving way inside the image (§3, "Docker") and the Docker host's steps in §4; and five cases, C26 to C30, with C11, C17 and C22 extended: thirty cases. Measured against
technitium/dns-server:latest(v15.5.1): 29 met, 0 not met, 1 not applicable (C15). C11 and C26 to C30 were first seen failing against theinstall.shofmain, and so were C17's step for a TERM mid-sweep and C22's for--dir /. What C22's last step checks, no state folder left by a refused run, was seen failing by hand against theinstall.shof 7cf2d09. And a correction the same day: the variable is upstream's since v15.5 (§1.7), so the official image honours it, the fork build the 2026-09-07 status relies on is not needed any more, and the†has come off W2, F3 and S2.
2026-10-02. Bare-metal systemd setup is now managed end to end. C31 measures first activation, an update without a restart, clean uninstall and refusal to remove an administrator-modified drop-in: thirty-one cases.
The console goes in front of a DNS server that a whole house resolves through. The installer is the only part of this project that writes to somebody else's machine, so it is the part that has to be boring.
1 · What upstream does
Everything in this section is read from upstream's own sources in the fork
(projects/technitium-ui/) or measured against the official image. Nothing here
is inferred from documentation.
1.1 Where the web root comes from
DnsWebService.cs:1832 (upstream master):
builder.Environment.WebRootFileProvider = new PhysicalFileProvider(Path.Combine(_appFolder, "www"))
_appFolder is the directory of DnsServerApp.dll (DnsWebService.cs:165), so
the web root is always a www folder next to the binary. It is not
configurable, it is not in dns.config, and nothing in the console can change
it.
2026-10-01: that was upstream before v15.5. Since v15.5 the provider is built
from the folder DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH names when it is set
and the folder exists, and from www next to the binary otherwise
(DnsWebService.cs:1805-1816 at the v15.5.1 tag; §1.7). It is still not in
dns.config, and nothing in the console can change it. What follows holds for
whichever folder is served; at v15.5.1 the no-cache line is :1950.
Two consequences that the installer lives with:
- The provider is created once, at start, from a path string, and resolves every request against it. Replacing the folder's contents (even deleting the folder and recreating it) is picked up without restarting the service (probe C11). Changing which folder is served is not: that needs a restart.
- Every static file is answered with
Cache-Control: no-cache(DnsWebService.cs:1977), and this console's assets are content-hashed on top of that. There is no browser-cache problem to warn anybody about.
Dotfiles in the web root are not served: /.technitium-console answers 404,
a sibling without the dot answers 200. That is ASP.NET's default exclusion of
hidden files, and it matters twice: an in-root marker cannot be verified over
HTTP, and a non-hidden one would be readable by anyone who can reach the login
page.
1.2 Installing on bare metal
DnsServerApp/install.sh: installs the ASP.NET runtime under /opt/dotnet,
downloads DnsServerPortable.tar.gz, extracts it into $dnsDir, installs ICU,
creates the dns-server system user, writes dns.service (systemd) or
/etc/init.d/dns (OpenRC), and rewrites /etc/resolv.conf to 127.0.0.1.
$dnsDir is /etc/dns when /etc/dns/config exists and /opt/technitium/dns
otherwise. That condition is about an older layout that kept the application
under /etc/dns, not about Alpine: the current image has no /etc/dns/config
and Alpine follows the same rule as Debian. The comment in our install.sh
saying otherwise is wrong; the folder list it produces is still right.
1.3 Updating on bare metal
The same script. An update is tar -zxf over the installation directory:
an extraction, not a replacement:
files present in the archive are overwritten; files that are not in it stay where they are.
So after a server update a web root carrying this console holds both
consoles: upstream's index.html, js/, css/ restored on top of our
assets/, admin/, zones/… and the server serves upstream's index.html.
The administrator gets the stock console back and a directory of orphans
underneath (measured; the probe rebuilds this state in C3).
1.4 Docker
The web root lives inside the image at /opt/technitium/dns/www
(Dockerfile). An update is a new image, so anything written into the
container's own files is gone with the next recreate: which is what
docker exec <c> sh -c "curl … | sh" installs into, and the installer now says
so when it does (C11).
2026-10-01: two layouts survive. Since v15.5 the variable can point at a
volume or a bind mount, and that is the one the README gives Docker users: an
init image, ghcr.io/bygarcia/technitium-console, copies the console into a
volume that the server mounts read-only (D1–D4). Before 15.5 the only one is a
bind mount from the host over www, which is what dev/compose.yaml does for
development. The previous wording ("the only layout that survives is a bind
mount over that path") stopped being true with 15.5.
1.5 Windows
DnsServerWindowsSetup/appinstall.iss, a different installer with a different
layout. Out of scope. The README should say so instead of leaving a POSIX
script to fail on its own.
1.6 The custom lists
www/json/readme.txt is upstream's own contract with the administrator: any
<name>-builtin.json can be overridden by a <name>-custom.json written by
hand, the custom file always wins, and the built-in ones are expected to be
overwritten on update. Three pairs exist today (quick-block-lists,
quick-forwarders-list, dnsclient-server-list), read at
www/js/main.js:816, :855 and www/js/dnsclient.js:53, and by this console
at src/lib/quick-lists.ts:38.
It is an open set by design: the rule is the naming pattern, not the three names. The installer must treat it as a glob, and does (probe C4).
These files live in the web root and in no release. Replacing the web root deletes them, which is the whole reason this clause exists.
1.7 The environment variable
2026-10-01: it is upstream's since v15.5 (2026-09-19): CHANGELOG.md:10
at the v15.5.0 tag, "Added new DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH
environment variable … PR #2138", and v15.5.1 carries it.
DnsWebService.cs:1805-1814 at v15.5.1 reads it, uses www when it is unset,
and when the folder does not exist writes the log line W3 quotes and falls back
to www. Upstream documents it in EnvironmentVariables.md:11, a file of its
own for variables read on every start, whose note says that changing one needs a
restart: not in DockerEnvironmentVariables.md, as the PR had it. The
official image honours it since v15.5: the probe measured it against
technitium/dns-server:latest (v15.5.1) on 2026-09-30, and C12 and C13 ran and
were met against it. What follows is this section as written on 2026-09-07, when
the variable did not exist in any release; its first bullet is true only of
servers before 15.5, and the installer still does not trust a version number for
it (W3).
DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH is twelve lines on the fork's
feat/configurable-www-folder branch (1f097ea1): it overrides the web root and
falls back to the default when unset or when the folder does not exist, so the
server always starts. The maintainer has said in writing that he will merge it;
the PR is pushed but not opened.
Two facts the installer has to respect:
- No released server honours it. Setting it on the official image changes nothing: the stock console keeps being served (measured). An installer that trusted the variable would install into a folder nobody reads and report success.
- It is discoverable without credentials: it shows up in the environment of
the running process (
/proc/1/environin the container, theDnsServerAppprocess on bare metal), which is where an installer should read it from rather than guessing at unit files or compose files.
The PR also had a hole: the variable was not in DockerEnvironmentVariables.md,
where upstream documents every other one: and that file opens with a NOTE
saying its variables are read only on first start, which is exactly what this
one does not do. Both are fixed before opening: the row is there and the
exception is named.
And the branch has now been built and run, which it never had been: a server from it serves the folder the variable names, falls back with its log line when the folder is missing, and a console installed into that folder is what answers (C12, C13). Twelve lines that nobody had executed are not twelve lines that work; now they are.
1.8 Version, without a token
api/user/checkForUpdate needs a session. The server prints its version at
startup into /var/log/technitium/dns/<date>.log:
[2026-09-07 06:03:29 UTC] DNS Server (v15.4.0.0) was started successfully.
That is the credential-free source for the version, and it is what the backup needs in order to know whether it has gone stale.
2 · Two installation modes
The contract below is written for both, because which one applies is not our choice: it depends on the server in front of us.
Mode A: replacement. The only mode possible today: install over
<appFolder>/www, keep a copy of the stock console, restore it on uninstall.
It cannot survive a server update (§1.3), so under this mode an update means the
administrator re-runs the installer. 2026-10-01: no longer the only mode since
v15.5 (§1.7). It is what the installer does when the variable is not set, and
the only mode on a server before 15.5.
Mode B: side by side. Once the variable lands: install into a folder of our
own, never touch www, and let the variable point the server at it. Server
updates stop mattering. This is the mode the project wants, and the reason the
PR exists. 2026-10-01: the variable landed in v15.5 (§1.7). Mode B is what any
server from 15.5 on gets once the variable is set (W2), and it is the layout of
the Docker image (D1–D4).
Mode B costs one restart the first time the served path changes, and one when that change is undone, because the variable is read once at start (S2). Every reinstall after that costs none, and mode A never costs one at all.
Under systemd the folder must not be under /home or /root
(ProtectHome=true in upstream's unit), and the service reads it fine
everywhere else despite ProtectSystem=strict, which is read-only and not
invisible. /opt/technitium-console satisfies both.
3 · The contract
✓ met and measured. Every clause carries the probe case that decides it; none
is left to a reading of the script. The three mode B clauses carry a †: they
are measured against a build of the fork's feat/configurable-www-folder
branch, not against a released server, and they stay that way until upstream
merges it. 2026-10-01: upstream released it in v15.5 (§1.7), and C12 and C13
were met against the official v15.5.1 on 2026-09-30, so the † has come off
the three.
Filesystem security amendment, 2026-10-04
Root pathname operations require an immutable namespace against lower-privilege accounts. Console, backup, state and custom-list trees must be root-owned with no group or other write bits on files, directories or ancestors. Links, special files and newline-containing names are rejected before copies and cleanup. Archive inspection rejects links and device members before extraction; the extracted tree is checked before publication, and published files use mode 0644. The installer refuses unsafe trees instead of changing ownership while another account may retain open writable files. State and staging are protected under the state directory; state updates and managed drop-ins use unique temporary files. Uninstall first selects the trusted recorded console folder, even when the server already returned to a service-owned stock console.
This tightens earlier acceptance of service-owned web roots. Use a dedicated root-owned console folder or a volume mounted read-only in the DNS server. Custom lists in an unsafe stock tree need an administrator's inspected manual copy; the installer does not import them through a privileged copy. This amendment has offline GNU and BusyBox regression coverage, rather than a repeat of the historical live-server measurements above. Remote filesystem servers that can change root-owned objects remain outside this local permission model.
2026-10-15: a group write bit is accepted when the group is root (gid 0), the
rule sudo applies to sudoers. Technitium's install script extracts
DnsServerPortable.tar.gz as root, the archive stores every file as 0664, and
tar run as root keeps that mode: a stock web root, and a replacement-mode
console after any server update, is root:root 0664. The amendment above
refused it, so upgrade and uninstall stopped on a stock file. Write by a
non-root group or by others is still refused.
2026-10-18, after a security audit: that group bit is trusted only when no
object in the tree carries a POSIX access control list, because on one that
does the group bits are the list's mask and can stand for another account. Lists
are read from the + of GNU or uutils ls -l, or from getfacl where ls shows
none (BusyBox); with neither, group write is refused, with the remedy in the
message. And W3 narrows: a folder named by the running server's environment that
does not exist yet is created only when it is /opt/technitium-console, the
folder the README documents. The service's own account can set that variable,
so any other new folder must be created by the administrator first. The
offline regressions are in dev/installer-security.test.py.
Where it installs
-
W1 ✓ (C14) The web root is resolved, in order:
--dir; the value of the variable read from the running server's environment;wwwnext to the running server's binary; the two known folders as a last resort. It is never assumed when the running server can be asked: the process is found by what it runs, in/proc, and its web root is derived from the path of theDnsServerApp.dllit was started with. The two known folders are still there, for the case of a host preparing a folder for a container, where there is no process to ask. -
W2 ✓ (C12) When the running server honours the variable, the console is installed where it points and
wwwis not touched. Measured: the console answers from the folder the variable names, and the stockwwwstill holds the console the server shipped. -
W3 ✓ (C15) When the variable is set but the running server ignores it, the installer does not install into it. Support is established by measurement, before anything is installed: write a probe file with an unguessable name into the folder the variable names, ask the web service for it, delete it whatever the answer, and only then decide. Not by a version number, and not by installing first and looking afterwards: an installer that has to undo an install to find out where it belongs has already got it wrong. The probe file is not hidden, or the answer is a 404 for the wrong reason (§1.1).
A 404 has two possible causes and they are told apart in the startup log, which the fork writes on purpose: "Web Service is falling back to the default web root folder since the folder configured by the DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH environment variable does not exist". With that line, the server honours the variable and fell back because the folder was missing when it started: create it, install, restart (S2). Without it, the server does not know the variable at all, and the console goes where it is actually read. The half of this clause that a released server can exercise is the refusal, and that is what C15 measures; the other half rides on C12. 2026-10-01: since v15.5 it is the other way round. A released server honours the variable and rides on C12, and the refusal needs a server before 15.5: C15 is not applicable against
technitium/dns-server:latest, and runs against an older image given withIMAGE=(§5). -
W4 ✓ (C10) A target folder that does not exist is created. Nothing is backed up that was never there, which is what used to make the documented Docker flow fail before it had installed anything.
-
W5 ✓ (C9) A web root that is a mount point is installed into, in place. Nothing ever removes or renames the web root itself (see A1) so the layout the README asks Docker users to create is no longer a special case.
-
W6 ✓ (C22) 2026-09-30. The installer writes only into a folder it can show is a console, because publishing sweeps, as root, everything the release does not ship. The folder has to be missing, empty, the one its own state says it installed into, the stock console (an
index.htmltitled Technitium DNS Server next tojs/main.jsandjson/readme.txt, true of every release since v11, and of the hybrid of §1.3), or this console (anindex.htmlthat mounts#rootand names anassets/*.jsthat is there). Anything else stops the run before anything is written, with or without--yesand with or without a TTY. Before this,--dir /opt/technitium/dnsby mistake removed the server's binaries and configuration, with no backup: there was noindex.htmlto back up, and without asking, becausecurl … | sudo shhas no TTY to ask on. 2026-10-01 (C28): a folder holding nothing butjson/andjson/*-custom.jsoncounts as empty. Publishing neither overwrites nor sweeps those files, so there is nothing in it to lose, and it is how a host folder for the Docker image is prepared with lists written by hand before the first run. One file of any other kind beside them and the folder is refused as before (C22; C29, lists andnotes.txt). Measured with the amendment on 2026-10-01, by C22, C28 and C29. 2026-10-01 (C22,--dir /):--dir /, or any path that resolves to the root folder, is refused. It used to resolve to no folder at all, and no folder means "ask the server", so the console went into the server's web root, which nobody had asked for. 2026-10-01 (C22, a refused run): a run W6 stops leaves no trace outside the folder either. What the sweep may remove is listed in a temporary folder of the run's own, and the staging folder, with the state folder around it, is made only once W6 has let the run through; a run that made the state folder and stops before writing any state removes it on the way out. -
W7 ✓ (C23) 2026-09-30. The running server is identified, not matched. A command line ending in
DnsServerApp.dllmakes a process a candidate; it is believed only when every uid it runs as (read from/proc/<pid>/status, not from the owner of/proc/<pid>) and the owners of itsDnsServerApp.dll, of the folder holding it and of that folder'swwware root or the service's account:dns-server, which upstream's installer has used since v15.0, or theUser=of the unit that runs the server. Candidates that fail are named and ignored; a process in another root filesystem (a container seen from its host) is not a candidate. Two believable servers with different web roots stop the run, listing both and asking for--dir. A web root the installer found by itself is refused if any folder on its path, its backup, or any folder inside it is a symbolic link; a path given with--diris resolved once and every later step, the state included, uses the resolved one.
The administrator's files
-
F1 ✓ (C1, C4) Every
json/*-custom.jsonin the web root survives an install. The glob is the rule, not the three names that exist today. -
F2 ✓ (C5) …and survives an uninstall, including a list written long after the install, which no backup can contain. It is the one command an administrator runs expecting to lose nothing. It is kept by never being touched: a
*-custom.jsonalready in place is neither overwritten by a restore nor swept away as a leftover. -
F3 ✓ (C13) In mode B the lists travel both ways, because the web app fetches them relative to whatever folder is being served. On install they are copied from
www/jsoninto the console's own folder, and the administrator is told that the one to edit from now on is the new one. On uninstall everything matching the pattern in the console's folder (including lists written after the install, which is where F2 bites again) is copied back towww/jsonbefore the folder goes. -
F4 ✓ (C16) The installer never modifies the server's data. It writes to exactly three places: the web root it is installing into, the backup it keeps, and its own state (A5). Everything else (
dns.config, the zones, the users, the logs,/etc/resolv.conf, the service unit) is out of bounds, with the single exception of the service change in §4, which is offered and confirmed before it happens.Reading is not writing. The environment of the running process, the startup log and the unit files are read freely, and W1, W3 and A7 are built on exactly that: the way not to guess is to go and look. C16 measures the writing half the only way worth measuring it: a checksum of
/etc/dns, the logs, the units and/etc/resolv.confbefore and after an install and an uninstall, which has to come out identical.
Replacing atomically
-
A1 ✓ (C17) The web root is never observably broken, mount point or not. It is also never renamed, moved or removed, and that is a change from how this clause was first written: swapping two directories takes two renames, and between them there is no web root at all, which is precisely what A2 forbids. A rename is not an option, it is a shortcut that loses the guarantee. So there is one path, in place, and the order is what makes it safe:
- hashed assets, fonts, images and
json/first, under their own names; - every
index.htmllast, and the entry one last of all; - leftovers of the previous console removed only after that.
Step 2 is the publication point, and each file within it is replaced by a write-then-rename so no page is ever half written. What makes the step safe is not that it is a single instant (it is not, there is one
index.htmlper route) but that during it every page a request can be handed finds the files it names: the new assets are already there and the old ones are not gone yet. Whichever version of a page a browser gets in that window, it works.C17 measures it at the worst possible moment: a
cpthat fails on every page, which stops the run exactly at the publication point. The assets of the new console are in, no page has been swapped, and what is being served is the previous console, whole. - hashed assets, fonts, images and
-
A2 ✓ (C17, C7) At every moment there is a complete console being served: the previous one before the publication point, the new one after it. That is the guarantee, and it is the only one an installer can actually keep: a
SIGKILLor a power cut mid-copy is not a failure it gets to handle, and on a mount point it cannot fall back on a rename either.So the promise splits in two. A failure the installer sees it undoes itself, and the previous console is back when it exits. A stop it never sees leaves the invariant standing anyway: because of A1's order, plus possible leftovers: a staging folder, an orphan of the console being replaced, a state file that says a run started. Those are repaired by the next run, which therefore has to recognise them instead of tripping over them.
Both halves are measured: C17 stops a run at the publication point and finds a whole console still being served, and C7 hands the next run the wreckage of an interrupted one and expects it repaired.
-
A3 ✓ (C7) The uninstall keys off the backup and the state, never off a marker: there is no marker to key off any more (A6).
-
A4 ✓ (C2) Re-running is idempotent, and the backup is taken exactly once: a second run must never save this console as "the original".
-
A5 ✓ (C18) The install state (mode, version, web root, backup path, and the server version the backup was taken from) lives outside the served folder, in
/var/lib/technitium-console/. Bookkeeping in the web root is either invisible to the installer (dotfiles 404, §1.1) or visible to the internet (non-dotfiles 200). Neither is a reason to put it there. -
A6 ✓ (C8) The web root holds the console and nothing else: no marker, no staging folder, nothing hidden. The staging area lives with the state, so a half-finished download is never inside the folder being served. 2026-10-01: one exception, in the Docker image's volume only: the marker
.technitium-consoleat its root (Docker, D1). It is a dotfile, so it is never served (§1.1), and the init reads it from the filesystem, not over HTTP. No uninstall keys off it (A3):--into-volumehas no uninstall. The✓is not C8's alone for this half: C26 finds nothing in the volume but the console and the marker, and the marker not served, and C29 finds not even the marker after an init that was refused; measured on 2026-10-01. Why the marker is there is in "Docker", below. -
A7 ✓ (C19) When the version recorded with the backup and the running one differ, the backup is the console of another server:
--uninstallstops, names both versions, and does nothing. Restoring it anyway takes a flag of its own (--restore-mismatched-backup) and--yesdoes not grant it:--yesmeans "do not ask me the ordinary question", not "accept an incompatible restore". The message names the repair that does not need us at all: re-run upstream's own installer, which puts back the console the running version ships. -
A8 ✓ (C24) 2026-09-30.
--uninstallacts on the folder on record (webrootin the state), not on the one the server points at now. They differ when the variable was removed and the server restarted before uninstalling, and then the installer says so and leaves the current one alone. In mode B the recorded folder is removed only if it still holds this console, and never when it is the server's ownwww; before this, thatrm -rfwent to whatever folder was resolved at the time: the stock console, in the case above. In mode A the restore is held to W6. Same night (1.1.1): removing it has to finish when the folder is a mount point, as it is in the Docker layout. It is emptied and then removed if it can be; if it cannot, it stays empty and no restart is offered, because with the variable still set the server would serve an empty folder. Found installing 1.1.0 from the published release; C13 now checks the uninstall's exit code on its bind-mounted folder, which it had not.
Fresh install and update behave the same
- U1 ✓ (C1, C2, C3) The end state is the same whether the target is the stock console, an older build of this one, or the hybrid a server update leaves behind (§1.3). No leftovers of the console being replaced.
- U2 ✓ (C20) The hybrid is detected and named. An administrator whose console reverted after a server update is told what happened, and told in the same breath that the backup is older than the server now running, which is the fact A7 will stop them on later.
What it installs
- I1 ✓ (C25) 2026-09-30. A release download is checked against the
technitium-console.tar.gz.sha256the release publishes next to it (.github/workflows/release.yml). A mismatch, or no checksum at all, stops the run with nothing changed.--fromis not checked, because there is nothing to check it against, and the output says so. It is an integrity check against a broken or altered download, not a signature: whoever can publish a release can publish its checksum. The archive is unpacked with--no-same-owner, so the staging copy belongs to root whatever the tarball says; the probe does not measure that half, because what reaches the web root is written bycpas root either way. 2026-10-01 (C26; C29, bad or missing checksum): with--into-volume, the Docker image's mode,--fromis checked after all: the.sha256next to the tarball is required, and a mismatch or a missing one stops the run with nothing written, as above. The image carries the release's checksum, so D4 holds when the image runs as well as in CI. Measured with the amendment on 2026-10-01, by C25, C26 and C29.
Docker
The init image runs install.sh --into-volume /target on the tarball it
carries, into a volume the official server mounts read-only at the folder its
DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH names. Inside that run the folder is
given, not looked for: there is no server to find, so W1, W3 and W7's
identification have nothing to do, and the folder is installed into side by
side, with nothing backed up. W4 gives way: /target has to be a mount point,
not a folder to create (C29). The rest of the clauses above still apply inside
that run, notably W5, W6, W7's links, F1, F4, A1, A2, A4, U1, S1 and I1 as
amended.
One thing is the init's own. Its state (A5) lives in its container's layer and
goes with it, so the record that the volume is the console's lives in the
volume: after every check has passed and before the first file is copied, the
init writes .technitium-console at the volume's root, and --into-volume
accepts a volume that holds it as its own (W6). That is what lets A2's repair
half hold in the init: a first copy interrupted halfway leaves assets and no
index.html, which W6 alone would refuse forever, and the next run of the init (the next up, or the next update) finds the marker and finishes it (C29,
interrupted first copy, then re-run). The marker stays; it is the one exception
to A6.
These four are what the layout adds.
- D1 ✓ (C29) The init never keeps the DNS server from starting. The
compose block the README gives has no
depends_onfrom the server on the init, and the init neither looks for the server nor waits for it. When the init fails a check (a folder W6 refuses, no volume mounted at/target, a bad or missing checksum; C29, bad or missing checksum), it stops before publishing, exits non-zero having written nothing into the volume, not even the marker, and the server starts all the same and serves whatever the volume holds: a whole console on an update, nothing on a first install. A failure during the copy leaves A1's order in force, as D2 says: on an update, a whole console is served throughout; on a first install, nothing yet, and the next run finishes it (the marker, above). Nothing on a first install is also what is served for the second or two before the init's first publication point: A2 is about replacing a console, and on a first install there is none yet to keep serving. Not covered: a firstdocker compose uphas to be able to pull both images, which is the registry's business, not the installer's. - D2 ✓ (C27) Updating the console does not restart the DNS server. The
documented update,
docker compose pull technitium-console && docker compose up -d technitium-console, recreates the init only: the server'sStartedAtdoes not change and the new console is served at once (§1.1). Bringing the whole file up again, with nothing about the server changed, does not touch it either. An update that fails leaves the server alone too, and a whole console served: the init stops before writing (I1, W6), or mid-copy with A1's order in force. A plaindocker compose pull && docker compose up -dalso updates the server when upstream has a new image, and that restart is the server's, not the console's; the README says which command does which. - D3 ✓ (C28) Custom lists in the volume survive. Every
json/*-custom.jsonin the volume survives an update of the console and a--force-recreateof the server, and so does one in a host folder bound in its place (C28, bind folder, update and--force-recreate). A host folder holding only such lists before the first run is accepted (W6); one holding them and any other file is refused (C29, lists andnotes.txt), and D1 says what the server does then. - D4 ✓ (C26, and CI) The image carries the release's tarball, byte for
byte. CI downloads it from the release it has just published, checks it
against the release's
.sha256before building, and after pushing checks it again inside each of the three platforms (.github/workflows/release.yml, jobimage). The init checks it once more when it runs (I1). C26 measures the local half: the image built fromdist/carries the tarball it was built with. A check that fails before building stops the job with nothing pushed. One that fails after pushing turns the job red with the tags already out, and the init's own check is what stands between those bytes and a volume. Re-running theimagejob on its own downloads the same bytes again, so it only helps a failure that was transient: the network, the registry. A release asset that does not match its own.sha256fails the same way every time, and needs a new release. Never the whole workflow on a published tag: it would rebuild the tarball under it with other bytes and another checksum.
The service and the browser
- S1 ✓ (C11, C21, C31) Publishing into the folder already being served does
not restart the DNS service. C11
measures the premise: with the service running and never restarted, a full
replacement of the web root is served immediately, and C21 measures the
clause, with a
systemctlof its own that records being called and never is. C31 proves that first managed systemd activation restarts once and the update after it does not. A restart is a resolution outage for everything behind this server; it is not spent on copying files. - S2 ✓ (C31) A restart happens only when the path being served or the environment changes, because the file provider is built once at start (§1.1). That is the first install into the variable's folder and the uninstall out of it, not a property of mode B: a later reinstall into the same folder, with the environment untouched, restarts nothing, which is what C12 and C13 show. On systemd, the installer publishes first, writes its own drop-in, restarts once and verifies both process environment and served asset. A failed activation removes the drop-in and restarts back onto stock. Uninstall reverses that order: stock is verified before the dedicated folder is removed.
- S3 ✓ (C21) No "press Ctrl+F5" advice: every static file is
no-cacheand the assets are hashed (§1.1). Advice that is not true trains people to ignore the rest.
4 · Service boundaries
- Windows. Say so; do not fail at it (§1.5).
- Installing or updating the DNS server itself. If it is not there, stop.
- The managed systemd drop-in. On a running 15.5+ server with exactly one
unit that names that process's
DnsServerApp.dll, the default installation publishes to/opt/technitium-consoleand owns/etc/systemd/system/<unit>.d/technitium-console.conf. It never edits the upstream unit. An existing unrecorded drop-in is left alone. Uninstall removes only the exact two-line file it wrote; a changed file is administrator-owned and stops the uninstall.--no-configure-serviceopts out. OpenRC and Docker remain outside this automation, where the equivalent belongs to/etc/conf.d/dnsand to the compose file. 2026-10-01 (C30): on a Docker host it readsdocker inspectand the startup log throughdocker exec, and prints the exact change for each container (the way in, and with--uninstallthe way out) with the container, service, file and volume names it found, and exits 0. It never prints$0, which undercurl … | sudo shissh. - SELinux relabelling, distro packages, and updating the console by itself. Not now, and each would need its own contract.
5 · How it is checked
npm run build && sh dev/installer-probe.sh # exit code = cases that failed
Twenty-one cases, one throwaway container each, off the official image, so every
case starts from the same stock console and nothing on the machine running it is
touched. Every clause that can be decided by a machine has one, which sometimes
means building the situation rather than waiting for it: C14 starts a server from
a folder neither known path points at, C16 lets one run once and stops it so
/etc/dns can be compared byte for byte, C17 installs a cp that fails on every
page so the run dies exactly at the publication point, and C21 installs a
systemctl whose only job is to record having been called.
2026-09-30: twenty-five now. C22 aims --dir at the server's own folder, C23
starts look-alike processes (one as nobody, one whose www is a link, two at
once), C24 starts the server by hand so it can be restarted without the variable
before uninstalling, and C25 installs a curl that plays GitHub with whatever
checksum the case wants. INSTALLER=<file> measures another install.sh, which
is how the four were seen failing on the old one.
2026-10-01: thirty. C26 to C29 build the init image from dist/ with this
checkout's docker/Dockerfile and install.sh, and bring up the README's
compose block against the official server: a fresh install, an update, the
custom lists, and an init that fails. C30 runs install.sh from stdin in
docker:cli with the Docker socket, which is how curl … | sudo sh runs it on
a Docker host. C11 now also expects the warning about installing into a
container's own files, and runs on any server, as it always has. The five new
ones need a server that honours the variable, and are not applicable otherwise.
2026-10-02: thirty-one. C31 starts the server behind a recording systemd stand-in. It proves that the first install writes the dedicated folder and drop-in before one restart, an update restarts nothing, uninstall restarts onto stock before deleting the folder, custom lists make both trips, and a locally changed drop-in is not removed.
C12, C13 and C15 depend on what the image can do, and the probe does not decide
that by decree: it detects the capability the same way W3 says the installer
must: it starts the image with the variable pointing at a folder holding a probe
file, asks the web service for it, and believes the answer. Against
technitium/dns-server:latest the answer is no, so C12 and C13 stand down and
C15 runs, which is the refusal case. Against a build of the fork branch the
answer is yes, and it is the other way round.
Both sides have been run. Nineteen cases pass against the official image, and
twenty against technitium-dns-server:wwwvar, built from the branch in four
commands (dev/README.md). Nothing here is a case that has never been seen
passing.
The gate for the installer round is the same as every other round in this
project: the probe at zero on both images: met on 2026-09-07, nineteen
cases against technitium/dns-server:latest and twenty against a server that
honours the variable. What is left is not a measurement: it is upstream merging
the branch, at which point the † comes off W2, F3 and S2 and the image stops
having to be built by hand.
2026-10-01: it happened. Upstream released the variable in v15.5 (§1.7), and
the two sides have swapped. Against technitium/dns-server:latest (v15.5.1) the
capability answer is yes, so C12 and C13 run and C15 stands down: 24 met and 1
not applicable on 2026-09-30, 29 and 1 on 2026-10-01 with the Docker cases. C15,
the refusal, now needs an image before 15.5, given with IMAGE=; it was
measured against v15.4 on 2026-09-07, and again on 2026-10-01 against
technitium/dns-server:15.4.0, after the Docker changes to install.sh: met.
That run also failed C24, whose first two steps need a server that honours the
variable and waited for it with a curl 15.4.0 does not have. The same day
those two steps were gated by the capability, like C12 and C13; the third, a
recorded folder that is no longer this console, runs on every image, because
--into-volume can leave such a record on any server; and the case waits for
the port in /proc/net instead. Against 15.4.0 then: 23 met, 0 not met, 7 not
applicable, so on 2026-10-01 the probe is at zero on both images again. The †
is off W2, F3 and S2, and no server has to be built by hand. C26 to C30 are
gated by the same answer as C12 and C13.
6 · What contracting it found, and what the implementation did
Five of these were failures the probe caught the day the contract was written. All five are fixed; the last two are notes, not code.
- The README documented a Docker flow that did not work:
--diron a folder that did not exist installed nothing (W4). --uninstalldestroyed exactly the files the installer goes out of its way to preserve, when the administrator had written them after installing (F2).- The installer restarted the DNS server for no reason (S1) and told the administrator to bypass a browser cache that is not there (S3).
- A run that died between the wipe and the copy left no console at all and locked the way back (A2).
- Bookkeeping lived in the folder the server serves (A5, A6).
And one thing the implementation found that the contract had got wrong: A1's rename shortcut is not available. Swapping a staged tree for the old one takes two renames, and between them there is no web root: the exact state A2 says can never exist. So there is no rename path at all, on any filesystem: one order, in place, everywhere. The clause was rewritten rather than the guarantee weakened.
None of the five were visible from the code alone; all came out of running the installer against a real server.
2026-09-30. The audit of that day found three more, by reading this time,
and each has a probe case that fails on the script it was found in: a sweep with
no check on what it was sweeping (W6, C22), a server taken to be the first
process whose command line ended the right way, with a www that could be a
link (W7, C23), and an uninstall that removed the folder it resolved instead of
the one it had installed into (A8, C24). Left as they are, on purpose:
confirm still answers yes when there is no TTY, because the documented path is
curl … | sudo sh and W6 is what makes that safe, not a question nobody can
answer; and a web root owned by the service account can still race the
installer between a check and a write, because closing that needs writes that
do not follow links, which POSIX sh, cp and mv cannot promise. Which is the same lesson About left, in a
different costume: a script that has never been run against the thing it
manages is a draft, not a tool.