Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ tmp
tags
*.tar.bz2
.coverage
coverage/
36 changes: 36 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,42 @@ Measured on 2026-09-24 against upstream master (33c43b8), following the
project decision 0003 (90 percent floor per repository, 95 percent for every
file our changes touch).

## Branch fix/fence-no-silent-skip: shell 99.57 percent (2026-09-28)

`lib/init-fence.sh` gains `fence_close_port` and `fence_open_port`, and
`iptables_add_redirect` refuses a port it cannot redirect instead of leaving
it open. `tests/test-init-fence.bats` grows from 27 to 31 tests (116 bats
over the six files): the refusal for one family and for both, the insertion
first in the INPUT chain so it precedes the appliance's own ACCEPT rules,
the removal loop, the fatal path when a port can be neither redirected nor
refused, and the same path through the script, which is the one that proves
the library stops its caller rather than dying invisibly.

Measured with kcov 43: `bin/turnkey-init-fence` 28/28, `lib/init-fence.sh`
78/79 (98.73), `firstboot.d/01ipconfig` 29/29, `lib/ipconfig.sh` 72/72,
`firstboot.d/29tagid` 19/19, `lib/tagid.sh` 8/8, total 99.57.

The one uncovered line of `lib/init-fence.sh` is line 154, the first line of
the multi-line `simplehttpd.py` invocation, which kcov attributes to a later
line. It is the same line that was uncovered before this branch, and it is
not part of this change. Every line this branch adds is covered.

The shell gate stays at 98, the lowest file rounded down; 98.73 clears it
and decision 0003's 95 percent bar for a file a change touches.

After review (2026-09-29): the stubs say which commands the fence issues,
not what they do to traffic, so `tests/test-init-fence-netfilter.bats`
(8 tests) asks the real netfilter. `tests/netns-sandbox` runs the library
against real iptables and ip6tables in a network namespace of its own, with
a client namespace on the other end of a veth pair, and connects to the
ports over IPv6, IPv4 and loopback; the only stand-in is the answer
"there is no nat table" (or "the filter table refuses the insert") for one
family. It found that the default ICMP refusal left an IPv6 client waiting
for its timeout, that the refusal also refused loopback, and that a
restore of the appliance firewall's own rules removes the whole fence.
125 bats in all; `lib/init-fence.sh` 83/84 (98.81), total 99.58, the same
one line uncovered.

## bin/keel-host-keys (2026-09-29, keel-core#8)

`tests/test-host-keys.bats`, 22 tests, measures `bin/keel-host-keys` at
Expand Down
29 changes: 29 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
inithooks (2.3.6+keel7) trixie; urgency=high

* lib/init-fence.sh: the fence no longer reports a fence it did not put up.
A family whose nat table cannot be used, which is the normal case in an
LXC container whose host has not loaded (ip6)table_nat, had its REDIRECT
rule skipped with a warning, and the service then started and reported
success with the application ports reachable over that family. The port
is now refused in the filter table instead, inserted ahead of the rules
the appliance firewall accepts, so what the fence promises holds by
another means: the fence page stays reachable on its own ports, as it
became in 2.3.6+keel1, and the application behind the fenced port does
not answer. The refusal is a TCP reset, because an IPv6 client given
the default ICMP error waits for its timeout instead of failing, and it
does not apply to loopback, which a REDIRECT never did either.
* lib/init-fence.sh: a port that can be neither redirected nor refused is
fatal, with a message, because then nothing is fencing it. Fatal means
the unit fails and its stop-post removes every rule the fence
installed, so the application answers on every port again; it does not
mean the port is left closed.
* lib/init-fence.sh: stopping removes the refusals with the redirects, so
a stopped or failed fence leaves no port closed behind it.
* turnkey-init-fence.service: ordered after netfilter-persistent and
Webmin's webmin-iptables and webmin-ip6tables, which restore a saved
firewall at boot. A restore flushes the tables it names and every fence
rule in them, redirects and refusals alike; ordering covers boot, and a
restore while the fence is up still unfences every port.

-- Marcos Mendez <mendez.foto@gmail.com> Tue, 29 Sep 2026 04:34:01 +0000

inithooks (2.3.6+keel6) trixie; urgency=high

* keel-host-keys.service: generate the SSH host keys, the TLS pair in
Expand Down
2 changes: 2 additions & 0 deletions debian/inithooks.turnkey-init-fence.service
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Description=TurnKey Initialization web interface fence
After=network.target network-online.target
After=iptables.service firewalld.service ip6tables.service ipset.service nftables.service
# a restore of a saved firewall flushes the fence rules: start after them
After=netfilter-persistent.service webmin-iptables.service webmin-ip6tables.service
Before=apache2.service nginx.service lighttpd.service tomcat10.service tomcat11.service

[Service]
Expand Down
74 changes: 71 additions & 3 deletions lib/init-fence.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,71 @@ fence_nat_available() {
"$1" -t nat -n -L PREROUTING >/dev/null 2>&1
}

# fence_refusal DPORT
# The rule fence_close_port inserts and fence_open_port deletes, after the
# chain name. Traffic that arrives on loopback is left alone, because a
# REDIRECT in PREROUTING never applied to locally originated traffic, and a
# refused port must behave like a redirected one for anything on the
# appliance itself. tcp-reset, not the default ICMP error: measured in a
# network namespace, an IPv6 client that gets icmp6-port-unreachable for
# its SYN keeps retrying until it times out, so the port looked silent
# rather than refused.
fence_refusal() {
echo "! -i lo -p tcp -m tcp --dport $1 -j REJECT --reject-with tcp-reset"
}

# fence_close_port IPT DPORT
# Refuses DPORT on IPT's filter INPUT chain, inserted first so that it comes
# before whatever the appliance firewall accepts. This is what the fence
# does for a port it cannot redirect: the fence page is not reachable on
# that port over that family, and neither is the application standing behind
# it.
#
# First in the chain as it stands when the fence starts. A restore of a
# saved firewall (iptables-restore of /etc/iptables/rules.v4 or rules.v6,
# which is what Webmin's Firewall module applies) flushes the tables it
# names, and takes this rule with it, as it takes the REDIRECT rules in the
# nat table. The unit is ordered after the services that restore one at
# boot; a restore while the fence is up unfences every port, and nothing
# here can prevent it.
#
# Fails, with a message, when the port can be neither redirected nor
# refused, because then nothing is fencing it and the caller must not report
# that anything is. What follows a failure is not a closed port: the start
# fails, the unit's stop-post removes every rule the fence installed, and
# the application answers on every port again, with a failed unit to say so.
fence_close_port() {
local ipt=$1
local dport=$2
local -a rule
read -r -a rule <<< "$(fence_refusal "$dport")"
fence_open_port "$ipt" "$dport"
if ! "$ipt" -t filter -I INPUT 1 "${rule[@]}"; then
echo "<3>$ipt cannot refuse $dport either, nothing is fencing it" >&2
return 1
fi
echo "Refusing port $dport on $ipt for as long as the fence is up"
}

# fence_open_port IPT DPORT
# Removes every refusal fence_close_port left behind for DPORT. Stopping has
# to succeed, so this never fails: a family that cannot be asked at all ends
# the loop exactly as a family with nothing left to remove does, and the
# caller cannot tell the two apart.
fence_open_port() {
local ipt=$1
local dport=$2
local -a rule
read -r -a rule <<< "$(fence_refusal "$dport")"
while "$ipt" -t filter -D INPUT "${rule[@]}" 2>/dev/null; do
:
done
}

iptables_delete_redirect() {
local dport=$1
local to_port=$2
local ipt
echo "Removing REDIRECT firewall rule: $dport => $to_port"
while iptables -t nat -D PREROUTING -p tcp --dport "$dport" \
-j REDIRECT --to-port "$to_port" 2>/dev/null; do
Expand All @@ -37,6 +99,9 @@ iptables_delete_redirect() {
-j REDIRECT --to-port "$to_port" 2>/dev/null; do
:
done
for ipt in iptables ip6tables; do
fence_open_port "$ipt" "$dport"
done
}

iptables_add_redirect() {
Expand All @@ -46,10 +111,13 @@ iptables_add_redirect() {
echo "Adding REDIRECT firewall rule: $dport => $to_port"
iptables_delete_redirect "$dport" "$to_port"
for ipt in iptables ip6tables; do
# skip a family whose nat table can't be used rather than failing,
# the fence is still reachable on $to_port
# a family whose nat table can't be used gets the port refused
# instead of redirected. Skipping it was worse than doing nothing:
# the caller went on to report a fence that was not there, over the
# family an LXC container is most likely to be reachable on.
if ! fence_nat_available "$ipt"; then
echo "WARNING: $ipt nat table unavailable, skipping REDIRECT rule: $dport => $to_port" >&2
echo "<4>$ipt nat table unavailable, refusing $dport rather than redirecting it to $to_port" >&2
fence_close_port "$ipt" "$dport" || return 1
continue
fi
$ipt -t nat -A PREROUTING -p tcp --dport "$dport" -j REDIRECT --to-port "$to_port"
Expand Down
24 changes: 24 additions & 0 deletions tests/fixtures/webmin-fw.rules.v4
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
COMMIT
*mangle
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
COMMIT
*filter
:FORWARD ACCEPT [0:0]
:INPUT DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp -m icmp --icmp-type echo-request -j ACCEPT
-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 12321 -j ACCEPT
COMMIT
24 changes: 24 additions & 0 deletions tests/fixtures/webmin-fw.rules.v6
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
COMMIT
*mangle
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
COMMIT
*filter
:FORWARD ACCEPT [0:0]
:INPUT DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
-A INPUT -p ipv6-icmp -j ACCEPT
-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 12321 -j ACCEPT
COMMIT
Loading
Loading