Skip to content

Commit 097696b

Browse files
committed
sdk: add keyless tls
1 parent 410d8c1 commit 097696b

13 files changed

Lines changed: 416 additions & 251 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
4040

4141
## TLS and Identity Invariants
4242

43-
1. **Relay holds the TLS private key for admin/API (root domain) only.** SDK calls `/v1/sign` on the relay via `RemoteSigner` for admin/API TLS termination. For SNI-passthrough routes, the relay peeks the ClientHello for SNI then bridges the raw encrypted connection — the backend/tunnel endpoint terminates TLS and holds those keys, not the relay.
44-
- Why: admin/API key material stays on the relay; tenant TLS passthrough avoids key distribution to the relay entirely.
43+
1. **Relay terminates admin/API TLS directly and also exposes `/v1/sign` as the keyless signer for tenant passthrough TLS.** The relay still does not terminate tenant traffic; it peeks ClientHello for SNI and bridges raw encrypted bytes, while the backend/tunnel endpoint performs the handshake using a `RemoteSigner`.
44+
- Why: preserves SNI passthrough data flow while centralizing certificate signing behind the relay keyless endpoint.
4545

4646
2. **/sdk/* control-plane auth is token.** Admission order is IP ban -> Lease -> Token.
4747
- Admin/API TLS listener does not request client certificates.

cmd/demo-app/main.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,10 @@ var (
4040
)
4141

4242
func main() {
43-
zerolog.TimeFieldFormat = time.RFC3339
4443
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
4544
logger := log.With().Str("component", "demo-app").Logger()
4645

47-
flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URL (https only)")
46+
flag.StringVar(&flagServerURL, "server-url", "https://gosunuts.xyz", "relay API URL (https only)")
4847
flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
4948
flag.StringVar(&flagName, "name", "demo-app", "backend display name")
5049
flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")

cmd/portal-tunnel/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
3030
## Notes
3131

3232
- The current runtime accepts multiple relay URLs but uses the first one.
33-
- If tenant TLS material is not provided internally, the SDK generates a self-signed certificate for the registered hostnames.
33+
- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
3434
- When the local service is unreachable, the tunnel returns an HTTP 503 page.

cmd/relay-server/main.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ type relayServerConfig struct {
3131
}
3232

3333
func main() {
34-
zerolog.TimeFieldFormat = time.RFC3339
3534
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
3635
logger := log.With().Str("component", "relay-server").Logger()
3736

cmd/relay-server/serve.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"gosuda.org/portal/portal"
1616
"gosuda.org/portal/portal/acme"
17+
"gosuda.org/portal/portal/keyless"
1718
)
1819

1920
func runServer(cfg relayServerConfig) error {
@@ -42,6 +43,10 @@ func runServer(cfg relayServerConfig) error {
4243
if err != nil {
4344
return fmt.Errorf("ensure relay certificate: %w", err)
4445
}
46+
signer, err := keyless.NewSigner(keyFile)
47+
if err != nil {
48+
return fmt.Errorf("create keyless signer: %w", err)
49+
}
4550

4651
frontend := NewFrontend(cfg.PortalURL)
4752
admin := NewAdmin(cfg.AdminSecretKey, cfg.TrustProxyHeaders, frontend)
@@ -52,11 +57,17 @@ func runServer(cfg relayServerConfig) error {
5257
SNIListenAddr: sniListenAddr,
5358
RootHost: rootHost,
5459
RootFallbackAddr: loopbackAddr(apiListenAddr),
60+
KeylessSignerHandler: func() http.Handler {
61+
if signer == nil {
62+
return nil
63+
}
64+
return signer.Handler()
65+
}(),
5566
APITLS: portal.TLSMaterialConfig{
5667
CertPEM: mustRead(certFile),
5768
KeyPEM: mustRead(keyFile),
5869
},
59-
APIHandlerWrapper: serveAPI(frontend, admin, cfg),
70+
APIHandlerWrapper: serveAPI(frontend, admin, signer, cfg),
6071
})
6172
if err != nil {
6273
return fmt.Errorf("create relay server: %w", err)
@@ -81,12 +92,14 @@ func runServer(cfg relayServerConfig) error {
8192
return server.Wait()
8293
}
8394

84-
func serveAPI(frontend *Frontend, admin *Admin, cfg relayServerConfig) func(http.Handler) http.Handler {
95+
func serveAPI(frontend *Frontend, admin *Admin, signer *keyless.Signer, cfg relayServerConfig) func(http.Handler) http.Handler {
8596
return func(base http.Handler) http.Handler {
8697
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8798
switch {
8899
case isRelayControlPlanePath(r.URL.Path):
89100
base.ServeHTTP(w, r)
101+
case r.URL.Path == "/v1/sign":
102+
base.ServeHTTP(w, r)
90103
case r.URL.Path == "/healthz":
91104
base.ServeHTTP(w, r)
92105
case isFrontendRootAssetPath(r.URL.Path):

internal/testutil/cert.go

Lines changed: 0 additions & 57 deletions
This file was deleted.

portal/keyless/client.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package keyless
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"crypto/x509"
7+
"encoding/pem"
8+
"errors"
9+
"fmt"
10+
"net"
11+
"net/url"
12+
"strings"
13+
"time"
14+
15+
keylesslib "github.com/gosuda/keyless_tls/keyless"
16+
)
17+
18+
func BuildClientTLSConfig(relayURL string, domains []string) (*tls.Config, ioCloser, error) {
19+
parsed, err := url.Parse(strings.TrimSpace(relayURL))
20+
if err != nil {
21+
return nil, nil, fmt.Errorf("parse relay url: %w", err)
22+
}
23+
serverName := parsed.Hostname()
24+
if serverName == "" {
25+
return nil, nil, errors.New("relay hostname is required")
26+
}
27+
28+
certPEM, rootCAPEM, err := ResolveMaterials(context.Background(), relayURL, serverName)
29+
if err != nil {
30+
return nil, nil, fmt.Errorf("prepare keyless materials: %w", err)
31+
}
32+
for _, domain := range domains {
33+
domain = strings.TrimSpace(domain)
34+
if domain == "" {
35+
continue
36+
}
37+
verifyErr := VerifyCertificateHostname(certPEM, domain)
38+
if verifyErr != nil {
39+
return nil, nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, verifyErr)
40+
}
41+
}
42+
43+
remoteSigner, err := keylesslib.NewRemoteSigner(keylesslib.RemoteSignerConfig{
44+
Endpoint: relayURL,
45+
ServerName: serverName,
46+
KeyID: RelayKeyID,
47+
RootCAPEM: rootCAPEM,
48+
}, certPEM)
49+
if err != nil {
50+
return nil, nil, fmt.Errorf("create keyless remote signer: %w", err)
51+
}
52+
53+
tlsConfig, err := keylesslib.NewServerTLSConfig(keylesslib.ServerTLSConfig{
54+
CertPEM: certPEM,
55+
Signer: remoteSigner,
56+
NextProtos: []string{"http/1.1"},
57+
MinVersion: tls.VersionTLS12,
58+
})
59+
if err != nil {
60+
_ = remoteSigner.Close()
61+
return nil, nil, fmt.Errorf("create keyless tls config: %w", err)
62+
}
63+
return tlsConfig, remoteSigner, nil
64+
}
65+
66+
type ioCloser interface {
67+
Close() error
68+
}
69+
70+
func ResolveMaterials(ctx context.Context, endpoint, serverName string) ([]byte, []byte, error) {
71+
chainPEM, err := FetchEndpointCertificateChain(ctx, endpoint, serverName)
72+
if err != nil {
73+
return nil, nil, fmt.Errorf("fetch signer certificate chain: %w", err)
74+
}
75+
if len(chainPEM) == 0 {
76+
return nil, nil, errors.New("keyless certificate chain is required")
77+
}
78+
return append([]byte(nil), chainPEM...), append([]byte(nil), chainPEM...), nil
79+
}
80+
81+
func VerifyCertificateHostname(certPEM []byte, hostname string) error {
82+
_, leaf, err := ParseCertificateChainPEM(certPEM)
83+
if err != nil {
84+
return err
85+
}
86+
return leaf.VerifyHostname(hostname)
87+
}
88+
89+
func ParseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, error) {
90+
if len(certPEM) == 0 {
91+
return nil, nil, errors.New("certificate PEM is empty")
92+
}
93+
94+
var chain [][]byte
95+
rest := certPEM
96+
for {
97+
block, next := pem.Decode(rest)
98+
if block == nil {
99+
break
100+
}
101+
if block.Type == "CERTIFICATE" {
102+
chain = append(chain, block.Bytes)
103+
}
104+
rest = next
105+
}
106+
if len(chain) == 0 {
107+
return nil, nil, errors.New("no certificate blocks found")
108+
}
109+
110+
leaf, err := x509.ParseCertificate(chain[0])
111+
if err != nil {
112+
return nil, nil, fmt.Errorf("parse leaf certificate: %w", err)
113+
}
114+
return chain, leaf, nil
115+
}
116+
117+
func FetchEndpointCertificateChain(ctx context.Context, endpoint, serverName string) ([]byte, error) {
118+
raw := strings.TrimSpace(endpoint)
119+
if raw == "" {
120+
return nil, errors.New("endpoint is required")
121+
}
122+
if !strings.Contains(raw, "://") {
123+
raw = "https://" + raw
124+
}
125+
126+
u, err := url.Parse(raw)
127+
if err != nil {
128+
return nil, fmt.Errorf("parse endpoint url: %w", err)
129+
}
130+
if !strings.EqualFold(u.Scheme, "https") {
131+
return nil, errors.New("keyless endpoint must use https")
132+
}
133+
134+
host := u.Hostname()
135+
if host == "" {
136+
return nil, errors.New("endpoint hostname is empty")
137+
}
138+
port := u.Port()
139+
if port == "" {
140+
port = "443"
141+
}
142+
if serverName == "" {
143+
serverName = host
144+
}
145+
146+
dialer := &net.Dialer{Timeout: 5 * time.Second}
147+
rawConn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
148+
if err != nil {
149+
return nil, fmt.Errorf("dial signer endpoint: %w", err)
150+
}
151+
152+
tlsConn := tls.Client(rawConn, &tls.Config{
153+
MinVersion: tls.VersionTLS12,
154+
ServerName: serverName,
155+
InsecureSkipVerify: isLocalhost(host),
156+
NextProtos: []string{"http/1.1"},
157+
})
158+
defer tlsConn.Close()
159+
if err := tlsConn.HandshakeContext(ctx); err != nil {
160+
return nil, fmt.Errorf("tls handshake with signer endpoint: %w", err)
161+
}
162+
163+
peerCerts := tlsConn.ConnectionState().PeerCertificates
164+
if len(peerCerts) == 0 {
165+
return nil, errors.New("no peer certificates from signer endpoint")
166+
}
167+
168+
var chainPEM []byte
169+
for _, cert := range peerCerts {
170+
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{
171+
Type: "CERTIFICATE",
172+
Bytes: cert.Raw,
173+
})...)
174+
}
175+
return chainPEM, nil
176+
}
177+
178+
func isLocalhost(host string) bool {
179+
host = strings.TrimSpace(strings.ToLower(host))
180+
switch host {
181+
case "", "localhost":
182+
return true
183+
}
184+
if ip := net.ParseIP(host); ip != nil {
185+
return ip.IsLoopback()
186+
}
187+
return strings.HasSuffix(host, ".localhost")
188+
}

0 commit comments

Comments
 (0)