Carry over static addressing, the mode needing no DHCP rules

Copied from the sister repository and renamed. It pins the host to the address it already has, read off the running interface rather than typed, and rolls the file back if what it wrote will not parse.

It is carried over on the strength of being the tightest of the three addressing modes at the boundary: a guest with a static address never speaks to a DHCP server, so the rule set stays at four DROPs plus the named exceptions and needs neither half of the DHCP pair. It is offered rather than imposed, because an address reserved at the router is one source of truth instead of two and that is a real argument on somebody else's network.

All fifteen of its tests pass unchanged after the rename, which is the check that it depended on nothing CI-specific.
This commit is contained in:
2026-08-09 23:19:17 -07:00
parent 38a8df5d5a
commit 86bb6b1280
2 changed files with 416 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# Pin this host's network configuration to what it currently has.
#
# A CI runner wants a stable address: you reach it by name or number for the
# life of the machine, and a lease that moves is a machine you have to go and
# find. The Debian installer never asks - with a working DHCP server it
# configures the interface and moves on - so converting is a job for afterwards,
# and doing it by hand means transcribing four values into two files with no
# feedback until something fails hours later.
#
# So nothing here is invented. The address, prefix, gateway and nameservers are
# read off the running system and written back as configuration. The values do
# not change, which is what makes this safe to run over SSH: the interface
# already has this address, so there is nothing to renumber and no moment where
# the host is unreachable.
#
# What does change is who decides them next time. Before: a DHCP client, every
# boot, and whatever it is handed. After: these files.
#
# Idempotent. On a host already configured statically it reports that and
# changes nothing.
set -euo pipefail
PROG=${0##*/}
IFACES=${IFACES:-/etc/network/interfaces}
RESOLV=${RESOLV:-/etc/resolv.conf}
RESOLV_HEAD=${RESOLV_HEAD:-/etc/resolv.conf.head}
IFACE=""
ADDRESS=""
GATEWAY=""
NAMESERVERS=""
NONINTERACTIVE=0
[ -t 0 ] || NONINTERACTIVE=1
usage() {
cat >&2 <<EOF
Usage: sudo $PROG [--iface NAME] [--address CIDR] [--gateway IP]
[--nameserver IP]... [--non-interactive]
Writes this host's current addressing into $IFACES and
$RESOLV, so it survives without a DHCP client.
Every value defaults to what the interface already has, so the usual
invocation takes no arguments at all and changes nothing about how the host is
reachable - only what decides that after the next start.
--iface NAME interface to pin (default: the one carrying the default route)
--address CIDR e.g. 192.0.2.5/24 (default: its current address)
--gateway IP (default: the current default route)
--nameserver IP repeatable (default: what $RESOLV names)
--non-interactive take the defaults; do not prompt
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--iface) IFACE="${2:?}"; shift ;;
--address) ADDRESS="${2:?}"; shift ;;
--gateway) GATEWAY="${2:?}"; shift ;;
--nameserver) NAMESERVERS="${NAMESERVERS:+$NAMESERVERS }${2:?}"; shift ;;
-n|--non-interactive) NONINTERACTIVE=1 ;;
-h|--help) usage ;;
*) echo "$PROG: unknown argument: $1" >&2; usage ;;
esac
shift
done
die() { echo "$PROG: $*" >&2; exit 1; }
say() { printf '%s\n' "$*" >&2; }
ask() {
local __var="$1" prompt="$2" def="${3:-}" reply=""
if [ "$NONINTERACTIVE" -eq 0 ]; then
if [ -n "$def" ]; then printf '%s [%s]: ' "$prompt" "$def" >&2
else printf '%s: ' "$prompt" >&2; fi
IFS= read -r reply </dev/tty || reply=""
fi
[ -n "$reply" ] || reply="$def"
printf -v "$__var" '%s' "$reply"
}
# --- what the host has right now --------------------------------------------
[ -n "$IFACE" ] || IFACE=$(ip route show default 2>/dev/null | awk '{print $5; exit}')
[ -n "$IFACE" ] || die "no default route, so there is no interface to pin.
Bring the network up first - there is nothing here to read."
[ -n "$ADDRESS" ] || ADDRESS=$(ip -o -4 addr show dev "$IFACE" scope global 2>/dev/null \
| awk '{print $4; exit}')
[ -n "$GATEWAY" ] || GATEWAY=$(ip route show default 2>/dev/null | awk '{print $3; exit}')
if [ -z "$NAMESERVERS" ]; then
NAMESERVERS=$(awk '/^[[:space:]]*nameserver/ {print $2}' "$RESOLV" 2>/dev/null | tr '\n' ' ')
# A loopback stub is not an answer: it is systemd-resolved forwarding to
# servers this file does not name, and writing 127.0.0.53 into a static
# config leaves the host pointing at a stub that will not be there.
case "$NAMESERVERS" in
*127.0.0.53*|*127.0.0.1*)
if command -v resolvectl >/dev/null 2>&1; then
real=$(resolvectl status 2>/dev/null | sed -n 's/.*DNS Servers: *//p' | tr '\n' ' ')
[ -z "$real" ] || NAMESERVERS="$real"
fi ;;
esac
NAMESERVERS=$(printf '%s' "$NAMESERVERS" | tr -s ' ' | sed 's/^ *//; s/ *$//')
fi
[ -n "$ADDRESS" ] || die "$IFACE has no global IPv4 address to pin."
[ -n "$GATEWAY" ] || die "no default gateway found for $IFACE."
# Refused rather than written empty. A static configuration with no resolver is
# a host that comes back from its next start unable to resolve anything, and
# the cause is invisible by then: every symptom names whatever was being
# reached rather than the file that stopped naming a server.
[ -n "$NAMESERVERS" ] || die "no nameserver found in $RESOLV, and none given.
Writing a static configuration now would leave this host with no resolver at
all after its next start. Pass --nameserver <ip>, or fix $RESOLV first."
# --- already done? -----------------------------------------------------------
if grep -qE "^[[:space:]]*iface[[:space:]]+$IFACE[[:space:]]+inet[[:space:]]+static" "$IFACES" 2>/dev/null; then
say "$IFACE is already configured statically in $IFACES - nothing to do."
say " address $ADDRESS"
say " gateway $GATEWAY"
say " resolver $NAMESERVERS"
exit 0
fi
grep -qE "^[[:space:]]*iface[[:space:]]+$IFACE[[:space:]]+inet[[:space:]]+dhcp" "$IFACES" 2>/dev/null \
|| die "no 'iface $IFACE inet dhcp' stanza in $IFACES.
This only converts an ifupdown DHCP stanza. Something else is configuring this
interface, and changing $IFACES would not affect it."
say "=============================================================="
say " Pinning $IFACE to what it already has"
say "=============================================================="
say
say "Read from the running system, not invented - so none of these change how"
say "this host is reachable. What changes is that they stop depending on a DHCP"
say "lease being renewed."
say
say
ask ADDRESS "Address and prefix" "$ADDRESS"
ask GATEWAY "Gateway" "$GATEWAY"
ask NAMESERVERS "Nameserver(s)" "$NAMESERVERS"
case "$ADDRESS" in */*) ;; *) die "address must carry a prefix, e.g. $ADDRESS/24" ;; esac
# --- write -------------------------------------------------------------------
backup="$IFACES.dejima-$$"
cp -a "$IFACES" "$backup"
[ ! -e "$RESOLV" ] || cp -a "$RESOLV" "$RESOLV.dejima-$$"
# Replace the dhcp stanza and everything indented under it. `auto` rather than
# `allow-hotplug`, so the interface comes up as part of boot rather than when a
# device happens to be enumerated - which is what a machine that is restarted on
# a schedule needs.
new_stanza="auto $IFACE
iface $IFACE inet static
address $ADDRESS
gateway $GATEWAY"
awk -v iface="$IFACE" -v stanza="$new_stanza" '
# allow-hotplug/auto lines for this interface are replaced by the stanza,
# which carries its own auto line.
$0 ~ "^[[:space:]]*(allow-hotplug|auto)[[:space:]]+" iface "[[:space:]]*$" { next }
$0 ~ "^[[:space:]]*iface[[:space:]]+" iface "[[:space:]]+inet[[:space:]]+dhcp" {
print stanza; inblk = 1; next
}
inblk && /^[[:space:]]+[^[:space:]]/ { next } # indented options of the old stanza
{ inblk = 0; print }
' "$backup" > "$IFACES.new"
mv "$IFACES.new" "$IFACES"
{
echo "# Written by $PROG. This host is statically addressed; nothing"
echo "# regenerates this file, so an edit here is the whole of the change."
for ns in $NAMESERVERS; do echo "nameserver $ns"; done
} > "$RESOLV.new"
cp "$RESOLV.new" "$RESOLV_HEAD"
mv "$RESOLV.new" "$RESOLV"
# --- validate, and put it back if it does not hold ---------------------------
# ifup --no-act parses the file and resolves the stanza without touching the
# interface. A file that does not parse would cost the host its network at the
# next start, by which time nobody is watching.
if command -v ifup >/dev/null 2>&1; then
if ! ifup --no-act "$IFACE" >/dev/null 2>&1; then
cp -a "$backup" "$IFACES"
[ ! -e "$RESOLV.dejima-$$" ] || cp -a "$RESOLV.dejima-$$" "$RESOLV"
rm -f "$backup" "$RESOLV.dejima-$$"
die "the generated $IFACES did not parse; it has been put back unchanged.
Nothing was applied and this host's networking is as it was."
fi
fi
say
say " written $IFACES"
say " written $RESOLV"
say " written $RESOLV_HEAD"
say " backup $backup"
say
# A DHCP client that still runs owns resolv.conf and will rewrite it, emptying
# it on a statically addressed host because it has no lease to take servers
# from. resolv.conf.head above survives that; disabling it is better.
for unit in dhcpcd dhcpcd5 isc-dhcp-client; do
if systemctl list-unit-files "$unit.service" >/dev/null 2>&1 \
&& systemctl is-enabled "$unit" >/dev/null 2>&1; then
systemctl disable --now "$unit" >/dev/null 2>&1 || true
say " disabled $unit - a static host has no use for a DHCP client"
fi
done
say
say "Nothing about the running network changed: the address written is the one"
say "$IFACE already has. It takes effect as configuration at the next start,"
say "which is also when it is first proven - so restart this VM before trusting"
say "it, while there is still a snapshot to go back to."
say
say " address $ADDRESS"
say " gateway $GATEWAY"
say " resolver $NAMESERVERS"
say
say "Reserve this address on your DHCP server, or put it outside the pool."
say "Nothing here can do that, and until it is done the server is free to hand"
say "the same address to something else."
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# Drives bin/dejima-devbox-static-ip against a stubbed ip/ifup/systemctl and
# fixture copies of the two files it edits.
#
# What is worth testing here is not that it can write a stanza. It is that the
# values it writes are the ones the host already has - a conversion that
# renumbers anything is one that costs you the machine over SSH - and that
# every way of producing an unusable configuration ends with the original files
# back in place. A host whose /etc/network/interfaces does not parse, or whose
# resolv.conf names no server, does not fail now: it fails at its next start,
# with nobody watching and nothing pointing here.
setup() {
BIN="${BATS_TEST_DIRNAME}/../bin/dejima-devbox-static-ip"
T="$BATS_TEST_TMPDIR"
mkdir -p "$T/bin"
cat > "$T/bin/ip" <<'EOF'
#!/bin/sh
case "$*" in
"route show default")
[ -n "${STUB_NO_ROUTE:-}" ] && exit 0
echo "default via 192.0.2.1 dev ens18 onlink" ;;
*"addr show dev ens18"*)
[ -n "${STUB_NO_ADDR:-}" ] && exit 0
echo "2: ens18 inet 192.0.2.58/24 brd 192.0.2.255 scope global ens18" ;;
esac
EOF
# ifup --no-act: the syntax check. STUB_IFUP_FAIL makes it reject.
cat > "$T/bin/ifup" <<'EOF'
#!/bin/sh
[ -n "${STUB_IFUP_FAIL:-}" ] && exit 1
exit 0
EOF
# No DHCP client units by default.
printf '#!/bin/sh\nexit 1\n' > "$T/bin/systemctl"
chmod +x "$T/bin"/*
export PATH="$T/bin:$PATH"
export IFACES="$T/interfaces" RESOLV="$T/resolv" RESOLV_HEAD="$T/resolv.head"
cat > "$IFACES" <<'EOF'
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
allow-hotplug ens18
iface ens18 inet dhcp
EOF
printf 'nameserver 192.0.2.11\n' > "$RESOLV"
}
run_tool() { run bash "$BIN" --non-interactive "$@"; }
@test "writes the address the interface already has, not a new one" {
run_tool
[ "$status" -eq 0 ]
grep -q '^ address 192.0.2.58/24$' "$IFACES"
grep -q '^ gateway 192.0.2.1$' "$IFACES"
}
@test "the dhcp stanza becomes static, and comes up at boot" {
run_tool
grep -q '^iface ens18 inet static$' "$IFACES"
! grep -q 'inet dhcp' "$IFACES"
# auto, not allow-hotplug: a machine restarted on a schedule needs its
# network back as part of boot rather than when a device is enumerated.
grep -q '^auto ens18$' "$IFACES"
! grep -q 'allow-hotplug' "$IFACES"
}
@test "other interfaces are left exactly as they were" {
run_tool
grep -q '^auto lo$' "$IFACES"
grep -q '^iface lo inet loopback$' "$IFACES"
grep -q '^source /etc/network/interfaces.d/\*$' "$IFACES"
}
@test "the resolver is written to both the file and the head file" {
run_tool
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
grep -q '^nameserver 192.0.2.11$' "$RESOLV_HEAD"
}
@test "several nameservers all survive" {
printf 'nameserver 192.0.2.11\nnameserver 192.0.2.12\n' > "$RESOLV"
run_tool
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
grep -q '^nameserver 192.0.2.12$' "$RESOLV"
}
@test "running it twice changes nothing the second time" {
run_tool
[ "$status" -eq 0 ]
cp "$IFACES" "$T/first"
run_tool
[ "$status" -eq 0 ]
[[ "$output" == *"already configured statically"* ]]
cmp "$T/first" "$IFACES"
}
@test "a host with no resolver is refused rather than pinned without one" {
# The failure this prevents is silent and delayed: the configuration is
# written, the running host keeps resolving on what it already had, and the
# next start produces a machine that resolves nothing.
: > "$RESOLV"
run_tool
[ "$status" -ne 0 ]
[[ "$output" == *"no nameserver"* ]]
grep -q 'inet dhcp' "$IFACES"
}
@test "an explicit nameserver satisfies a host that has none" {
: > "$RESOLV"
run_tool --nameserver 192.0.2.11
[ "$status" -eq 0 ]
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
}
@test "a loopback stub is not written as if it were a real server" {
# 127.0.0.53 is systemd-resolved's stub. Pinning it leaves the host
# pointing at something that is not there once resolved is not running.
printf 'nameserver 127.0.0.53\n' > "$RESOLV"
cat > "$T/bin/resolvectl" <<'EOF'
#!/bin/sh
echo " DNS Servers: 192.0.2.11"
EOF
chmod +x "$T/bin/resolvectl"
run_tool
[ "$status" -eq 0 ]
grep -q '^nameserver 192.0.2.11$' "$RESOLV"
! grep -q '127.0.0.53' "$RESOLV"
}
@test "a file that will not parse is put back, not left behind" {
STUB_IFUP_FAIL=1 run_tool
[ "$status" -ne 0 ]
[[ "$output" == *"put back unchanged"* ]]
grep -q 'inet dhcp' "$IFACES"
! grep -q 'inet static' "$IFACES"
}
@test "an interface configured some other way is refused, not rewritten" {
printf 'auto lo\niface lo inet loopback\n' > "$IFACES"
run_tool
[ "$status" -ne 0 ]
[[ "$output" == *"inet dhcp"* ]]
}
@test "no default route means there is nothing to read, and it says so" {
STUB_NO_ROUTE=1 run_tool
[ "$status" -ne 0 ]
[[ "$output" == *"no default route"* ]]
}
@test "an address given without a prefix is refused" {
run_tool --address 192.0.2.58
[ "$status" -ne 0 ]
[[ "$output" == *"prefix"* ]]
}
@test "the original interfaces file is kept as a backup" {
run_tool
[ "$status" -eq 0 ]
ls "$IFACES".dejima-* >/dev/null 2>&1
grep -q 'inet dhcp' "$IFACES".dejima-*
}
@test "a DHCP client still enabled is disabled" {
cat > "$T/bin/systemctl" <<'EOF'
#!/bin/sh
case "$1" in
list-unit-files) [ "$2" = "dhcpcd.service" ] && exit 0; exit 1 ;;
is-enabled) [ "$2" = "dhcpcd" ] && exit 0; exit 1 ;;
disable) echo "disabled dhcpcd" >> "$STUB_LOG"; exit 0 ;;
esac
exit 0
EOF
chmod +x "$T/bin/systemctl"
export STUB_LOG="$T/systemctl.log"; : > "$STUB_LOG"
run_tool
[ "$status" -eq 0 ]
grep -q 'disabled dhcpcd' "$STUB_LOG"
}