Skip to content

The init fence closes a port it cannot redirect - #18

Merged
marcos-mendez merged 5 commits into
masterfrom
fix/fence-no-silent-skip
Sep 29, 2026
Merged

marcos-mendez merged 5 commits into
masterfrom
fix/fence-no-silent-skip

Conversation

@marcos-mendez

@marcos-mendez marcos-mendez commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #17.

The problem

iptables_add_redirect skipped the REDIRECT rule for an address family
whose nat table cannot be used, warned, and carried on. bin/turnkey-init-fence
then started the mini server and reported success, and the unit sat
active, while the ports the fence was asked to stand in front of reached
the application over that family.

In an LXC container whose host has not loaded ip6table_nat that is the
normal path, not an edge case, and the warning lands in the journal of a
first boot nobody is watching. A mitigation that declines and reports
success is worse than one that is not there, because the rest of the system
is written as though it worked.

Why the skip is not simply reverted

#3 put it there deliberately. Before it, the fence exited 3 on the first
family it could not install a rule for: the unit failed, the mini server
never started and 29tagid then failed reloading it. Going back to that
trades one wrong answer for another, and a louder warning would still leave
the port open.

The container case #3 was serving is legitimate. What was wrong was the
conclusion: if the port cannot be redirected to the fence, the answer is
that the application behind it must not answer either, not that the fence
should shrug.

The change

fence_close_port refuses the port in the filter table, which a container
does have, inserted at the head of the INPUT chain so that it precedes the
ACCEPT rules webmin-fw installs for the same ports. Appending would have
put it after them and matched nothing. The rule is
! -i lo ... -j REJECT --reject-with tcp-reset: loopback is spared, as a
REDIRECT in PREROUTING always spared it, and the reset is because an IPv6
client given the default ICMP error waits for its timeout instead of
failing (measured, below).

The head of the chain is the chain as it stands when the fence starts. A
restore of a saved firewall flushes the tables it names, and with them the
refusal and the REDIRECT rules alike. The unit is ordered after the
services that restore one at boot (netfilter-persistent, Webmin's
webmin-iptables and webmin-ip6tables); a restore while the fence is up
still unfences every port, and the comment and changelog say so.

What the fence promises then holds either way:

  • the application behind a fenced port does not answer;
  • the fence page stays reachable on HTTP_FENCE_PORT and
    HTTPS_FENCE_PORT, which keeps turnkey-init-fence: skip a REDIRECT rule when its nat table is unavailable #3's outcome, including the 29tagid
    reload;
  • a port that can be neither redirected nor refused is fatal, with a <3>
    message, because then nothing at all is fencing it. That is the only case
    left where the unit fails, and it is a case where failing is the truth.
    Failing does not mean closed: stop-post then removes every rule the
    fence installed and the application answers on every port. It
    does not cascade the way the pre-turnkey-init-fence: skip a REDIRECT rule when its nat table is unavailable #3 failure did: 29tagid has asked
    systemctl is-active before reloading since 2.3.6+keel1, so a failed fence
    is skipped rather than fatal to the hook.

fence_open_port undoes it, and iptables_delete_redirect calls it for both
families, so stop and the stop-post cleanup after a failed run leave no
port closed behind them. That keeps the existing policy: a failed fence
cleans up rather than locking the appliance out, and the port is open again
because the fence is gone, not because it pretended.

The message moved from WARNING: to the <4> prefix the rest of the file
uses for a journal priority.

Tests

31 in the file, 116 in the suite: the refusal for one family and for both,
the insertion at the head of the chain, the removal loop, and the fatal path
twice, through the library and through the script. The second one matters on
its own: a library function that returns non-zero as an error only stops its
caller if the caller runs under errexit, and bats run turns errexit off, so
a test that only drives the library cannot see it (docs/traps.md, "A bats
suite cannot see a library that kills its caller").

One assertion in the pre-existing "both nat tables" test was a bare !
before a later command, so it asserted nothing; it is now a count that does.
#16 is rewriting the other negations in this file, so
expect a conflict in tests/test-init-fence.bats between the two.

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 over IPv6, IPv4 and loopback.
The only stand-in is the answer "no nat table" for one family. Against the
branch as first reviewed it fails 4, which are the review's findings plus
the silent IPv6 refusal.

kcov (first version): lib/init-fence.sh 78 of 79 lines, 98.73 percent,
total 99.57; now 83 of 84, 98.81, total 99.58, 125 bats. Over
the 98 gate and decision 0003's 95 percent bar. The uncovered line is line
154, the first line of the multi-line simplehttpd.py invocation that kcov
attributes to a later line; it was uncovered before this branch and is not
part of it. Every line this branch adds is covered.

What this does not do

Nothing is built or published and no build lock was taken, so this has not
run on a booted container; the rules are measured in a network namespace,
with the nf_tables backend where the appliance uses iptables-legacy.

2.3.6+keel6 is this one's; #14 has moved to keel7 and lands after it.

iptables_add_redirect skipped the REDIRECT rule for an address family whose
nat table cannot be used, warned, and carried on; bin/turnkey-init-fence
then started the mini server and reported success, with the ports the fence
was asked to stand in front of reaching the application over that family.
In an LXC container whose host has not loaded (ip6)table_nat that is the
normal path, and the warning lands in the journal of a first boot nobody is
watching. A mitigation that declines and reports success is worse than one
that is absent, because everything else is written as though it worked.

Going back to what #3 changed is not the answer: before it, the fence exited
3 on the first family it could not install a rule for, the mini server never
started and 29tagid failed reloading it. Nor is a louder warning, which
would still leave the port open.

So the fence now closes what it cannot redirect. fence_close_port refuses
the port in the filter table, which a container does have, inserted at the
head of the INPUT chain so that it precedes the ACCEPT rules the appliance
firewall installs for the same ports. What the fence promises holds either
way: the application behind a fenced port does not answer, and the fence
page stays reachable on its own ports, which is what #3 was for. A port that
can be neither redirected nor refused is fatal, with a message, because then
nothing at all is fencing it.

fence_open_port undoes it, and iptables_delete_redirect calls it for both
families, so stop and the stop-post cleanup after a failed run leave no port
closed behind them.

Four new tests, 31 in the file and 116 in the suite: the refusal for one
family and for both, the insertion at the head of the chain, the removal
loop, and the fatal path both through the library and through the script,
the second because a library that returns non-zero as an error only stops
its caller if the caller runs under errexit, and bats does not.

kcov: lib/init-fence.sh 78 of 79 lines, 98.73 percent, total 99.57. The
uncovered line is line 154, the first line of the multi-line simplehttpd.py
invocation that kcov attributes to a later line, uncovered before this
branch too and not part of it.

.gitignore gains the default output directory of tests/coverage.sh.

Closes #17.
@marcos-mendez

Copy link
Copy Markdown
Collaborator Author

Reviewed alongside Keel-Linux/common#12. Coverage reproduced exactly: 116
bats, lib/init-fence.sh 78/79 at 98.73, total 99.57, and the one uncovered
line is 154 and is uncovered on the parent commit too. The fatal path tested
twice, once through the library and once through the script, is the right
call and the only one that proves it — docs/traps.md, "A bats suite cannot
see a library that kills its caller". I also confirmed stop-post reaches
iptables_redirect stop on a failed start, so nothing this branch inserts
survives a failed run.


HIGH — lib/init-fence.sh:41 and the comment at :29-31: position 1 in the
live chain is not where the appliance's own rules come from.
common's
webmin-fw does not append to a running chain; it writes
/etc/iptables/rules.v4 and rules.v6 as whole tables, *filter through
COMMIT, meant to be applied with a restore. A restore flushes the chain,
so it takes out this branch's REJECTs and the fence's own ACCEPTs together
and the insertion position buys nothing against it. On the appliance I could
inspect, nothing applies those files at boot — INPUT is empty with policy
ACCEPT, so the insertion is uncontested there — but Webmin's Firewall module
applies them on demand, and an administrator who touches it while the fence
is up silently unfences everything. The unit orders itself after
iptables.service, ip6tables.service, nftables.service,
firewalld.service and ipset.service, none of which is what applies those
two files. Either order against the thing that does, or narrow the comment
to say the position is about the live chain only.

MEDIUM — lib/init-fence.sh:91: the fatal path ends unfenced, not closed.
return 1 reaches bin/turnkey-init-fence under -eu, the unit fails,
ExecStopPost runs stop-post, SERVICE_RESULT is not success and
iptables_redirect stop opens every port again — including the ones that
were successfully closed a moment earlier. So the outcome of "a port that can
be neither redirected nor refused is fatal" is: application answering on all
of them, no fence, and a unit that says so. That is the right trade against
the silent version and it is consistent with the cleanup policy, but the
changelog and the comment both read as though failing means closed. One
sentence in each saying which it is.

MEDIUM — lib/init-fence.sh:41: the refusal has no interface match. A
REDIRECT in PREROUTING never applied to locally originated traffic; a REJECT
in INPUT does. I grepped firstboot.d, everyboot.d and every Keel
appliance overlay and nothing talks to a fenced port over loopback today, so
this costs nothing now — but it is a real behaviour change for a first-boot
hook that one day does, and it will present as a hook that works everywhere
except where the fence had to fall back. -i lo -j ACCEPT ahead of it, or
! -i lo on the rule, is one line and keeps the two paths equivalent.

LOW — lib/init-fence.sh:39: the message precedes the rule.
fence_close_port announces the refusal and then tries to install it. On the
failing path the journal carries the claim and then its retraction, and on
the caller's return 1 the claim is the last positive thing said about that
port. Move the echo below the insert.

LOW — lib/init-fence.sh:54-57: fence_open_port cannot tell "nothing
left to remove" from "cannot ask".
Deliberate, and stopping does have to
succeed, so this is right — but the comment says "tolerates a family that
cannot be asked at all" without saying that the two are indistinguishable to
the caller. Half a sentence.

LOW — landing order. This branch and #16 collide on exactly one hunk of
tests/test-init-fence.bats: the bare ! before
printf ... | grep -q WARNING. This branch's replacement is a grep -c
that is compared, which is what #16 is after and does not need run ! to
get it, so this should land first and #16 drop that hunk — it still has four
other bare negations in the same file that this branch does not touch. Same
answer for 2.3.6+keel6: this one first, #14 bumps.


The reasoning for not reverting #3 is correct, and closing what cannot be
redirected is the right shape. The findings are about what the insertion
position actually guarantees, what the fatal path leaves behind, and saying
both out loud where the next reader will look.

Warning

marcos-mendez and others added 2 commits September 29, 2026 03:44
The fence tests stub iptables and assert the commands issued. That is
asserting the configuration: every review finding on this branch was about
what the rules do, and none of it was visible to the stubs.

tests/netns-sandbox runs lib/init-fence.sh against the real iptables and
ip6tables in a network namespace of its own (an unprivileged user
namespace where the kernel allows one, 'sudo -n unshare' otherwise, as on
the CI runner), with a client namespace on the other end of a veth pair.
A listener answers "app" on the fenced ports and "fence" on the fence's
own, and a probe connects over IPv6, IPv4 or loopback and reports what it
got: app, fence, refused, or silent. The only stand-in is the answer "no
nat table" (or "the filter insert is refused") for one family; what the
rules do to a packet is never modelled. tests/fixtures/webmin-fw.rules.v4
and .v6 are the files common's webmin-fw writes, rendered with its
defaults.

tests/test-init-fence-netfilter.bats, 8 tests. Against the branch as
reviewed, 4 fail, and they are the findings:

- without the IPv6 nat table the fenced ports over IPv6 are silent, not
  refused: a REJECT with the default icmp6-port-unreachable leaves an IPv6
  client retrying its SYN until it times out (IPv4 is refused);
- from the appliance itself, a refused port refuses loopback as well,
  where a redirected port never touched it;
- the fatal path ends with the application answering on every port, not
  with the port closed;
- a restore of the appliance firewall while the fence is up removes the
  fence, redirects and refusals alike.

The stub tests change to the rule the next commit installs, and gain two
assertions: the journal never says a port is refused when the insert
failed, and the unit is ordered after every service that restores a saved
firewall at boot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…restore does

Review of this branch, reproduced against the real netfilter by the
previous commit, and what each finding became:

- HIGH, a restore flushes the chain. Confirmed, and wider than the
  refusal: iptables-restore of the files common's webmin-fw writes flushes
  nat as well, so it takes the REDIRECT rules too, which has been true
  since before this branch. The unit is now ordered after
  netfilter-persistent, webmin-iptables and webmin-ip6tables, the services
  that apply a saved firewall at boot (Webmin creates the last two when its
  Firewall module is set to activate at boot; a built core has neither
  unit today). A restore while the fence is up cannot be ordered against,
  and fence_close_port's comment and the changelog now say so instead of
  implying position 1 holds.
- MEDIUM, the fatal path ends unfenced. It does; the comment and the
  changelog say so, and a netfilter test shows it.
- MEDIUM, no interface match. The refusal is now '! -i lo', so a refused
  port behaves like a redirected one for anything on the appliance.
- Found by measuring, not in the review: the refusal is now
  '--reject-with tcp-reset'. With the default ICMP error an IPv6 client
  waited for its connect timeout, so over IPv6, which is the family this
  fallback exists for, the port looked silent rather than refused.
- LOW, the message precedes the rule: it follows the insert now.
- LOW, fence_open_port cannot tell "nothing left" from "cannot ask": said.

The rule is built in one place, fence_refusal, so the insert and the
delete cannot drift apart.

125 bats; kcov lib/init-fence.sh 83/84 (98.81), total 99.58, the same
line uncovered as before the branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
marcos-mendez added a commit that referenced this pull request Sep 29, 2026
…inherited ones

A change of approach within this branch, so the three parts:

1. Why stripping does not work. tagid_strip_remote_scripts removed
   <script> elements whose src was double or single quoted and remote.
   Review measured an unquoted src, a stylesheet, an image and a frame
   getting past it, and a script with a body leaving its body behind as
   page text. Each is a regular expression away from being handled, and
   the next form is another.
2. Whether it can be made to work. Only as far as a parser agrees with a
   browser, and none does on malformed markup: Python's HTMLParser, the
   best candidate here, does not see an image after '<!-->' or after
   '</script x>', and a browser loads both. A defence built on it would
   have the detector's blind spots, which is what review found the first
   time.
3. Why rebuilding is better. It needs no judgement of the page at all.
   Nothing in the tree writes a page into the /var copy but this hook, so
   replacing that copy with the packaged pages loses nothing, and a page
   the package did not ship is not served, however it is written. The
   packaged pages are what the tests judge, by two detectors.

What 29tagid does now, on every run, under turnkey-init as well:

- builds the pages in a directory beside HTDOCS (mktemp in its parent),
  never inside the directory the fence serves, which review noted the
  temporary index was;
- names the appliance in the copy, then swaps it in with two renames and
  removes the old one;
- on any failure, removes HTDOCS: the fence serves the packaged htdocs
  when the writable copy is missing, so a failure serves the unnamed page
  rather than an inherited one. It exits 1 with a <3> message; it used to
  exit 0 and keep the old page;
- stops before touching anything when mktemp fails (an empty directory
  name would have sent the copy to /) or when the default file sets no
  HTDOCS (the reviewed hook then copied the pages into its working
  directory, which the new test caught by doing it).

Under turnkey-init it used to exit at once. That exit was for the naming
half; the pages are what an operator whose first boot did not complete
gets served, so it runs.

lib/tagid.sh is down to tagid_app_name. The changelog entry is
2.3.6+keel7: this lands after #18, which has keel6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…-skip

master gained #16, which rewrote the bare negations in
tests/test-init-fence.bats. The one hunk both branches touched is the
"both nat tables" test, where #16 made 'grep -q WARNING' a 'run !' and
this branch had replaced it with counts of the inserts and of every
prefixed journal line; the counts are kept, as review of both asked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
@marcos-mendez

Copy link
Copy Markdown
Collaborator Author

Review addressed in 36663c5 and 7cc4139, then master merged in (d24ca3e). The fence is now measured against real iptables in a network namespace. The unit starts after the services that restore a saved firewall at boot, and the comment says an on-demand restore unfences every port. The fatal path is documented as open. The refusal skips loopback and is now a tcp-reset, because the default ICMP error left IPv6 clients hanging until they timed out.

…-skip

master gained #19 (keel-host-keys), which took 2.3.6+keel6. This branch's
entry becomes 2.3.6+keel7, above it, with the date of this merge;
COVERAGE.md keeps both sections. No code overlaps: keel-host-keys.service
is ordered before turnkey-init-fence.service and this branch only adds
After= lines to the latter. 147 bats, shell total 99.67.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
@marcos-mendez
marcos-mendez merged commit dca1963 into master Sep 29, 2026
3 checks passed
marcos-mendez added a commit that referenced this pull request Sep 29, 2026
…script

master gained #19 (keel-host-keys, 2.3.6+keel6) and #18 (the fence,
2.3.6+keel7). This branch's entry becomes 2.3.6+keel8, above both, with
the date of this merge. No code overlaps: #18 changes lib/init-fence.sh
and the fence unit, and fence_htdocs, which this branch's failure path
relies on to serve the packaged pages, is unchanged. Shell total 99.68,
29tagid 29/29; Python 196 passed, 3 skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
marcos-mendez added a commit that referenced this pull request Sep 29, 2026
…inherited ones

A change of approach within this branch, so the three parts:

1. Why stripping does not work. tagid_strip_remote_scripts removed
   <script> elements whose src was double or single quoted and remote.
   Review measured an unquoted src, a stylesheet, an image and a frame
   getting past it, and a script with a body leaving its body behind as
   page text. Each is a regular expression away from being handled, and
   the next form is another.
2. Whether it can be made to work. Only as far as a parser agrees with a
   browser, and none does on malformed markup: Python's HTMLParser, the
   best candidate here, does not see an image after '<!-->' or after
   '</script x>', and a browser loads both. A defence built on it would
   have the detector's blind spots, which is what review found the first
   time.
3. Why rebuilding is better. It needs no judgement of the page at all.
   Nothing in the tree writes a page into the /var copy but this hook, so
   replacing that copy with the packaged pages loses nothing, and a page
   the package did not ship is not served, however it is written. The
   packaged pages are what the tests judge, by two detectors.

What 29tagid does now, on every run, under turnkey-init as well:

- builds the pages in a directory beside HTDOCS (mktemp in its parent),
  never inside the directory the fence serves, which review noted the
  temporary index was;
- names the appliance in the copy, then swaps it in with two renames and
  removes the old one;
- on any failure, removes HTDOCS: the fence serves the packaged htdocs
  when the writable copy is missing, so a failure serves the unnamed page
  rather than an inherited one. It exits 1 with a <3> message; it used to
  exit 0 and keep the old page;
- stops before touching anything when mktemp fails (an empty directory
  name would have sent the copy to /) or when the default file sets no
  HTDOCS (the reviewed hook then copied the pages into its working
  directory, which the new test caught by doing it).

Under turnkey-init it used to exit at once. That exit was for the naming
half; the pages are what an operator whose first boot did not complete
gets served, so it runs.

lib/tagid.sh is down to tagid_app_name. The changelog entry is
2.3.6+keel7: this lands after #18, which has keel6.
marcos-mendez added a commit that referenced this pull request Sep 29, 2026
The init fence closes a port it cannot redirect
marcos-mendez added a commit that referenced this pull request Sep 29, 2026
…script

master gained #19 (keel-host-keys, 2.3.6+keel6) and #18 (the fence,
2.3.6+keel7). This branch's entry becomes 2.3.6+keel8, above both, with
the date of this merge. No code overlaps: #18 changes lib/init-fence.sh
and the fence unit, and fence_htdocs, which this branch's failure path
relies on to serve the packaged pages, is unchanged. Shell total 99.68,
29tagid 29/29; Python 196 passed, 3 skipped.
@marcos-mendez
marcos-mendez deleted the fix/fence-no-silent-skip branch September 29, 2026 06:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The init fence reports success for a family it did not fence

1 participant