diff --git a/backend/go.mod b/backend/go.mod index eb36f49b..98f4c1a6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -39,6 +39,7 @@ require ( github.com/orandin/slog-gorm v1.4.0 github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c github.com/oschwald/maxminddb-golang/v2 v2.4.1 + github.com/pires/go-proxyproto v0.15.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/zitadel/exifremove v0.1.0 diff --git a/backend/go.sum b/backend/go.sum index 56031885..ca9a4b07 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -421,6 +421,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/backend/internal/bootstrap/proxy_protocol.go b/backend/internal/bootstrap/proxy_protocol.go new file mode 100644 index 00000000..6f73ed94 --- /dev/null +++ b/backend/internal/bootstrap/proxy_protocol.go @@ -0,0 +1,55 @@ +package bootstrap + +import ( + "fmt" + "log/slog" + "net" + "time" + + "github.com/pires/go-proxyproto" +) + +const proxyProtocolReadHeaderTimeout = 5 * time.Second + +func newProxyProtocolListener(listener net.Listener, trustedProxies []string) (net.Listener, error) { + if len(trustedProxies) == 0 { + return listener, nil + } + + // PROXY protocol transports client metadata on TCP connections before any TLS handshake + network := listener.Addr().Network() + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return nil, fmt.Errorf("PROXY protocol requires a TCP listener, got %q", network) + } + + // Require headers from configured network proxies and reject every other network peer + trustedProxyPolicy, err := proxyproto.TrustProxyHeaderFromRanges(trustedProxies) + if err != nil { + return nil, fmt.Errorf("failed to configure PROXY protocol trusted proxies: %w", err) + } + + // We need to add another policy for loopback connections because healthcheck requests are made from the same host + // loopbackPolicy handles requests from trusted loopback like normal, but it also allows untrusted loopback connections to be accepted + // untrusted loopback connections aren't allowed to send PROXY headers though + loopbackPolicy, err := proxyproto.PolicyFromRanges(trustedProxies, proxyproto.USE, proxyproto.SKIP) + if err != nil { + return nil, fmt.Errorf("failed to configure PROXY protocol loopback proxies: %w", err) + } + + // Create a policy which decides which of the two policies to use based on the upstream address + policy := func(options proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + upstream, ok := options.Upstream.(*net.TCPAddr) + if ok && upstream.IP.IsLoopback() { + return loopbackPolicy(options) + } + + return trustedProxyPolicy(options) + } + + slog.Info("PROXY protocol enabled") + return &proxyproto.Listener{ + Listener: listener, + ConnPolicy: policy, + ReadHeaderTimeout: proxyProtocolReadHeaderTimeout, + }, nil +} diff --git a/backend/internal/bootstrap/proxy_protocol_test.go b/backend/internal/bootstrap/proxy_protocol_test.go new file mode 100644 index 00000000..0dea1ebd --- /dev/null +++ b/backend/internal/bootstrap/proxy_protocol_test.go @@ -0,0 +1,486 @@ +package bootstrap + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "io" + "math/big" + "net" + "net/http" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/pires/go-proxyproto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewProxyProtocolListenerDisabled(t *testing.T) { + listener, _ := newProxyProtocolTestConnection(t, "192.0.2.10") + + wrapped, err := newProxyProtocolListener(listener, nil) + + require.NoError(t, err) + assert.Same(t, listener, wrapped) +} + +func TestNewProxyProtocolListenerRequiresTCP(t *testing.T) { + serverConn, clientConn := net.Pipe() + t.Cleanup(func() { + _ = serverConn.Close() + _ = clientConn.Close() + }) + listener := &singleConnListener{ + conn: serverConn, + addr: &net.UnixAddr{Name: "/tmp/pocket-id.sock", Net: "unix"}, + closed: make(chan struct{}), + } + + _, err := newProxyProtocolListener(listener, []string{"127.0.0.1"}) + + require.Error(t, err) + assert.ErrorContains(t, err, "PROXY protocol requires a TCP listener") +} + +func TestNewProxyProtocolListenerUsesStrictTrustedProxyPolicy(t *testing.T) { + listener, _ := newProxyProtocolTestConnection(t, "192.0.2.10") + + wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"}) + require.NoError(t, err) + proxyListener := requireProxyProtocolListener(t, wrapped) + assert.Equal(t, proxyProtocolReadHeaderTimeout, proxyListener.ReadHeaderTimeout) + + policy, err := proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{ + Upstream: &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 12345}, + }) + require.NoError(t, err) + assert.Equal(t, proxyproto.REQUIRE, policy) + + _, err = proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{ + Upstream: &net.TCPAddr{IP: net.ParseIP("198.51.100.10"), Port: 12345}, + }) + require.ErrorIs(t, err, proxyproto.ErrInvalidUpstream) + + policy, err = proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{ + Upstream: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345}, + }) + require.NoError(t, err) + assert.Equal(t, proxyproto.SKIP, policy) +} + +func TestProxyProtocolListenerAllowsLoopbackWithoutHeader(t *testing.T) { + listener, clientConn := newProxyProtocolTestConnection(t, "127.0.0.1") + wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"}) + require.NoError(t, err) + + serverConn, err := wrapped.Accept() + require.NoError(t, err) + + writeErr := make(chan error, 1) + go func() { + _, err := clientConn.Write([]byte("healthcheck")) + writeErr <- err + }() + + payload := make([]byte, len("healthcheck")) + _, err = io.ReadFull(serverConn, payload) + require.NoError(t, err) + require.NoError(t, <-writeErr) + assert.Equal(t, "healthcheck", string(payload)) + assert.Equal(t, "127.0.0.1:12345", serverConn.RemoteAddr().String()) +} + +func TestProxyProtocolListenerAcceptsHeaderFromTrustedLoopback(t *testing.T) { + listener, clientConn := newProxyProtocolTestConnection(t, "::1") + wrapped, err := newProxyProtocolListener(listener, []string{"::1"}) + require.NoError(t, err) + + serverConn, err := wrapped.Accept() + require.NoError(t, err) + + source := &net.TCPAddr{IP: net.ParseIP("2001:db8::15"), Port: 42300} + dest := &net.TCPAddr{IP: net.ParseIP("::1"), Port: 1411} + writeErr := make(chan error, 1) + go func() { + header := proxyproto.HeaderProxyFromAddrs(2, source, dest) + if _, err := header.WriteTo(clientConn); err != nil { + writeErr <- err + return + } + _, err := clientConn.Write([]byte("payload")) + writeErr <- err + }() + + payload := make([]byte, len("payload")) + _, err = io.ReadFull(serverConn, payload) + require.NoError(t, err) + require.NoError(t, <-writeErr) + assert.Equal(t, "payload", string(payload)) + assert.Equal(t, source.String(), serverConn.RemoteAddr().String()) +} + +func TestProxyProtocolListenerAllowsTrustedLoopbackWithoutHeader(t *testing.T) { + listener, clientConn := newProxyProtocolTestConnection(t, "::1") + wrapped, err := newProxyProtocolListener(listener, []string{"::1"}) + require.NoError(t, err) + + serverConn, err := wrapped.Accept() + require.NoError(t, err) + + writeErr := make(chan error, 1) + go func() { + _, err := clientConn.Write([]byte("healthcheck")) + writeErr <- err + }() + + payload := make([]byte, len("healthcheck")) + _, err = io.ReadFull(serverConn, payload) + require.NoError(t, err) + require.NoError(t, <-writeErr) + assert.Equal(t, "healthcheck", string(payload)) + assert.Equal(t, "[::1]:12345", serverConn.RemoteAddr().String()) +} + +func TestProxyProtocolListenerDropsUntrustedPeerAndContinues(t *testing.T) { + untrustedServerConn, untrustedClientConn := newAddressedPipe("198.51.100.10") + trustedServerConn, trustedClientConn := newAddressedPipe("192.0.2.10") + listener := &queuedConnListener{ + conns: []net.Conn{untrustedServerConn, trustedServerConn}, + addr: trustedServerConn.LocalAddr(), + } + t.Cleanup(func() { + _ = listener.Close() + _ = untrustedServerConn.Close() + _ = trustedServerConn.Close() + _ = untrustedClientConn.Close() + _ = trustedClientConn.Close() + }) + + wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"}) + require.NoError(t, err) + serverConn, err := wrapped.Accept() + require.NoError(t, err) + + source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300} + dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443} + writeErr := make(chan error, 1) + go func() { + header := proxyproto.HeaderProxyFromAddrs(1, source, dest) + if _, err := header.WriteTo(trustedClientConn); err != nil { + writeErr <- err + return + } + _, err := trustedClientConn.Write([]byte("payload")) + writeErr <- err + }() + + payload := make([]byte, len("payload")) + _, err = io.ReadFull(serverConn, payload) + require.NoError(t, err) + require.NoError(t, <-writeErr) + assert.Equal(t, source.String(), serverConn.RemoteAddr().String()) + + _, err = untrustedClientConn.Write([]byte("payload")) + require.Error(t, err) +} + +func TestProxyProtocolListenerExtractsClientAddress(t *testing.T) { + testCases := []struct { + name string + version byte + source *net.TCPAddr + dest *net.TCPAddr + }{ + { + name: "version 1 IPv4", + version: 1, + source: &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300}, + dest: &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443}, + }, + { + name: "version 2 IPv6", + version: 2, + source: &net.TCPAddr{IP: net.ParseIP("2001:db8::15"), Port: 42300}, + dest: &net.TCPAddr{IP: net.ParseIP("2001:db8::20"), Port: 443}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10") + wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"}) + require.NoError(t, err) + + serverConn, err := wrapped.Accept() + require.NoError(t, err) + + writeErr := make(chan error, 1) + go func() { + header := proxyproto.HeaderProxyFromAddrs(testCase.version, testCase.source, testCase.dest) + if _, err := header.WriteTo(clientConn); err != nil { + writeErr <- err + return + } + _, err := clientConn.Write([]byte("payload")) + writeErr <- err + }() + + payload := make([]byte, len("payload")) + _, err = io.ReadFull(serverConn, payload) + require.NoError(t, err) + require.NoError(t, <-writeErr) + assert.Equal(t, "payload", string(payload)) + assert.Equal(t, testCase.source.String(), serverConn.RemoteAddr().String()) + assert.Equal(t, testCase.dest.String(), serverConn.LocalAddr().String()) + }) + } +} + +func TestProxyProtocolListenerRejectsMissingHeader(t *testing.T) { + listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10") + wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"}) + require.NoError(t, err) + + serverConn, err := wrapped.Accept() + require.NoError(t, err) + + go func() { + _, _ = clientConn.Write([]byte("GET /healthz HTTP/1.1\r\n")) + }() + + _, err = serverConn.Read(make([]byte, 1)) + require.ErrorIs(t, err, proxyproto.ErrNoProxyProtocol) +} + +func TestProxyProtocolListenerSetsGinClientIP(t *testing.T) { + listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10") + wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"}) + require.NoError(t, err) + + engine := gin.New() + require.NoError(t, engine.SetTrustedProxies(nil)) + clientIP := make(chan string, 1) + engine.GET("/client-ip", func(c *gin.Context) { + clientIP <- c.ClientIP() + c.Status(http.StatusNoContent) + }) + server := &http.Server{ + Handler: engine, + ReadHeaderTimeout: time.Second, + } + t.Cleanup(func() { + _ = server.Close() + }) + go func() { + _ = server.Serve(wrapped) + }() + + source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300} + dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443} + writeErr := make(chan error, 1) + go func() { + header := proxyproto.HeaderProxyFromAddrs(1, source, dest) + if _, err := header.WriteTo(clientConn); err != nil { + writeErr <- err + return + } + _, err := clientConn.Write([]byte("GET /client-ip HTTP/1.1\r\nHost: pocket-id.example\r\nConnection: close\r\n\r\n")) + writeErr <- err + }() + + select { + case actualClientIP := <-clientIP: + assert.Equal(t, "203.0.113.15", actualClientIP) + case <-time.After(time.Second): + t.Fatal("timed out waiting for Gin to handle the proxied request") + } + require.NoError(t, <-writeErr) +} + +func TestProxyProtocolHeaderIsReadBeforeTLS(t *testing.T) { + listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10") + proxyListener, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"}) + require.NoError(t, err) + + serverTLSConfig, clientTLSConfig := newProxyProtocolTestTLSConfigs(t) + tlsListener := tls.NewListener(proxyListener, serverTLSConfig) + serverConn, err := tlsListener.Accept() + require.NoError(t, err) + + source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300} + dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443} + ctx := t.Context() + clientErr := make(chan error, 1) + go func() { + header := proxyproto.HeaderProxyFromAddrs(2, source, dest) + if _, err := header.WriteTo(clientConn); err != nil { + clientErr <- err + return + } + + tlsClient := tls.Client(clientConn, clientTLSConfig) + if err := tlsClient.HandshakeContext(ctx); err != nil { + clientErr <- err + return + } + _, err := tlsClient.Write([]byte("payload")) + clientErr <- err + }() + + tlsServerConn, ok := serverConn.(*tls.Conn) + require.True(t, ok) + require.NoError(t, tlsServerConn.HandshakeContext(ctx)) + payload := make([]byte, len("payload")) + _, err = io.ReadFull(serverConn, payload) + require.NoError(t, err) + require.NoError(t, <-clientErr) + assert.Equal(t, "payload", string(payload)) + assert.Equal(t, source.String(), serverConn.RemoteAddr().String()) +} + +func requireProxyProtocolListener(t *testing.T, listener net.Listener) *proxyproto.Listener { + t.Helper() + + proxyListener, ok := listener.(*proxyproto.Listener) + require.True(t, ok) + return proxyListener +} + +func newProxyProtocolTestConnection(t *testing.T, upstreamIP string) (*singleConnListener, net.Conn) { + t.Helper() + + serverConn, clientConn := newAddressedPipe(upstreamIP) + listener := &singleConnListener{ + conn: serverConn, + addr: serverConn.LocalAddr(), + closed: make(chan struct{}), + } + + t.Cleanup(func() { + _ = listener.Close() + _ = clientConn.Close() + }) + return listener, clientConn +} + +func newAddressedPipe(upstreamIP string) (net.Conn, net.Conn) { + serverConn, clientConn := net.Pipe() + return &addressedConn{ + Conn: serverConn, + localAddr: &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 1411}, + remoteAddr: &net.TCPAddr{IP: net.ParseIP(upstreamIP), Port: 12345}, + }, clientConn +} + +func newProxyProtocolTestTLSConfigs(t *testing.T) (*tls.Config, *tls.Config) { + t.Helper() + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"localhost"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + certificate, err := x509.ParseCertificate(certificateDER) + require.NoError(t, err) + + roots := x509.NewCertPool() + roots.AddCert(certificate) + + serverConfig := &tls.Config{ + Certificates: []tls.Certificate{{ + Certificate: [][]byte{certificateDER}, + PrivateKey: privateKey, + }}, + MinVersion: tls.VersionTLS13, + } + clientConfig := &tls.Config{ + RootCAs: roots, + ServerName: "localhost", + MinVersion: tls.VersionTLS13, + } + return serverConfig, clientConfig +} + +type singleConnListener struct { + conn net.Conn + addr net.Addr + accepted bool + closed chan struct{} + closeOnce sync.Once +} + +func (l *singleConnListener) Accept() (net.Conn, error) { + if l.accepted { + <-l.closed + return nil, net.ErrClosed + } + l.accepted = true + return l.conn, nil +} + +func (l *singleConnListener) Close() error { + var closeErr error + l.closeOnce.Do(func() { + close(l.closed) + closeErr = l.conn.Close() + }) + return closeErr +} + +func (l *singleConnListener) Addr() net.Addr { + return l.addr +} + +type addressedConn struct { + net.Conn + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *addressedConn) LocalAddr() net.Addr { + return c.localAddr +} + +func (c *addressedConn) RemoteAddr() net.Addr { + return c.remoteAddr +} + +type queuedConnListener struct { + conns []net.Conn + addr net.Addr +} + +func (l *queuedConnListener) Accept() (net.Conn, error) { + if len(l.conns) == 0 { + return nil, net.ErrClosed + } + + conn := l.conns[0] + l.conns = l.conns[1:] + return conn, nil +} + +func (l *queuedConnListener) Close() error { + for _, conn := range l.conns { + _ = conn.Close() + } + return nil +} + +func (l *queuedConnListener) Addr() net.Addr { + return l.addr +} diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 2ea0e162..9969045f 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -222,7 +222,18 @@ func initServer(r *gin.Engine) (*serverConfig, error) { } addr := socket.addr + listener := socket.listener + + // Wrap the listener with a proxy protocol listener if configured and not using a Unix socket + if len(common.EnvConfig.ProxyProtocol) > 0 && common.EnvConfig.UnixSocket == "" { + listener, err = newProxyProtocolListener(socket.listener, common.EnvConfig.ProxyProtocol) + if err != nil { + _ = socket.listener.Close() + return nil, err + } + } + server := newHTTPServer(r, protocols) return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil diff --git a/backend/internal/cmds/healthcheck.go b/backend/internal/cmds/healthcheck.go index bbea825c..5b9f4665 100644 --- a/backend/internal/cmds/healthcheck.go +++ b/backend/internal/cmds/healthcheck.go @@ -2,6 +2,7 @@ package cmds import ( "context" + "crypto/tls" "fmt" "log/slog" "net" @@ -64,13 +65,27 @@ func init() { }, } - healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID") + healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", defaultEndpoint(), "Endpoint for Pocket ID") healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID") healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode") rootCmd.AddCommand(healthcheckCmd) } +// The server only serves TLS when both a certificate and a key file are configured +func tlsEnabled() bool { + return common.EnvConfig.TLSCertFile != "" && common.EnvConfig.TLSKeyFile != "" +} + +func defaultEndpoint() string { + scheme := "http" + if tlsEnabled() { + scheme = "https" + } + + return scheme + "://localhost:" + common.EnvConfig.Port +} + func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) { url := strings.TrimRight(flags.Endpoint, "/") + "/healthz" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -78,17 +93,25 @@ func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResul return nil, fmt.Errorf("failed to create request object for %q: %w", url, err) } - client := http.DefaultClient + transport := http.DefaultTransport.(*http.Transport).Clone() + if flags.UnixSocket != "" { - transport := http.DefaultTransport.(*http.Transport).Clone() transport.Proxy = nil transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { dialer := net.Dialer{} return dialer.DialContext(ctx, "unix", flags.UnixSocket) } - client = &http.Client{Transport: transport} } + // If TLS cert and key files are provided, there is a high chance that the server is using a self-signed certificate + if tlsEnabled() { + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // no sensitive data is transmitted in healthcheck + } + } + + client := &http.Client{Transport: transport} + res, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to perform request to %q: %w", url, err) diff --git a/backend/internal/cmds/healthcheck_test.go b/backend/internal/cmds/healthcheck_test.go index 3df83cd8..0da483a6 100644 --- a/backend/internal/cmds/healthcheck_test.go +++ b/backend/internal/cmds/healthcheck_test.go @@ -2,15 +2,24 @@ package cmds import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" "net" "net/http" "net/http/httptest" + "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" ) func TestHealthcheckTCPSuccess(t *testing.T) { @@ -77,3 +86,123 @@ func TestHealthcheckUnixSocket(t *testing.T) { require.Equal(t, http.StatusNoContent, result.StatusCode) require.Equal(t, "http://localhost:1411/healthz", result.URL) } + +// The server also serves TLS over a UNIX socket, so the healthcheck must keep dialing +// the socket instead of falling back to the host in the endpoint +func TestHealthcheckUnixSocketWithTLS(t *testing.T) { + setTLSFiles(t) + + socketPath := filepath.Join(shortTempDir(t), "s.sock") + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath) + require.NoError(t, err) + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/healthz", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }), + ReadHeaderTimeout: time.Second, + TLSConfig: newSelfSignedTLSConfig(t), + } + + errCh := make(chan error, 1) + go func() { + errCh <- server.ServeTLS(listener, "", "") + }() + + // The connection is upgraded to HTTP/2, which keeps a graceful shutdown waiting for the idle client connection + t.Cleanup(func() { + require.NoError(t, server.Close()) + require.ErrorIs(t, <-errCh, http.ErrServerClosed) + }) + + result, err := healthcheck(t.Context(), healthcheckFlags{ + Endpoint: "https://localhost:1411", + UnixSocket: socketPath, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, result.StatusCode) +} + +// The healthcheck must reach a server that serves TLS with a self-signed certificate +func TestHealthcheckTLSSelfSignedCertificate(t *testing.T) { + setTLSFiles(t) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/healthz", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + result, err := healthcheck(t.Context(), healthcheckFlags{ + Endpoint: server.URL, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, result.StatusCode) +} + +func TestDefaultEndpoint(t *testing.T) { + t.Run("uses http if no TLS certificate is configured", func(t *testing.T) { + assert.Equal(t, "http://localhost:"+common.EnvConfig.Port, defaultEndpoint()) + }) + + t.Run("uses https if a TLS certificate is configured", func(t *testing.T) { + setTLSFiles(t) + assert.Equal(t, "https://localhost:"+common.EnvConfig.Port, defaultEndpoint()) + }) +} + +// t.TempDir embeds the test name, which can exceed the maximum socket path length +func shortTempDir(t *testing.T) string { + t.Helper() + + dir, err := os.MkdirTemp("", "pid") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + + return dir +} + +// Only the presence of the paths matters, the healthcheck never reads the files +func setTLSFiles(t *testing.T) { + t.Helper() + + certFile, keyFile := common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile + t.Cleanup(func() { + common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile = certFile, keyFile + }) + + common.EnvConfig.TLSCertFile = "cert.pem" + common.EnvConfig.TLSKeyFile = "key.pem" +} + +func newSelfSignedTLSConfig(t *testing.T) *tls.Config { + t.Helper() + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"localhost"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + + return &tls.Config{ + Certificates: []tls.Certificate{{ + Certificate: [][]byte{certificateDER}, + PrivateKey: privateKey, + }}, + MinVersion: tls.VersionTLS13, + } +} diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index 601f92da..578b6155 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -46,6 +46,7 @@ type EnvConfigSchema struct { DbProvider DbProvider DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` TrustProxy TrustProxyConfig `env:"TRUST_PROXY"` + ProxyProtocol TrustProxyConfig `env:"PROXY_PROTOCOL"` TrustedPlatform string `env:"TRUSTED_PLATFORM"` AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"` AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` @@ -158,6 +159,9 @@ func ValidateEnvConfig(config *EnvConfigSchema) error { if config.SystemdSocket && config.UnixSocket != "" { return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive") } + if len(config.ProxyProtocol) > 0 && config.UnixSocket != "" { + return errors.New("PROXY_PROTOCOL and UNIX_SOCKET are mutually exclusive") + } if config.AuditLogRetentionDays <= 0 { return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0") @@ -414,6 +418,11 @@ func (config *TrustProxyConfig) UnmarshalText(text []byte) error { proxies := strings.Split(value, ",") for i, proxy := range proxies { proxy = strings.TrimSpace(proxy) + if net.ParseIP(proxy) == nil { + if _, _, err := net.ParseCIDR(proxy); err != nil { + return fmt.Errorf("invalid proxy IP address or CIDR %q", proxy) + } + } proxies[i] = proxy } diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index 2eabdbcc..c13cc6d5 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -118,6 +118,7 @@ func TestParseEnvConfig(t *testing.T) { t.Setenv("METRICS_ENABLED", "true") t.Setenv("TRACING_ENABLED", "false") t.Setenv("TRUST_PROXY", "true") + t.Setenv("PROXY_PROTOCOL", "true") t.Setenv("ANALYTICS_DISABLED", "false") t.Setenv("ALLOW_INSECURE_CALLBACK_URLS", "false") @@ -125,6 +126,7 @@ func TestParseEnvConfig(t *testing.T) { require.NoError(t, err) assert.True(t, EnvConfig.UiConfigDisabled) assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.TrustProxy) + assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.ProxyProtocol) assert.False(t, EnvConfig.AnalyticsDisabled) assert.False(t, EnvConfig.AllowInsecureCallbackURLs) }) @@ -147,6 +149,34 @@ func TestParseEnvConfig(t *testing.T) { assert.Nil(t, EnvConfig.TrustProxy) }) + t.Run("should parse PROXY protocol trusted proxy IP addresses and CIDR ranges", func(t *testing.T) { + EnvConfig = defaultConfig() + t.Setenv("PROXY_PROTOCOL", "10.0.0.0/8, 192.168.1.10, ::1/128") + + err := parseAndValidateEnvConfig(t) + require.NoError(t, err) + assert.Equal(t, TrustProxyConfig{"10.0.0.0/8", "192.168.1.10", "::1/128"}, EnvConfig.ProxyProtocol) + }) + + t.Run("should reject an invalid PROXY protocol trusted proxy", func(t *testing.T) { + EnvConfig = defaultConfig() + t.Setenv("PROXY_PROTOCOL", "not-an-ip") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid proxy IP address or CIDR") + }) + + t.Run("should reject PROXY protocol with a UNIX socket", func(t *testing.T) { + EnvConfig = defaultConfig() + t.Setenv("PROXY_PROTOCOL", "true") + t.Setenv("UNIX_SOCKET", "/tmp/pocket-id.sock") + + err := parseAndValidateEnvConfig(t) + require.Error(t, err) + assert.ErrorContains(t, err, "PROXY_PROTOCOL and UNIX_SOCKET are mutually exclusive") + }) + t.Run("should allow insecure callback URLs by default", func(t *testing.T) { assert.True(t, defaultConfig().AllowInsecureCallbackURLs) })