From e3c81b2f064f4be8f16675d36c280e6942ffb209 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Fri, 17 Apr 2026 21:00:19 -0700 Subject: [PATCH 1/9] Augment PROXY protocol v2 with TLS metadata TLVs - Fix IPv6: detect address family at runtime instead of hardcoding TCPv4 - Add PP2_SUBTYPE_SSL_CLIENT_CERT with full DER-encoded client cert - Add PP2_TYPE_AUTHORITY (SNI) and PP2_TYPE_ALPN top-level TLVs - Add unit tests for TLV construction and transport protocol detection - Extend integration test to parse and validate all new TLVs Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 11 +- docs/ACCESS-FLAGS.md | 8 + docs/FLAGS.md | 5 +- docs/PROXY-PROTOCOL.md | 132 ++++++++ docs/QUICKSTART.md | 1 + main.go | 36 ++- main_test.go | 41 +++ proxy/proxy.go | 149 ++++++++- proxy/proxy_test.go | 375 ++++++++++++++++++++++- tests/common.py | 21 ++ tests/test-server-proxy-protocol-conn.py | 132 ++++++++ tests/test-server-proxy-protocol-tls.py | 167 ++++++++++ tests/test-server-proxy-protocol.py | 126 ++++++-- 13 files changed, 1164 insertions(+), 40 deletions(-) create mode 100644 docs/PROXY-PROTOCOL.md create mode 100644 tests/test-server-proxy-protocol-conn.py create mode 100644 tests/test-server-proxy-protocol-tls.py diff --git a/README.md b/README.md index c2bb42f8f6..af10e41567 100644 --- a/README.md +++ b/README.md @@ -373,10 +373,13 @@ See [SOCKET-ACTIVATION](docs/SOCKET-ACTIVATION.md) for examples. ### PROXY Protocol Support Ghostunnel in server mode supports signalling of transport connection information -to the backend using the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) -(v2), just pass the `--proxy-protocol` flag on startup. Note that the backend must -also support the PROXY protocol and must be configured to use it when setting -this option. +to the backend using the [PROXY protocol](https://www.haproxy.org/download/3.1/doc/proxy-protocol.txt) +(v2), just pass the `--proxy-protocol` flag on startup. Use `--proxy-protocol-mode` +to also include TLS metadata and/or client certificate details. Note that the +backend must support the PROXY protocol and must be configured to use it when +setting this option. + +See [PROXY-PROTOCOL](docs/PROXY-PROTOCOL.md) for details on modes and TLV extensions. ### Landlock Support diff --git a/docs/ACCESS-FLAGS.md b/docs/ACCESS-FLAGS.md index 743172cff2..afd550a07a 100644 --- a/docs/ACCESS-FLAGS.md +++ b/docs/ACCESS-FLAGS.md @@ -67,6 +67,14 @@ from any client. This means that anyone will be able to establish a connection to the Ghostunnel server. This flag is mutually exclusive with other access control flags. +### Passing client identity to backends + +Ghostunnel verifies client certificates before forwarding connections, but +backends may also need to know the client's identity for their own access +control, logging, or auditing. Use `--proxy-protocol-mode=tls-full` to forward +the client certificate (CN, full DER-encoded cert) to the backend via +[PROXY protocol v2]({{< ref "PROXY-PROTOCOL.md" >}}) TLV extensions. + ## Client mode Ghostunnel in client mode offers various flags that can be used to augment and diff --git a/docs/FLAGS.md b/docs/FLAGS.md index 00eeb1e00e..1f476c6c14 100644 --- a/docs/FLAGS.md +++ b/docs/FLAGS.md @@ -109,10 +109,13 @@ See [Socket Activation]({{< ref "SOCKET-ACTIVATION.md" >}}) for `systemd:NAME` a ### Proxying +See [PROXY Protocol]({{< ref "PROXY-PROTOCOL.md" >}}) for details on modes and TLV extensions. + | Flag | Description | |------|-------------| | `--target-status URL` | Address to target for status checking downstream healthchecks. Defaults to TCP healthcheck if not passed. | -| `--proxy-protocol` | Enable PROXY protocol v2 to signal connection info to backend. | +| `--proxy-protocol` | Enable PROXY protocol v2 with connection info only (equivalent to `--proxy-protocol-mode=conn`). | +| `--proxy-protocol-mode MODE` | PROXY protocol v2 mode: `conn`, `tls`, or `tls-full`. Mutually exclusive with `--proxy-protocol`. | | `--unsafe-target` | Do not limit target to localhost, `127.0.0.1`, `[::1]`, or UNIX sockets. See [Security]({{< ref "SECURITY.md" >}}). | ### Access Control diff --git a/docs/PROXY-PROTOCOL.md b/docs/PROXY-PROTOCOL.md new file mode 100644 index 0000000000..54ba714309 --- /dev/null +++ b/docs/PROXY-PROTOCOL.md @@ -0,0 +1,132 @@ +--- +title: PROXY Protocol +description: Pass original client connection metadata (IP, TLS version, client certificate) through to plaintext backends using HAProxy's PROXY protocol v2. +weight: 55 +--- + +When Ghostunnel terminates TLS, the backend only sees a plaintext connection +from Ghostunnel itself. It has no idea who the original client was, what TLS +version was negotiated, or whether a client certificate was presented. The PROXY +protocol fixes this: Ghostunnel prepends a small binary header to each +forwarded connection carrying the original client metadata. Backends can then +do logging, access control, or auditing based on client identity without +needing their own TLS stack. + +### Enabling + +See [Command-Line Flags]({{< ref "FLAGS.md" >}}) for the full flag reference. + +Pass `--proxy-protocol` in server mode to enable PROXY protocol v2 with +connection info (source/destination IP and port): + +```bash +ghostunnel server \ + --listen=:8443 \ + --target=localhost:8080 \ + --keystore=server.p12 \ + --cacert=ca.crt \ + --allow-ou=my-service \ + --proxy-protocol +``` + +To also include TLS metadata and/or client certificate details, use the +`--proxy-protocol-mode` flag: + +| Mode | What is sent | +|------|-------------| +| `conn` | Connection info only (src/dst IP+port). Same as bare `--proxy-protocol`. | +| `tls` | Connection info + TLS metadata (version, ALPN, SNI). No client cert details. | +| `tls-full` | Connection info + TLS metadata + full client certificate details. | + +```bash +# TLS metadata without client cert: +ghostunnel server ... --proxy-protocol-mode=tls + +# Everything, including client cert: +ghostunnel server ... --proxy-protocol-mode=tls-full +``` + +Using `--proxy-protocol-mode` implies `--proxy-protocol`; you do not need to +pass both. + +The backend will receive a PROXY protocol v2 binary header on each new +connection, followed by the normal application data stream. + +### What Ghostunnel sends + +Ghostunnel sends a **version 2** (binary format) header with the `PROXY` +command. The address family (IPv4 or IPv6) is detected from the incoming +connection. + +#### Address fields (all modes) + +| Field | Value | +|-------|-------| +| Source address/port | Original client IP and port | +| Destination address/port | Ghostunnel's listen IP and port | + +#### TLV extensions (`tls` and `tls-full` modes) + +When using `--proxy-protocol-mode=tls` or `--proxy-protocol-mode=tls-full`, +Ghostunnel includes TLV (Type-Length-Value) extensions with TLS connection +metadata: + +| TLV | Type | Description | +|-----|------|-------------| +| `PP2_TYPE_SSL` | `0x20` | Container for SSL/TLS metadata (see below) | +| `PP2_TYPE_AUTHORITY` | `0x02` | SNI hostname the client requested (if set) | +| `PP2_TYPE_ALPN` | `0x01` | Negotiated ALPN protocol, e.g. `h2` (if set) | + +#### SSL sub-TLVs + +The `PP2_TYPE_SSL` TLV contains a 5-byte sub-header followed by nested +sub-TLVs: + +**Sub-header:** + +| Field | Size | Description | +|-------|------|-------------| +| Client flags | 1 byte | Bitfield: `0x01` = SSL used, `0x02` = client cert on connection, `0x04` = client cert on session | +| Verify result | 4 bytes | `0` = certificate verified successfully | + +**Nested sub-TLVs (always present in `tls` and `tls-full` modes):** + +| Sub-TLV | Type | Example value | +|---------|------|---------------| +| `PP2_SUBTYPE_SSL_VERSION` | `0x21` | `TLS 1.3` | + +**Nested sub-TLVs (`tls-full` mode only, when a client certificate was provided):** + +| Sub-TLV | Type | Description | +|---------|------|-------------| +| `PP2_SUBTYPE_SSL_CN` | `0x22` | Client certificate Common Name | +| `PP2_SUBTYPE_SSL_CLIENT_CERT` | `0x28` | Full client certificate in DER (ASN.1) encoding | + +The `tls-full` mode is useful when backends need to perform their own access +control or auditing based on client certificate identity. See [Access Control +Flags]({{< ref "ACCESS-FLAGS.md" >}}) for how Ghostunnel itself verifies +client certificates before forwarding. + +Note: `PP2_SUBTYPE_SSL_CLIENT_CERT` (`0x28`) is not part of the original +HAProxy spec but is supported by the +[go-proxyproto](https://github.com/pires/go-proxyproto) library and others. +The spec requires receivers to ignore unknown TLV types, so this is safe. + +### Backend requirements + +Your backend must be configured to expect PROXY protocol headers. It needs to +parse the binary header before reading application data. Most servers and +frameworks support this: + +- **nginx**: `proxy_protocol` parameter on `listen` directive +- **Apache**: `mod_remoteip` with `RemoteIPProxyProtocol` +- **HAProxy**: `accept-proxy` on `bind` lines +- **Custom apps**: use a PROXY protocol parsing library for your language + +Backends that aren't expecting PROXY protocol will see the binary header as +garbage at the start of the stream and will reject the connection. + +### References + +- [PROXY protocol specification](https://www.haproxy.org/download/3.1/doc/proxy-protocol.txt) (HAProxy, covers v1 and v2; see section 2.2 for the TLV type registry) +- [go-proxyproto](https://github.com/pires/go-proxyproto) (Go library used by Ghostunnel) diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 97dcc890e7..130810c262 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -134,4 +134,5 @@ forwarded the plaintext request to the backend. - [Access Control Flags]({{< ref "ACCESS-FLAGS.md" >}}): control who can connect (CN, OU, DNS/URI SAN, OPA) - [ACME Support]({{< ref "ACME.md" >}}): automatic certificates from Let's Encrypt - [Metrics & Profiling]({{< ref "METRICS.md" >}}): status port, Prometheus metrics, pprof +- [PROXY Protocol]({{< ref "PROXY-PROTOCOL.md" >}}): pass client connection metadata to backends - [Socket Activation]({{< ref "SOCKET-ACTIVATION.md" >}}) and [Systemd Watchdog]({{< ref "WATCHDOG.md" >}}): run Ghostunnel as a service diff --git a/main.go b/main.go index 7d36e40094..9a582d5e46 100644 --- a/main.go +++ b/main.go @@ -77,7 +77,8 @@ var ( serverListenAddress = serverCommand.Flag("listen", "Address and port to listen on (can be HOST:PORT, unix:PATH, systemd:NAME or launchd:NAME).").PlaceHolder("ADDR").Required().String() serverForwardAddress = serverCommand.Flag("target", "Address to forward connections to (can be HOST:PORT or unix:PATH).").PlaceHolder("ADDR").Required().String() serverStatusTargetAddress = serverCommand.Flag("target-status", "Address to target for status checking downstream healthchecks. Defaults to a TCP healthcheck if this flag is not passed.").Default("").String() - serverProxyProtocol = serverCommand.Flag("proxy-protocol", "Enable PROXY protocol v2 to signal connection info to backend").Bool() + serverProxyProtocol = serverCommand.Flag("proxy-protocol", "Enable PROXY protocol v2 (connection info only, equivalent to --proxy-protocol-mode=conn).").Bool() + serverProxyProtocolMode = serverCommand.Flag("proxy-protocol-mode", "PROXY protocol v2 mode: conn (connection info only), tls (add TLS version/ALPN/SNI metadata), tls-full (add TLS metadata and client certificate). Mutually exclusive with --proxy-protocol.").Enum("conn", "tls", "tls-full") serverUnsafeTarget = serverCommand.Flag("unsafe-target", "If set, does not limit target to localhost, 127.0.0.1, [::1], or UNIX sockets.").Bool() serverAllowAll = serverCommand.Flag("allow-all", "Allow all clients, do not check client cert subject.").Bool() serverAllowedCNs = serverCommand.Flag("allow-cn", "Allow clients with given common name (can be repeated).").PlaceHolder("CN").Strings() @@ -395,9 +396,19 @@ func serverValidateFlags() error { if err := validateServerOPA(hasAccessFlags, hasOPAFlags); err != nil { return err } + if err := validateServerProxyProtocol(); err != nil { + return err + } return validateCipherSuites() } +func validateServerProxyProtocol() error { + if *serverProxyProtocol && *serverProxyProtocolMode != "" { + return errors.New("--proxy-protocol and --proxy-protocol-mode are mutually exclusive") + } + return nil +} + func validateClientCredentials() error { count := validateCredentials([]bool{ *keystorePath != "", @@ -433,6 +444,25 @@ func clientValidateFlags() error { return validateCipherSuites() } +// serverProxyProtoMode computes the ProxyProtocolMode from the +// --proxy-protocol and --proxy-protocol-mode flags. +func serverProxyProtoMode() proxy.ProxyProtocolMode { + if *serverProxyProtocolMode != "" { + switch *serverProxyProtocolMode { + case "tls": + return proxy.ProxyProtocolTLS + case "tls-full": + return proxy.ProxyProtocolTLSFull + default: + return proxy.ProxyProtocolConn + } + } + if *serverProxyProtocol { + return proxy.ProxyProtocolConn + } + return proxy.ProxyProtocolOff +} + func main() { err := run(os.Args[1:]) if err != nil { @@ -679,7 +709,7 @@ func serverListen(env *Environment) error { env.dial, logger, proxyLoggerFlags(*quiet), - *serverProxyProtocol, + serverProxyProtoMode(), ) if *statusAddress != "" { @@ -724,7 +754,7 @@ func clientListen(env *Environment) error { env.dial, logger, proxyLoggerFlags(*quiet), - false, + proxy.ProxyProtocolOff, ) if *statusAddress != "" { diff --git a/main_test.go b/main_test.go index 586d88bc72..d48b04488a 100644 --- a/main_test.go +++ b/main_test.go @@ -458,6 +458,47 @@ func TestProxyLoggingFlags(t *testing.T) { assert.Equal(t, proxyLoggerFlags([]string{"conns", "conn-errs"}), proxy.LogHandshakeErrors) } +func TestServerProxyProtoMode(t *testing.T) { + // Save and restore globals + origProto := *serverProxyProtocol + origMode := *serverProxyProtocolMode + defer func() { + *serverProxyProtocol = origProto + *serverProxyProtocolMode = origMode + }() + + // Neither flag set → Off + *serverProxyProtocol = false + *serverProxyProtocolMode = "" + assert.Equal(t, proxy.ProxyProtocolOff, serverProxyProtoMode()) + + // Only --proxy-protocol → Conn + *serverProxyProtocol = true + *serverProxyProtocolMode = "" + assert.Equal(t, proxy.ProxyProtocolConn, serverProxyProtoMode()) + + // Only --proxy-protocol-mode=tls → TLS + *serverProxyProtocol = false + *serverProxyProtocolMode = "tls" + assert.Equal(t, proxy.ProxyProtocolTLS, serverProxyProtoMode()) + + // Only --proxy-protocol-mode=tls-full → TLSFull + *serverProxyProtocol = false + *serverProxyProtocolMode = "tls-full" + assert.Equal(t, proxy.ProxyProtocolTLSFull, serverProxyProtoMode()) + + // Only --proxy-protocol-mode=conn → Conn + *serverProxyProtocol = false + *serverProxyProtocolMode = "conn" + assert.Equal(t, proxy.ProxyProtocolConn, serverProxyProtoMode()) + + // Both set: validation rejects this combination + *serverProxyProtocol = true + *serverProxyProtocolMode = "tls-full" + err := validateServerProxyProtocol() + assert.ErrorContains(t, err, "mutually exclusive") +} + // failingTLSConfigSource is a mock TLSConfigSource that always returns errors type failingTLSConfigSource struct{} diff --git a/proxy/proxy.go b/proxy/proxy.go index aaa3ee07be..fc104fe070 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -19,7 +19,9 @@ package proxy import ( "context" "crypto/tls" + "encoding/binary" "errors" + "fmt" "io" "net" "strings" @@ -31,6 +33,20 @@ import ( sem "golang.org/x/sync/semaphore" ) +// ProxyProtocolMode controls PROXY protocol v2 header generation. +type ProxyProtocolMode int + +const ( + // ProxyProtocolOff disables PROXY protocol headers. + ProxyProtocolOff ProxyProtocolMode = iota + // ProxyProtocolConn sends connection info (src/dst IP+port) only, no TLVs. + ProxyProtocolConn + // ProxyProtocolTLS sends connection info + TLS metadata (version, ALPN, SNI) without client cert details. + ProxyProtocolTLS + // ProxyProtocolTLSFull sends connection info + all TLVs including client certificate. + ProxyProtocolTLSFull +) + var ( openCounter = metrics.GetOrRegisterCounter("conn.open", metrics.DefaultRegistry) connTimeoutCounter = metrics.GetOrRegisterCounter("conn.timeout", metrics.DefaultRegistry) @@ -79,7 +95,7 @@ type Proxy struct { loggerFlags int // Enable HAproxy's PROXY protocol // see: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt - proxyProtocol bool + proxyProtocol ProxyProtocolMode // Internal wait group to keep track of outstanding handlers. handlers *sync.WaitGroup // Semaphore to limit the max. number of connections. @@ -91,14 +107,128 @@ type Proxy struct { pool sync.Pool } -func proxyProtoHeader(c net.Conn) *proxyproto.Header { - return &proxyproto.Header{ +// PROXY protocol v2 client flag constants (from spec section 2.2.5). +const ( + pp2ClientSSL = 0x01 + pp2ClientCertConn = 0x02 + pp2ClientCertSess = 0x04 +) + +func transportProtocol(c net.Conn) proxyproto.AddressFamilyAndProtocol { + if addr, ok := c.RemoteAddr().(*net.TCPAddr); ok { + if addr.IP.To4() != nil { + return proxyproto.TCPv4 + } + return proxyproto.TCPv6 + } + return proxyproto.TCPv4 +} + +func proxyProtoHeader(c net.Conn, tlsState *tls.ConnectionState, mode ProxyProtocolMode, logger Logger) *proxyproto.Header { + h := &proxyproto.Header{ Version: 2, Command: proxyproto.PROXY, - TransportProtocol: proxyproto.TCPv4, + TransportProtocol: transportProtocol(c), SourceAddr: c.RemoteAddr(), DestinationAddr: c.LocalAddr(), } + + if tlsState != nil && mode >= ProxyProtocolTLS { + tlvs, err := buildTLVs(tlsState, mode) + if err != nil { + logger.Printf("proxy: failed to build PROXY protocol TLVs: %s", err) + } else if len(tlvs) > 0 { + if err := h.SetTLVs(tlvs); err != nil { + logger.Printf("proxy: failed to set PROXY protocol TLVs: %s", err) + } + } + } + + return h +} + +// buildTLVs constructs the top-level TLV list from TLS connection state. +func buildTLVs(state *tls.ConnectionState, mode ProxyProtocolMode) ([]proxyproto.TLV, error) { + var tlvs []proxyproto.TLV + + // PP2_TYPE_ALPN + if state.NegotiatedProtocol != "" { + tlvs = append(tlvs, proxyproto.TLV{ + Type: proxyproto.PP2_TYPE_ALPN, + Value: []byte(state.NegotiatedProtocol), + }) + } + + // PP2_TYPE_AUTHORITY (SNI) + if state.ServerName != "" { + tlvs = append(tlvs, proxyproto.TLV{ + Type: proxyproto.PP2_TYPE_AUTHORITY, + Value: []byte(state.ServerName), + }) + } + + // PP2_TYPE_SSL with nested sub-TLVs + sslTLV, err := buildSSLTLV(state, mode) + if err != nil { + return nil, err + } + tlvs = append(tlvs, sslTLV) + + return tlvs, nil +} + +// buildSSLTLV constructs the PP2_TYPE_SSL TLV with its 5-byte sub-header +// and nested sub-TLVs containing TLS connection metadata. +func buildSSLTLV(state *tls.ConnectionState, mode ProxyProtocolMode) (proxyproto.TLV, error) { + var subTLVs []proxyproto.TLV + + // Always include TLS version + subTLVs = append(subTLVs, proxyproto.TLV{ + Type: proxyproto.PP2_SUBTYPE_SSL_VERSION, + Value: []byte(tls.VersionName(state.Version)), + }) + + // Client certificate fields (only in TLSFull mode and if a cert was presented) + if mode == ProxyProtocolTLSFull && len(state.PeerCertificates) > 0 { + cert := state.PeerCertificates[0] + + if cert.Subject.CommonName != "" { + subTLVs = append(subTLVs, proxyproto.TLV{ + Type: proxyproto.PP2_SUBTYPE_SSL_CN, + Value: []byte(cert.Subject.CommonName), + }) + } + + // Full DER-encoded client certificate (extension, not in HAProxy spec) + subTLVs = append(subTLVs, proxyproto.TLV{ + Type: proxyproto.PP2_SUBTYPE_SSL_CLIENT_CERT, + Value: cert.Raw, + }) + } + + // Build 5-byte sub-header: 1 byte flags + 4 bytes verify result + var flags byte = pp2ClientSSL + if mode == ProxyProtocolTLSFull && len(state.PeerCertificates) > 0 { + // Set both flags: Ghostunnel doesn't distinguish connection-level vs + // session-level (resumed) cert presentation — the cert was verified + // on this connection either way. + flags |= pp2ClientCertConn | pp2ClientCertSess + } + var header [5]byte + header[0] = flags + binary.BigEndian.PutUint32(header[1:5], 0) // verify=0, cert already verified by ghostunnel + + // Encode sub-TLVs and append after the 5-byte header + subTLVBytes, err := proxyproto.JoinTLVs(subTLVs) + if err != nil { + return proxyproto.TLV{}, fmt.Errorf("encoding SSL sub-TLVs: %w", err) + } + + value := make([]byte, len(header)+len(subTLVBytes)) + copy(value, header[:]) + copy(value[len(header):], subTLVBytes) + + return proxyproto.TLV{Type: proxyproto.PP2_TYPE_SSL, Value: value}, nil } // New creates a new proxy. @@ -109,7 +239,7 @@ func New( dial DialFunc, logger Logger, loggerFlags int, - proxyProtocol bool) *Proxy { + proxyProtocol ProxyProtocolMode) *Proxy { ctx, cancel := context.WithCancel(context.Background()) @@ -219,8 +349,13 @@ func (p *Proxy) Accept() { return } - if p.proxyProtocol { - h := proxyProtoHeader(conn) + if p.proxyProtocol != ProxyProtocolOff { + var tlsState *tls.ConnectionState + if tlsConn, ok := conn.(*tls.Conn); ok { + state := tlsConn.ConnectionState() + tlsState = &state + } + h := proxyProtoHeader(conn, tlsState, p.proxyProtocol, p.Logger) _, err = h.WriteTo(backend) if err != nil { p.logConditional(LogConnectionErrors, "error writing proxy header: %s", err) diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index e46a81c9a0..fa5597dc9d 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -20,9 +20,17 @@ import ( "bufio" "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" "errors" "fmt" "io" + "math/big" "net" "os" "testing" @@ -45,11 +53,11 @@ func (m *failingListener) Close() error { return nil } func (m *failingListener) Addr() net.Addr { return nil } func proxyForTest(listener net.Listener, dialer DialFunc) *Proxy { - return New(listener, 5*time.Second, 5*time.Second, 5*time.Second, 1, dialer, &testLogger{}, LogEverything, false) + return New(listener, 5*time.Second, 5*time.Second, 5*time.Second, 1, dialer, &testLogger{}, LogEverything, ProxyProtocolOff) } func proxyForTestWithProxyProtocol(listener net.Listener, dialer DialFunc) *Proxy { - return New(listener, 5*time.Second, 5*time.Second, 5*time.Second, 1, dialer, &testLogger{}, LogEverything, true) + return New(listener, 5*time.Second, 5*time.Second, 5*time.Second, 1, dialer, &testLogger{}, LogEverything, ProxyProtocolConn) } func TestAbortedConnection(t *testing.T) { @@ -534,7 +542,7 @@ func TestForceHandshakeNonTLSConn(t *testing.T) { func TestLogConnectionMessageDisabled(t *testing.T) { // Test with LogConnections disabled - p := New(nil, 5*time.Second, 5*time.Second, 0, 0, nil, &testLogger{}, 0, false) + p := New(nil, 5*time.Second, 5*time.Second, 0, 0, nil, &testLogger{}, 0, ProxyProtocolOff) // Create pipe connections src, dst := net.Pipe() @@ -552,7 +560,7 @@ func TestLogConditional(t *testing.T) { }} // Test with flag enabled - p := New(nil, 5*time.Second, 5*time.Second, 0, 0, nil, logger, LogConnectionErrors, false) + p := New(nil, 5*time.Second, 5*time.Second, 0, 0, nil, logger, LogConnectionErrors, ProxyProtocolOff) p.logConditional(LogConnectionErrors, "test message") assert.True(t, logged, "should log when flag is enabled") @@ -569,3 +577,362 @@ type callbackLogger struct { func (c *callbackLogger) Printf(format string, v ...any) { c.callback(format, v...) } + +func TestTransportProtocol(t *testing.T) { + t.Run("IPv4", func(t *testing.T) { + ln, err := net.Listen("tcp4", "127.0.0.1:0") + assert.Nil(t, err) + defer ln.Close() + + go func() { + c, _ := ln.Accept() + if c != nil { + c.Close() + } + }() + + conn, err := net.Dial("tcp4", ln.Addr().String()) + assert.Nil(t, err) + defer conn.Close() + + assert.Equal(t, proxyproto.TCPv4, transportProtocol(conn)) + }) + + t.Run("IPv6", func(t *testing.T) { + ln, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skip("IPv6 not available") + } + defer ln.Close() + + go func() { + c, _ := ln.Accept() + if c != nil { + c.Close() + } + }() + + conn, err := net.Dial("tcp6", ln.Addr().String()) + assert.Nil(t, err) + defer conn.Close() + + assert.Equal(t, proxyproto.TCPv6, transportProtocol(conn)) + }) + + t.Run("non-TCP fallback", func(t *testing.T) { + conn := &mockConn{} // RemoteAddr returns *net.IPAddr, not *net.TCPAddr + assert.Equal(t, proxyproto.TCPv4, transportProtocol(conn)) + }) +} + +// selfSignedCert creates a self-signed certificate for testing. +func selfSignedCert(t *testing.T) (tls.Certificate, *x509.Certificate) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + assert.Nil(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "test-cn", + OrganizationalUnit: []string{"test-ou"}, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + assert.Nil(t, err) + + parsedCert, err := x509.ParseCertificate(certDER) + assert.Nil(t, err) + + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + }, parsedCert +} + +func TestBuildSSLTLV(t *testing.T) { + t.Run("without client cert", func(t *testing.T) { + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + } + + tlv, err := buildSSLTLV(state, ProxyProtocolTLSFull) + assert.Nil(t, err) + assert.Equal(t, proxyproto.PP2_TYPE_SSL, tlv.Type) + + // Parse 5-byte sub-header + assert.True(t, len(tlv.Value) >= 5, "SSL TLV value must be at least 5 bytes") + flags := tlv.Value[0] + verify := binary.BigEndian.Uint32(tlv.Value[1:5]) + + assert.Equal(t, byte(pp2ClientSSL), flags, "should only have PP2_CLIENT_SSL flag") + assert.Equal(t, uint32(0), verify, "verify result should be 0") + + // Parse nested sub-TLVs + subTLVs, err := proxyproto.SplitTLVs(tlv.Value[5:]) + assert.Nil(t, err) + + // Should have VERSION only, not CN/CLIENT_CERT + typeSet := make(map[proxyproto.PP2Type][]byte) + for _, st := range subTLVs { + typeSet[st.Type] = st.Value + } + + assert.Contains(t, typeSet, proxyproto.PP2_SUBTYPE_SSL_VERSION) + assert.Equal(t, "TLS 1.3", string(typeSet[proxyproto.PP2_SUBTYPE_SSL_VERSION])) + assert.NotContains(t, typeSet, proxyproto.PP2_SUBTYPE_SSL_CN) + assert.NotContains(t, typeSet, proxyproto.PP2_SUBTYPE_SSL_CLIENT_CERT) + }) + + t.Run("with client cert", func(t *testing.T) { + _, parsedCert := selfSignedCert(t) + + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + PeerCertificates: []*x509.Certificate{parsedCert}, + } + + tlv, err := buildSSLTLV(state, ProxyProtocolTLSFull) + assert.Nil(t, err) + + // Parse sub-header + flags := tlv.Value[0] + assert.Equal(t, byte(pp2ClientSSL|pp2ClientCertConn|pp2ClientCertSess), flags) + + // Parse nested sub-TLVs + subTLVs, err := proxyproto.SplitTLVs(tlv.Value[5:]) + assert.Nil(t, err) + + typeSet := make(map[proxyproto.PP2Type][]byte) + for _, st := range subTLVs { + typeSet[st.Type] = st.Value + } + + assert.Equal(t, "test-cn", string(typeSet[proxyproto.PP2_SUBTYPE_SSL_CN])) + assert.NotContains(t, typeSet, proxyproto.PP2_SUBTYPE_SSL_KEY_ALG) + assert.Equal(t, parsedCert.Raw, typeSet[proxyproto.PP2_SUBTYPE_SSL_CLIENT_CERT]) + }) + + t.Run("TLS mode excludes client cert", func(t *testing.T) { + _, parsedCert := selfSignedCert(t) + + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + PeerCertificates: []*x509.Certificate{parsedCert}, + } + + tlv, err := buildSSLTLV(state, ProxyProtocolTLS) + assert.Nil(t, err) + + // Parse sub-header: should only have PP2_CLIENT_SSL (no cert flags) + flags := tlv.Value[0] + assert.Equal(t, byte(pp2ClientSSL), flags, "TLS mode should not set cert flags") + + // Parse nested sub-TLVs: should have version but no cert details + subTLVs, err := proxyproto.SplitTLVs(tlv.Value[5:]) + assert.Nil(t, err) + + typeSet := make(map[proxyproto.PP2Type][]byte) + for _, st := range subTLVs { + typeSet[st.Type] = st.Value + } + + assert.Contains(t, typeSet, proxyproto.PP2_SUBTYPE_SSL_VERSION) + assert.NotContains(t, typeSet, proxyproto.PP2_SUBTYPE_SSL_CN) + assert.NotContains(t, typeSet, proxyproto.PP2_SUBTYPE_SSL_CLIENT_CERT) + }) +} + +func TestBuildTLVs(t *testing.T) { + t.Run("with ALPN and SNI", func(t *testing.T) { + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + NegotiatedProtocol: "h2", + ServerName: "example.com", + } + + tlvs, err := buildTLVs(state, ProxyProtocolTLSFull) + assert.Nil(t, err) + + typeSet := make(map[proxyproto.PP2Type][]byte) + for _, tlv := range tlvs { + typeSet[tlv.Type] = tlv.Value + } + + assert.Equal(t, "h2", string(typeSet[proxyproto.PP2_TYPE_ALPN])) + assert.Equal(t, "example.com", string(typeSet[proxyproto.PP2_TYPE_AUTHORITY])) + assert.Contains(t, typeSet, proxyproto.PP2_TYPE_SSL) + }) + + t.Run("without ALPN and SNI", func(t *testing.T) { + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + } + + tlvs, err := buildTLVs(state, ProxyProtocolTLSFull) + assert.Nil(t, err) + + typeSet := make(map[proxyproto.PP2Type][]byte) + for _, tlv := range tlvs { + typeSet[tlv.Type] = tlv.Value + } + + assert.NotContains(t, typeSet, proxyproto.PP2_TYPE_ALPN) + assert.NotContains(t, typeSet, proxyproto.PP2_TYPE_AUTHORITY) + assert.Contains(t, typeSet, proxyproto.PP2_TYPE_SSL) + }) + + t.Run("TLS mode with client cert", func(t *testing.T) { + _, parsedCert := selfSignedCert(t) + + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + NegotiatedProtocol: "h2", + ServerName: "example.com", + PeerCertificates: []*x509.Certificate{parsedCert}, + } + + tlvs, err := buildTLVs(state, ProxyProtocolTLS) + assert.Nil(t, err) + + typeSet := make(map[proxyproto.PP2Type][]byte) + for _, tlv := range tlvs { + typeSet[tlv.Type] = tlv.Value + } + + // ALPN, SNI, SSL should be present + assert.Equal(t, "h2", string(typeSet[proxyproto.PP2_TYPE_ALPN])) + assert.Equal(t, "example.com", string(typeSet[proxyproto.PP2_TYPE_AUTHORITY])) + assert.Contains(t, typeSet, proxyproto.PP2_TYPE_SSL) + + // SSL TLV should have version but no client cert sub-TLVs + sslValue := typeSet[proxyproto.PP2_TYPE_SSL] + subTLVs, err := proxyproto.SplitTLVs(sslValue[5:]) + assert.Nil(t, err) + + subTypeSet := make(map[proxyproto.PP2Type]bool) + for _, st := range subTLVs { + subTypeSet[st.Type] = true + } + assert.True(t, subTypeSet[proxyproto.PP2_SUBTYPE_SSL_VERSION]) + assert.False(t, subTypeSet[proxyproto.PP2_SUBTYPE_SSL_CN]) + assert.False(t, subTypeSet[proxyproto.PP2_SUBTYPE_SSL_CLIENT_CERT]) + }) +} + +func TestProxyProtoHeaderWithTLS(t *testing.T) { + _, parsedCert := selfSignedCert(t) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + assert.Nil(t, err) + defer ln.Close() + + go func() { + c, _ := ln.Accept() + if c != nil { + c.Close() + } + }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + assert.Nil(t, err) + defer conn.Close() + + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + ServerName: "example.com", + PeerCertificates: []*x509.Certificate{parsedCert}, + } + + h := proxyProtoHeader(conn, state, ProxyProtocolTLSFull, &testLogger{}) + assert.Equal(t, uint8(2), h.Version) + assert.Equal(t, proxyproto.PROXY, proxyproto.ProtocolVersionAndCommand(h.Command)) + assert.Equal(t, proxyproto.TCPv4, proxyproto.AddressFamilyAndProtocol(h.TransportProtocol)) + + // Verify TLVs are present + tlvs, err := h.TLVs() + assert.Nil(t, err) + assert.True(t, len(tlvs) > 0, "should have TLVs when TLS state is provided") + + typeSet := make(map[proxyproto.PP2Type]bool) + for _, tlv := range tlvs { + typeSet[tlv.Type] = true + } + assert.True(t, typeSet[proxyproto.PP2_TYPE_SSL]) + assert.True(t, typeSet[proxyproto.PP2_TYPE_AUTHORITY]) +} + +func TestProxyProtoHeaderWithoutTLS(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + assert.Nil(t, err) + defer ln.Close() + + go func() { + c, _ := ln.Accept() + if c != nil { + c.Close() + } + }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + assert.Nil(t, err) + defer conn.Close() + + h := proxyProtoHeader(conn, nil, ProxyProtocolTLSFull, &testLogger{}) + assert.Equal(t, uint8(2), h.Version) + + // Verify no TLVs when no TLS state + tlvs, err := h.TLVs() + assert.Nil(t, err) + assert.Empty(t, tlvs, "should have no TLVs when TLS state is nil") +} + +func TestProxyProtoHeaderConnMode(t *testing.T) { + _, parsedCert := selfSignedCert(t) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + assert.Nil(t, err) + defer ln.Close() + + go func() { + c, _ := ln.Accept() + if c != nil { + c.Close() + } + }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + assert.Nil(t, err) + defer conn.Close() + + state := &tls.ConnectionState{ + Version: tls.VersionTLS13, + CipherSuite: tls.TLS_AES_128_GCM_SHA256, + ServerName: "example.com", + PeerCertificates: []*x509.Certificate{parsedCert}, + } + + // Conn mode should send connection info but no TLVs, even with TLS state + h := proxyProtoHeader(conn, state, ProxyProtocolConn, &testLogger{}) + assert.Equal(t, uint8(2), h.Version) + assert.Equal(t, proxyproto.PROXY, proxyproto.ProtocolVersionAndCommand(h.Command)) + + tlvs, err := h.TLVs() + assert.Nil(t, err) + assert.Empty(t, tlvs, "conn mode should have no TLVs even with TLS state") +} diff --git a/tests/common.py b/tests/common.py index 1c647a6d5b..c9b1e19e1a 100755 --- a/tests/common.py +++ b/tests/common.py @@ -9,6 +9,7 @@ import ssl import os import platform +import struct import urllib.error import urllib.request @@ -17,6 +18,26 @@ TIMEOUT = int(os.environ.get('GHOSTUNNEL_TEST_TIMEOUT', '10')) +def parse_tlvs(data): + """Parse a PROXY protocol v2 TLV vector from raw bytes. + + Returns a list of (type, value) tuples.""" + tlvs = [] + i = 0 + while i < len(data): + if i + 3 > len(data): + raise Exception("truncated TLV at offset {0}".format(i)) + tlv_type = data[i] + tlv_len = struct.unpack('!H', data[i+1:i+3])[0] + i += 3 + if i + tlv_len > len(data): + raise Exception("truncated TLV value at offset {0}".format(i)) + tlv_value = data[i:i+tlv_len] + i += tlv_len + tlvs.append((tlv_type, tlv_value)) + return tlvs + + def _poll_sleep(iteration): """Exponential backoff: 0.05, 0.1, 0.2, 0.4, 0.8, 1.0, 1.0, ...""" time.sleep(min(0.05 * (2 ** iteration), 1.0)) diff --git a/tests/test-server-proxy-protocol-conn.py b/tests/test-server-proxy-protocol-conn.py new file mode 100644 index 0000000000..b04327c62f --- /dev/null +++ b/tests/test-server-proxy-protocol-conn.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 + +""" +Tests that bare --proxy-protocol sends a valid PROXY protocol v2 header +with connection info only (no TLVs) to the backend. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, \ + TlsClient, print_ok, run_ghostunnel, terminate, \ + LISTEN_PORT, TARGET_PORT, TIMEOUT +import socket +import struct + +# PROXY protocol v2 signature (12 bytes) +PP2_SIGNATURE = b'\r\n\r\n\x00\r\nQUIT\n' + +ghostunnel = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('server') + root.create_signed_cert('client') + + # start ghostunnel with bare --proxy-protocol (conn mode, no TLVs) + ghostunnel = run_ghostunnel(['server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-ou=client', + '--proxy-protocol', + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # set up backend listener manually + backend = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + backend.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + so_reuseport = getattr(socket, 'SO_REUSEPORT', None) + if so_reuseport is not None: + backend.setsockopt(socket.SOL_SOCKET, so_reuseport, 1) + backend.settimeout(TIMEOUT) + backend.bind((LOCALHOST, TARGET_PORT)) + backend.listen(1) + + # wait for ghostunnel to start + TcpClient(STATUS_PORT).connect(20) + + # connect a TLS client through the tunnel + client = TlsClient('client', 'root', LISTEN_PORT) + client.connect() + + # accept the backend connection + conn, _ = backend.accept() + conn.settimeout(TIMEOUT) + + # read the PROXY protocol v2 header (16 bytes minimum) + header = b'' + while len(header) < 16: + chunk = conn.recv(16 - len(header)) + if not chunk: + raise Exception("connection closed before full header received") + header += chunk + + # verify signature + if header[:12] != PP2_SIGNATURE: + raise Exception("invalid PROXY protocol v2 signature") + print_ok("PROXY protocol v2 signature verified") + + # verify version and command + ver_cmd = header[12] + version = (ver_cmd & 0xF0) >> 4 + command = ver_cmd & 0x0F + if version != 2 or command != 1: + raise Exception("expected v2 PROXY, got v={0} cmd={1}".format( + version, command)) + print_ok("version=2, command=PROXY verified") + + # verify address family + fam_proto = header[13] + if fam_proto not in (0x11, 0x21): + raise Exception("unexpected family/protocol: 0x{0:02x}".format(fam_proto)) + print_ok("address family/protocol verified: 0x{0:02x}".format(fam_proto)) + + # read remaining payload + payload_len = struct.unpack('!H', header[14:16])[0] + payload = b'' + while len(payload) < payload_len: + chunk = conn.recv(payload_len - len(payload)) + if not chunk: + raise Exception("connection closed before payload received") + payload += chunk + + # parse address data + if fam_proto == 0x11: + addr_size = 12 + src_addr = socket.inet_ntoa(payload[0:4]) + dst_addr = socket.inet_ntoa(payload[4:8]) + src_port = struct.unpack('!H', payload[8:10])[0] + dst_port = struct.unpack('!H', payload[10:12])[0] + print_ok("src={0}:{1} dst={2}:{3}".format( + src_addr, src_port, dst_addr, dst_port)) + if src_addr != '127.0.0.1': + raise Exception("expected source 127.0.0.1, got {0}".format(src_addr)) + elif fam_proto == 0x21: + addr_size = 36 + else: + addr_size = 0 + print_ok("PROXY protocol address data verified") + + # verify NO TLVs after address data (conn mode) + tlv_data = payload[addr_size:] + if len(tlv_data) != 0: + raise Exception( + "expected no TLVs in conn mode, but got {0} bytes".format( + len(tlv_data))) + print_ok("no TLVs present (conn mode correct)") + + # send application data and verify it passes through + test_data = b'hello proxy protocol conn' + client.get_socket().send(test_data) + received = conn.recv(len(test_data)) + if received != test_data: + raise Exception("application data mismatch") + print_ok("application data passed through correctly after PROXY header") + + conn.close() + backend.close() + client.cleanup() + + print_ok("OK") +finally: + terminate(ghostunnel) diff --git a/tests/test-server-proxy-protocol-tls.py b/tests/test-server-proxy-protocol-tls.py new file mode 100644 index 0000000000..58ed9aaeba --- /dev/null +++ b/tests/test-server-proxy-protocol-tls.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 + +""" +Tests that --proxy-protocol-mode=tls sends a PROXY protocol v2 header +with TLS metadata TLVs (SSL version, ALPN, SNI) but without client +certificate details. +""" + +from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, \ + TlsClient, print_ok, run_ghostunnel, terminate, \ + LISTEN_PORT, TARGET_PORT, TIMEOUT, parse_tlvs +import socket +import struct + +# PROXY protocol v2 signature (12 bytes) +PP2_SIGNATURE = b'\r\n\r\n\x00\r\nQUIT\n' + +# TLV type constants +PP2_TYPE_SSL = 0x20 +PP2_SUBTYPE_SSL_VERSION = 0x21 +PP2_SUBTYPE_SSL_CN = 0x22 +PP2_SUBTYPE_SSL_CLIENT_CERT = 0x28 + +# SSL client flags +PP2_CLIENT_SSL = 0x01 +PP2_CLIENT_CERT_CONN = 0x02 +PP2_CLIENT_CERT_SESS = 0x04 + + +ghostunnel = None +try: + # create certs + root = RootCert('root') + root.create_signed_cert('server') + root.create_signed_cert('client') + + # start ghostunnel with --proxy-protocol-mode=tls + ghostunnel = run_ghostunnel(['server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-ou=client', + '--proxy-protocol-mode=tls', + '--status={0}:{1}'.format(LOCALHOST, + STATUS_PORT)]) + + # set up backend listener manually + backend = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + backend.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + so_reuseport = getattr(socket, 'SO_REUSEPORT', None) + if so_reuseport is not None: + backend.setsockopt(socket.SOL_SOCKET, so_reuseport, 1) + backend.settimeout(TIMEOUT) + backend.bind((LOCALHOST, TARGET_PORT)) + backend.listen(1) + + # wait for ghostunnel to start + TcpClient(STATUS_PORT).connect(20) + + # connect a TLS client through the tunnel + client = TlsClient('client', 'root', LISTEN_PORT) + client.connect() + + # accept the backend connection + conn, _ = backend.accept() + conn.settimeout(TIMEOUT) + + # read the PROXY protocol v2 header + header = b'' + while len(header) < 16: + chunk = conn.recv(16 - len(header)) + if not chunk: + raise Exception("connection closed before full header received") + header += chunk + + # verify signature + if header[:12] != PP2_SIGNATURE: + raise Exception("invalid PROXY protocol v2 signature") + print_ok("PROXY protocol v2 signature verified") + + # verify version and command + ver_cmd = header[12] + version = (ver_cmd & 0xF0) >> 4 + command = ver_cmd & 0x0F + if version != 2 or command != 1: + raise Exception("expected v2 PROXY, got v={0} cmd={1}".format( + version, command)) + print_ok("version=2, command=PROXY verified") + + # read remaining payload + fam_proto = header[13] + payload_len = struct.unpack('!H', header[14:16])[0] + payload = b'' + while len(payload) < payload_len: + chunk = conn.recv(payload_len - len(payload)) + if not chunk: + raise Exception("connection closed before payload received") + payload += chunk + + # skip address data + if fam_proto == 0x11: + addr_size = 12 + elif fam_proto == 0x21: + addr_size = 36 + else: + addr_size = 0 + + # parse TLVs + tlv_data = payload[addr_size:] + if len(tlv_data) == 0: + raise Exception("no TLVs present in PROXY header (expected TLS metadata)") + tlvs = parse_tlvs(tlv_data) + tlv_dict = {t: v for t, v in tlvs} + print_ok("parsed {0} TLV(s) from PROXY header".format(len(tlvs))) + + # verify PP2_TYPE_SSL is present + if PP2_TYPE_SSL not in tlv_dict: + raise Exception("PP2_TYPE_SSL not found in TLVs") + + ssl_value = tlv_dict[PP2_TYPE_SSL] + if len(ssl_value) < 5: + raise Exception("PP2_TYPE_SSL value too short") + + # Parse SSL sub-header flags + ssl_flags = ssl_value[0] + if not (ssl_flags & PP2_CLIENT_SSL): + raise Exception("PP2_CLIENT_SSL flag not set") + # In tls mode, cert flags should NOT be set even though a client cert was presented + if ssl_flags & PP2_CLIENT_CERT_CONN: + raise Exception("PP2_CLIENT_CERT_CONN should not be set in tls mode") + print_ok("PP2_TYPE_SSL flags correct for tls mode: 0x{0:02x}".format(ssl_flags)) + + # Parse SSL sub-TLVs + ssl_sub_tlvs = parse_tlvs(ssl_value[5:]) + ssl_sub_dict = {t: v for t, v in ssl_sub_tlvs} + + # Should have version + if PP2_SUBTYPE_SSL_VERSION not in ssl_sub_dict: + raise Exception("PP2_SUBTYPE_SSL_VERSION not found") + ssl_version = ssl_sub_dict[PP2_SUBTYPE_SSL_VERSION].decode('ascii') + if 'TLS' not in ssl_version: + raise Exception("unexpected SSL version: {0}".format(ssl_version)) + print_ok("SSL version: {0}".format(ssl_version)) + + # Should NOT have client cert details + if PP2_SUBTYPE_SSL_CN in ssl_sub_dict: + raise Exception("PP2_SUBTYPE_SSL_CN should not be present in tls mode") + if PP2_SUBTYPE_SSL_CLIENT_CERT in ssl_sub_dict: + raise Exception("PP2_SUBTYPE_SSL_CLIENT_CERT should not be present in tls mode") + print_ok("no client cert sub-TLVs present (tls mode correct)") + + # send application data and verify + test_data = b'hello proxy protocol tls' + client.get_socket().send(test_data) + received = conn.recv(len(test_data)) + if received != test_data: + raise Exception("application data mismatch") + print_ok("application data passed through correctly") + + conn.close() + backend.close() + client.cleanup() + + print_ok("OK") +finally: + terminate(ghostunnel) diff --git a/tests/test-server-proxy-protocol.py b/tests/test-server-proxy-protocol.py index 3c5e228556..b6f73dd3bf 100755 --- a/tests/test-server-proxy-protocol.py +++ b/tests/test-server-proxy-protocol.py @@ -1,19 +1,34 @@ #!/usr/bin/env python3 """ -Tests that --proxy-protocol sends a valid PROXY protocol v2 header -to the backend before forwarding application data. +Tests that --proxy-protocol-mode=tls-full sends a PROXY protocol v2 header +to the backend before forwarding application data, including TLS +metadata TLVs (SSL, ALPN, Authority, client cert). """ from common import LOCALHOST, RootCert, STATUS_PORT, TcpClient, \ TlsClient, print_ok, run_ghostunnel, terminate, \ - LISTEN_PORT, TARGET_PORT, TIMEOUT + LISTEN_PORT, TARGET_PORT, TIMEOUT, parse_tlvs import socket import struct # PROXY protocol v2 signature (12 bytes) PP2_SIGNATURE = b'\r\n\r\n\x00\r\nQUIT\n' +# TLV type constants +PP2_TYPE_ALPN = 0x01 +PP2_TYPE_AUTHORITY = 0x02 +PP2_TYPE_SSL = 0x20 +PP2_SUBTYPE_SSL_VERSION = 0x21 +PP2_SUBTYPE_SSL_CN = 0x22 +PP2_SUBTYPE_SSL_CLIENT_CERT = 0x28 + +# SSL client flags +PP2_CLIENT_SSL = 0x01 +PP2_CLIENT_CERT_CONN = 0x02 +PP2_CLIENT_CERT_SESS = 0x04 + + ghostunnel = None try: # create certs @@ -21,14 +36,14 @@ root.create_signed_cert('server') root.create_signed_cert('client') - # start ghostunnel with --proxy-protocol + # start ghostunnel with --proxy-protocol-mode=tls-full (full TLS metadata + client cert) ghostunnel = run_ghostunnel(['server', '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), '--keystore=server.p12', '--cacert=root.crt', '--allow-ou=client', - '--proxy-protocol', + '--proxy-protocol-mode=tls-full', '--status={0}:{1}'.format(LOCALHOST, STATUS_PORT)]) @@ -71,7 +86,6 @@ print_ok("PROXY protocol v2 signature verified") # verify version and command (byte 12) - # version = high nibble (should be 0x2), command = low nibble (0x1 = PROXY) ver_cmd = header[12] version = (ver_cmd & 0xF0) >> 4 command = ver_cmd & 0x0F @@ -82,34 +96,104 @@ print_ok("version=2, command=PROXY verified") # verify address family and protocol (byte 13) - # 0x11 = AF_INET + STREAM, 0x21 = AF_INET6 + STREAM fam_proto = header[13] if fam_proto not in (0x11, 0x21): raise Exception("unexpected family/protocol: 0x{0:02x}".format(fam_proto)) print_ok("address family/protocol verified: 0x{0:02x}".format(fam_proto)) - # read address data (length is in bytes 14-15) - addr_len = struct.unpack('!H', header[14:16])[0] - addr_data = b'' - while len(addr_data) < addr_len: - chunk = conn.recv(addr_len - len(addr_data)) + # read remaining payload (address data + TLVs) + payload_len = struct.unpack('!H', header[14:16])[0] + payload = b'' + while len(payload) < payload_len: + chunk = conn.recv(payload_len - len(payload)) if not chunk: - raise Exception("connection closed before address data received") - addr_data += chunk + raise Exception("connection closed before payload received") + payload += chunk + # parse address data if fam_proto == 0x11: - # IPv4: 4+4+2+2 = 12 bytes (src_addr, dst_addr, src_port, dst_port) - if addr_len < 12: - raise Exception("IPv4 address data too short: {0}".format(addr_len)) - src_addr = socket.inet_ntoa(addr_data[0:4]) - dst_addr = socket.inet_ntoa(addr_data[4:8]) - src_port = struct.unpack('!H', addr_data[8:10])[0] - dst_port = struct.unpack('!H', addr_data[10:12])[0] + # IPv4: 4+4+2+2 = 12 bytes + addr_size = 12 + if payload_len < addr_size: + raise Exception("IPv4 address data too short: {0}".format(payload_len)) + src_addr = socket.inet_ntoa(payload[0:4]) + dst_addr = socket.inet_ntoa(payload[4:8]) + src_port = struct.unpack('!H', payload[8:10])[0] + dst_port = struct.unpack('!H', payload[10:12])[0] print_ok("src={0}:{1} dst={2}:{3}".format(src_addr, src_port, dst_addr, dst_port)) if src_addr != '127.0.0.1': raise Exception("expected source 127.0.0.1, got {0}".format(src_addr)) + elif fam_proto == 0x21: + # IPv6: 16+16+2+2 = 36 bytes + addr_size = 36 + if payload_len < addr_size: + raise Exception("IPv6 address data too short: {0}".format(payload_len)) + else: + addr_size = 0 print_ok("PROXY protocol address data verified") + # parse TLVs from remaining payload after address data + tlv_data = payload[addr_size:] + if len(tlv_data) == 0: + raise Exception("no TLVs present in PROXY header") + + tlvs = parse_tlvs(tlv_data) + tlv_dict = {t: v for t, v in tlvs} + print_ok("parsed {0} TLV(s) from PROXY header".format(len(tlvs))) + + # --- Verify PP2_TYPE_SSL (0x20) --- + if PP2_TYPE_SSL not in tlv_dict: + raise Exception("PP2_TYPE_SSL (0x20) not found in TLVs") + + ssl_value = tlv_dict[PP2_TYPE_SSL] + if len(ssl_value) < 5: + raise Exception("PP2_TYPE_SSL value too short: {0} bytes".format(len(ssl_value))) + + # Parse 5-byte SSL sub-header + ssl_flags = ssl_value[0] + ssl_verify = struct.unpack('!I', ssl_value[1:5])[0] + + if not (ssl_flags & PP2_CLIENT_SSL): + raise Exception("PP2_CLIENT_SSL flag not set") + if not (ssl_flags & PP2_CLIENT_CERT_CONN): + raise Exception("PP2_CLIENT_CERT_CONN flag not set (client cert was presented)") + if ssl_verify != 0: + raise Exception("expected verify=0 (success), got {0}".format(ssl_verify)) + print_ok("PP2_TYPE_SSL flags verified: flags=0x{0:02x}, verify={1}".format( + ssl_flags, ssl_verify)) + + # Parse nested SSL sub-TLVs + ssl_sub_tlvs = parse_tlvs(ssl_value[5:]) + ssl_sub_dict = {t: v for t, v in ssl_sub_tlvs} + print_ok("parsed {0} SSL sub-TLV(s)".format(len(ssl_sub_tlvs))) + + # Verify SSL_VERSION + if PP2_SUBTYPE_SSL_VERSION not in ssl_sub_dict: + raise Exception("PP2_SUBTYPE_SSL_VERSION not found") + ssl_version = ssl_sub_dict[PP2_SUBTYPE_SSL_VERSION].decode('ascii') + if 'TLS' not in ssl_version: + raise Exception("unexpected SSL version: {0}".format(ssl_version)) + print_ok("SSL version: {0}".format(ssl_version)) + + # Verify SSL_CN (client cert CN) + if PP2_SUBTYPE_SSL_CN not in ssl_sub_dict: + raise Exception("PP2_SUBTYPE_SSL_CN not found") + ssl_cn = ssl_sub_dict[PP2_SUBTYPE_SSL_CN].decode('utf-8') + if ssl_cn != 'client': + raise Exception("expected CN='client', got '{0}'".format(ssl_cn)) + print_ok("SSL CN: {0}".format(ssl_cn)) + + # Verify SSL_CLIENT_CERT (DER-encoded X.509) + if PP2_SUBTYPE_SSL_CLIENT_CERT not in ssl_sub_dict: + raise Exception("PP2_SUBTYPE_SSL_CLIENT_CERT not found") + client_cert_der = ssl_sub_dict[PP2_SUBTYPE_SSL_CLIENT_CERT] + if len(client_cert_der) == 0: + raise Exception("client cert DER data is empty") + # Basic DER validation: should start with SEQUENCE tag (0x30) + if client_cert_der[0] != 0x30: + raise Exception("client cert DER doesn't start with SEQUENCE tag") + print_ok("SSL client cert: {0} bytes of DER data".format(len(client_cert_der))) + # send application data through the tunnel and verify it arrives test_data = b'hello proxy protocol' client.get_socket().send(test_data) From 3e7f531dc23d0666115bb44a76aca59e767d8e79 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 11:00:42 -0700 Subject: [PATCH 2/9] Some additional testing for PROXY protocol --- main_test.go | 18 ++++++++++++ proxy/proxy_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/main_test.go b/main_test.go index d48b04488a..7953215431 100644 --- a/main_test.go +++ b/main_test.go @@ -347,6 +347,24 @@ func TestServerFlagValidation(t *testing.T) { *serverAllowQuery = "" *serverAllowedURIs = nil *keystorePath = "" + + // Test: --proxy-protocol and --proxy-protocol-mode are mutually exclusive + *keystorePath = "file" + *serverAllowAll = true + *serverProxyProtocol = true + *serverProxyProtocolMode = "tls" + err = serverValidateFlags() + assert.NotNil(t, err, "--proxy-protocol and --proxy-protocol-mode are mutually exclusive") + + // Test: --proxy-protocol-mode alone is valid + *serverProxyProtocol = false + *serverProxyProtocolMode = "tls" + err = serverValidateFlags() + assert.Nil(t, err, "--proxy-protocol-mode alone should be valid") + *serverProxyProtocol = false + *serverProxyProtocolMode = "" + *serverAllowAll = false + *keystorePath = "" } func TestClientFlagValidation(t *testing.T) { diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index fa5597dc9d..5c560b3de0 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -902,6 +902,78 @@ func TestProxyProtoHeaderWithoutTLS(t *testing.T) { assert.Empty(t, tlvs, "should have no TLVs when TLS state is nil") } +func TestProxyProtocolTLSModeSuccess(t *testing.T) { + cert, _ := selfSignedCert(t) + + // TLS listener (incoming) + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + tcpLn, err := net.Listen("tcp", "127.0.0.1:0") + assert.Nil(t, err) + incoming := tls.NewListener(tcpLn, tlsCfg) + + // Plain TCP target (backend) + target, err := net.Listen("tcp", "127.0.0.1:0") + assert.Nil(t, err) + + dialer := func(ctx context.Context) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", target.Addr().String()) + } + + p := New(incoming, 5*time.Second, 5*time.Second, 5*time.Second, 1, dialer, &testLogger{}, LogEverything, ProxyProtocolTLS) + go p.Accept() + defer p.Shutdown() + + // Connect with TLS client + src, err := tls.Dial("tcp", incoming.Addr().String(), &tls.Config{ + InsecureSkipVerify: true, + ServerName: "example.com", + }) + assert.Nil(t, err) + + dst, err := target.Accept() + assert.Nil(t, err) + + // Read and verify PROXY protocol header on backend + header, err := proxyproto.Read(bufio.NewReaderSize(dst, 512)) + assert.Nil(t, err, "should be able to read proxy protocol header") + assert.Equal(t, uint8(2), header.Version) + assert.Equal(t, proxyproto.PROXY, proxyproto.ProtocolVersionAndCommand(header.Command)) + assert.Equal(t, proxyproto.TCPv4, proxyproto.AddressFamilyAndProtocol(header.TransportProtocol)) + + // Verify TLVs contain TLS metadata + tlvs, err := header.TLVs() + assert.Nil(t, err) + + typeSet := make(map[proxyproto.PP2Type]bool) + for _, tlv := range tlvs { + typeSet[tlv.Type] = true + } + assert.True(t, typeSet[proxyproto.PP2_TYPE_SSL], "should have SSL TLV") + assert.True(t, typeSet[proxyproto.PP2_TYPE_AUTHORITY], "should have Authority (SNI) TLV") + + // Verify data flows through + _, _ = src.Write([]byte("A")) + received := make([]byte, 1) + for { + n, err := dst.Read(received) + if err != io.EOF { + assert.Nil(t, err, "should receive data on target") + } + if n == 1 { + break + } + } + assert.Equal(t, []byte("A"), received) + + p.Shutdown() + dst.Close() + src.Close() + p.Wait() +} + func TestProxyProtoHeaderConnMode(t *testing.T) { _, parsedCert := selfSignedCert(t) From eb573282be2989ddb5baf0360eb1123cbd8959f4 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 11:09:51 -0700 Subject: [PATCH 3/9] Docs: Be consistent about title case usage --- docs/ACME.md | 8 ++++---- docs/CERTIFICATES.md | 18 +++++++++--------- docs/DOCKER.md | 4 ++-- docs/GRACEFUL-SHUTDOWN.md | 6 +++--- docs/HSM-PKCS11.md | 8 ++++---- docs/KEYCHAIN.md | 12 ++++++------ docs/METRICS.md | 10 +++++----- docs/QUICKSTART.md | 12 ++++++------ docs/SECURITY.md | 14 +++++++------- docs/SPIFFE-WORKLOAD-API.md | 2 +- docs/WATCHDOG.md | 4 ++-- 11 files changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/ACME.md b/docs/ACME.md index 07bc1f9deb..6dca448435 100644 --- a/docs/ACME.md +++ b/docs/ACME.md @@ -9,7 +9,7 @@ certificate via the [ACME][acme-rfc] protocol. This is powered by [certmagic][certmagic], which handles certificate storage, renewal, and OCSP stapling. -## Basic usage +## Basic Usage To enable ACME, use the `--auto-acme-cert` flag with the FQDN to obtain a certificate for. You must also specify an email address with @@ -41,7 +41,7 @@ resolve to the public listening interface IP. Ghostunnel uses the [TLS-ALPN-01][tls-alpn-01] challenge type (HTTP-01 is disabled), so port 443 must be reachable. -## Certificate storage and renewal +## Certificate Storage and Renewal Certmagic stores certificates and account keys on disk. The default location depends on your OS: @@ -57,7 +57,7 @@ intervention or `--timed-reload` is needed for ACME certificates. If a valid certificate already exists locally, Ghostunnel loads it from cache on startup without contacting the CA. -## Revoking or force-renewing +## Revoking or Force-Renewing Certmagic handles renewal automatically, but if you need to force a renewal (e.g. after a key compromise), delete the certificate and key files from the @@ -71,7 +71,7 @@ described in [RFC 8555 Section 7.6][acme-revoke]. [certbot-revoke]: https://eff-certbot.readthedocs.io/en/latest/using.html#revoking-certificates [acme-revoke]: https://datatracker.ietf.org/doc/html/rfc8555#section-7.6 -## Startup retry behavior +## Startup Retry Behavior On startup, Ghostunnel attempts to obtain the initial certificate up to 5 times with exponential backoff (starting at 5 seconds, capped at 2 minutes). diff --git a/docs/CERTIFICATES.md b/docs/CERTIFICATES.md index d025acc67a..19ad1bfaea 100644 --- a/docs/CERTIFICATES.md +++ b/docs/CERTIFICATES.md @@ -8,7 +8,7 @@ Ghostunnel supports several certificate and private key formats. The format is auto-detected from the file extension or by inspecting the first few bytes, so you don't need to specify it explicitly. -## Formats at a glance +## Formats at a Glance | Format | Extensions | Flag | Notes | |--------|-----------|------|-------| @@ -21,7 +21,7 @@ bytes, so you don't need to specify it explicitly. These options are mutually exclusive with each other and with `--use-workload-api`, `--keychain-identity`, and PKCS#11 flags. -## PEM files (separate cert and key) +## PEM Files (Separate Cert and Key) Pass the certificate chain and private key as two separate PEM files: @@ -50,7 +50,7 @@ any intermediate CA certificates: The key file must contain a single PEM-encoded private key (RSA, ECDSA, or Ed25519). -## PEM keystore (combined file) +## PEM Keystore (Combined File) A single PEM file containing both the certificate chain and private key can be passed with `--keystore`. The private key can appear anywhere in the file, @@ -118,7 +118,7 @@ ghostunnel server \ --allow-cn client ``` -## CA bundle +## CA Bundle The `--cacert` flag accepts a PEM file containing one or more trusted CA certificates. If omitted, Ghostunnel uses the system trust store. @@ -129,7 +129,7 @@ To build a CA bundle from individual certificates: cat root-ca.pem intermediate-ca.pem > cacert.pem ``` -## Format auto-detection +## Format Auto-Detection Ghostunnel detects the format in this order: @@ -141,15 +141,15 @@ Ghostunnel detects the format in this order: In practice, just use the right file extension and Ghostunnel will do the right thing. -## Common operations +## Common Operations -### Inspect a PEM certificate +### Inspect a PEM Certificate ```bash openssl x509 -in server-cert.pem -noout -text ``` -### Inspect a PKCS#12 file +### Inspect a PKCS#12 File ```bash openssl pkcs12 -in server.p12 -info -nokeys @@ -168,7 +168,7 @@ openssl pkcs12 -in server.p12 -cacerts -nokeys -out ca-chain.pem openssl pkcs12 -in server.p12 -nocerts -nodes -out server-key.pem ``` -### Verify a certificate chain +### Verify a Certificate Chain ```bash openssl verify -CAfile cacert.pem server-cert.pem diff --git a/docs/DOCKER.md b/docs/DOCKER.md index e1d37f6f56..13a727c092 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -15,7 +15,7 @@ variants are available: The `latest` tags always point to the most recent release. -## Pulling an image +## Pulling an Image ```bash # Distroless (smallest, no shell) @@ -51,7 +51,7 @@ container) and `host.docker.internal` for `--target` (to reach services on the Docker host). You may need `--unsafe-target` since `host.docker.internal` is not localhost. -## Building images from source +## Building Images from Source ```bash go tool mage docker:build diff --git a/docs/GRACEFUL-SHUTDOWN.md b/docs/GRACEFUL-SHUTDOWN.md index 70a6f23612..017f9dcedd 100644 --- a/docs/GRACEFUL-SHUTDOWN.md +++ b/docs/GRACEFUL-SHUTDOWN.md @@ -9,7 +9,7 @@ accepting new connections and waits for existing connections to drain before exiting. If connections do not drain within the configured timeout, the process force-exits. -## Shutdown triggers +## Shutdown Triggers There are three ways to initiate a graceful shutdown: @@ -44,7 +44,7 @@ curl -X POST --cacert test-keys/cacert.pem https://localhost:6060/_shutdown Any HTTP method other than POST returns 405 Method Not Allowed. -## Shutdown sequence +## Shutdown Sequence When a shutdown is triggered, the following happens in order: @@ -71,7 +71,7 @@ When a shutdown is triggered, the following happens in order: See [Command-Line Flags]({{< ref "FLAGS.md" >}}) for the full flag reference. -## Choosing a shutdown timeout +## Choosing a Shutdown Timeout The default timeout of 5 minutes is deliberately generous. Consider your workload when tuning this value: diff --git a/docs/HSM-PKCS11.md b/docs/HSM-PKCS11.md index a5711dff50..5d99333ed0 100644 --- a/docs/HSM-PKCS11.md +++ b/docs/HSM-PKCS11.md @@ -99,7 +99,7 @@ usually want slot **9a** (Authentication): | 9d | Key Management | Encryption | | 9e | Card Authentication | Physical access | -### Generating a key and certificate +### Generating a Key and Certificate Generate a key pair on the YubiKey itself (the private key never leaves the device): @@ -119,7 +119,7 @@ Sign the CSR with your CA, then import the signed certificate back: yubico-piv-tool -s 9a -a import-certificate -i server-cert.pem ``` -### Exporting the certificate for Ghostunnel +### Exporting the Certificate for Ghostunnel Ghostunnel reads the certificate chain from disk, not from the PKCS#11 module, so you'll need to export it: @@ -177,7 +177,7 @@ pkcs11-tool --module /path/to/libykcs11.dylib -L pkcs11-tool --module /path/to/libykcs11.dylib -O ``` -## Certificate hotswapping +## Certificate Hotswapping When using PKCS#11, certificate hotswapping (via `SIGHUP`/`SIGUSR1` or `--timed-reload`) reloads only the certificate from disk. The private key @@ -188,7 +188,7 @@ Note that Landlock sandboxing is automatically disabled when PKCS#11 is used, as PKCS#11 modules are opaque shared libraries that may need access to arbitrary files and sockets. -## Inspecting PKCS#11 state +## Inspecting PKCS#11 State If you need to inspect the state of a PKCS#11 module/token, we recommend the [`pkcs11-tool`][pkcs11-tool] utility from OpenSC. For example, it can be used diff --git a/docs/KEYCHAIN.md b/docs/KEYCHAIN.md index fddb775bea..96b2e89830 100644 --- a/docs/KEYCHAIN.md +++ b/docs/KEYCHAIN.md @@ -9,7 +9,7 @@ Keychain or Windows Certificate Store. This lets you use Secure Enclave-backed keys on Touch ID MacBooks, hardware-backed keys via CNG on Windows, or simply manage certificates through the OS instead of as files on disk. -## Prerequisites: creating a PKCS#12 bundle +## Prerequisites: Creating a PKCS#12 Bundle Both macOS and Windows import certificates from [PKCS#12][openssl-pkcs12] (`.p12` / `.pfx`) files. If you have a PEM certificate and key, bundle them @@ -64,7 +64,7 @@ and [TN3137: On Mac keychain APIs and implementations][apple-tn3137]. [apple-keychain-services]: https://developer.apple.com/documentation/security/keychain-services [apple-tn3137]: https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains -### Secure Enclave and hardware tokens +### Secure Enclave and Hardware Tokens On Touch ID MacBooks, private keys can live in the Secure Enclave. Pass `--keychain-require-token` so Ghostunnel only loads keys backed by a hardware @@ -134,7 +134,7 @@ See Microsoft's [certutil reference][ms-certutil], [ms-store-locations]: https://learn.microsoft.com/en-us/windows/win32/seccrypto/system-store-locations [ms-import-pfx]: https://learn.microsoft.com/en-us/powershell/module/pki/import-pfxcertificate -## Selecting a certificate +## Selecting a Certificate Certificates from the keychain can be selected using one or both of the following flags: @@ -147,7 +147,7 @@ When both flags are specified, Ghostunnel selects certificates where both attributes match (logical AND). If multiple certificates match, the one with the latest expiration date (NotAfter) is used. -## Usage examples +## Usage Examples ### macOS @@ -184,7 +184,7 @@ ghostunnel client \ --cacert cacert.pem ``` -## Certificate reloading +## Certificate Reloading Keychain certificates support reloading via `SIGHUP`/`SIGUSR1` or `--timed-reload`. On reload, Ghostunnel re-queries the keychain for a @@ -192,7 +192,7 @@ certificate matching the same identity/issuer criteria. If the certificate has been updated in the keychain (e.g. renewed), the new certificate will be used for subsequent connections. -## Removing certificates +## Removing Certificates **macOS**: remove an identity (certificate + private key) by Common Name: diff --git a/docs/METRICS.md b/docs/METRICS.md index 2df67c97cf..5c24334a14 100644 --- a/docs/METRICS.md +++ b/docs/METRICS.md @@ -63,7 +63,7 @@ information on profiling via pprof, see the [`runtime/pprof`][pprof] and [http-pprof]: https://pkg.go.dev/net/http/pprof [pprof-bug]: https://github.com/golang/go/issues/20939 -## Shutdown endpoint +## Shutdown Endpoint If `--enable-shutdown` is set, a `/_shutdown` endpoint is available on the status port. Sending an HTTP POST request to this endpoint will trigger a @@ -73,7 +73,7 @@ including signal handling, connection draining, and the `--shutdown-timeout` flag, see [Graceful Shutdown]({{< ref "GRACEFUL-SHUTDOWN.md" >}}). -## Backend healthchecks +## Backend Healthchecks The `/_status` endpoint includes a backend healthcheck. By default, Ghostunnel performs a TCP connection check against the `--target` address. You can override @@ -88,7 +88,7 @@ The `/_status` JSON response includes: If the backend check fails, the `/_status` endpoint returns HTTP 503. -## Metric names +## Metric Names Ghostunnel exports the following base metrics: @@ -173,7 +173,7 @@ scrape_configs: If the status port uses HTTP (see below), set `scheme: http` and drop the `tls_config` block. -## Metrics export +## Metrics Export Metrics are always available via the status port endpoints (`/_metrics/json`, `/_metrics/prometheus`). Additionally, metrics can be pushed to external systems: @@ -183,7 +183,7 @@ Metrics are always available via the status port endpoints (`/_metrics/json`, * `--metrics-url=URL`: push via HTTP POST (JSON format) at the interval set by `--metrics-interval` (default: 30s) -## Exposing status port with HTTP instead of HTTPS +## Exposing Status Port with HTTP Instead of HTTPS By default, Ghostunnel uses HTTPS for the status port. You can force it to use HTTP by prefixing the status address with "http://". diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 97dcc890e7..daf9f7b47f 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -27,7 +27,7 @@ To build from source (requires [Go](https://go.dev/doc/install)): go tool mage go:build ``` -## Generate test certificates +## Generate Test Certificates If you already maintain a PKI, you can skip this step and use your existing certificates. The steps below are for generating test certificates for @@ -67,7 +67,7 @@ PKI toolkit that can generate CAs and sign certificates. See the and [openssl-x509](https://docs.openssl.org/master/man1/openssl-x509/) docs for creating CAs and signing certificates. -## Start a backend service +## Start a Backend Service Ghostunnel is protocol-agnostic and works with any TCP-based protocol, not just HTTP. For this demo we'll use a simple HTTP server as the backend: @@ -76,7 +76,7 @@ just HTTP. For this demo we'll use a simple HTTP server as the backend: python3 -m http.server 8080 & ``` -## Run Ghostunnel server +## Run Ghostunnel Server In a new terminal, start a server that listens for TLS on port 8443 and forwards plaintext to the backend on port 8080. Only clients with CN=client @@ -92,7 +92,7 @@ ghostunnel server \ --allow-cn client ``` -## Run Ghostunnel client +## Run Ghostunnel Client In another terminal, start a client that listens for plaintext on port 8081 and connects to the server over TLS: @@ -106,7 +106,7 @@ ghostunnel client \ --cacert test-keys/cacert.pem ``` -## Test the tunnel +## Test the Tunnel In a third terminal, send a request through the tunnel: @@ -127,7 +127,7 @@ in TLS with the client certificate, and forwarded it to the Ghostunnel server. The server verified the client cert (CN=client), unwrapped TLS, and forwarded the plaintext request to the backend. -## Next steps +## Next Steps - [Command-Line Flags]({{< ref "FLAGS.md" >}}): full flag reference - [Certificate Formats]({{< ref "CERTIFICATES.md" >}}): PEM, PKCS#12, JCEKS, and chain ordering diff --git a/docs/SECURITY.md b/docs/SECURITY.md index da12ba9bbb..75bdc3ed14 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -6,13 +6,13 @@ weight: 15 ## TLS protocol -### Protocol versions +### Protocol Versions Ghostunnel enforces a minimum TLS version of **TLS 1.2**. TLS 1.0 and 1.1 are not supported. TLS 1.3 is supported and will be negotiated when both sides support it. -### Cipher suites +### Cipher Suites The following cipher suites are enabled by default, in order of preference: @@ -47,14 +47,14 @@ and cannot be configured by the application. The TLS 1.3 suites listed above are available when TLS 1.3 is negotiated. The configurable cipher suite list only affects TLS 1.2 connections. -### Curve preferences +### Curve Preferences In server mode, key exchange prefers the following elliptic curves: 1. **X25519**: fast, constant-time, widely supported 2. **P-256 (secp256r1)**: hardware-accelerated on most platforms -### Client authentication +### Client Authentication In server mode, Ghostunnel requires and verifies client certificates by default (`RequireAndVerifyClientCert`). This can be disabled with @@ -65,7 +65,7 @@ certificates. It is typically consumed by monitoring systems that may not have client certs. Like other addresses, it defaults to localhost and is not exposed to the network unless explicitly configured otherwise. -## Address restrictions +## Address Restrictions Listen and target addresses are restricted to localhost and UNIX sockets by default, to prevent accidental exposure of plaintext traffic. @@ -102,7 +102,7 @@ On Linux, Ghostunnel uses [Landlock][landlock] to restrict its own process privileges after startup. Landlock is a kernel-level access control mechanism that limits which files and network ports a process can access. -### How it works +### How It Works After parsing flags and loading certificates, Ghostunnel builds a minimal set of Landlock rules based on the flags it was given: @@ -114,7 +114,7 @@ of Landlock rules based on the flags it was given: access for `--target`, `--metrics-graphite`, `--metrics-url`, and SPIFFE Workload API ports. DNS (TCP/53) is always allowed. -### Best-effort mode +### Best-Effort Mode Landlock is applied in best-effort mode. If the kernel does not support Landlock (network rules require Linux 6.7+), Ghostunnel logs a warning and diff --git a/docs/SPIFFE-WORKLOAD-API.md b/docs/SPIFFE-WORKLOAD-API.md index 9eaa01bc5d..0f78d4b4cf 100644 --- a/docs/SPIFFE-WORKLOAD-API.md +++ b/docs/SPIFFE-WORKLOAD-API.md @@ -65,7 +65,7 @@ ghostunnel client \ --verify-uri spiffe://domain.test/backend ``` -## Trust bundle updates +## Trust Bundle Updates When using the Workload API, Ghostunnel automatically watches for updates to both the X.509 identity (certificate and key) and the trusted root CA diff --git a/docs/WATCHDOG.md b/docs/WATCHDOG.md index 6bd0ddfe91..83c1337846 100644 --- a/docs/WATCHDOG.md +++ b/docs/WATCHDOG.md @@ -8,7 +8,7 @@ Ghostunnel supports systemd's [notify][sd-notify] and watchdog functionality on Linux. This allows systemd to know when Ghostunnel is ready and to automatically restart it if it becomes unresponsive. -## How it works +## How It Works When running as a [`Type=notify-reload`][systemd-service] service: @@ -23,7 +23,7 @@ When running as a [`Type=notify-reload`][systemd-service] service: `SIGHUP` to the process, which triggers a certificate reload (same as sending `SIGHUP` manually). -## Example unit file +## Example Unit File ```ini [Unit] From c6bfd159ce0a1ef9acd600b035f5f4f7591069b6 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 11:13:05 -0700 Subject: [PATCH 4/9] Be consistent about docs headers for proxy protocol doc --- docs/PROXY-PROTOCOL.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/PROXY-PROTOCOL.md b/docs/PROXY-PROTOCOL.md index 54ba714309..eaf2971007 100644 --- a/docs/PROXY-PROTOCOL.md +++ b/docs/PROXY-PROTOCOL.md @@ -12,7 +12,7 @@ forwarded connection carrying the original client metadata. Backends can then do logging, access control, or auditing based on client identity without needing their own TLS stack. -### Enabling +## Enabling See [Command-Line Flags]({{< ref "FLAGS.md" >}}) for the full flag reference. @@ -52,20 +52,20 @@ pass both. The backend will receive a PROXY protocol v2 binary header on each new connection, followed by the normal application data stream. -### What Ghostunnel sends +## What Ghostunnel Sends Ghostunnel sends a **version 2** (binary format) header with the `PROXY` command. The address family (IPv4 or IPv6) is detected from the incoming connection. -#### Address fields (all modes) +### Address Fields (All Modes) | Field | Value | |-------|-------| | Source address/port | Original client IP and port | | Destination address/port | Ghostunnel's listen IP and port | -#### TLV extensions (`tls` and `tls-full` modes) +### TLV Extensions (`tls` and `tls-full` Modes) When using `--proxy-protocol-mode=tls` or `--proxy-protocol-mode=tls-full`, Ghostunnel includes TLV (Type-Length-Value) extensions with TLS connection @@ -77,7 +77,7 @@ metadata: | `PP2_TYPE_AUTHORITY` | `0x02` | SNI hostname the client requested (if set) | | `PP2_TYPE_ALPN` | `0x01` | Negotiated ALPN protocol, e.g. `h2` (if set) | -#### SSL sub-TLVs +### SSL Sub-TLVs The `PP2_TYPE_SSL` TLV contains a 5-byte sub-header followed by nested sub-TLVs: @@ -112,7 +112,7 @@ HAProxy spec but is supported by the [go-proxyproto](https://github.com/pires/go-proxyproto) library and others. The spec requires receivers to ignore unknown TLV types, so this is safe. -### Backend requirements +## Backend Requirements Your backend must be configured to expect PROXY protocol headers. It needs to parse the binary header before reading application data. Most servers and @@ -126,7 +126,7 @@ frameworks support this: Backends that aren't expecting PROXY protocol will see the binary header as garbage at the start of the stream and will reject the connection. -### References +## References - [PROXY protocol specification](https://www.haproxy.org/download/3.1/doc/proxy-protocol.txt) (HAProxy, covers v1 and v2; see section 2.2 for the TLV type registry) - [go-proxyproto](https://github.com/pires/go-proxyproto) (Go library used by Ghostunnel) From 37c6b01fa2cd3135b73e10a58fab44c46b5e1c2b Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 11:14:40 -0700 Subject: [PATCH 5/9] Fix one more header case in access flags --- docs/ACCESS-FLAGS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ACCESS-FLAGS.md b/docs/ACCESS-FLAGS.md index afd550a07a..be53e2de87 100644 --- a/docs/ACCESS-FLAGS.md +++ b/docs/ACCESS-FLAGS.md @@ -67,7 +67,7 @@ from any client. This means that anyone will be able to establish a connection to the Ghostunnel server. This flag is mutually exclusive with other access control flags. -### Passing client identity to backends +### Passing Client Identity to Backends Ghostunnel verifies client certificates before forwarding connections, but backends may also need to know the client's identity for their own access From 3261d4aca84777372a79e31e73c620a76fcecd97 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 11:51:19 -0700 Subject: [PATCH 6/9] Add version info to docs to show when features became available --- docs/ACCESS-FLAGS.md | 13 ++++++++----- docs/GRACEFUL-SHUTDOWN.md | 4 ++++ docs/HSM-PKCS11.md | 3 +-- docs/KEYCHAIN.md | 4 ++-- docs/METRICS.md | 2 ++ docs/PROXY-PROTOCOL.md | 2 +- docs/SECURITY.md | 4 ++++ docs/WATCHDOG.md | 2 ++ 8 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/ACCESS-FLAGS.md b/docs/ACCESS-FLAGS.md index be53e2de87..40c67d566f 100644 --- a/docs/ACCESS-FLAGS.md +++ b/docs/ACCESS-FLAGS.md @@ -55,7 +55,7 @@ with `spiffe://ghostunnel/client1` or `spiffe://ghostunnel/client2` URI SANs (as well as other values). See documentation for the [wildcard][wildcard] package for more information. -* `--allow-policy` and `--allow-query` +* `--allow-policy` and `--allow-query` (*OPA bundle support since v1.9.0*) Allow clients where a Rego policy evaluates to `true` with the given query. For more information, see the Open Policy Agent section below. @@ -71,9 +71,10 @@ control flags. Ghostunnel verifies client certificates before forwarding connections, but backends may also need to know the client's identity for their own access -control, logging, or auditing. Use `--proxy-protocol-mode=tls-full` to forward -the client certificate (CN, full DER-encoded cert) to the backend via -[PROXY protocol v2]({{< ref "PROXY-PROTOCOL.md" >}}) TLV extensions. +control, logging, or auditing. Use `--proxy-protocol-mode=tls-full` (available +since v1.10.0) to forward the client certificate (CN, full DER-encoded cert) to +the backend via [PROXY protocol v2]({{< ref "PROXY-PROTOCOL.md" >}}) TLV +extensions. ## Client mode @@ -127,7 +128,7 @@ with `spiffe://ghostunnel/server1` or `spiffe://ghostunnel/server2` URI SANs (as well as other values). See documentation for the [wildcard][wildcard] package for more information. -* `--verify-policy` and `--verify-query` +* `--verify-policy` and `--verify-query` (*OPA bundle support since v1.9.0*) Verify that a Rego policy evaluates to `true` with the given query. For more information, see the Open Policy Agent section below. @@ -143,6 +144,8 @@ but the backend doesn't require mutual authentication. ## Open Policy Agent +*Available since v1.7.0, OPA bundle support available since v1.9.0.* + Ghostunnel has support for [Open Policy Agent][opa] (OPA), both in server and client mode. The policy must be provided as an [OPA bundle][opa-bundles] on disk and the use of OPA is mutually exclusive with any other `allow` (or diff --git a/docs/GRACEFUL-SHUTDOWN.md b/docs/GRACEFUL-SHUTDOWN.md index 017f9dcedd..fc7958c5ac 100644 --- a/docs/GRACEFUL-SHUTDOWN.md +++ b/docs/GRACEFUL-SHUTDOWN.md @@ -35,6 +35,8 @@ reload certificates and OPA policies on a fixed interval. ### HTTP endpoint (`/_shutdown`) +*Available since v1.8.1.* + If `--enable-shutdown` is set (requires `--status`), you can trigger a shutdown via HTTP POST: @@ -92,6 +94,8 @@ connection behavior and may be relevant when tuning shutdown. See ## Integration with systemd +*Available since v1.8.0.* + When running as a systemd service with `Type=notify-reload`, Ghostunnel notifies systemd of its state transitions (ready, reloading, stopping). The graceful shutdown sequence integrates naturally with systemd's service diff --git a/docs/HSM-PKCS11.md b/docs/HSM-PKCS11.md index 5d99333ed0..b3a84e2a30 100644 --- a/docs/HSM-PKCS11.md +++ b/docs/HSM-PKCS11.md @@ -47,8 +47,7 @@ ghostunnel server \ The `--pkcs11-module`, `--pkcs11-token-label` and `--pkcs11-pin` flags can be used to select the private key to be used from the PKCS#11 module. It's also possible to use environment variables to set PKCS#11 options instead of flags (via -`PKCS11_MODULE`, `PKCS11_TOKEN_LABEL` and `PKCS11_PIN`), useful if you don't -want to show the PIN on the command line. +`PKCS11_MODULE`, `PKCS11_TOKEN_LABEL` and `PKCS11_PIN`), useful if you don't want to show the PIN on the command line. Note that `--cert` needs to point to the certificate chain that corresponds to the private key in the PKCS#11 module, with the leaf certificate being the diff --git a/docs/KEYCHAIN.md b/docs/KEYCHAIN.md index 96b2e89830..01dc72c803 100644 --- a/docs/KEYCHAIN.md +++ b/docs/KEYCHAIN.md @@ -121,8 +121,8 @@ Get-ChildItem Cert:\CurrentUser\My | Format-Table Subject, Thumbprint, NotAfter on Windows, Ghostunnel searches three stores in this order: 1. **MY** (Current User), the personal certificate store -2. **CURRENT_SERVICE**, the current service account's certificates (if accessible) -3. **LOCAL_MACHINE**, machine-wide certificates (if accessible; may require elevation) +2. **CURRENT_SERVICE**, the current service account's certificates (if accessible, *since v1.8.1*) +3. **LOCAL_MACHINE**, machine-wide certificates (if accessible; may require elevation, *since v1.8.1*) Stores that fail to open are skipped rather than causing an error. diff --git a/docs/METRICS.md b/docs/METRICS.md index 5c24334a14..3a54370407 100644 --- a/docs/METRICS.md +++ b/docs/METRICS.md @@ -65,6 +65,8 @@ information on profiling via pprof, see the [`runtime/pprof`][pprof] and ## Shutdown Endpoint +*Available since v1.8.1.* + If `--enable-shutdown` is set, a `/_shutdown` endpoint is available on the status port. Sending an HTTP POST request to this endpoint will trigger a graceful shutdown of the Ghostunnel process. Any other HTTP method returns 405 diff --git a/docs/PROXY-PROTOCOL.md b/docs/PROXY-PROTOCOL.md index eaf2971007..8c4ed16430 100644 --- a/docs/PROXY-PROTOCOL.md +++ b/docs/PROXY-PROTOCOL.md @@ -30,7 +30,7 @@ ghostunnel server \ ``` To also include TLS metadata and/or client certificate details, use the -`--proxy-protocol-mode` flag: +`--proxy-protocol-mode` flag (*available since v1.10.0*): | Mode | What is sent | |------|-------------| diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 75bdc3ed14..1f002268cf 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -98,6 +98,8 @@ localhost risks unauthorized access to the proxied service. ## Landlock sandboxing +*Available since v1.8.0. Enabled by default since v1.9.0.* + On Linux, Ghostunnel uses [Landlock][landlock] to restrict its own process privileges after startup. Landlock is a kernel-level access control mechanism that limits which files and network ports a process can access. @@ -122,6 +124,8 @@ continues without sandboxing. ### Disabling Landlock +*Available since v1.9.0.* + Landlock can be disabled with `--disable-landlock` if it causes issues with your deployment. This is not recommended. Landlock is also automatically disabled when PKCS#11 is in use, since PKCS#11 modules are opaque shared diff --git a/docs/WATCHDOG.md b/docs/WATCHDOG.md index 83c1337846..22514fdb75 100644 --- a/docs/WATCHDOG.md +++ b/docs/WATCHDOG.md @@ -4,6 +4,8 @@ description: Integrate with the systemd watchdog timer for automatic restart on weight: 85 --- +*Available since v1.8.0.* + Ghostunnel supports systemd's [notify][sd-notify] and watchdog functionality on Linux. This allows systemd to know when Ghostunnel is ready and to automatically restart it if it becomes unresponsive. From 8f601527fae5c3adeb894abe591a64219967d03a Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 11:56:38 -0700 Subject: [PATCH 7/9] Add release notes for v1.10.0-rc.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- releases/v1.10.0-rc.1.md | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 releases/v1.10.0-rc.1.md diff --git a/releases/v1.10.0-rc.1.md b/releases/v1.10.0-rc.1.md new file mode 100644 index 0000000000..7002d5f2cd --- /dev/null +++ b/releases/v1.10.0-rc.1.md @@ -0,0 +1,51 @@ +--- +title: v1.10.0-rc.1 +date: 2026-04-19 +prerelease: true +--- + +Release candidate for v1.10.0. + +## New Features + +* **PROXY protocol v2 TLS metadata.** New `--proxy-protocol-mode` flag for + PROXY protocol v2 with optional TLS metadata TLVs (#705). Modes: `conn` + (connection info only, same as the existing `--proxy-protocol` flag), `tls` + (adds TLS version/ALPN/SNI), and `tls-full` (adds TLS metadata and client + certificate). + +## Code Quality Changes + +* **Native code correctness fixes.** Landed a number of fixes in the macOS + keychain and Windows certificate store code, identified through GitHub code + scanning (CodeQL, Copilot Autofix) and local AI development tools. These + include CFObject memory leaks in macOS CertificateChain, data races in macOS + keystore lazy initialization, a C string leak in `launchdSocket`, a C array + leak in `getProviderParam` on Windows, and incorrect certificate store search + order on Windows (#656, #694, #699, #700, #704). +* **Certloader safety improvements.** Replaced `unsafe.Pointer` with + `atomic.Pointer[T]` in certloader (#677), extracted shared `baseCertificate` + struct to reduce duplication (#679), replaced `github.com/pkg/errors` with + stdlib `errors` and `fmt` (#684), and improved error context in PKCS#11 code + paths (#690). +* **Dependency cleanup.** Removed the `certigo` dependency (#664), switched + from `fullsailor/pkcs7` to `smallstep/pkcs7` (#663), and replaced + `github.com/pkg/errors` with stdlib `errors` and `fmt` (#684). Various + dependency upgrades via Dependabot. + +## Testing Improvements + +* **Windows integration testing.** The integration test suite can now run on + Windows (#695), and we added a number of new unit and integration tests for + better coverage of features like platform keychain identities. +* **Faster & better integration test suite.** Parallelized integration tests with + dynamic port allocation and improved timeout handling, significantly reducing + test suite runtime (#662, #696, #703). +* **New unit and integration tests.** Added unit and integration tests for + keystore handling, certstore reload paths, and edge cases across multiple + packages (#662, #697, #702, #703). + +## Other + +* **Website.** Launched project website on ghostunnel.dev and made + comprehensive documentation improvements (#657, #659, #704, #707). From 611878571001e56144bd9664206ae12a378a2f82 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 12:25:40 -0700 Subject: [PATCH 8/9] Show latest stable/pre-release on website, linkify GH PRs --- website/layouts/_default/list.html | 91 +++++++++++++++++++---- website/layouts/partials/linkify-prs.html | 2 + website/static/css/style.css | 12 ++- 3 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 website/layouts/partials/linkify-prs.html diff --git a/website/layouts/_default/list.html b/website/layouts/_default/list.html index b5ccf31d75..98ad3967e0 100644 --- a/website/layouts/_default/list.html +++ b/website/layouts/_default/list.html @@ -19,32 +19,40 @@

{{ .Title }}

{{ .Title }}

All releases of Ghostunnel, in reverse chronological order. Pre-built binaries are available on GitHub Releases (linked below) and Docker images can be found on Docker Hub.

- {{ $pages := .Pages.ByDate.Reverse }} - {{ range $i, $p := $pages }} - {{ if eq $i 1 }} -
{{ else }} diff --git a/website/layouts/partials/linkify-prs.html b/website/layouts/partials/linkify-prs.html new file mode 100644 index 0000000000..2bab894dfd --- /dev/null +++ b/website/layouts/partials/linkify-prs.html @@ -0,0 +1,2 @@ +{{ $base := site.Params.github }} +{{ . | replaceRE `([^&\w])#(\d+)` (printf `$1#$2` $base) | safeHTML }} diff --git a/website/static/css/style.css b/website/static/css/style.css index cefd8f464e..5c9d5d3fc2 100644 --- a/website/static/css/style.css +++ b/website/static/css/style.css @@ -783,9 +783,15 @@ a:hover { } .badge.pre-release { - background: var(--badge-bg); - color: var(--badge-text); - border: 1px solid rgba(14, 138, 158, 0.2); + background: rgba(210, 140, 0, 0.1); + color: #b57800; + border: 1px solid rgba(210, 140, 0, 0.25); +} + +.badge.stable { + background: rgba(30, 140, 60, 0.1); + color: #1a7a30; + border: 1px solid rgba(30, 140, 60, 0.25); } /* ============================================= From 4e6920f0b843bcc5e191ae7f03f661f6ca2f31b3 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sun, 19 Apr 2026 12:34:56 -0700 Subject: [PATCH 9/9] Improve wording a little bit in release notes --- releases/v1.10.0-rc.1.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/releases/v1.10.0-rc.1.md b/releases/v1.10.0-rc.1.md index 7002d5f2cd..f2452813c1 100644 --- a/releases/v1.10.0-rc.1.md +++ b/releases/v1.10.0-rc.1.md @@ -20,7 +20,7 @@ Release candidate for v1.10.0. keychain and Windows certificate store code, identified through GitHub code scanning (CodeQL, Copilot Autofix) and local AI development tools. These include CFObject memory leaks in macOS CertificateChain, data races in macOS - keystore lazy initialization, a C string leak in `launchdSocket`, a C array + keychain lazy initialization, a C string leak in `launchdSocket`, a C array leak in `getProviderParam` on Windows, and incorrect certificate store search order on Windows (#656, #694, #699, #700, #704). * **Certloader safety improvements.** Replaced `unsafe.Pointer` with @@ -42,7 +42,7 @@ Release candidate for v1.10.0. dynamic port allocation and improved timeout handling, significantly reducing test suite runtime (#662, #696, #703). * **New unit and integration tests.** Added unit and integration tests for - keystore handling, certstore reload paths, and edge cases across multiple + keychain handling, certstore reload paths, and edge cases across multiple packages (#662, #697, #702, #703). ## Other