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:
2 files changed
+251
-1
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user