Skip to content

Commit cf99f22

Browse files
committed
Add listener side proxy protocol support and enable it in traefik
1 parent 68c481f commit cf99f22

File tree

6 files changed

+241
-53
lines changed

6 files changed

+241
-53
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ require (
8383
github.com/pion/stun/v3 v3.1.0
8484
github.com/pion/transport/v3 v3.1.1
8585
github.com/pion/turn/v3 v3.0.1
86+
github.com/pires/go-proxyproto v0.11.0
8687
github.com/pkg/sftp v1.13.9
8788
github.com/prometheus/client_golang v1.23.2
8889
github.com/quic-go/quic-go v0.55.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,8 @@ github.com/pion/turn/v3 v3.0.1 h1:wLi7BTQr6/Q20R0vt/lHbjv6y4GChFtC33nkYbasoT8=
474474
github.com/pion/turn/v3 v3.0.1/go.mod h1:MrJDKgqryDyWy1/4NT9TWfXWGMC7UHT6pJIv1+gMeNE=
475475
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
476476
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
477+
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
478+
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
477479
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
478480
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
479481
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

infrastructure_files/getting-started.sh

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ initialize_default_values() {
326326
BIND_LOCALHOST_ONLY="true"
327327
EXTERNAL_PROXY_NETWORK=""
328328

329+
# Traefik static IP within the internal bridge network
330+
TRAEFIK_IP="172.30.0.10"
331+
329332
# NetBird Proxy configuration
330333
ENABLE_PROXY="false"
331334
PROXY_DOMAIN=""
@@ -565,7 +568,7 @@ render_docker_compose_traefik_builtin() {
565568
container_name: netbird-proxy
566569
# Hairpin NAT fix: route domain back to traefik's static IP within Docker
567570
extra_hosts:
568-
- \"$NETBIRD_DOMAIN:172.30.0.10\"
571+
- \"$NETBIRD_DOMAIN:$TRAEFIK_IP\"
569572
restart: unless-stopped
570573
networks: [netbird]
571574
depends_on:
@@ -583,6 +586,7 @@ render_docker_compose_traefik_builtin() {
583586
- traefik.tcp.routers.proxy-passthrough.service=proxy-tls
584587
- traefik.tcp.routers.proxy-passthrough.priority=1
585588
- traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443
589+
- traefik.tcp.services.proxy-tls.loadbalancer.proxyProtocol.version=2
586590
logging:
587591
driver: \"json-file\"
588592
options:
@@ -602,7 +606,7 @@ services:
602606
restart: unless-stopped
603607
networks:
604608
netbird:
605-
ipv4_address: 172.30.0.10
609+
ipv4_address: $TRAEFIK_IP
606610
command:
607611
# Logging
608612
- "--log.level=INFO"
@@ -744,6 +748,10 @@ server:
744748
cliRedirectURIs:
745749
- "http://localhost:53000/"
746750
751+
reverseProxy:
752+
trustedHTTPProxies:
753+
- "$TRAEFIK_IP/32"
754+
747755
store:
748756
engine: "sqlite"
749757
encryptionKey: "$DATASTORE_ENCRYPTION_KEY"
@@ -792,6 +800,10 @@ NB_PROXY_OIDC_CLIENT_ID=netbird-proxy
792800
NB_PROXY_OIDC_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2
793801
NB_PROXY_OIDC_SCOPES=openid,profile,email
794802
NB_PROXY_FORWARDED_PROTO=https
803+
# Enable PROXY protocol to preserve client IPs through L4 proxies (Traefik TCP passthrough)
804+
NB_PROXY_PROXY_PROTOCOL=true
805+
# Trust Traefik's IP for PROXY protocol headers
806+
NB_PROXY_TRUSTED_PROXIES=$TRAEFIK_IP
795807
EOF
796808
return 0
797809
}

proxy/cmd/proxy/cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ var (
5656
certKeyFile string
5757
certLockMethod string
5858
wgPort int
59+
proxyProtocol bool
5960
)
6061

6162
var rootCmd = &cobra.Command{
@@ -90,6 +91,7 @@ func init() {
9091
rootCmd.Flags().StringVar(&certKeyFile, "cert-key-file", envStringOrDefault("NB_PROXY_CERTIFICATE_KEY_FILE", "tls.key"), "TLS certificate key filename within the certificate directory")
9192
rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease")
9293
rootCmd.Flags().IntVar(&wgPort, "wg-port", envIntOrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments")
94+
rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies")
9395
}
9496

9597
// Execute runs the root command.
@@ -165,6 +167,7 @@ func runServer(cmd *cobra.Command, args []string) error {
165167
TrustedProxies: parsedTrustedProxies,
166168
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
167169
WireguardPort: wgPort,
170+
ProxyProtocol: proxyProtocol,
168171
}
169172

170173
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)

proxy/proxyprotocol_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package proxy
2+
3+
import (
4+
"net"
5+
"net/netip"
6+
"testing"
7+
"time"
8+
9+
proxyproto "github.com/pires/go-proxyproto"
10+
log "github.com/sirupsen/logrus"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) {
16+
srv := &Server{
17+
Logger: log.StandardLogger(),
18+
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
19+
ProxyProtocol: true,
20+
}
21+
22+
raw, err := net.Listen("tcp", "127.0.0.1:0")
23+
require.NoError(t, err)
24+
defer raw.Close()
25+
26+
ln := srv.wrapProxyProtocol(raw)
27+
28+
realClientIP := "203.0.113.50"
29+
realClientPort := uint16(54321)
30+
31+
accepted := make(chan net.Conn, 1)
32+
go func() {
33+
conn, err := ln.Accept()
34+
if err != nil {
35+
return
36+
}
37+
accepted <- conn
38+
}()
39+
40+
// Connect and send a PROXY v2 header.
41+
conn, err := net.Dial("tcp", ln.Addr().String())
42+
require.NoError(t, err)
43+
defer conn.Close()
44+
45+
header := &proxyproto.Header{
46+
Version: 2,
47+
Command: proxyproto.PROXY,
48+
TransportProtocol: proxyproto.TCPv4,
49+
SourceAddr: &net.TCPAddr{IP: net.ParseIP(realClientIP), Port: int(realClientPort)},
50+
DestinationAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 443},
51+
}
52+
_, err = header.WriteTo(conn)
53+
require.NoError(t, err)
54+
55+
select {
56+
case accepted := <-accepted:
57+
defer accepted.Close()
58+
host, _, err := net.SplitHostPort(accepted.RemoteAddr().String())
59+
require.NoError(t, err)
60+
assert.Equal(t, realClientIP, host, "RemoteAddr should reflect the PROXY header source IP")
61+
case <-time.After(2 * time.Second):
62+
t.Fatal("timed out waiting for connection")
63+
}
64+
}
65+
66+
func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) {
67+
srv := &Server{
68+
Logger: log.StandardLogger(),
69+
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
70+
}
71+
72+
policy, err := srv.proxyProtocolPolicy(&net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234})
73+
require.NoError(t, err)
74+
assert.Equal(t, proxyproto.REQUIRE, policy, "trusted source should require PROXY header")
75+
}
76+
77+
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
78+
srv := &Server{
79+
Logger: log.StandardLogger(),
80+
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
81+
}
82+
83+
policy, err := srv.proxyProtocolPolicy(&net.TCPAddr{IP: net.ParseIP("203.0.113.50"), Port: 1234})
84+
require.NoError(t, err)
85+
assert.Equal(t, proxyproto.IGNORE, policy, "untrusted source should have PROXY header ignored")
86+
}
87+
88+
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
89+
srv := &Server{
90+
Logger: log.StandardLogger(),
91+
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
92+
}
93+
94+
policy, err := srv.proxyProtocolPolicy(&net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"})
95+
require.NoError(t, err)
96+
assert.Equal(t, proxyproto.REJECT, policy, "unparsable address should be rejected")
97+
}

0 commit comments

Comments
 (0)