go-ztp implements a Zero-Trust Proxy / Protocol Gateway.
The go-ztp package is released under the terms of the MIT license. For details, see the LICENSE file in the repository root.
The go-ztp package is very new, very unstable and very fluid. It is far too early for interested parties to utilize this package for anything other than pure testing/development/PoC endeavors. But, with that said ...
HELP IS WANTED: contributions, 3rd party QA, PEN tests, SME reviews ... at this point I'm not going to turn down anything.
If you or your organization use my software regularly and find it useful, I only ask that you donate to animal shelters, non-profit environmental entities or similar. If you cannot afford a monetary contribution to these causes, please volunteer at animal shelters and/or visit kill shelters for the purpose of liberating animals unfairly awaiting execution.
This package relies upon the following packages from the Go standard library:
bufiobytescontextcrypto/ecdsacrypto/ellipticcrypto/randcrypto/rsacrypto/tlscrypto/x509crypto/x509/pkixencoding/base64encoding/jsonencoding/pemerrorsiologmath/bignetnet/httpospath/filepathstrconvstringssyncsync/atomictestingtime
For third party dependencies, whether direct or indirect, see the Dependency Graph.
The following subsections describe basic steps for testing this application, or authoring your own listener for your own purposes.
The _example folder contains basic server.go and client.go demo files. This section describes them.
The server.go file represents your ZTP listener. It is in this file that services are registered and elements such as preauth, authenticators, directory backends and policy routines (authorization controls) are defined. By default, as described in the ZTP Wire Protocol section below, the listener listens on all addresses via TCP/9743.
For the sake of simplicity, the server.go file uses the simplest directory backend -- a YAML directory. The directory data is found within the flat.example.yaml file and, at present, contains two valid test users: "alice" and "bob".
The client.go file represents a general client. This file connects to the listener and requests a particular service based on the client PKI certificate, in which the service name is established as a TLS SNI, e.g.: udp-dns.svc.internal. Naturally users may have their own domain and may name their SNI-routed services according to their own established naming conventions.
This service is very dependent on a solid PKI infrastructure, namely a valid CA utility or service which issues X.509 server and client certificates. For the purpose of my own testing, I simply used openssl. Below are the commands I used personally to generate the certificates referenced in server.go and client.go:
Here is the command for generating the issuing private key and certificate.
openssl req -x509 \
-newkey rsa:4096
-keyout ca.key
-out ca.pem
-sha256 -days 365
-nodes
-subj "/CN=Test CA"
A mode of 0444 is sufficient for protecting the issuing certificate. A mode of 0400 (read for owner only) is strongly recommended for protecting its private key. If this key is compromised, a malicious entity can issue certificates on your behalf. This would be undesirable.
Here is the command for generating our server (listener) private key:
openssl genrsa -out server.key 2048
A mode of 0400 (read for owner only) is strongly recommended for protecting this private key.
Here is the command which generates the server certificate signing request (CSR):
openssl req -new \
-key server.key \
-out server.csr \
-config server-openssl.cnf
A mode of 0444 (read for everyone) is sufficient for protecting this CSR, though strictly speaking it need not be preserved after signing takes place.
Here is the command used to sign our server certificate using our CA, carrying SAN DNS:localhost:
openssl x509 -req \
-in server.csr \
-CA ca.pem \
-CAkey ca.key \
-CAcreateserial \
-days 365 \
-out server.pem \
-extfile server-openssl.cnf \
-extensions req_ext
A mode of 0444 (read for everyone) is sufficient for protecting this certificate.
Here is the server-openssl.cnf file referenced above. Note that we're using wildcard DNS SANs for an entire subdomain (svc.internal), as the proxy listener may be hosting multiple services:
[ req ]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[ dn ]
CN = ztp.svc.internal
[ req_ext ]
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = localhost
DNS.2 = *.svc.internal
Here is the command for generating our client's private key (intended for test user "alice"):
openssl genrsa -out client.key 2048
A mode of 0400 (read for owner only) is strongly recommended for protecting this private key.
Here is the command which generates the client certificate signing request (CSR):
openssl req -new \
-key client.key \
-out client.csr \
-config client-openssl.cnf
A mode of 0444 (read for everyone) is sufficient for protecting this CSR, though strictly speaking it need not be preserved after signing takes place.
Here is the command used to sign our client certificate using our CA, carrying an Email SAN for "alice@example.com":
openssl x509 -req \
-in client.csr \
-CA ca.pem \
-CAkey ca.key \
-CAcreateserial \
-days 365 \
-out client.pem \
-extfile client-openssl.cnf \
-extensions req_ext
A mode of 0444 (read for everyone) is sufficient for protecting this certificate.
Here is the client-openssl.cnf file referenced above:
[ req ]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[ dn ]
CN = alice@example.com
[ req_ext ]
subjectAltName = email:alice@example.com
Repeat as needed for other client certificates, such as for the test user "bob".
Of course, actual organizations may have their own procedures for generating keys and certificates. So long as the SAN configuration resembles that shown above (with respect to your own custom domain/naming conventions), you should be clear to proceed.
Its a safe bet the reason you're using this package is to proxy some kind of protocol-bound service request to a system, ostensibly one that is not directly accessible to you, hence the need for a proxy. This section offers some pointers to help the user to that end.
Users have two primary options:
- Real: use a real backend server or service -- HTTP, LDAP, MySQL, to name a few -- that already exists in your server armada
- Fake: Use a dumb listener like
ncoropenssl s_server
For the purposes of simple testing for end users who may or may not have access to such systems, we'll be using nc.
First, this section assumes you've copied and/or tailored the client.go and server.go examples within the _examples directory. This would entail registering the desired backend service in server.go, which would correspond to the nc listener we're about to launch, e.g.:
{
Name: `quic.svc.internal`, // must match SNI in client certificate(s), wildcards OK
Protocol: ztp.ProtocolQUIC, // the transport protocol associated with this service
Address: `:4433`, // whatever, can also lock this down to a single address, like 127.0.0.1:4433
Groups: nil, // normally you'd want groups, but not needed here
Handler: ztp.NewQUICProxyHandler(), // or write your own custom handler
},Second, this section assumes you've generated or provided your own issuing CA certificate, a listener cert and key and a client cert and key. If not, see the PKI section above. If you're generating your own CA to sign other certificates, you'll need to have its private key -- which is VERY sensitive -- on-hand. On this topic, this section assumes the aforementioned modifications to the server.go and client.go include references to these cryptographic files as needed.
Assuming all of this tracks, we're now ready to start testing. The following example assumes the above variables are as shown, for instance the port, service, etc. Alter as needed for your own purposes.
First, launch nc -kul 4433, which translates to "keep-alive UDP listening on service port 4433". For those unaware, QUIC is a subset of UDP in essence, which is why it might appear that we're "mixing" these protocols haphazardly.
Next, launch your ZTP listener via go run _examples/server.go (or you could build a binary and just use that).
Finally, launch your generic ZTP "dumb" client via go run _examples/client.go.
If all goes well, you should see nc barf up some bizarre Unicode Question Mark characters. This means the test was successful, and your terminal is merely doing its best to interpret control bytes out of context.
So what happened?
- You launched a backend listener,
nc - You launched your ZTP listener, which was configured to interact with the aforementioned backend
- You launched a basic ZTP client, which (once the user was verified and authenticated by the ZTP listener) sent a forged QUIC packet through the proxy, which relayed it to
nc
Of course, better tests would revolve around actual, real networking services and not dumb listeners receiving fake test packets. This is where you, the end user, come in. It would probably also make sense to write a real client ... one that isn't "dumb".
Good luck!
This section defines the ZTP wire protocol used by the proxy listener, a secure multiplexing gateway that accepts inbound client connections on a single TCP/TLS port and routes them to backend services based on authenticated identity and TLS Server Name Indication (SNI).
The ZTP Listener provides:
- Mutual TLS authentication
- Identity extraction
- SNI‑based service selection
- Policy enforcement
- Protocol dispatch
- Transport‑agnostic payload forwarding (TCP, UDP, QUIC)
The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.
- Client: An entity initiating a connection to the ZTP Listener
- Proxy: The ZTP listener accepting inbound connections
- Backend: A service endpoint to which the proxy forwards traffic
- Identity: The authenticated client identity derived from the TLS handshake
- Service: A configured backend identified by an SNI hostname
- Declared Protocol: The protocol explicitly configured for a service
- Detected Protocol: The protocol inferred from the first bytes of application data
The ZTP Listener operates exclusively over TCP via IPv4 and IPv6.
The default listener port for ZTP is 9743/TCP. This port is unassigned, non‑privileged, and selected to avoid conflicts with existing services. Clients MUST initiate a TCP connection to this port before any further protocol steps occur.
Immediately after TCP connection establishment, the TLS layer is initialized.
The client MUST initiate a TLS 1.3 handshake. Earlier versions MUST be rejected.
Clients MUST include an SNI extension in the ClientHello.
The SNI hostname identifies the desired backend service. Example:
dns-udp.svc.internal
If the SNI does not match a configured service, the proxy MUST terminate the connection.
The proxy performs mutual TLS authentication. Clients MUST present a certificate trusted by the proxy’s configured CA set.
Identity extraction rules (e.g., SAN email, subject CN) are implementation‑defined but MUST be deterministic.
If authentication fails, the proxy MUST terminate the connection.
After successful TLS handshake and identity extraction, the proxy performs:
- Lookup of the service by SNI
- Authorization of the identity against the service’s access policy
If authorization fails, the proxy MUST send an error message and close the connection.
After service lookup, the proxy determines the protocol to be used for backend communication.
If the service configuration specifies a protocol (e.g., udp, tcp, https), the proxy MUST use that protocol and MUST NOT perform protocol detection.
If the service does not declare a protocol, the proxy MAY inspect the first bytes of application data to infer the protocol.
Protocol detection is implementation‑defined and out of scope for this document.
If the handler associated with the service does not support the declared protocol, the proxy MUST terminate the connection.
Once protocol selection is complete, the proxy transitions into transparent forwarding mode.
For TCP‑based services:
- The proxy establishes a TCP connection to the backend
- All TLS‑protected application data from the client is forwarded verbatim
- All backend responses are forwarded back to the client
For UDP‑based services:
- The proxy creates a UDP socket bound to an ephemeral port
- The first application data packet received from the client MUST be forwarded as a single UDP datagram
- The proxy MUST wait for a single UDP response from the backend
- The response MUST be forwarded to the client as TLS application data
- After forwarding the response, the proxy MAY close the connection
For QUIC‑based services, the proxy treats QUIC traffic as opaque, datagram‑oriented payloads transported over UDP. The proxy does not participate in QUIC version negotiation, handshake processing, connection ID management, or any other QUIC‑specific state machinery. All QUIC semantics remain strictly between the client and the backend QUIC server.
QUIC Forwarding Behavior
- The proxy creates a UDP socket bound to an ephemeral port
- The first application data packet received from the client MUST be forwarded as a single UDP datagram to the backend QUIC server
- The proxy MUST NOT inspect, modify, or interpret any QUIC header fields, including but not limited to the long‑header form, version field, destination connection ID, source connection ID, token, length, or encrypted payload
- The proxy MUST wait for a single UDP response from the backend
- The response MUST be forwarded to the client as TLS application data
- After forwarding the response, the proxy MAY close the connection
QUIC State Handling Requirements
- The proxy MUST NOT attempt to maintain QUIC connection state
- The proxy MUST NOT track or rewrite connection IDs
- The proxy MUST NOT perform retransmissions, loss recovery, congestion control, or any other QUIC transport‑layer behavior
- QUIC services MUST be explicitly declared as quic in service configuration to ensure correct handler selection
QUIC Limitations
The following limitations apply to QUIC‑based services.
The proxy does not implement any QUIC transport‑layer behavior, including but not limited to:
- version negotiation
- connection establishment
- handshake processing
- packet number management
- retransmission
- loss detection
- congestion control
The proxy does not maintain per‑connection QUIC state and does not track connection IDs across packets.
The proxy does not support forwarding multiple QUIC datagrams in a single session unless explicitly implemented by the handler. This specification only requires forwarding of a single request datagram and a single response datagram.
The proxy does not support 0‑RTT data, session resumption, or any QUIC‑TLS integration features.
The proxy does not support multiplexing multiple QUIC streams within a single TLS session. Each TLS session corresponds to a single forwarded QUIC datagram exchange.
The proxy does not validate QUIC packet integrity, encryption, or formatting. All QUIC packets are treated as opaque byte sequences.
QUIC Security Considerations
The following security considerations apply to QUIC‑based services.
Because QUIC packets are encrypted end‑to‑end between the client and backend, the proxy cannot inspect or validate QUIC payloads. The proxy relies entirely on TLS‑level authentication and authorization for access control.
The proxy does not enforce QUIC version restrictions. Any QUIC version identifier present in the forwarded datagram is accepted and forwarded without validation.
The proxy does not protect against QUIC‑level amplification attacks. Implementations SHOULD ensure that backend QUIC servers enforce appropriate anti‑amplification limits.
The proxy does not enforce QUIC connection ID routing rules. If the backend relies on connection ID–based load balancing, the proxy MUST be positioned such that it does not interfere with backend routing semantics.
The proxy does not provide replay protection beyond what TLS provides. QUIC servers MUST implement their own replay mitigation as required by the QUIC transport specification.
Because QUIC is forwarded over UDP, backend QUIC servers MUST implement their own rate limiting, anti‑spoofing, and DoS protections.
The following diagram illustrates the complete sequence of packets exchanged during a typical ZTP session.
Client Proxy
------ -----
TCP SYN --------------------------------->
TCP SYN/ACK <-----------------------------
TCP ACK --------------------------------->
TLS ClientHello
- SNI = service name
- Client certificate
----------------------------------------->
TLS ServerHello
<-----------------------------------------
TLS Encrypted Handshake
<---------------------------------------->
(TLS session established)
Application Data (opaque bytes)
----------------------------------------->
Proxy performs:
- Identity extraction
- SNI lookup
- Authorization
- Protocol selection
- Handler dispatch
Proxy -> Backend (TCP or UDP)
----------------------------------------->
Backend -> Proxy
<-----------------------------------------
TLS Application Data (response)
<-----------------------------------------
Client or Proxy closes connection
- All traffic between client and proxy is encrypted via TLS 1.3
- Backend traffic is not encrypted unless the backend protocol provides its own security
- Identity is bound to the TLS session and cannot be spoofed
- SNI is authenticated and cannot be altered post‑handshake
- UDP tunneling does not provide replay protection beyond what TLS provides
At the time of this writing, this is all theoretical. I know it should work, but writing a demo for this is a couple lines down on my TO DO list.
That said, nothing in the ZTP wire protocol specification, nor in this implementation of it, necessarily requires traffic always be forwarded to a backend. In fact, the whole reason that happens with the built-in handlers today is because I wrote them to do just that.
But the ZTP framework also allows for handlers of a different sort: protocol translators, in essence letting ZTP serve not only as a proxy but also a protocol gateway.
In theory, a handler can be written to act as a client unto itself. For example, a handler could be designed to act as a DUA -- a traditional DAP/LDAP directory client -- and receive instructions by way of HTTP GET, POST and DELETE commands from the end user, perhaps encoded as JSON.
Now, it is true: traffic is still "passing through a handler" from client to backend, so if we consider semantics, sure its still "kind of a proxy mechanism". But, more specifically, the handler is acting as a "go-between", converting HTTP codes and commands, setting the termination point and relaying those commands to the appropriate counterpart DUA functions for execution by the handler directly.
Just a thought :)