Skip to content

Commit bbb89f5

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

File tree

6 files changed

+276
-55
lines changed

6 files changed

+276
-55
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: 35 additions & 4 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=""
@@ -388,7 +391,7 @@ check_existing_installation() {
388391
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
389392
echo "You can use the following commands:"
390393
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
391-
echo " rm -f docker-compose.yml dashboard.env config.yaml proxy.env nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt"
394+
echo " rm -f docker-compose.yml dashboard.env config.yaml proxy.env traefik-dynamic.yaml nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt"
392395
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
393396
exit 1
394397
fi
@@ -407,6 +410,8 @@ generate_configuration_files() {
407410
# This will be overwritten with the actual token after netbird-server starts
408411
echo "# Placeholder - will be updated with token after netbird-server starts" > proxy.env
409412
echo "NB_PROXY_TOKEN=placeholder" >> proxy.env
413+
# TCP ServersTransport for PROXY protocol v2 to the proxy backend
414+
render_traefik_dynamic > traefik-dynamic.yaml
410415
fi
411416
;;
412417
1)
@@ -554,18 +559,22 @@ init_environment() {
554559
############################################
555560

556561
render_docker_compose_traefik_builtin() {
557-
# Generate proxy service section if enabled
562+
# Generate proxy service section and Traefik dynamic config if enabled
558563
local proxy_service=""
559564
local proxy_volumes=""
565+
local traefik_file_provider=""
566+
local traefik_dynamic_volume=""
560567
if [[ "$ENABLE_PROXY" == "true" ]]; then
568+
traefik_file_provider=' - "--providers.file.filename=/etc/traefik/dynamic.yaml"'
569+
traefik_dynamic_volume=" - ./traefik-dynamic.yaml:/etc/traefik/dynamic.yaml:ro"
561570
proxy_service="
562571
# NetBird Proxy - exposes internal resources to the internet
563572
proxy:
564573
image: $NETBIRD_PROXY_IMAGE
565574
container_name: netbird-proxy
566575
# Hairpin NAT fix: route domain back to traefik's static IP within Docker
567576
extra_hosts:
568-
- \"$NETBIRD_DOMAIN:172.30.0.10\"
577+
- \"$NETBIRD_DOMAIN:$TRAEFIK_IP\"
569578
restart: unless-stopped
570579
networks: [netbird]
571580
depends_on:
@@ -583,6 +592,7 @@ render_docker_compose_traefik_builtin() {
583592
- traefik.tcp.routers.proxy-passthrough.service=proxy-tls
584593
- traefik.tcp.routers.proxy-passthrough.priority=1
585594
- traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443
595+
- traefik.tcp.services.proxy-tls.loadbalancer.serverstransport=pp-v2@file
586596
logging:
587597
driver: \"json-file\"
588598
options:
@@ -602,7 +612,7 @@ services:
602612
restart: unless-stopped
603613
networks:
604614
netbird:
605-
ipv4_address: 172.30.0.10
615+
ipv4_address: $TRAEFIK_IP
606616
command:
607617
# Logging
608618
- "--log.level=INFO"
@@ -629,12 +639,14 @@ services:
629639
# gRPC transport settings
630640
- "--serverstransport.forwardingtimeouts.responseheadertimeout=0s"
631641
- "--serverstransport.forwardingtimeouts.idleconntimeout=0s"
642+
$traefik_file_provider
632643
ports:
633644
- '443:443'
634645
- '80:80'
635646
volumes:
636647
- /var/run/docker.sock:/var/run/docker.sock:ro
637648
- netbird_traefik_letsencrypt:/letsencrypt
649+
$traefik_dynamic_volume
638650
logging:
639651
driver: "json-file"
640652
options:
@@ -744,6 +756,10 @@ server:
744756
cliRedirectURIs:
745757
- "http://localhost:53000/"
746758
759+
reverseProxy:
760+
trustedHTTPProxies:
761+
- "$TRAEFIK_IP/32"
762+
747763
store:
748764
engine: "sqlite"
749765
encryptionKey: "$DATASTORE_ENCRYPTION_KEY"
@@ -773,6 +789,17 @@ EOF
773789
return 0
774790
}
775791

792+
render_traefik_dynamic() {
793+
cat <<'EOF'
794+
tcp:
795+
serversTransports:
796+
pp-v2:
797+
proxyProtocol:
798+
version: 2
799+
EOF
800+
return 0
801+
}
802+
776803
render_proxy_env() {
777804
cat <<EOF
778805
# NetBird Proxy Configuration
@@ -792,6 +819,10 @@ NB_PROXY_OIDC_CLIENT_ID=netbird-proxy
792819
NB_PROXY_OIDC_ENDPOINT=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2
793820
NB_PROXY_OIDC_SCOPES=openid,profile,email
794821
NB_PROXY_FORWARDED_PROTO=https
822+
# Enable PROXY protocol to preserve client IPs through L4 proxies (Traefik TCP passthrough)
823+
NB_PROXY_PROXY_PROTOCOL=true
824+
# Trust Traefik's IP for PROXY protocol headers
825+
NB_PROXY_TRUSTED_PROXIES=$TRAEFIK_IP
795826
EOF
796827
return 0
797828
}

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: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
opts := proxyproto.ConnPolicyOptions{
73+
Upstream: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234},
74+
}
75+
policy, err := srv.proxyProtocolPolicy(opts)
76+
require.NoError(t, err)
77+
assert.Equal(t, proxyproto.REQUIRE, policy, "trusted source should require PROXY header")
78+
}
79+
80+
func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) {
81+
srv := &Server{
82+
Logger: log.StandardLogger(),
83+
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
84+
}
85+
86+
opts := proxyproto.ConnPolicyOptions{
87+
Upstream: &net.TCPAddr{IP: net.ParseIP("203.0.113.50"), Port: 1234},
88+
}
89+
policy, err := srv.proxyProtocolPolicy(opts)
90+
require.NoError(t, err)
91+
assert.Equal(t, proxyproto.IGNORE, policy, "untrusted source should have PROXY header ignored")
92+
}
93+
94+
func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) {
95+
srv := &Server{
96+
Logger: log.StandardLogger(),
97+
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
98+
}
99+
100+
opts := proxyproto.ConnPolicyOptions{
101+
Upstream: &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"},
102+
}
103+
policy, err := srv.proxyProtocolPolicy(opts)
104+
require.NoError(t, err)
105+
assert.Equal(t, proxyproto.REJECT, policy, "unparsable address should be rejected")
106+
}

0 commit comments

Comments
 (0)