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).
85 lines
3.5 KiB
Go
85 lines
3.5 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|