feat(edge): close the panel NodePort to the public after the tunnel is up

After the Cloudflare tunnel connector is installed and the origin has rolled
out, applyCloudflareEdge now fences the panel NodePort so the origin is
reachable only over loopback -- the hop the host-side connector uses -- and
never from a public interface. This closes the Access-bypass hole where a direct
https://<node-ip>:<nodeport>/ with the right Host header reached the origin
behind Cloudflare Access.

The fence is an nftables table hooked at prerouting priority -300 (raw), before
kube-proxy's NodePort DNAT (dstnat, -100), so it catches the packet on its
original destination port; a filter/INPUT rule would miss the DNAT'd, then
FORWARDed NodePort packet. Loopback is accepted first, so the connector origin
hop is untouched; the inet family fences a public IPv6 NodePort too.

It is gated on the connector actually serving (verifyConnectorServing polls
`cloudflared tunnel info`): fencing a dead tunnel would sever the only web path
to a still-up origin. If serving cannot be confirmed the port is left open (its
pre-tunnel state) and the failure is surfaced loudly. unfenceOriginNodePort is
the on-host break-glass reversal. The nft/cloudflared calls are INTEGRATION-ONLY;
the ruleset shape and the conn-count gate are pure and unit-tested.

KNOWN-LIMITATION: targets nftables; firewalld-native coordination is not yet
handled (a firewalld reload can flush the standalone table).
This commit is contained in:
flyemoji committed 2026-07-01 15:39:05 +09:00
1 parent a531f5e42a
commit e058a64a9b
2 files changed
+251 -1

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package main
import (
"strings"
"testing"
)
// TestOriginFenceRuleset pins the security-relevant shape of the nftables fence that
// closes the panel NodePort to the public interface after the Cloudflare tunnel is
// up. The real `nft` apply is INTEGRATION-ONLY; what MUST hold regardless of the host
// is the ruleset itself, so it is asserted here.
func TestOriginFenceRuleset(t *testing.T) {
const port = 30443
rs := originFenceRuleset(port)
// The private table so the fence adds/removes atomically without touching other
// host rules.
if !strings.Contains(rs, "table inet "+felisEdgeTable) {
t.Errorf("ruleset missing dedicated inet table %q:\n%s", felisEdgeTable, rs)
}
// inet family (not ip) so a public IPv6 NodePort is fenced too.
if strings.Contains(rs, "table ip "+felisEdgeTable) {
t.Errorf("ruleset must use the inet family to cover IPv6, not ip:\n%s", rs)
}
// prerouting hook at priority -300 (raw), which runs BEFORE kube-proxy's NodePort
// DNAT (dstnat, -100). A filter/INPUT rule would miss the DNAT'd, then-FORWARDed
// NodePort packet; this ordering is what makes the fence actually catch it.
if !strings.Contains(rs, "hook prerouting priority -300") {
t.Errorf("ruleset must hook prerouting at priority -300 (before kube-proxy dstnat):\n%s", rs)
}
// Loopback is accepted first so the connector's 127.0.0.1 origin hop is never cut.
if !strings.Contains(rs, `iif "lo" accept`) {
t.Errorf("ruleset must accept loopback before dropping, or it cuts the connector origin hop:\n%s", rs)
}
// The port itself is dropped.
if !strings.Contains(rs, "tcp dport 30443 drop") {
t.Errorf("ruleset must drop tcp dport 30443:\n%s", rs)
}
// The drop must come AFTER the loopback accept, or loopback would be dropped too.
loIdx := strings.Index(rs, `iif "lo" accept`)
dropIdx := strings.Index(rs, "tcp dport 30443 drop")
if loIdx < 0 || dropIdx < 0 || loIdx > dropIdx {
t.Errorf("loopback accept must precede the port drop:\n%s", rs)
}
}
// TestOriginFenceRulesetHonorsPort proves the rule targets the configured NodePort,
// not a hardcoded 30443 — a deployment that overrode FELIS_PANEL_NODEPORT must fence
// the port it actually exposed.
func TestOriginFenceRulesetHonorsPort(t *testing.T) {
rs := originFenceRuleset(30500)
if !strings.Contains(rs, "tcp dport 30500 drop") {
t.Errorf("ruleset must fence the configured port 30500:\n%s", rs)
}
if strings.Contains(rs, "30443") {
t.Errorf("ruleset must not carry the default 30443 when a different port is configured:\n%s", rs)
}
}
// TestConnectorConnCount covers the two JSON shapes cloudflared has emitted for
// `tunnel info --output json`, plus the safe-zero fallbacks — the gate that stops the
// fence from closing 30443 while the tunnel is dead.
func TestConnectorConnCount(t *testing.T) {
cases := []struct {
name string
json string
want int
}{
{"top-level conns", `{"id":"t","conns":[{"colo_name":"sin08"},{"colo_name":"sin09"}]}`, 2},
{"nested connectors", `{"id":"t","connectors":[{"id":"c","conns":[{"colo_name":"sin08"}]}]}`, 1},
{"both shapes summed", `{"conns":[{}],"connectors":[{"conns":[{}]},{"conns":[{}]}]}`, 3},
{"healthy-but-empty", `{"id":"t","conns":[],"connectors":[]}`, 0},
{"no connections field", `{"id":"t","name":"felis"}`, 0},
{"garbage is not a healthy tunnel", `not json`, 0},
{"empty", ``, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := connectorConnCount([]byte(c.json)); got != c.want {
t.Errorf("connectorConnCount(%s) = %d, want %d", c.json, got, c.want)
}
})
}
}