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..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.
@@ -67,6 +67,15 @@ 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` (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
Ghostunnel in client mode offers various flags that can be used to augment and
@@ -119,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.
@@ -135,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/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/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/GRACEFUL-SHUTDOWN.md b/docs/GRACEFUL-SHUTDOWN.md
index 70a6f23612..fc7958c5ac 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:
@@ -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:
@@ -44,7 +46,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 +73,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:
@@ -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 a5711dff50..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
@@ -99,7 +98,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 +118,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 +176,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 +187,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..01dc72c803 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
@@ -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.
@@ -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..3a54370407 100644
--- a/docs/METRICS.md
+++ b/docs/METRICS.md
@@ -63,7 +63,9 @@ 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
+
+*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
@@ -73,7 +75,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 +90,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 +175,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 +185,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/PROXY-PROTOCOL.md b/docs/PROXY-PROTOCOL.md
new file mode 100644
index 0000000000..8c4ed16430
--- /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 (*available since v1.10.0*):
+
+| 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..30d5194e65 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,11 +127,12 @@ 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
- [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/docs/SECURITY.md b/docs/SECURITY.md
index da12ba9bbb..1f002268cf 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.
@@ -98,11 +98,13 @@ 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.
-### 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 +116,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
@@ -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/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..22514fdb75 100644
--- a/docs/WATCHDOG.md
+++ b/docs/WATCHDOG.md
@@ -4,11 +4,13 @@ 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.
-## How it works
+## How It Works
When running as a [`Type=notify-reload`][systemd-service] service:
@@ -23,7 +25,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]
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..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) {
@@ -458,6 +476,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..5c560b3de0 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,434 @@ 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 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)
+
+ 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/releases/v1.10.0-rc.1.md b/releases/v1.10.0-rc.1.md
new file mode 100644
index 0000000000..f2452813c1
--- /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
+ 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
+ `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
+ keychain 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).
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)
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 @@
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.