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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,35 @@ jobs:
- name: Test
run: go test -v -race -cover ./...

proxy-init-iptables:
name: proxy-init iptables rules
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
# This job runs a repo script as root via `sudo -E` and needs no git
# access afterwards, so don't leave the job token in .git/config.
persist-credentials: false

# The harness builds its rules inside `unshare --net`, so it never touches
# the runner's own networking. It needs root for unshare + iptables, the
# dummy module to generate a routable external packet, and both iptables
# backends so the legacy-detection case is exercised rather than skipped.
- name: Install iptables backends
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq iptables iproute2 kmod
sudo modprobe dummy || echo "dummy module unavailable; capture packet may not be generated"

# Gates the interception rules themselves: chain placement and ordering,
# the DNS carve-out, the non-TCP drop, the fail-closed guards, and — for
# transparent inbound — that the ambient DNAT precedes AB_REDIRECT's
# ztunnel-mark RETURN. That ordering decides whether mesh-delivered traffic
# is validated or waved through, and nothing else in CI covers it.
- name: Test enforce-redirect + transparent inbound rules
run: sudo -E authbridge/proxy-init/test-enforce-redirect.sh

python-test:
name: Python Tests
runs-on: ubuntu-latest
Expand Down
45 changes: 45 additions & 0 deletions authbridge/authlib/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,34 @@ type ListenerConfig struct {
// the reverse role is active. Ignored outside proxy-sidecar mode.
Roles []string `yaml:"roles" json:"roles"`

// InboundInterception selects HOW inbound traffic reaches the reverse
// proxy's pipeline, in proxy-sidecar mode with the reverse role active:
//
// reverse-proxy (default) — the operator binds AuthBridge on the agent's
// original port and relocates the agent to a free one
// ("port stealing"), so the Service needs no patching.
// Forwards to the single reverse_proxy_backend URL.
// transparent — proxy-init PREROUTING-REDIRECTs inbound TCP to
// transparent_inbound_addr; the listener recovers the port
// the client actually addressed via SO_ORIGINAL_DST and
// forwards there over loopback. The agent keeps its own
// port, so no PORT env var is imposed on it and every port
// it listens on is covered — not just the first declared
// one. Requires proxy-init (NET_ADMIN) and is Linux-only.
//
// Defaults to reverse-proxy: transparent adds a privileged init container,
// so it is opt-in, and leaving it unset keeps existing deployments
// byte-identical.
InboundInterception string `yaml:"inbound_interception" json:"inbound_interception"`

// TransparentInboundAddr is the bind address for the inbound transparent
// listener (inbound_interception: transparent). The proxy-sidecar preset
// defaults it to ":8083" when that mode is selected — 8080 (reverse), 8081
// (forward) and 8082 (transparent egress) are already taken. It MUST match
// proxy-init's INBOUND_TRANSPARENT_PORT, or PREROUTING will REDIRECT to a
// dead port and inbound traffic will break outright.
TransparentInboundAddr string `yaml:"transparent_inbound_addr" json:"transparent_inbound_addr"`

// TransparentProxyAddr is the bind address for the outbound transparent
// listener used by proxy-sidecar enforce-redirect mode: iptables REDIRECTs
// the agent's bypass egress here, and the listener recovers the original
Expand Down Expand Up @@ -469,6 +497,23 @@ const (
RoleForward = "forward" // outbound forward proxy
)

// Valid ListenerConfig.InboundInterception values. Interception is two
// independent axes (inbound, outbound), so the inbound mechanism is a field on
// the reverse role rather than a role of its own — matching the outbound
// transparent listener, which likewise rides inside the forward role instead of
// being separately selectable.
const (
InboundInterceptionReverseProxy = "reverse-proxy" // fixed backend (default)
InboundInterceptionTransparent = "transparent" // SO_ORIGINAL_DST per connection
)

// InboundTransparent reports whether the inbound transparent listener should
// run instead of the fixed-backend reverse proxy. Empty means the default
// (reverse-proxy), so callers need no separate zero-value check.
func (l ListenerConfig) InboundTransparent() bool {
return l.InboundInterception == InboundInterceptionTransparent
}

// ActiveRoles returns the set of proxy roles to run in proxy-sidecar mode. An
// empty Roles list defaults to BOTH roles (the full pod deployment), so
// existing configs and the operator path are unchanged; a non-empty list runs
Expand Down
140 changes: 140 additions & 0 deletions authbridge/authlib/config/inbound_interception_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package config

import "testing"

// TestApplyPreset_TransparentInbound covers the mutual exclusion between the
// two inbound mechanisms. Filling reverse_proxy_addr alongside transparent
// interception would bind a port nothing routes to — and if it collided with
// the agent's own port (which transparent mode deliberately leaves in place),
// the pod would fail to start.
func TestApplyPreset_TransparentInbound(t *testing.T) {
cfg := &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{
InboundInterception: InboundInterceptionTransparent,
}}
ApplyPreset(cfg)

if cfg.Listener.TransparentInboundAddr != ":8083" {
t.Errorf("transparent_inbound_addr = %q, want :8083", cfg.Listener.TransparentInboundAddr)
}
if cfg.Listener.ReverseProxyAddr != "" {
t.Errorf("transparent inbound must not fill reverse_proxy_addr, got %q", cfg.Listener.ReverseProxyAddr)
}
// The forward role is still active by default, so egress is untouched.
if cfg.Listener.ForwardProxyAddr != ":8081" || cfg.Listener.TransparentProxyAddr != ":8082" {
t.Errorf("egress defaults changed: forward=%q transparent-out=%q",
cfg.Listener.ForwardProxyAddr, cfg.Listener.TransparentProxyAddr)
}
}

// TestApplyPreset_DefaultIsReverseProxy locks the opt-in contract: an unset
// inbound_interception must leave the preset byte-identical to today.
func TestApplyPreset_DefaultIsReverseProxy(t *testing.T) {
cfg := &Config{Mode: ModeProxySidecar}
ApplyPreset(cfg)

if cfg.Listener.ReverseProxyAddr != ":8080" {
t.Errorf("reverse_proxy_addr = %q, want :8080 (default mechanism unchanged)", cfg.Listener.ReverseProxyAddr)
}
if cfg.Listener.TransparentInboundAddr != "" {
t.Errorf("default must not fill transparent_inbound_addr, got %q", cfg.Listener.TransparentInboundAddr)
}
}

// TestApplyPreset_TransparentInboundUserOverride ensures an operator-chosen port
// survives the preset — it has to match proxy-init's INBOUND_TRANSPARENT_PORT,
// so silently overwriting it would break ingress.
func TestApplyPreset_TransparentInboundUserOverride(t *testing.T) {
cfg := &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{
InboundInterception: InboundInterceptionTransparent,
TransparentInboundAddr: ":19083",
}}
ApplyPreset(cfg)
if cfg.Listener.TransparentInboundAddr != ":19083" {
t.Errorf("transparent_inbound_addr = %q, want the operator's :19083", cfg.Listener.TransparentInboundAddr)
}
}

func TestInboundTransparent(t *testing.T) {
for _, tc := range []struct {
value string
want bool
}{
{"", false},
{InboundInterceptionReverseProxy, false},
{InboundInterceptionTransparent, true},
} {
if got := (ListenerConfig{InboundInterception: tc.value}).InboundTransparent(); got != tc.want {
t.Errorf("InboundTransparent(%q) = %v, want %v", tc.value, got, tc.want)
}
}
}

func TestValidate_InboundInterception(t *testing.T) {
tests := []struct {
name string
cfg *Config
wantErr bool
}{
{
name: "transparent needs no reverse_proxy_backend",
cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{
InboundInterception: InboundInterceptionTransparent,
}},
},
{
name: "reverse-proxy still requires a backend",
cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{
InboundInterception: InboundInterceptionReverseProxy,
}},
wantErr: true,
},
{
name: "default still requires a backend",
cfg: &Config{Mode: ModeProxySidecar},
// unchanged behavior for existing configs
wantErr: true,
},
{
name: "unknown value is rejected at startup",
cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{
InboundInterception: "transparant", // typo an operator would make
ReverseProxyBackend: "http://127.0.0.1:8000",
}},
wantErr: true,
},
{
name: "transparent without the reverse role is a no-op, so rejected",
cfg: &Config{Mode: ModeProxySidecar, Listener: ListenerConfig{
Roles: []string{RoleForward},
InboundInterception: InboundInterceptionTransparent,
}},
wantErr: true,
},
{
name: "envoy-sidecar rejects the field (Envoy already intercepts inbound)",
cfg: &Config{Mode: ModeEnvoySidecar, Listener: ListenerConfig{
InboundInterception: InboundInterceptionTransparent,
}},
wantErr: true,
},
{
name: "waypoint rejects the field",
cfg: &Config{Mode: ModeWaypoint, Listener: ListenerConfig{
InboundInterception: InboundInterceptionTransparent,
}},
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := Validate(tc.cfg)
if tc.wantErr && err == nil {
t.Fatal("expected a validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
})
}
}
11 changes: 10 additions & 1 deletion authbridge/authlib/config/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ func ApplyPreset(cfg *Config) {
// it didn't ask for. main.go starts a proxy iff its role is active.
roles := cfg.Listener.ActiveRoles()
if roles[RoleReverse] {
setDefault(&cfg.Listener.ReverseProxyAddr, ":8080")
// The two inbound mechanisms are mutually exclusive: transparent
// interception REDIRECTs to its own port and leaves the agent on the
// port it already binds, so filling reverse_proxy_addr there would
// bind a port nothing routes to (and, if it collided with the agent's
// own port, would break the pod).
if cfg.Listener.InboundTransparent() {
setDefault(&cfg.Listener.TransparentInboundAddr, ":8083")
} else {
setDefault(&cfg.Listener.ReverseProxyAddr, ":8080")
}
}
if roles[RoleForward] {
setDefault(&cfg.Listener.ForwardProxyAddr, ":8081")
Expand Down
21 changes: 19 additions & 2 deletions authbridge/authlib/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ func validateListeners(cfg *Config) error {
if cfg.Listener.ReverseProxyAddr != "" {
return fmt.Errorf("envoy-sidecar mode does not support reverse_proxy_addr (use proxy-sidecar mode)")
}
if cfg.Listener.InboundInterception != "" {
return fmt.Errorf("envoy-sidecar mode does not support inbound_interception (Envoy already intercepts inbound transparently)")
}
if cfg.Listener.ExtAuthzAddr != "" {
return fmt.Errorf("envoy-sidecar mode does not support ext_authz_addr (use waypoint mode)")
}
case ModeWaypoint:
if cfg.Listener.ExtProcAddr != "" {
return fmt.Errorf("waypoint mode does not support ext_proc_addr (use envoy-sidecar mode)")
}
if cfg.Listener.InboundInterception != "" {
return fmt.Errorf("waypoint mode does not support inbound_interception (the waypoint owns inbound)")
}
if cfg.Listener.ReverseProxyAddr != "" {
return fmt.Errorf("waypoint mode does not support reverse_proxy_addr")
}
Expand All @@ -54,11 +60,22 @@ func validateListeners(cfg *Config) error {
return fmt.Errorf("listener.roles: %q is not a valid role (use %q and/or %q)", r, RoleReverse, RoleForward)
}
}
switch cfg.Listener.InboundInterception {
case "", InboundInterceptionReverseProxy, InboundInterceptionTransparent:
// valid
default:
return fmt.Errorf("listener.inbound_interception: %q is not valid (use %q or %q)",
cfg.Listener.InboundInterception, InboundInterceptionReverseProxy, InboundInterceptionTransparent)
}
roles := cfg.Listener.ActiveRoles()
if cfg.Listener.InboundTransparent() && !roles[RoleReverse] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: validate.go catches every other inbound-interception misconfiguration — invalid enum, missing reverse role, unsupported in envoy-sidecar/waypoint modes — but cannot catch the one that silently breaks all inbound traffic: listener.transparent_inbound_addr disagreeing with proxy-init's INBOUND_TRANSPARENT_PORT.

On mismatch, PREROUTING REDIRECTs to a port nothing is bound to, so every inbound connection is refused. The failure is at the iptables/process boundary, so neither side can see the other — and both defaulting to 8083 means it only bites an operator who overrides one of them.

You have already done the reasonable mitigations: the requirement is called out in three code comments and in the proxy-init README ("A mismatch redirects traffic to a dead port"), and the POD_IP case is genuinely fail-closed rather than half-enforcing. So this is a residual-risk note, not a defect.

If it is worth closing further, the cheapest option is a startup log line recording the bound transparent-inbound address, so the value is greppable in pod logs when someone debugs connection-refused. A stronger option, if the operator sets both, is having it write the port to one place both consume so they cannot drift.

return fmt.Errorf("listener.inbound_interception: transparent requires the %q role (it selects how inbound reaches the inbound pipeline)", RoleReverse)
}
// The reverse proxy forwards inbound traffic to reverse_proxy_backend,
// so it's required only when the reverse role is active. A forward-only
// deployment needs no backend.
if roles[RoleReverse] && cfg.Listener.ReverseProxyBackend == "" {
// deployment needs no backend, and transparent interception derives the
// backend per connection from SO_ORIGINAL_DST rather than from config.
if roles[RoleReverse] && !cfg.Listener.InboundTransparent() && cfg.Listener.ReverseProxyBackend == "" {
return fmt.Errorf("proxy-sidecar mode with the reverse role requires listener.reverse_proxy_backend")
}
// The TLS bridge only rewrites outbound (forward-proxy) traffic; enabling
Expand Down
12 changes: 12 additions & 0 deletions authbridge/authlib/listener/internal/tlssniff/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ type peekedConn struct {
// socket reads via net.Conn embedding.
func (c *peekedConn) Read(p []byte) (int, error) { return c.br.Read(p) }

// NetConn returns the connection this wrapper peeked, so a caller that
// needs something only a lower layer knows can unwrap through the
// sniffer. The transparent inbound listener uses it to reach the
// original destination it recovered via SO_ORIGINAL_DST, which would
// otherwise be hidden behind this wrapper (and behind *tls.Conn on the
// TLS path). Deliberately mirrors (*tls.Conn).NetConn so both layers
// satisfy one structural interface.
//
// The returned conn must not be read from directly — buffered bytes
// live in this wrapper's bufio.Reader.
func (c *peekedConn) NetConn() net.Conn { return c.Conn }

// ErrUnexpected is returned when the listener encounters a state it
// can't recover from (e.g. SetReadDeadline failed). Callers don't
// match against it — it surfaces only via Accept's error return.
Expand Down
Loading
Loading