keyless_tls
keyless_tls is designed so that the tunnel application handles the TLS handshake and traffic encryption/decryption, while only the CertificateVerify signature is delegated to a remote signer.
- TLS engine, session keys, traffic crypto:
tunneling app - TLS signing (
CertificateVerify): remoterelay signer - Signer transport:
HTTPS + JSONwith mandatorymTLS
This repository supports two usage modes:
- Use as an SDK library (
keylesspackage) - Run the provided binaries under
cmd/*
Choose your integration path first
- I want to attach directly to my app (
http.Server): SDK mode - I want to run it immediately and validate behavior: Binary mode
1) Using the SDK library
Core concept
The tunnel app keeps only the public certificate chain (cert PEM) and does not hold the private key.
The keyless SDK attaches a remote signer as if it were a crypto.Signer, so handshake signing is performed remotely.
Public APIs
keyless.AttachToHTTPServer: simplest entry point (attach directly tohttp.Server)keyless.NewRemoteSigner: create a remote signer client explicitlykeyless.NewServerTLSConfig: buildtls.Configmanually
Easiest setup (AttachToHTTPServer)
package main
import (
"log"
"net/http"
"os"
"github.com/gosuda/keyless_tls/keyless"
)
func main() {
certPEM := mustRead("certs/public-chain.crt")
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok\n"))
})
srv := &http.Server{
Addr: ":8443",
Handler: mux,
}
remoteSigner, err := keyless.AttachToHTTPServer(srv, keyless.HTTPServerAttachConfig{
CertPEM: certPEM,
RemoteSigner: keyless.RemoteSignerConfig{
Endpoint: "127.0.0.1:9443",
ServerName: "relay.internal",
KeyID: "relay-cert",
RootCAPEM: mustRead("certs/relay-ca.crt"),
ClientCertPEM: mustRead("certs/tunnel-client.crt"),
ClientKeyPEM: mustRead("certs/tunnel-client.key"),
},
})
if err != nil {
log.Fatal(err)
}
defer remoteSigner.Close()
log.Fatal(srv.ListenAndServeTLS("", ""))
}
func mustRead(path string) []byte {
b, err := os.ReadFile(path)
if err != nil {
panic(err)
}
return b
}Advanced setup (NewRemoteSigner + NewServerTLSConfig)
Use this when you already have your own tls.Config construction flow, or when integrating with components other than http.Server.
rSigner, err := keyless.NewRemoteSigner(remoteSignerCfg, certPEM)
if err != nil {
// handle error
}
defer rSigner.Close()
tlsConf, err := keyless.NewServerTLSConfig(keyless.ServerTLSConfig{
CertPEM: certPEM,
Signer: rSigner,
NextProtos: []string{"h2", "http/1.1"},
// MinVersion: tls.VersionTLS13,
})
if err != nil {
// handle error
}SDK: SNI metadata for relay routing (caller-controlled)
If you are implementing your own relay/proxy with this library, use the relay/l4
APIs to inspect ClientHello and route by SNI/ALPN while keeping all policy in caller code.
l4.InspectClientHello(conn, timeout): parseServerName/ALPNProtocolsand return a wrappednet.Connl4.Proxy.DialByClientHello(ctx, info, parseErr): caller decides route/fallback/reject policy
How this works in practice:
- incoming TCP connection arrives
- library reads only ClientHello metadata (no TLS termination)
- your callback receives
info.ServerName,info.ALPNProtocols, andparseErr - your code selects upstream target (or rejects)
- relay continues raw TCP forwarding with no payload loss
Typical SDK routing policies:
- Multi-tenant host routing:
app1.example.com -> tenant A,app2.example.com -> tenant B - Protocol-aware routing:
h2preferred upstream vshttp/1.1upstream - Strict security mode: reject when ClientHello parse fails
- Compatibility mode: fallback to default upstream when parse fails
Concrete policy example (easy to adapt):
routes := map[string]string{
"app1.demo.local": "127.0.0.1:9001",
"app2.demo.local": "127.0.0.1:9002",
}
proxy := &l4.Proxy{
ListenAddr: ":443",
ClientHelloTimeout: 2 * time.Second,
DialByClientHello: func(ctx context.Context, info l4.ClientHelloInfo, parseErr error) (net.Conn, error) {
d := net.Dialer{Timeout: 3 * time.Second}
// 1) Decide what to do with non-TLS / invalid ClientHello
if parseErr != nil {
// strict mode: return nil, parseErr
// compatibility mode: send to default route
return d.DialContext(ctx, "tcp", "127.0.0.1:9011")
}
// 2) SNI host-based route
if target, ok := routes[strings.ToLower(strings.TrimSuffix(info.ServerName, "."))]; ok {
return d.DialContext(ctx, "tcp", target)
}
// 3) Optional ALPN-aware split
for _, proto := range info.ALPNProtocols {
if proto == "h2" {
return d.DialContext(ctx, "tcp", "127.0.0.1:9443")
}
}
// 4) Default route
return d.DialContext(ctx, "tcp", "127.0.0.1:9011")
},
}For a complete runnable SDK-style routing sample with 10 hosts, see examples/relay-10-targets.
SDK integration checklist
- Deploy only the public certificate chain (
cert PEM) in the tunnel app - Configure signer endpoint/server name/
KeyID/root CA - Provide mTLS client materials (
client cert/key) - Call
remoteSigner.Close()on shutdown
2) Using binaries
cmd/ contains production-oriented main packages (runnable binaries).
Example applications are separated under examples/.
Command layout
cmd/relay-signer: remote signer HTTPS servercmd/relay-l4: L4 TCP relay with optional SNI-based route mappingexamples/tunnel-http: example tunnel HTTP server integrated with the SDKexamples/relay-10-targets: one relay server routing to 10 target hosts via SNI
SNI/ALPN routing hook for custom relays
If you are building your own relay/proxy, use relay/l4.InspectClientHello to read
ClientHello metadata (ServerName, ALPNProtocols) without terminating TLS.
The helper returns a wrapped net.Conn that replays already-read bytes, so your
relay can continue normal TCP forwarding after routing decisions.
relay/l4.Proxy also supports callback-based dialing through
DialByClientHello(ctx, info, parseErr), so all policy decisions (fallback, reject,
default route) remain in caller code.
Quick start with three processes
- Run signer server
go run ./cmd/relay-signer \
-listen :9443 \
-key-id relay-cert \
-tls-cert certs/relay-server.crt \
-tls-key certs/relay-server.key \
-sign-key certs/relay-signing.key- Run tunnel app
go run ./examples/tunnel-http \
-listen :8443 \
-cert certs/public-chain.crt \
-signer-addr 127.0.0.1:9443 \
-signer-name relay.internal \
-key-id relay-cert \
-client-cert certs/tunnel-client.crt \
-client-key certs/tunnel-client.key \
-root-ca certs/relay-ca.crt- Run L4 relay
go run ./cmd/relay-l4 \
-listen :443 \
-route app1.example.com=127.0.0.1:8443 \
-default-upstream 127.0.0.1:8443SNI route mode (-route can be repeated):
go run ./cmd/relay-l4 \
-listen :443 \
-route app1.example.com=127.0.0.1:8441 \
-route app2.example.com=127.0.0.1:8442 \
-default-upstream 127.0.0.1:8440cmd/relay-l4 does not enforce routing policy. Caller-side policy is controlled by flags,
including whether ClientHello parse failures may use the default upstream.
Useful cmd/relay-l4 route-mode flags:
-route host=upstream(repeatable): explicit SNI mapping-default-upstream: fallback target for unknown SNI-allow-parse-error: allow non-TLS/invalid ClientHello to use fallback-clienthello-timeout: maximum ClientHello inspection time
Example app: one relay routing 10 target hosts
examples/relay-10-targets demonstrates a practical ingress layout:
- one public relay listener
- ten target tunnel apps
- SNI-based target selection implemented by caller code
Run the example relay:
go run ./examples/relay-10-targets \
-listen :443 \
-upstream-host 127.0.0.1 \
-base-port 9001 \
-domain demo.local \
-default-upstream 127.0.0.1:9011Generated static routes:
app1.demo.local -> 127.0.0.1:9001app2.demo.local -> 127.0.0.1:9002app3.demo.local -> 127.0.0.1:9003app4.demo.local -> 127.0.0.1:9004app5.demo.local -> 127.0.0.1:9005app6.demo.local -> 127.0.0.1:9006app7.demo.local -> 127.0.0.1:9007app8.demo.local -> 127.0.0.1:9008app9.demo.local -> 127.0.0.1:9009app10.demo.local -> 127.0.0.1:9010
Policy remains caller-owned:
- known SNI: route to mapped upstream
- unknown SNI: route to
-default-upstreamwhen configured - non-TLS or invalid ClientHello: route to
-default-upstreamwhen configured, otherwise reject
Important flags for examples/relay-10-targets:
-listen: public relay address-upstream-host: host used for generated targets-base-port: first target port (app1)-domain: host suffix used for SNI matching-default-upstream: optional fallback upstream-dial-timeout: upstream dial timeout-clienthello-timeout: ClientHello inspection timeout
mTLS is required for signer transport
Signer and tunnel clients must always be configured for mutual TLS.
go run ./cmd/relay-signer \
-listen :9443 \
-key-id relay-cert \
-tls-cert certs/relay-server.crt \
-tls-key certs/relay-server.key \
-client-ca certs/client-ca.crt \
-sign-key certs/relay-signing.key
go run ./examples/tunnel-http \
-listen :8443 \
-cert certs/public-chain.crt \
-signer-addr 127.0.0.1:9443 \
-signer-name relay.internal \
-key-id relay-cert \
-client-cert certs/tunnel-client.crt \
-client-key certs/tunnel-client.key \
-root-ca certs/relay-ca.crtSecurity and operations notes
- Store private keys only in
relay-signer; never distribute them to tunnel apps - Keep only the public certificate chain in tunnel apps
- Enforce signer mTLS and pair it with
KeyID-scoped ACLs
Signer API contract (/v1/sign)
Request:
{
"key_id": "relay-cert",
"algorithm": "RSA_PSS_SHA256",
"digest": "<base64>",
"timestamp_unix": 1735628400,
"nonce": "c4d76ad40f5d8f95a1fe4b2f1c922f4a"
}Response:
{
"key_id": "relay-cert",
"algorithm": "RSA_PSS_SHA256",
"signature": "<base64>"
}Package structure
keyless: SDK for application developers (tunnel app integration point)keyless/signerclient: remote signer client implementationrelay/signrpc: signer JSON request/response typesrelay/signer: signing service/key storerelay/server: signer HTTPS (mandatory mTLS) server launcherkeyless/lifecycle: per-lease mTLS identity management (issue, renew, validate, disk-backed encrypted store)relay/l4: TCP passthrough relay + optional ClientHello (SNI/ALPN) inspection hook
Current status
This implementation is at an early stage. Before production use, consider adding:
- replay cache
- rate limiting
- key rotation policy
- observability (OTel/metrics/log correlation)