Files
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
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 <[email protected]>
2026-10-27 00:06:06 +07:00

1595 lines
69 KiB
Bash

#!/bin/sh
#
# technitium-console installer.
#
# Installs an alternative administration console for Technitium DNS Server, and
# can put the original back.
#
# curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh
#
# What it has to guarantee is written down in docs/2026-09-07-installer-contract.md
# and measured by dev/installer-probe.sh. Two of those clauses shape everything
# below:
#
# · At every moment there is a complete console being served: the previous one
# before the publication point, the new one after it. So files are never
# removed before their replacements are in place, and every page is published
# after the assets it names.
# · The server's own data is never modified. Three places are written: the web
# root, the backup, and this installer's state. Reading is another matter:
# /proc, the logs and the unit files are read on purpose, because the way not
# to guess where the console lives is to go and look.
#
# And one that came out of the 2026-09-30 audit: publishing sweeps, as root,
# whatever the release does not ship. So nothing is ever swept, or removed on
# the way out, that the installer cannot show is a console: an empty folder, the
# stock console, this one, or the folder its own state says it installed into.
#
# It is POSIX sh: the official image is Debian, but people run this on Alpine too.
set -eu
umask 022
REPO="byGarcia/technitium-console"
STATE_DIR="/var/lib/technitium-console"
STATE="$STATE_DIR/install.state"
BACKUP_SUFFIX=".original"
VAR_NAME="DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH"
DOCKER_DOCS="https://github.com/$REPO#docker"
IMAGE_REF="ghcr.io/bygarcia/technitium-console"
# What the administrator typed, never $0: under `curl … | sudo sh` that is "sh".
ONE_LINER="curl -sSL https://raw.githubusercontent.com/$REPO/main/install.sh | sudo sh -s --"
CONSOLE_DIR="/opt/technitium-console"
# The init's record, at the root of its volume, that the volume is the console's
# (contract, "Docker"). Its own state lives in its container's layer and goes
# with it; this does not.
MARKER=".technitium-console"
VERSION="latest"
SOURCE="" # local file or URL, for air-gapped installs
WWW_DIR=""
DIR_GIVEN="no"
WEB_URL=""
ACTION="install"
ASSUME_YES="no"
ALLOW_MISMATCH="no"
CONFIGURE_SERVICE="yes"
INTO_VOLUME="" # what the Docker image runs: see --into-volume
say() { printf ' %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %-34s %s\n' "$1" "${2:-}"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf '\n \033[31m✗\033[0m %s\n\n' "$*" >&2; exit 1; }
usage() {
cat <<EOF
technitium-console installer
--uninstall put the original console back
--version <tag> install a specific release (default: latest)
--from <path|url> install from a tarball you already have
--dir <path> web root to install into (default: ask the running server)
--url <base> where its web console answers (default: http://127.0.0.1:5380)
--yes do not ask for confirmation
--no-configure-service
do not create a systemd drop-in; replace the stock web
root unless --dir or the server environment says otherwise
--into-volume <path>
copy the console into the volume mounted at <path> and
stop. It is what the Docker image runs; needs --from.
--restore-mismatched-backup
uninstall even though the backup is from another server
version. --yes does not grant this one.
--help this text
EOF
exit 0
}
# --version goes into the download URL, and curl resolves dot segments there:
# "../../other/repo/releases/download/x" would fetch someone else's tarball
# and its checksum. A tag has none of those characters.
validate_version() { # a release tag such as v1.3.1 or v1.4.0-rc.1
case "$1" in
latest) return 0 ;;
''|.*|*..*|*[!0-9A-Za-z.+-]*) return 1 ;;
v[0-9]*|[0-9]*) return 0 ;;
esac
return 1
}
while [ $# -gt 0 ]; do
case "$1" in
--uninstall) ACTION="uninstall" ;;
--version) VERSION="${2:?--version needs a tag}"; shift ;;
--from) SOURCE="${2:?--from needs a path or URL}"; shift ;;
--dir) WWW_DIR="${2:?--dir needs a path}"; DIR_GIVEN="yes"; shift ;;
--url) WEB_URL="${2:?--url needs a base URL}"; shift ;;
--yes|-y) ASSUME_YES="yes" ;;
--no-configure-service) CONFIGURE_SERVICE="no" ;;
--restore-mismatched-backup) ALLOW_MISMATCH="yes" ;;
--into-volume) INTO_VOLUME="${2:?--into-volume needs a path}"; shift ;;
--help|-h) usage ;;
*) die "unknown option: $1 (try --help)" ;;
esac
shift
done
validate_version "$VERSION" || die "--version takes a release tag, such as v1.3.1: $VERSION"
if [ "$(id -u)" != "0" ]; then
if [ -n "$INTO_VOLUME" ]; then
die "the init has to run as root to write the console into its volume. Take user: off
the technitium-console service (or run it with --user 0): $DOCKER_DOCS"
fi
die "this has to run as root: prefix it with sudo."
fi
printf '\n \033[1mtechnitium-console\033[0m\n\n'
# ------------------------------------------------------------ links and folders
#
# Everything below writes as root. A symbolic link anywhere on the way turns
# "the web root" into wherever the link points, and a web root is often owned by
# the account the server runs as, not by root. So a path the installer found by
# itself is refused if it goes through a link, and a path given with --dir is
# resolved once, here, and used resolved from then on: the one the administrator
# typed is not the one written to, the one it leads to is.
resolve_path() { # absolute, every link resolved, even where its tail does not exist yet
rp="$(printf '%s' "$1" | sed 's|/*$||')"
case "$rp" in /*) ;; '') rp="/" ;; *) rp="$(pwd -P)/$rp" ;; esac
rp_tail=""
while [ ! -d "$rp" ]; do
[ ! -L "$rp" ] || die "--dir $1 goes through a link to nothing ($rp)."
rp_tail="/${rp##*/}$rp_tail"; rp="${rp%/*}"; [ -n "$rp" ] || rp="/"
done
case "$rp_tail" in
*/..|*/../*) die "--dir $1 climbs out of a folder that does not exist yet. Give it without '..'." ;;
esac
rp="$(cd "$rp" && pwd -P)"
printf '%s%s' "${rp%/}" "$rp_tail"
}
# --into-volume is the Docker image's mode (README, "Docker"): a volume shared
# with the DNS server's container, the release tarball the image carries, and
# nothing to look for. The server may not even be up yet, and it must not have
# to be (contract D1). The version is the image's tag and the way out is taking
# the volume away, so the options that mean something else are refused here.
if [ -n "$INTO_VOLUME" ]; then
[ "$ACTION" = "install" ] || die "--into-volume only installs. On Docker the console is removed by taking
its volume out of the DNS server: $DOCKER_DOCS"
[ "$DIR_GIVEN" = "no" ] || die "--into-volume and --dir both say where. Give one."
if [ "$VERSION" != "latest" ] || [ -n "$WEB_URL" ]; then
die "--version and --url do not apply to --into-volume: the version is the image's
tag, ghcr.io/bygarcia/technitium-console:<version>."
fi
if [ -z "$SOURCE" ] || [ ! -f "$SOURCE" ]; then
die "--into-volume installs the tarball the image carries: give it with --from <file>."
fi
WWW_DIR="$INTO_VOLUME"; DIR_GIVEN="yes"
fi
if [ "$DIR_GIVEN" = "yes" ]; then
DIR_ASKED="--dir $WWW_DIR"
[ -z "$INTO_VOLUME" ] || DIR_ASKED="--into-volume $INTO_VOLUME"
WWW_DIR="$(resolve_path "$WWW_DIR")" || exit 1
# resolve_path writes the root folder as nothing, and no folder at all would
# mean "ask the server": the run would install somewhere it was not told to.
[ -n "$WWW_DIR" ] || die "$DIR_ASKED is the root folder. Installing removes whatever the console does
not ship, so it goes into a folder of its own. Nothing was changed."
fi
# A folder is a mount point when this process's mount table lists it as one.
# The table writes a space, a tab, a newline and a backslash in a path as \040,
# \011, \012 and \134; the folder is passed through the environment, because
# awk -v would read its backslashes as escapes.
is_mount_point() { # folder
IMP_DIR="$1" awk '
function plain(s, r, i) {
gsub(/\\040/, " ", s); gsub(/\\011/, "\t", s); gsub(/\\012/, "\n", s)
# The backslash last, and not by gsub: awks disagree about "\\" there.
r = ""
while ((i = index(s, "\\134")) > 0) { r = r substr(s, 1, i - 1) "\\"; s = substr(s, i + 4) }
return r s
}
plain($5) == ENVIRON["IMP_DIR"] { found = 1 } END { exit !found }' /proc/self/mountinfo 2>/dev/null
}
if [ -n "$INTO_VOLUME" ] && ! is_mount_point "$WWW_DIR"; then
die "$WWW_DIR is not a mounted volume, so whatever is copied there goes with this
container. Mount the console's volume at $WWW_DIR: $DOCKER_DOCS"
fi
refuse_links() { # folder about to be written into
rl_walked=""
rl_ifs="$IFS"; IFS=/; set -f
# shellcheck disable=SC2086
set -- "$1" ${1#/}
IFS="$rl_ifs"; set +f
rl_dir="$1"; shift
for rl_part in "$@"; do
[ -n "$rl_part" ] || continue
rl_walked="$rl_walked/$rl_part"
[ ! -L "$rl_walked" ] || die "$rl_walked is a symbolic link. Not writing through it: point at the real folder with --dir."
done
[ ! -L "$rl_dir$BACKUP_SUFFIX" ] || die "$rl_dir$BACKUP_SUFFIX is a symbolic link. Not writing through it."
[ -d "$rl_dir" ] || return 0
rl_inside="$(find "$rl_dir" -type l -exec test -d {} \; -print 2>/dev/null | head -1)"
[ -z "$rl_inside" ] || die "$rl_inside is a link to a folder, inside the web root. Not writing through it."
}
# Path checks alone do not close a race in a service-owned directory. POSIX sh
# cannot open directory descriptors with O_NOFOLLOW, so this installer refuses
# any namespace a non-root owner can change, including an ancestor. Do not try
# to repair ownership here: recursive chown/chmod would race through that same
# namespace. Use a dedicated root-owned folder, or the read-only Docker volume.
# Group write is accepted only when the group is root's own (gid 0), the rule
# sudo applies to sudoers: Technitium's tarball ships every file 0664, and tar
# run as root keeps that mode, so a stock web root is root:root 0664.
# And only when nothing carries a POSIX access control list: on an object with
# one, the group bits are the list's mask, so a group write bit on root:root can
# be a named account that setfacl let in. GNU and uutils `ls -l` mark such
# objects with a "+" after the mode (uutils for any extended attribute, which
# errs on the safe side) and with "?" when they cannot read it. BusyBox ls
# shows nothing, so there getfacl answers if it is installed; with neither the
# answer is "unknown", and then group write is not trusted.
acl_state() { # [-R] path: prints none, present or unknown
as_all=""; [ "$1" = "-R" ] && { as_all="yes"; shift; }
if ls --version >/dev/null 2>&1; then
as_list="$(ls -ld -- "$1" 2>/dev/null)" || { printf unknown; return 0; }
if [ -n "$as_all" ] && [ -d "$1" ]; then
as_list="$as_list
$(ls -lRA -- "$1" 2>/dev/null)" || { printf unknown; return 0; }
fi
as_hit="$(printf '%s\n' "$as_list" | while IFS= read -r as_line; do
case "$as_line" in
??????????+*) printf present; break ;;
??????????\?*) printf unknown; break ;;
esac
done)"
printf '%s' "${as_hit:-none}"
elif command -v getfacl >/dev/null 2>&1; then
if [ -n "$as_all" ]; then
as_list="$(getfacl -R -P -s -c -p -- "$1" 2>/dev/null)" || { printf unknown; return 0; }
else
as_list="$(getfacl -s -c -p -- "$1" 2>/dev/null)" || { printf unknown; return 0; }
fi
[ -z "$as_list" ] && printf none || printf present
else
printf unknown
fi
}
ACL_HELP="Remove the access control list or the group write bit (chmod -R g-w), or
use a dedicated root-owned console folder under /opt. Where ls cannot show
access control lists (BusyBox), installing coreutils or acl lets it check."
secure_path() { # absolute directory, optionally not created yet
case "$1" in
/*) ;;
*) die "the console folder must be absolute: $1" ;;
esac
case "$1" in *"
"*) die "a console path cannot contain a newline." ;; esac
sp_walk="/"
sp_saved_ifs="$IFS"; IFS=/; set -f
set -- "$1" ${1#/}
IFS="$sp_saved_ifs"; set +f
sp_target="$1"; shift
for sp_part in '' "$@"; do
case "$sp_part" in .|..) die "unsafe folder component in $sp_target" ;; esac
[ -z "$sp_part" ] || sp_walk="${sp_walk%/}/$sp_part"
[ ! -L "$sp_walk" ] || die "$sp_walk is a symbolic link. Nothing was changed."
if [ -e "$sp_walk" ]; then
[ -d "$sp_walk" ] || die "$sp_walk is not a directory."
sp_meta="$(stat -c '%u:%g:%a' "$sp_walk")" || die "cannot inspect $sp_walk"
sp_mode="${sp_meta##*:}"; sp_gid="${sp_meta#*:}"; sp_gid="${sp_gid%:*}"
if [ "${sp_meta%%:*}" != 0 ] || [ "$((0$sp_mode & 0002))" != 0 ] \
|| { [ "$sp_gid" != 0 ] && [ "$((0$sp_mode & 0020))" != 0 ]; }; then
die "$sp_walk can be changed by a non-root account. Root publication is unsafe.
Use a dedicated root-owned console folder under /opt, or mount the console
volume read-only in the DNS server. See $DOCKER_DOCS."
fi
if [ "$((0$sp_mode & 0020))" != 0 ]; then
case "$(acl_state "$sp_walk")" in
none) ;;
present) die "$sp_walk is group-writable and has an access control list or
extended attribute, which may let another account write to it. Root
publication is unsafe. $ACL_HELP" ;;
*) die "$sp_walk is group-writable, and this system cannot show whether an
access control list lets another account write to it. $ACL_HELP" ;;
esac
fi
fi
done
}
# find output is used as one pathname per line below. Verify the original
# objects before serializing them, not the lines after their identity is lost.
# Reject links (including hard links) and special files before any root copy.
secure_tree() { # existing tree or a directory that may be created
secure_path "$1"
[ -d "$1" ] || return 0
# Group write on gid 0 is trusted below only when no object in the tree has
# an access control list (see acl_state).
st_acl="$(acl_state -R "$1")"
TC_ACL="$st_acl" find "$1" -exec sh -c '
for p do
case "$p" in *"
"*) printf "unsafe control character in pathname\n" >&2; exit 1 ;; esac
[ ! -L "$p" ] || { printf "symbolic link in console tree: %s\n" "$p" >&2; exit 1; }
if [ -d "$p" ]; then
m=$(stat -c "%u:%g:%a" "$p") || exit 1
mode=${m##*:}; g=${m#*:}; g=${g%:*}; w=0022; [ "$g" = 0 ] && [ "$TC_ACL" = none ] && w=0002
[ "${m%%:*}" = 0 ] && [ "$((0$mode & w))" = 0 ] || {
if [ "$g" = 0 ] && [ "$TC_ACL" != none ] && [ "$((0$mode & 0020))" != 0 ]; then
printf "group write, with an access control list present or not visible: %s\n" "$p" >&2
else
printf "root-only writable namespace required: %s\n" "$p" >&2
fi
exit 1;
}
elif [ -f "$p" ]; then
m=$(stat -c "%u:%g:%a:%h" "$p") || exit 1
mode=${m#*:*:}; mode=${mode%:*}; g=${m#*:}; g=${g%%:*}; w=0022; [ "$g" = 0 ] && [ "$TC_ACL" = none ] && w=0002
[ "${m%%:*}" = 0 ] && [ "$((0$mode & w))" = 0 ] && [ "${m##*:}" = 1 ] || {
if [ "$g" = 0 ] && [ "$TC_ACL" != none ] && [ "$((0$mode & 0020))" != 0 ]; then
printf "group write, with an access control list present or not visible: %s\n" "$p" >&2
else
printf "root-owned, non-shared regular file required: %s\n" "$p" >&2
fi
exit 1;
}
else
printf "special file in console tree: %s\n" "$p" >&2; exit 1
fi
done
' sh {} + || {
[ "$st_acl" = none ] && die "unsafe console tree at $1. No files were published."
die "unsafe console tree at $1. No files were published. $ACL_HELP"
}
}
safe_relative() { # a pathname read from one of our line-delimited lists
case "$1" in
''|/*|.|..|./*|../*|*/../*|*/..|*/./*|*/.) die "unsafe relative console pathname: $1" ;;
esac
}
# Reject link/device archive members before tar can act on them. Both GNU tar
# and BusyBox reject parent traversal; also reject it explicitly in the list.
# GNU tar escapes control characters with backslashes; the extracted tree is
# checked again for literal newlines on BusyBox before publication.
check_archive() {
tar -tzf "$1" > "$TMP/archive-names" || die "that file is not a valid archive."
while IFS= read -r ca_name; do
case "$ca_name" in
/*|*\\*|..|../*|*/../*|*/..) die "unsafe archive pathname: $ca_name" ;;
esac
done < "$TMP/archive-names"
tar -tvzf "$1" > "$TMP/archive-types" || die "cannot inspect archive members."
awk 'substr($0, 1, 1) != "-" && substr($0, 1, 1) != "d" { bad = 1 } END { exit bad }' \
"$TMP/archive-types" || die "the archive contains a link, special file or invalid member."
}
# What a console looks like, from the files alone. The stock one has carried the
# same title, js/main.js and json/readme.txt since v11 (DnsServerCore/www); this
# one is an index.html that mounts #root and names a hashed script under
# assets/ that is there. A hybrid left by a server update reads as the stock one.
is_stock_console() {
[ -f "$1/index.html" ] && [ -f "$1/js/main.js" ] && [ -f "$1/json/readme.txt" ] \
&& grep -q '<title>Technitium DNS Server</title>' "$1/index.html"
}
is_this_console() {
[ -f "$1/index.html" ] && grep -q '<div id="root"></div>' "$1/index.html" || return 1
entry="$(sed -n 's|.*src="\./\(assets/[^"]*\.js\)".*|\1|p' "$1/index.html" | head -1)"
[ -n "$entry" ] && [ -f "$1/$entry" ]
}
# A folder holding nothing but the administrator's lists is as good as empty:
# publishing neither overwrites nor sweeps json/*-custom.json (copy_one,
# publish), so there is nothing in it to lose. It is how a host folder for the
# Docker image is prepared, with lists written by hand before the first run.
only_custom_lists() { # folder
[ -z "$(find "$1" -mindepth 1 ! -path "$1/json" ! -path "$1/json/*-custom.json" -print 2>/dev/null | head -1)" ]
}
# The server's configuration folder (/etc/dns in the image) is never a console,
# whatever else is in it: a mount swapped by mistake, or a marker beside it,
# must not make it one. These are the files the server keeps there
# (DnsServer.cs, DnsWebService.cs: dns.config, auth.config, webservice.config,
# zones/*.zone). A bare zones/ is not one of them: this console ships a zones/.
server_config_in() { # folder (prints what gives it away, or fails)
for sc_f in dns.config auth.config webservice.config; do
if [ -e "$1/$sc_f" ]; then printf '%s' "$sc_f"; return 0; fi
done
for sc_f in "$1"/zones/*.zone; do
if [ -e "$sc_f" ]; then printf 'zones/%s' "${sc_f##*/}"; return 0; fi
done
return 1
}
# Publishing replaces what the release ships and sweeps everything else. That is
# right for a console and a disaster for anything else: --dir /opt/technitium/dns
# by mistake is the server's binaries and configuration gone, with no backup,
# because there was no index.html to back up. So the folder must be one of these,
# and otherwise nothing is changed at all.
may_write_into() { # folder
[ -e "$1" ] || return 0
[ -d "$1" ] || die "$1 is not a folder."
if hc_found="$(server_config_in "$1")"; then
if [ -n "$INTO_VOLUME" ]; then
die "$1 holds a DNS server's configuration ($hc_found), not a console.
Installing removes whatever the console does not ship. Mount the console's own
volume at $1, not the server's /etc/dns one: $DOCKER_DOCS
Nothing was changed."
fi
die "$1 holds a DNS server's configuration ($hc_found), not a console.
Installing removes whatever the console does not ship. Nothing was changed."
fi
[ -n "$(ls -A "$1" 2>/dev/null)" ] || return 0
only_custom_lists "$1" && return 0
# The init's volume, marked as its own before its first copy: a copy stopped
# halfway is no console yet, and the next run has to be able to finish it.
if [ -n "$INTO_VOLUME" ] && [ -f "$1/$MARKER" ] && [ ! -L "$1/$MARKER" ]; then return 0; fi
[ "$(state_get webroot)" = "$1" ] && return 0
is_stock_console "$1" && return 0
is_this_console "$1" && return 0
die "$1 is not empty and it is not a Technitium console.
Installing removes whatever the console does not ship, so it only goes into
an empty folder, the server's web root, or a folder it is already in.
Nothing was changed."
}
# ----------------------------------------------------------------------- state
#
# Outside the web root, always. Bookkeeping left in the served folder is either
# invisible to us (the web service does not serve dotfiles) or visible to anyone
# who can reach the login page.
state_get() { [ -f "$STATE" ] && sed -n "s/^$1=//p" "$STATE" | tail -1 || true; }
state_set() {
secure_tree "$STATE_DIR"
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
tmp="$(mktemp "$STATE_DIR/state.XXXXXX")" || die "could not create private state."
{ [ -f "$STATE" ] && grep -v "^$1=" "$STATE" || true; } > "$tmp"
printf '%s=%s\n' "$1" "$2" >> "$tmp"
mv -f "$tmp" "$STATE"
}
state_clear() { rm -f "$STATE"; }
# ------------------------------------------------------- asking the real server
#
# No `ps`: /proc is there on every system this runs on, and busybox and procps
# disagree about everything else.
find_systemd_unit_file() {
# Upstream's installer writes dns.service; the Proxmox community script writes
# technitium.service. Match on what the unit runs, not on what it is called.
for unit in /etc/systemd/system/*.service /lib/systemd/system/*.service; do
[ -f "$unit" ] || continue
if grep -q 'DnsServerApp\.dll' "$unit" 2>/dev/null; then printf '%s\n' "$unit"; return 0; fi
done
return 1
}
find_systemd_unit() { unit="$(find_systemd_unit_file)" && basename "$unit"; }
find_systemd_unit_for_app() { # exact running DnsServerApp.dll; fails if ambiguous
fsu_found=""
fsu_count=0
fsu_seen="|"
for fsu_file in /etc/systemd/system/*.service /lib/systemd/system/*.service; do
[ -f "$fsu_file" ] || continue
grep -Fq "$SERVER_APP/DnsServerApp.dll" "$fsu_file" 2>/dev/null || continue
fsu_name="$(basename "$fsu_file")"
case "$fsu_seen" in *"|$fsu_name|"*) continue ;; esac
fsu_seen="$fsu_seen$fsu_name|"
fsu_found="$fsu_file"
fsu_count=$((fsu_count + 1))
done
[ "$fsu_count" = "1" ] || return 1
printf '%s\n' "$fsu_found"
}
# Root runs it in Docker and under the community script. Upstream's installer
# has run it as dns-server since v15.0, and hands it the application folder
# (DnsServerApp/install.sh: serviceUser, chown -R; User= in systemd.service,
# command_user in openrc.service). A unit that says otherwise is believed too.
service_uids() {
printf '0\n'
id -u dns-server 2>/dev/null || true
if unit="$(find_systemd_unit_file)"; then
user="$(sed -n 's/^User=//p' "$unit" | tail -1)"
if [ -n "$user" ]; then id -u "$user" 2>/dev/null || true; fi
fi
}
# A command line is something anybody can write, so matching one is how a
# candidate is found, not how it is believed. A candidate is believed when
# nobody but root or the service's own account could have started it: every uid
# the process runs as, its DnsServerApp.dll, the folder that holds it and that
# folder's www all belong to one of them. Anything else is named and ignored.
untrusted_uid() { # pid, trusted uids (prints why not)
# /proc/<pid> itself is owned by root for any process that made itself
# non-dumpable, so the uids are read from status, all four of them.
ut_uids="$(sed -n 's/^Uid:[[:space:]]*//p' "/proc/$1/status" 2>/dev/null)"
[ -n "$ut_uids" ] || { echo "whose it is cannot be read"; return 0; }
for ut_u in $ut_uids; do
printf '%s\n' "$2" | grep -qx "$ut_u" || { echo "it runs as uid $ut_u"; return 0; }
done
return 0
}
untrusted_files() { # the DnsServerApp.dll it names, trusted uids (prints why not)
case "$1" in /*) ;; *) echo "it names DnsServerApp.dll by a relative path"; return 0 ;; esac
[ -f "$1" ] || { echo "$1 is not there"; return 0; }
for ut_f in "$1" "${1%/DnsServerApp.dll}" "${1%/DnsServerApp.dll}/www"; do
[ -e "$ut_f" ] || [ -L "$ut_f" ] || continue
ut_u="$(stat -c %u "$ut_f")"
printf '%s\n' "$2" | grep -qx "$ut_u" || { echo "$ut_f belongs to uid $ut_u"; return 0; }
done
return 0
}
# Sets SERVER_PID and SERVER_APP, or leaves them empty. SERVERS_FOUND says how
# many different servers were believed: two that disagree about the web root
# are not settled by picking one, and resolve_target stops on it.
SERVER_PID=""; SERVER_APP=""; SERVERS_FOUND=0; SERVERS=""
find_server() {
SERVER_PID=""; SERVER_APP=""; SERVERS_FOUND=0
fs_trusted="$(service_uids)"
fs_here="$(stat -L -c %d:%i / 2>/dev/null || true)"
SERVERS=""
for d in /proc/[0-9]*; do
[ -r "$d/cmdline" ] || continue
fs_dll="$(tr '\0' '\n' < "$d/cmdline" 2>/dev/null | grep 'DnsServerApp\.dll$' | head -1 || true)"
[ -n "$fs_dll" ] || continue
fs_pid="${d#/proc/}"
fs_why="$(untrusted_uid "$fs_pid" "$fs_trusted")"
if [ -z "$fs_why" ]; then
# Another root filesystem is a container seen from its host: its paths
# are not paths on this machine, and it is not a candidate at all.
fs_root="$(stat -L -c %d:%i "$d/root" 2>/dev/null || true)"
[ -z "$fs_root" ] || [ "$fs_root" = "$fs_here" ] || continue
[ -n "$fs_root" ] || fs_why="its root folder cannot be read"
fi
[ -n "$fs_why" ] || fs_why="$(untrusted_files "$fs_dll" "$fs_trusted")"
if [ -n "$fs_why" ]; then
warn "Not trusting process $fs_pid ($fs_dll): $fs_why."
continue
fi
SERVERS="$SERVERS$fs_pid ${fs_dll%/DnsServerApp.dll} $(server_env "$fs_pid" "$VAR_NAME")
"
done
[ -n "$SERVERS" ] || return 0
SERVERS_FOUND="$(printf '%s' "$SERVERS" | cut -f2- | sort -u | wc -l | tr -d ' ')"
if [ "$SERVERS_FOUND" = "1" ]; then
SERVER_PID="$(printf '%s' "$SERVERS" | head -1 | cut -f1)"
SERVER_APP="$(printf '%s' "$SERVERS" | head -1 | cut -f2)"
fi
}
server_env() {
tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | sed -n "s/^$2=//p" | head -1 || true
}
LOG_GLOBS="/var/log/technitium/dns /etc/dns/logs /opt/technitium/dns/logs"
server_logs() {
for d in $LOG_GLOBS; do
[ -d "$d" ] || continue
ls -1t "$d"/*.log 2>/dev/null | head -3
done
}
server_version() {
# The version is printed at startup and nowhere an unauthenticated caller can
# reach: api/user/checkForUpdate needs a session, and the console carries no
# version string.
logs="$(server_logs)"
[ -n "$logs" ] || return 0
# shellcheck disable=SC2086
grep -ho 'DNS Server (v[0-9.]*)' $logs 2>/dev/null | tail -1 | tr -d '()' | sed 's/DNS Server //' || true
}
logged_variable_fallback() {
logs="$(server_logs)"
[ -n "$logs" ] || return 1
# shellcheck disable=SC2086
grep -q "$VAR_NAME environment variable does not exist" $logs 2>/dev/null
}
web_base() { [ -n "$WEB_URL" ] && printf '%s' "${WEB_URL%/}" || printf 'http://127.0.0.1:5380'; }
web_answers() { command -v curl >/dev/null 2>&1 && curl -s -o /dev/null --max-time 5 "$(web_base)/"; }
web_serves() { curl -sf -o /dev/null --max-time 5 "$(web_base)/$1"; }
random_name() {
{ od -An -N8 -tx1 /dev/urandom 2>/dev/null || printf '%s %s' "$$" "$(date +%s)"; } | tr -d ' \n'
}
# Does the running server serve the folder the variable names? Asked before
# anything is installed, and answered by the server rather than by a version
# number: a probe file goes in, the web service is asked for it, and it comes
# straight back out.
serves_folder() {
folder="$1"
secure_tree "$folder"
created="no"
[ -d "$folder" ] || { mkdir -p "$folder"; created="yes"; }
probe="technitium-console-probe-$(random_name).txt"
printf 'probe\n' > "$folder/$probe"
answer=1
web_serves "$probe" && answer=0
rm -f "$folder/$probe"
if [ "$created" = "yes" ] && [ "$answer" != "0" ]; then rmdir "$folder" 2>/dev/null || true; fi
return "$answer"
}
# --------------------------------------------------- Docker, seen from the host
#
# On a host whose server runs in a container there is nothing here to install
# into: a container's own files are replaced every time it is recreated, and
# its environment and compose file are not this script's to edit (contract §4).
# So it reads what is there, with `docker inspect` and the startup log through
# `docker exec`, both read-only (F4), and prints the exact change, for the way
# in and for the way out. Printing it is what this path is for: it exits 0.
docker_servers() {
docker ps --no-trunc --format '{{.Names}}|{{.Command}}' 2>/dev/null \
| awk -F'|' '$2 ~ /DnsServerApp\.dll/ { print $1 }'
}
dk() { docker inspect -f "$2" "$1" 2>/dev/null || true; }
# Labels and environment can come from the image, so whoever built it wrote
# them. Control characters go before anything reaches the terminal, and the
# values that end up in a printed command are quoted there with shq.
dk_label() { dk "$1" "{{index .Config.Labels \"$2\"}}" | tr -d '[:cntrl:]'; }
dk_env() { dk "$1" '{{range .Config.Env}}{{println .}}{{end}}' | sed -n "s/^$2=//p" | head -1 | tr -d '[:cntrl:]'; }
# The source goes last and is everything after the fifth |, because a folder
# can have a | in its name. One with a newline or another control character
# would split the line, so docker says first whether %q leaves it as it is:
# "plain" when it does, and only a plain source is ever put in a command.
dk_mount() { # container, destination (prints "type|rw or ro|plain or odd|volume name|source")
dk "$1" '{{range .Mounts}}{{.Destination}}|{{.Type}}|{{.RW}}|{{if eq (printf "%q" .Source) (printf "\"%s\"" .Source)}}plain{{else}}odd{{end}}|{{.Name}}|{{.Source}}{{println}}{{end}}' \
| DM_DEST="$2" awk -F'|' '$1 == ENVIRON["DM_DEST"] {
s = $0; for (i = 0; i < 5; i++) s = substr(s, index(s, "|") + 1)
print $2 "|" ($3 == "true" ? "rw" : "ro") "|" $4 "|" $5 "|" s; exit
}'
}
# Sets DM_TYPE, DM_RO (":ro", or nothing when it is mounted read-write), DM_PLAIN,
# DM_WHERE (the volume's name, or the folder on the host) and DM_SOURCE.
dk_mount_read() {
IFS='|' read -r DM_TYPE dm_rw DM_PLAIN dm_name DM_SOURCE <<EOF
$1
EOF
DM_RO=":ro"
if [ "$dm_rw" = "rw" ]; then DM_RO=""; fi
DM_WHERE="$DM_SOURCE"
if [ "$DM_TYPE" = "volume" ]; then DM_WHERE="$dm_name"; fi
}
# A host folder a command may be printed for: read whole, absolute, not / and
# with nothing in it that a terminal or a shell would read differently.
nameable() { # folder
[ "$DM_PLAIN" = "plain" ] || return 1
case "$1" in
''|/|[!/]*|*[[:cntrl:]]*|*/.|*/./*|*/..|*/../*|*//*) return 1 ;;
esac
return 0
}
# The command that removes a host folder. It is printed only for a folder this
# script can see and that is_this_console recognises: an index.html that mounts
# #root and names an entry script that is there, the same test W6 trusts before
# publishing sweeps a folder. That does not show nothing else is in it, which is
# why the lists are named first. Anything else gets no command, and ds_remove
# says to look at it.
folder_removal() { # folder
nameable "$1" || return 0
[ -d "$1" ] && is_this_console "$1" || return 0
printf 'sudo rm -rf %s' "$(shq "$1")"
}
dk_has_source() {
dk "$1" '{{range .Mounts}}{{.Source}}{{println}}{{end}}' | grep -qxF "$2"
}
dk_version() {
docker exec "$1" sh -c 'grep -ho "DNS Server (v[0-9.]*)" /var/log/technitium/dns/*.log 2>/dev/null | tail -1' 2>/dev/null \
| sed -n 's/.*(v\([0-9.]*\)).*/\1/p'
}
before_15_5() {
[ -n "$1" ] || return 1
bv_major="${1%%.*}"; bv_rest="${1#*.}"; bv_minor="${bv_rest%%.*}"
[ "$bv_major" -lt 15 ] || { [ "$bv_major" -eq 15 ] && [ "$bv_minor" -lt 5 ]; }
}
at_least_15_5() {
al_version="${1#v}"
al_major="${al_version%%.*}"
al_rest="${al_version#*.}"
[ "$al_rest" != "$al_version" ] || return 1
al_minor="${al_rest%%.*}"
case "$al_major:$al_minor" in *[!0-9:]*|:|*:) return 1 ;; esac
[ "$al_major" -gt 15 ] || { [ "$al_major" -eq 15 ] && [ "$al_minor" -ge 5 ]; }
}
dk_init_service() {
docker ps -a --filter "label=com.docker.compose.project=$1" \
--format '{{.Image}}|{{.Label "com.docker.compose.service"}}' 2>/dev/null \
| awk -F'|' '$1 ~ /technitium-console/ { print $2; exit }' | tr -d '[:cntrl:]'
}
shq() { # a word as a shell reads it back, quoted only when it has to be
case "$1" in
''|*[!A-Za-z0-9_./:@%+=,-]*) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;;
*) printf '%s' "$1" ;;
esac
}
# The `docker compose` that reaches a container's project from any folder: its
# name, its files and, when it is not theirs, its folder, all from the labels
# Compose put on the container.
dk_compose() { # container
dc_cmd="docker compose -p $(shq "$(dk_label "$1" com.docker.compose.project)")"
dc_files="$(dk_label "$1" com.docker.compose.project.config_files)"
dc_dir="$(dk_label "$1" com.docker.compose.project.working_dir)"
dc_first=""
dc_ifs="$IFS"; IFS=','; set -f
for dc_f in $dc_files; do
if [ -z "$dc_first" ]; then dc_first="$dc_f"; fi
dc_cmd="$dc_cmd -f $(shq "$dc_f")"
done
IFS="$dc_ifs"; set +f
if [ -n "$dc_dir" ] && [ "$dc_dir" != "${dc_first%/*}" ]; then
dc_cmd="$dc_cmd --project-directory $(shq "$dc_dir")"
fi
printf '%s' "$dc_cmd"
}
# A volume or a folder goes once, after every container that mounts it has let
# go of it. So when two of them share one, the command is given in the steps of
# the last, and the others say where it is. Returns 1 when it is not given here.
ds_remove() { # container, source of its mount, the command or nothing, the words before it, [this installer's state]
dr_how="$3"
if [ -z "$dr_how" ]; then
dr_what="the folder mounted there"
if nameable "$DM_WHERE"; then dr_what="$(shq "$DM_WHERE")"; fi
dr_how="look at what $dr_what holds and remove it yourself: this script cannot vouch that it is only the console."
if [ -n "${5:-}" ]; then dr_how="$dr_how Its record here goes with it: sudo rm -rf $5"; fi
fi
dr_with=""
for dr_c in $DS_ALL; do
if dk_has_source "$dr_c" "$2"; then dr_with="$dr_with $dr_c"; fi
done
dr_with="${dr_with# }"; dr_last="${dr_with##* }"
case "$dr_with" in
*" "*) ;;
*) say " - $4$dr_how"; return 0 ;;
esac
if [ "$1" = "$dr_last" ]; then
say " - Once $(printf '%s' "$dr_with" | sed 's/ /, /g') no longer mount it: $dr_how"
return 0
fi
say " - Not removed here: other containers mount it too. It goes once, in the steps for $dr_last."
return 1
}
docker_steps() {
ds_c="$1"
ds_project="$(dk_label "$ds_c" com.docker.compose.project)"
ds_service="$(dk_label "$ds_c" com.docker.compose.service)"
ds_files="$(dk_label "$ds_c" com.docker.compose.project.config_files)"
ds_dc=""
if [ -n "$ds_project" ]; then ds_dc="$(dk_compose "$ds_c")"; fi
ds_version="$(dk_version "$ds_c")"
ds_folder="$(dk_env "$ds_c" "$VAR_NAME")"
ds_console=""
if [ -n "$ds_folder" ]; then ds_console="$(dk_mount "$ds_c" "$ds_folder")"; fi
ds_legacy="$(dk_mount "$ds_c" /opt/technitium/dns/www)"
ds_state=""
printf '\n container %s%s%s\n\n' "$ds_c" "${ds_version:+ v$ds_version}" \
"${ds_service:+ · service \"$ds_service\" in ${ds_files:-its compose file}}"
if [ -n "$ds_console" ]; then
dk_mount_read "$ds_console"
ds_short="$DM_WHERE"
if [ "$DM_TYPE" = "volume" ] && [ -n "$ds_project" ]; then ds_short="${DM_WHERE#"${ds_project}"_}"; fi
if [ "$(state_get webroot)" = "$DM_WHERE" ]; then ds_state=" $STATE_DIR"; fi
if [ "$ACTION" = "uninstall" ]; then
say "To remove the console (the server goes back to the one its image ships):"
say ""
say " - Keep any json/*-custom.json you edited: they are in $DM_TYPE $(shq "$DM_WHERE")."
if [ -n "$ds_project" ]; then
say " - In ${ds_files:-the compose file}, take these two lines out of \"$ds_service\":"
say " - $VAR_NAME=$ds_folder"
say " - $ds_short:$ds_folder$DM_RO"
if [ "$DM_TYPE" = "bind" ]; then say " (docker reports the path resolved; your file may write it relative to itself)"; fi
if [ "$DM_TYPE" = "volume" ]; then
say " and remove the service that runs $IMAGE_REF, and \"$ds_short:\" under"
say " the top-level volumes:."
else
say " and remove the service that runs $IMAGE_REF."
fi
say " - $ds_dc up -d --remove-orphans"
say " It restarts \"$ds_service\" once, because its environment changes."
else
say " - Re-create $ds_c without -e $(shq "$VAR_NAME=$ds_folder")"
say " and without -v $(shq "$DM_WHERE:$ds_folder$DM_RO"). That is its only restart."
fi
if [ "$DM_TYPE" = "volume" ]; then
ds_remove "$ds_c" "$DM_SOURCE" "docker volume rm $(shq "$DM_WHERE")" "" || true
else
ds_rm="$(folder_removal "$DM_WHERE")"
ds_remove "$ds_c" "$DM_SOURCE" "$ds_rm${ds_rm:+$ds_state}" "" "${ds_state# }" || true
fi
else
say "The console is already set up: $ds_folder is served from $DM_TYPE $DM_WHERE."
say "To update it, without restarting the DNS server:"
say ""
if [ -n "$ds_project" ]; then
ds_init="$(dk_init_service "$ds_project")"
ds_init="$(shq "${ds_init:-technitium-console}")"
say " $ds_dc pull $ds_init && $ds_dc up -d $ds_init"
else
say " docker pull $IMAGE_REF:latest"
if [ "$DM_TYPE" = "volume" ] || nameable "$DM_WHERE"; then
say " docker run --rm -v $(shq "$DM_WHERE:/target") $IMAGE_REF:latest"
else
say " and the image run with the folder mounted there at /target. It cannot be"
say " named safely in a command: look at it first."
fi
fi
fi
elif [ -n "$ds_legacy" ]; then
dk_mount_read "$ds_legacy"
if [ "$DM_TYPE" = "volume" ]; then
ds_short="$DM_WHERE"
if [ -n "$ds_project" ]; then ds_short="${DM_WHERE#"${ds_project}"_}"; fi
say "It serves the docker volume $(shq "$DM_WHERE") mounted over its own web root, the layout used before 15.5."
else
ds_short="$DM_WHERE"
if [ "$(state_get webroot)" = "$DM_WHERE" ]; then ds_state=" $STATE_DIR"; fi
if nameable "$DM_WHERE"; then
say "It serves $(shq "$DM_WHERE") mounted over its own web root, the layout used before 15.5."
else
say "It serves a host folder that cannot be named safely in a command, mounted over"
say "its own web root: the layout used before 15.5."
fi
fi
if [ "$ACTION" = "uninstall" ]; then
say "To remove the console:"
say ""
if [ "$DM_TYPE" = "volume" ]; then
say " - Keep any json/*-custom.json you edited: they are in that volume, under json/."
elif nameable "$DM_WHERE"; then
say " - Keep any json/*-custom.json you edited, from $(shq "${DM_WHERE%/}/json")."
else
say " - Keep any json/*-custom.json you edited, from the json/ of the folder mounted there."
fi
if [ -n "$ds_project" ]; then
say " - In ${ds_files:-the compose file}, take this line out of \"$ds_service\":"
say " - $ds_short:/opt/technitium/dns/www$DM_RO"
if [ "$DM_TYPE" = "volume" ]; then
say " and \"$ds_short:\" under the top-level volumes:."
else
say " (docker reports the path resolved; your file may write it relative to itself)"
fi
say " - $ds_dc up -d $(shq "$ds_service")"
say " Its only restart: it comes back on the console its image ships."
else
say " - Re-create $ds_c without -v $(shq "$DM_WHERE:/opt/technitium/dns/www$DM_RO") (its only restart)."
fi
if [ "$DM_TYPE" = "volume" ]; then
ds_remove "$ds_c" "$DM_SOURCE" "docker volume rm $(shq "$DM_WHERE")" "Then: " || true
else
ds_rm="$(folder_removal "$DM_WHERE")"
if ds_remove "$ds_c" "$DM_SOURCE" "$ds_rm${ds_rm:+$ds_state}" "Then, and not before: " "${ds_state# }"; then
say " Emptied while still mounted, it would leave the server nothing to serve."
fi
fi
else
if [ "$DM_TYPE" = "volume" ]; then
say "Updating it from this host would mean writing into Docker's own storage,"
say "which this script does not do."
elif nameable "$DM_WHERE"; then
say "To update the console in it:"
say ""
say " $ONE_LINER --dir $(shq "$DM_WHERE")"
else
say "That folder cannot be named safely in a command: look at what it holds"
say "before running the installer on it."
fi
if ! before_15_5 "$ds_version"; then
say ""
say "On 15.5 or later, the image is simpler and survives server updates: $DOCKER_DOCS"
elif [ "$DM_TYPE" = "volume" ]; then
say "Mount a folder of this host there instead, and install into it:"
say ""
say " $ONE_LINER --dir $CONSOLE_DIR"
say " - $CONSOLE_DIR:/opt/technitium/dns/www:ro"
fi
fi
elif [ "$ACTION" = "uninstall" ]; then
say "It serves the console its image ships. Nothing to remove."
elif before_15_5 "$ds_version"; then
say "v$ds_version cannot serve a folder of its own: that came with 15.5. Update the"
say "server and run this again, or install into a folder on this host and mount it"
say "over the container's web root:"
say ""
say " $ONE_LINER --dir $CONSOLE_DIR"
say " - $CONSOLE_DIR:/opt/technitium/dns/www:ro"
elif [ -n "$ds_project" ]; then
say "In ${ds_files:-your compose file}, add to \"$ds_service\":"
say ""
say " environment:"
say " - $VAR_NAME=$CONSOLE_DIR"
say " volumes:"
say " - technitium-console:$CONSOLE_DIR:ro"
say ""
say "and this service and volume, which copy the console in and stop:"
say ""
say " services:"
say " technitium-console:"
say " image: $IMAGE_REF:latest"
say " volumes:"
say " - technitium-console:/target"
say " restart: \"no\""
say " volumes:"
say " technitium-console:"
say ""
say "Then: $ds_dc up -d"
say "It restarts \"$ds_service\" once, because its environment changes. Updates do not."
if [ -z "$ds_version" ]; then say "It needs Technitium 15.5 or later."; fi
else
say "Fill a volume with the console, on this host:"
say ""
say " docker volume create technitium-console"
say " docker run --rm -v technitium-console:/target $IMAGE_REF:latest"
say ""
say "and re-create $ds_c with these two added to its docker run (its only restart):"
say ""
say " -e $VAR_NAME=$CONSOLE_DIR"
say " -v technitium-console:$CONSOLE_DIR:ro"
if [ -z "$ds_version" ]; then say "It needs Technitium 15.5 or later."; fi
fi
}
DS_ALL="" # the containers whose steps are being printed: see ds_remove
docker_guidance() {
dg_servers="$(docker_servers)"
[ -n "$dg_servers" ] || return 0
DS_ALL="$dg_servers"
say "Technitium runs in Docker here. Nothing is written into a container: it would"
say "be lost the next time the container is recreated. These are the steps instead."
say "More: $DOCKER_DOCS"
for dg_c in $dg_servers; do docker_steps "$dg_c"; done
printf '\n'
exit 0
}
docker_guidance_for() {
command -v docker >/dev/null 2>&1 || return 0
dgf_found=""
for dgf_c in $(docker_servers); do
if dk_has_source "$dgf_c" "$1"; then dgf_found="$dgf_found $dgf_c"; fi
done
[ -n "$dgf_found" ] || return 0
DS_ALL="$dgf_found"
say "$1 is mounted into a container, so undoing it is a change to that container."
for dgf_c in $dgf_found; do docker_steps "$dgf_c"; done
printf '\n'
exit 0
}
# Inside a container, a folder that is neither a mount point nor under one is
# part of the container's own files, and goes with the next recreate. That is
# what `docker exec <c> sh -c "curl … | sh"` installs into.
in_container_layer() { # folder
[ -f /.dockerenv ] || [ -f /run/.containerenv ] || return 1
icl_d="$1"
while [ -n "$icl_d" ] && [ "$icl_d" != "/" ]; do
is_mount_point "$icl_d" && return 1
icl_d="${icl_d%/*}"
done
return 0
}
# ---------------------------------------------------------------- where it goes
MODE="" # replacement | side-by-side
RESTART_NEEDED="no"
SERVER_VERSION=""
MANAGE_SYSTEMD="no"
MANAGED_UNIT=""
MANAGED_DROPIN=""
resolve_target() {
if [ -n "$INTO_VOLUME" ]; then
MODE="side-by-side" # a folder of its own: nothing to back up, ever
secure_tree "$WWW_DIR"
refuse_links "$WWW_DIR"
ok "Installing into the volume" "$WWW_DIR"
return 0
fi
SERVER_VERSION="$(server_version)"
find_server
# Uninstall follows the trusted record even if the server already returned
# to a service-owned stock folder. That stock folder is not a write target.
if [ "$ACTION" = "uninstall" ]; then
recorded_target="$(state_get webroot)"
if [ -n "$recorded_target" ]; then
WWW_DIR="$recorded_target"
MODE="$(state_get mode)"; MODE="${MODE:-replacement}"
secure_tree "$WWW_DIR"
refuse_links "$WWW_DIR"
return 0
fi
fi
if [ -n "$WWW_DIR" ]; then
MODE="${1:-replacement}"
secure_tree "$WWW_DIR"
refuse_links "$WWW_DIR"
return 0
fi
if [ "$SERVERS_FOUND" -gt 1 ]; then
printf '\n \033[31m✗\033[0m more than one Technitium DNS Server is running here:\n\n' >&2
printf '%s' "$SERVERS" | while IFS=' ' read -r p a c; do
printf ' pid %-8s %s\n' "$p" "${c:-$a/www}" >&2
done
printf '\n Not choosing between them. Say which web root with --dir <path>.\n\n' >&2
exit 1
fi
if [ -n "$SERVER_PID" ]; then
configured="$(server_env "$SERVER_PID" "$VAR_NAME")"
app="$SERVER_APP"
if [ -n "$configured" ]; then
secure_tree "$configured"
refuse_links "$configured" # the probe below already writes into it
# That folder is the choice of the server's environment, which the
# service's own account can set, not of the administrator. One that does
# not exist yet is created only where the README puts this console's
# folder; anywhere else the administrator creates it or names it.
if [ ! -e "$configured" ] && [ "$configured" != "$CONSOLE_DIR" ]; then
die "$VAR_NAME names $configured, which does not exist yet.
Not creating a folder that the server's environment chose. If it is the
one you meant, create it (sudo mkdir -m 0755 $configured) and run this again."
fi
if ! web_answers; then
warn "$VAR_NAME is set, but the web console does not answer at $(web_base)."
warn "Not installing where nothing can be checked. Pass --url, or --dir to decide yourself."
elif serves_folder "$configured"; then
WWW_DIR="$configured"; MODE="side-by-side"
ok "Serving from its own folder" "$WWW_DIR"
return 0
elif logged_variable_fallback; then
WWW_DIR="$configured"; MODE="side-by-side"; RESTART_NEEDED="yes"
ok "Its own folder, not yet in use" "$WWW_DIR"
say "The server logged that this folder did not exist when it started, so it"
say "is serving the default one until it is restarted."
return 0
else
warn "$VAR_NAME is set to $configured, but this server does not honour it."
warn "Installing there would put the console where nobody reads it."
fi
fi
# On 15.5+ systemd installs, keep the alternative console outside the DNS
# server's application folder. The unit is matched to the process above,
# never selected merely by its name. Publishing happens before the drop-in
# is written, so the single restart can only point at a complete console.
if [ "$CONFIGURE_SERVICE" = "yes" ] && at_least_15_5 "$SERVER_VERSION" \
&& command -v systemctl >/dev/null 2>&1 \
&& managed_unit_file="$(find_systemd_unit_for_app)"; then
MANAGED_UNIT="$(basename "$managed_unit_file")"
MANAGED_DROPIN="/etc/systemd/system/$MANAGED_UNIT.d/technitium-console.conf"
if [ -e "$MANAGED_DROPIN" ] && [ "$(state_get managed_dropin)" != "$MANAGED_DROPIN" ]; then
warn "$MANAGED_DROPIN already exists and is not managed by this installer."
warn "Leaving it alone and using the server's own web root."
else
WWW_DIR="$CONSOLE_DIR"; MODE="side-by-side"; MANAGE_SYSTEMD="yes"
secure_tree "$WWW_DIR"
refuse_links "$WWW_DIR"
ok "Dedicated console folder" "$WWW_DIR"
say "The installer will add a systemd drop-in for $MANAGED_UNIT and restart it once."
return 0
fi
fi
if [ -n "$app" ] && [ -d "$app" ]; then
WWW_DIR="$app/www"; MODE="replacement"
secure_tree "$WWW_DIR"
refuse_links "$WWW_DIR"
ok "Technitium DNS Server found" "$WWW_DIR"
return 0
fi
fi
# Nothing running here. Either the server is in a container, or this is a host
# preparing a folder to mount into one.
for d in /opt/technitium/dns/www /etc/dns/www; do
if [ -d "$d" ]; then
WWW_DIR="$d"; MODE="replacement"
secure_tree "$WWW_DIR"
refuse_links "$WWW_DIR"
ok "Technitium DNS Server found" "$WWW_DIR"
return 0
fi
done
if command -v docker >/dev/null 2>&1; then docker_guidance; fi
die "no Technitium DNS Server found. Point at its web root with --dir <path>."
}
# ------------------------------------------------------------------- publishing
#
# The order is the whole point, and it is the same one whether the web root can
# be renamed or not, because it cannot be when it is a mount point, and because
# swapping two directories by rename has a moment in between with no web root at
# all, which is exactly what must never happen.
#
# 1 · assets, fonts, images and lists first, each replaced atomically
# 2 · the pages after them, deepest first, the entry page last
# 3 · what the previous console left, and only then
#
# During step 2 every page a request can be handed finds the files it names: the
# new ones are already there and the old ones are not gone yet.
CUSTOM_GLOB='json/*-custom.json'
copy_one() { # src root, dst root, relative path
safe_relative "$3"
case "$3" in
$CUSTOM_GLOB) if [ -e "$2/$3" ]; then return 0; fi ;; # the administrator's file wins
esac
mkdir -p "$2/$(dirname "$3")"
# The namespace has been checked as root-only writable. Remove a leftover
# temporary file from an interrupted publication before replacing it.
rm -f "$2/$3.tc-new"
cp -f "$1/$3" "$2/$3.tc-new"
mv -f "$2/$3.tc-new" "$2/$3"
}
# What step 3 may sweep is decided before W6 looks at the folder, and nothing
# is ever added to it: only what was there then. Whatever appears afterwards is
# not this run's to remove: the server writing its first files, if the folder
# turns out to be its own (a volume swapped onto the init's /target, both
# started together). Taken before W6 and not after it, so that anything already
# there was there when W6 judged the folder.
snapshot() { # folder, a folder of its own for the lists
secure_tree "$1"
( cd "$1" 2>/dev/null && find . -type f -print ) | sed 's|^\./||' > "$2/before"
( cd "$1" 2>/dev/null && find . -mindepth 1 -type d -print ) | sed 's|^\./||' | sort -r > "$2/dirs"
}
publish() { # src, dst, the folder snapshot wrote its lists in
secure_tree "$1"
secure_tree "$2"
src="$1"; dst="$2"; list="$3/list"
mkdir -p "$dst"
( cd "$src" && find . -type f ! -name index.html -print ) | sed 's|^\./||' > "$list"
while IFS= read -r f; do [ -n "$f" ] && copy_one "$src" "$dst" "$f"; done < "$list"
( cd "$src" && find . -type f -name index.html -print ) | sed 's|^\./||' \
| awk '{ n = gsub(/\//, "/"); print n " " $0 }' | sort -rn | cut -d' ' -f2- > "$list"
while IFS= read -r f; do [ -n "$f" ] && copy_one "$src" "$dst" "$f"; done < "$list"
while IFS= read -r f; do
[ -n "$f" ] || continue
safe_relative "$f"
case "$f" in $CUSTOM_GLOB) continue ;; esac
if [ -n "$INTO_VOLUME" ] && [ "$f" = "$MARKER" ]; then continue; fi
[ -e "$src/$f" ] || rm -f "$dst/$f"
done < "$3/before"
# Deepest first (sort -r puts a/b before a), and only folders that were there
# before and are empty now.
while IFS= read -r d; do
if [ -n "$d" ]; then safe_relative "$d"; rmdir "$dst/$d" 2>/dev/null || true; fi
done < "$3/dirs"
}
carry_custom_lists() { # from, to (used when the served folder changes)
[ -d "$1/json" ] || return 0
cc_any="no"
for cc_file in "$1"/json/*-custom.json; do
if [ -e "$cc_file" ] || [ -L "$cc_file" ]; then cc_any="yes"; break; fi
done
[ "$cc_any" = "yes" ] || return 0
secure_tree "$1"
secure_tree "$2"
for f in "$1"/json/*-custom.json; do
[ -f "$f" ] || continue
mkdir -p "$2/json"
[ -e "$2/json/$(basename "$f")" ] || cp -f "$f" "$2/json/$(basename "$f")"
done
}
verify_checksum() { # tarball, its .sha256 as sha256sum writes it
command -v sha256sum >/dev/null 2>&1 || die "sha256sum is needed to check the console, and is not installed."
vc_expected="$(cut -d' ' -f1 < "$2")"
vc_actual="$(sha256sum "$1" | cut -d' ' -f1)"
[ -n "$vc_expected" ] && [ "$vc_expected" = "$vc_actual" ]
}
confirm() { # question
[ "$ASSUME_YES" = "yes" ] && return 0
[ -t 0 ] || return 0
printf '\n %s [y/N] ' "$1"
read -r answer
printf '\n'
case "$answer" in [yY]*) return 0 ;; *) die "nothing was changed." ;; esac
}
restart_server() {
if command -v systemctl >/dev/null 2>&1 && unit="$(find_systemd_unit)"; then
systemctl restart "$unit" && ok "Service restarted" "$unit" || warn "Could not restart $unit. Restart it yourself."
elif [ -x /sbin/rc-service ] && [ -f /etc/init.d/dns ]; then
rc-service dns restart >/dev/null && ok "Service restarted" "dns"
else
warn "Restart your DNS server so it starts serving the new folder."
fi
}
managed_dropin_body() {
printf '[Service]\nEnvironment=%s=%s\n' "$VAR_NAME" "$CONSOLE_DIR"
}
managed_dropin_is_ours() {
[ -f "$1" ] || return 1
md_expected="$(managed_dropin_body)"
md_actual="$(cat "$1")"
[ "$md_actual" = "$md_expected" ]
}
wait_for_managed_console() { # asset
wf_i=0
while [ "$wf_i" -lt 30 ]; do
find_server
if [ -n "$SERVER_PID" ] \
&& [ "$(server_env "$SERVER_PID" "$VAR_NAME")" = "$CONSOLE_DIR" ] \
&& web_serves "$1"; then return 0; fi
wf_i=$((wf_i + 1)); sleep 1
done
return 1
}
wait_for_stock_console() {
wf_i=0
while [ "$wf_i" -lt 30 ]; do
find_server
if [ -n "$SERVER_PID" ] && [ -z "$(server_env "$SERVER_PID" "$VAR_NAME")" ] \
&& web_serves "js/main.js"; then return 0; fi
wf_i=$((wf_i + 1)); sleep 1
done
return 1
}
activate_managed_systemd() { # asset
secure_tree "$(dirname "$MANAGED_DROPIN")"
mkdir -p "$(dirname "$MANAGED_DROPIN")"
ad_tmp="$(mktemp "$(dirname "$MANAGED_DROPIN")/technitium-console.XXXXXX")"
managed_dropin_body > "$ad_tmp"
chmod 0644 "$ad_tmp"
mv -f "$ad_tmp" "$MANAGED_DROPIN"
state_set managed_unit "$MANAGED_UNIT"
state_set managed_dropin "$MANAGED_DROPIN"
if systemctl daemon-reload && systemctl restart "$MANAGED_UNIT" \
&& wait_for_managed_console "$1"; then
ok "Service configured and restarted" "$MANAGED_UNIT"
return 0
fi
warn "The service did not come back on the dedicated console. Rolling back."
rm -f "$MANAGED_DROPIN"
rmdir "$(dirname "$MANAGED_DROPIN")" 2>/dev/null || true
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl restart "$MANAGED_UNIT" >/dev/null 2>&1 || true
wait_for_stock_console || true
find "$WWW_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
rmdir "$WWW_DIR" 2>/dev/null || true
state_clear
die "systemd activation failed. The drop-in was removed and the stock console was restored."
}
# ------------------------------------------------------------------- uninstall
if [ "$ACTION" = "uninstall" ]; then
secure_tree "$STATE_DIR"
resolve_target
# The folder to undo is the one the install wrote to, which is on record. The
# one the server points at now can be another: take the variable away and
# restart, and "now" is the server's own www.
RECORDED="$(state_get webroot)"
if [ -n "$RECORDED" ] && [ "$RECORDED" != "$WWW_DIR" ]; then
warn "The console was installed in $RECORDED; the server now points at $WWW_DIR."
warn "Undoing it in $RECORDED, the folder on record. $WWW_DIR is left as it is."
WWW_DIR="$RECORDED"
secure_tree "$WWW_DIR"
refuse_links "$WWW_DIR"
fi
BACKUP="$(state_get backup)"
[ -n "$BACKUP" ] || BACKUP="$WWW_DIR$BACKUP_SUFFIX"
RECORDED_MODE="$(state_get mode)"
[ -n "$RECORDED_MODE" ] || RECORDED_MODE="$MODE"
RECORDED_UNIT="$(state_get managed_unit)"
RECORDED_DROPIN="$(state_get managed_dropin)"
if [ "$RECORDED_MODE" = "side-by-side" ]; then
# The whole folder goes, so it has to still be this console. A record is not
# enough on its own: the folder could have been emptied and reused since.
is_this_console "$WWW_DIR" || die "$WWW_DIR does not hold this console any more. Not removing it:
look at what is in it, and remove it yourself if it should go."
stock="${SERVER_APP:+$SERVER_APP/www}"
[ -n "$stock" ] && [ -d "$stock" ] || die "cannot find the console the server ships to hand back to."
[ "$stock" != "$WWW_DIR" ] || die "$WWW_DIR is the server's own web root. Not removing it."
refuse_links "$stock"
if [ -n "$RECORDED_DROPIN" ]; then
[ -n "$RECORDED_UNIT" ] || die "the managed systemd unit is missing from $STATE. Nothing was changed."
[ "$RECORDED_DROPIN" = "/etc/systemd/system/$RECORDED_UNIT.d/technitium-console.conf" ] \
|| die "the recorded systemd drop-in has an unexpected path. Nothing was changed."
secure_tree "$(dirname "$RECORDED_DROPIN")"
managed_dropin_is_ours "$RECORDED_DROPIN" || die "$RECORDED_DROPIN was changed after installation.
Not removing an administrator's service configuration. Restore its original
two lines or remove the console manually. Nothing was changed."
confirm "Remove the systemd drop-in and restart $RECORDED_UNIT on its stock console?"
carry_custom_lists "$WWW_DIR" "$stock"
rm -f "$RECORDED_DROPIN"
if ! systemctl daemon-reload || ! systemctl restart "$RECORDED_UNIT" \
|| ! wait_for_stock_console; then
warn "The service did not return on its stock console. Restoring the drop-in."
managed_dropin_body > "$RECORDED_DROPIN"
chmod 0644 "$RECORDED_DROPIN"
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl restart "$RECORDED_UNIT" >/dev/null 2>&1 || true
wait_for_managed_console "$(cd "$WWW_DIR" && find assets -name '*.js' -type f 2>/dev/null | head -1 || true)" || true
die "the uninstall was rolled back; the console folder and state were left in place."
fi
rmdir "$(dirname "$RECORDED_DROPIN")" 2>/dev/null || true
find "$WWW_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
rmdir "$WWW_DIR" 2>/dev/null || true
state_clear
ok "Stock console restored" "$RECORDED_UNIT"
ok "Console removed" "$WWW_DIR"
printf '\n'
exit 0
fi
carry_custom_lists "$WWW_DIR" "$stock"
# The contents first and the folder after, because the folder may be a
# mount point (the Docker layout the README describes) and a mount point
# cannot be removed from inside: `rm -rf` on it empties it and then fails,
# which used to stop the uninstall halfway.
find "$WWW_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
if rmdir "$WWW_DIR" 2>/dev/null; then
state_clear
ok "Console removed" "$WWW_DIR"
say "Unset $VAR_NAME and the server goes back to its own console."
confirm "Restart the DNS server now so it stops looking at a folder that is gone?"
restart_server
else
# The folder stays, empty. A restart with the variable still set would
# serve that empty folder, so no restart is offered: the variable (or the
# mount) has to go first, and that is the administrator's to change.
state_clear
ok "Console removed" "$WWW_DIR (the folder itself stays: it is a mount point)"
warn "Unset $VAR_NAME, or remove the mount, BEFORE restarting the DNS server:"
warn "with the variable still set it would serve this empty folder."
fi
printf '\n'
exit 0
fi
# Replacement mode: the backup is the authority, not a marker in the web root.
if [ ! -d "$BACKUP" ]; then
# Installed with --dir into an empty folder, there never was an original.
# On a Docker host that folder is mounted into a container, and the way out
# is a change to the container, which docker_guidance_for prints.
docker_guidance_for "$WWW_DIR"
die "the original console is not at $BACKUP: there is nothing to restore."
fi
taken="$(state_get server_version)"
if [ -n "$taken" ] && [ -n "$SERVER_VERSION" ] && [ "$taken" != "$SERVER_VERSION" ]; then
if [ "$ALLOW_MISMATCH" != "yes" ]; then
printf '\n \033[31m✗\033[0m the backup is the console of another server version.\n\n' >&2
printf ' backed up from: %s\n running now: %s\n\n' "$taken" "$SERVER_VERSION" >&2
printf ' Restoring it would put an old console in front of a newer server. The\n' >&2
printf ' repair that does not need this installer is to run the DNS server'"'"'s own\n' >&2
printf ' installer again, which puts back the console your version ships.\n\n' >&2
printf ' To restore it anyway: --restore-mismatched-backup\n\n' >&2
exit 1
fi
warn "Restoring a $taken console onto a $SERVER_VERSION server, because you asked."
fi
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
PUBLISH_WORK="$(mktemp -d "$STATE_DIR/restore.XXXXXX")"
# A signal ends the run; it does not clean up and carry on (see the install).
trap 'rm -rf "$PUBLISH_WORK"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
snapshot "$WWW_DIR" "$PUBLISH_WORK"
may_write_into "$WWW_DIR"
publish "$BACKUP" "$WWW_DIR" "$PUBLISH_WORK" # custom lists in place are kept: see copy_one
rm -rf "$BACKUP"
state_clear
ok "Original console restored" "$WWW_DIR"
printf '\n'
exit 0
fi
# ---------------------------------------------------------------------- install
secure_tree "$STATE_DIR"
resolve_target
command -v tar >/dev/null 2>&1 || die "tar is needed and is not installed."
TMP="$STATE_DIR/staging"
# What the sweep may remove is listed before W6 looks, in a folder of this run's
# own outside the state, so that a run W6 refuses leaves nothing behind, not
# even an empty state folder. That one goes on the way out whenever this run
# made it and wrote no state into it.
LISTS="$(mktemp -d)" || die "could not create a working folder."
STATE_DIR_MADE="no"
[ -d "$STATE_DIR" ] || STATE_DIR_MADE="yes"
cleanup() {
rm -rf "$TMP" "$LISTS"
if [ "$STATE_DIR_MADE" = "yes" ]; then rmdir "$STATE_DIR" 2>/dev/null || true; fi
}
# A signal ends the run, and the exit cleans up. It must not clean up and carry
# on: the sweep compares the web root against those lists, and with them gone
# it would remove the console it has just published. docker stop and docker
# compose down send the init a TERM.
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
snapshot "$WWW_DIR" "$LISTS"
may_write_into "$WWW_DIR"
# A run that was interrupted leaves its staging folder behind. Nothing else is
# needed to repair it: publishing is idempotent and the sweep below removes
# whatever the interrupted run had already written. Said only once W6 has let
# this run through, because a run that is refused picks nothing up.
if [ -d "$TMP" ]; then
warn "A previous run did not finish. Picking up from a clean slate."
rm -rf "$TMP"
fi
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
mkdir -p "$TMP/dist"
if [ -n "$SOURCE" ] && [ -f "$SOURCE" ]; then
cp "$SOURCE" "$TMP/console.tar.gz"
ok "Console read from file" "$SOURCE"
if [ -n "$INTO_VOLUME" ]; then
# The image carries the release's checksum next to its tarball (contract
# D4). CI checked it before building; it is checked again where it counts.
[ -f "$SOURCE.sha256" ] || die "there is no checksum next to $SOURCE. Not installing what cannot be checked."
verify_checksum "$TMP/console.tar.gz" "$SOURCE.sha256" \
|| die "$SOURCE does not match the release's checksum. Nothing was changed."
ok "Checksum matches the release" "sha256"
else
warn "It is not verified: a tarball given with --from is installed as it is."
fi
else
if [ -n "$SOURCE" ]; then
URL="$SOURCE"
elif [ "$VERSION" = "latest" ]; then
URL="https://github.com/$REPO/releases/latest/download/technitium-console.tar.gz"
else
URL="https://github.com/$REPO/releases/download/$VERSION/technitium-console.tar.gz"
fi
command -v curl >/dev/null 2>&1 || die "curl is needed to download, or pass a local tarball with --from."
curl -fsSL "$URL" -o "$TMP/console.tar.gz" || die "could not download the console from $URL"
ok "Console downloaded" "$(du -h "$TMP/console.tar.gz" | cut -f1)"
if [ -n "$SOURCE" ]; then
warn "It is not verified: a URL given with --from has no checksum to hold it to."
else
# Every release publishes the tarball's SHA-256 next to it, as sha256sum
# writes it (.github/workflows/release.yml, "Pack"). No checksum, no install.
curl -fsSL "$URL.sha256" -o "$TMP/console.tar.gz.sha256" \
|| die "the release publishes no checksum at $URL.sha256. Not installing what cannot be checked."
verify_checksum "$TMP/console.tar.gz" "$TMP/console.tar.gz.sha256" \
|| die "the download does not match the checksum the release publishes. Nothing was changed."
ok "Checksum matches the release" "sha256"
fi
fi
# Owners come from this machine, not from whoever packed the archive.
check_archive "$TMP/console.tar.gz"
tar --no-same-owner --no-same-permissions -xzf "$TMP/console.tar.gz" -C "$TMP/dist" || die "that file is not a valid archive."
secure_tree "$TMP/dist"
# The served bundle is data: never publish archive execute or special bits.
find "$TMP/dist" -type f -exec chmod 0644 {} +
[ -f "$TMP/dist/index.html" ] || die "the archive does not look like a built console."
INSTALLED_AT="$(state_get webroot)"
if [ "$INSTALLED_AT" = "$WWW_DIR" ] && [ -f "$WWW_DIR/js/main.js" ]; then
# Upstream's installer updates by extracting over its folder, so a server
# update puts its own console back on top of ours without removing ours.
warn "The DNS server was updated since this console was installed: it put its"
warn "own console back on top of this one. Replacing it again."
warn "The backup still holds the console of $(state_get server_version), not of the version running now."
fi
if [ "$MODE" = "replacement" ]; then
BACKUP="$WWW_DIR$BACKUP_SUFFIX"
if [ -d "$BACKUP" ]; then
secure_tree "$BACKUP"
ok "Original console already saved" "$BACKUP"
elif [ -d "$WWW_DIR" ] && [ -f "$WWW_DIR/index.html" ]; then
secure_path "$BACKUP"
cp -a "$WWW_DIR" "$BACKUP"
ok "Original console backed up" "$BACKUP"
state_set server_version "${SERVER_VERSION:-unknown}"
fi
else
BACKUP=""
fi
CUSTOM_COUNT=0
for f in "$WWW_DIR"/json/*-custom.json; do
if [ -f "$f" ]; then CUSTOM_COUNT=$((CUSTOM_COUNT + 1)); fi
done
if [ "$CUSTOM_COUNT" -gt 0 ]; then
ok "Your custom lists stay where they are" "$CUSTOM_COUNT file(s)"
fi
if [ "$MODE" = "side-by-side" ] && [ -n "$SERVER_APP" ]; then
carry_custom_lists "$SERVER_APP/www" "$WWW_DIR"
fi
confirm "Install the console in $WWW_DIR?"
state_set phase publishing
state_set webroot "$WWW_DIR"
state_set mode "$MODE"
if [ -n "$BACKUP" ]; then state_set backup "$BACKUP"; fi
if [ -n "$INTO_VOLUME" ]; then
# Every check has passed (the mount point, W6, the checksum, the archive), and
# nothing is copied yet: from here on the volume is the console's, finished or
# not. Written by rename, like every file publish writes.
rm -f "$WWW_DIR/$MARKER.tc-new"
printf 'technitium-console sha256:%s\n' "$(sha256sum "$TMP/console.tar.gz" | cut -d' ' -f1)" > "$WWW_DIR/$MARKER.tc-new"
mv -f "$WWW_DIR/$MARKER.tc-new" "$WWW_DIR/$MARKER"
fi
publish "$TMP/dist" "$WWW_DIR" "$LISTS"
state_set version "$VERSION"
state_set phase done
ok "Console installed" "$WWW_DIR"
if [ -n "$INTO_VOLUME" ]; then
say ""
say "A DNS server that mounts this volume and names it in $VAR_NAME"
say "is serving it already: it needs no restart. To update, run a newer image of"
say "this one. To remove it: $DOCKER_DOCS"
printf '\n'
exit 0
fi
asset="$(cd "$TMP/dist" && find assets -name '*.js' -type f 2>/dev/null | head -1 || true)"
if [ "$MANAGE_SYSTEMD" = "yes" ]; then
say ""
say "Activating the dedicated folder requires the installation's only DNS service restart."
activate_managed_systemd "$asset"
ok "Serving the new console" "$(web_base)"
fi
if [ "$RESTART_NEEDED" = "yes" ]; then
say ""
say "The server is still serving its old folder: the path it serves is read once,"
say "when it starts. This is the only change that needs a restart."
confirm "Restart the DNS server now?"
restart_server
else
# It does not need one. The file provider resolves every request against the
# folder, so a console replaced underneath a running server is served at once.
if [ "$MANAGE_SYSTEMD" != "yes" ] && [ -n "$asset" ] && web_answers; then
if web_serves "$asset"; then
ok "Serving the new console" "$(web_base)"
else
warn "The console is in place but $(web_base) is not serving it."
warn "If your web console answers somewhere else, pass --url and run this again."
fi
fi
fi
if in_container_layer "$WWW_DIR"; then
say ""
warn "This ran inside a container, and $WWW_DIR is part of the container's own"
warn "files: the console goes the next time the container is recreated (an image"
warn "update, docker compose pull and up). Our Docker image installs it where it stays:"
warn "$DOCKER_DOCS"
fi
printf '\n To go back: %s --uninstall\n\n' "$ONE_LINER"