sdk: add keyless tls
Kim committed
Mar 6, 2026 at 13:33 UTC
097696ba7b422473c820d018396ce6d4145808c3
13 files changed
+416
-251
AGENTS.md
+2
-2
@@ -40,8 +40,8 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
40
41
## TLS and Identity Invariants
42
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.
45
46
2. **/sdk/* control-plane auth is token.** Admission order is IP ban -> Lease -> Token.
47
- Admin/API TLS listener does not request client certificates.
cmd/demo-app/main.go
+1
-2
@@ -40,11 +40,10 @@ var (
40
)
41
42
func main() {
43
- zerolog.TimeFieldFormat = time.RFC3339
43
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
44
logger := log.With().Str("component", "demo-app").Logger()
45
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)")
47
flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
48
flag.StringVar(&flagName, "name", "demo-app", "backend display name")
49
flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
cmd/portal-tunnel/README.md
+1
-1
@@ -30,5 +30,5 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
30
## Notes
31
32
- 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.
34
- When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/relay-server/main.go
-1
@@ -31,7 +31,6 @@ type relayServerConfig struct {
31
}
32
33
func main() {
34
- zerolog.TimeFieldFormat = time.RFC3339
34
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
35
logger := log.With().Str("component", "relay-server").Logger()
36
cmd/relay-server/serve.go
+15
-2
@@ -14,6 +14,7 @@ import (
14
15
"gosuda.org/portal/portal"
16
"gosuda.org/portal/portal/acme"
17
+ "gosuda.org/portal/portal/keyless"
18
)
19
20
func runServer(cfg relayServerConfig) error {
@@ -42,6 +43,10 @@ func runServer(cfg relayServerConfig) error {
43
if err != nil {
44
return fmt.Errorf("ensure relay certificate: %w", err)
45
}
46
+ signer, err := keyless.NewSigner(keyFile)
47
+ if err != nil {
48
+ return fmt.Errorf("create keyless signer: %w", err)
49
+ }
50
51
frontend := NewFrontend(cfg.PortalURL)
52
admin := NewAdmin(cfg.AdminSecretKey, cfg.TrustProxyHeaders, frontend)
@@ -52,11 +57,17 @@ func runServer(cfg relayServerConfig) error {
57
SNIListenAddr: sniListenAddr,
58
RootHost: rootHost,
59
RootFallbackAddr: loopbackAddr(apiListenAddr),
60
+ KeylessSignerHandler: func() http.Handler {
61
+ if signer == nil {
62
+ return nil
63
+ }
64
+ return signer.Handler()
65
+ }(),
66
APITLS: portal.TLSMaterialConfig{
67
CertPEM: mustRead(certFile),
68
KeyPEM: mustRead(keyFile),
69
},
59
- APIHandlerWrapper: serveAPI(frontend, admin, cfg),
70
+ APIHandlerWrapper: serveAPI(frontend, admin, signer, cfg),
71
})
72
if err != nil {
73
return fmt.Errorf("create relay server: %w", err)
@@ -81,12 +92,14 @@ func runServer(cfg relayServerConfig) error {
92
return server.Wait()
93
}
94
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 {
96
return func(base http.Handler) http.Handler {
97
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98
switch {
99
case isRelayControlPlanePath(r.URL.Path):
100
base.ServeHTTP(w, r)
101
+ case r.URL.Path == "/v1/sign":
102
+ base.ServeHTTP(w, r)
103
case r.URL.Path == "/healthz":
104
base.ServeHTTP(w, r)
105
case isFrontendRootAssetPath(r.URL.Path):
internal/testutil/cert.go
deleted
-57
@@ -1,57 +0,0 @@
1
-package testutil
2
-
3
-import (
4
- "crypto/ecdsa"
5
- "crypto/elliptic"
6
- "crypto/rand"
7
- "crypto/x509"
8
- "crypto/x509/pkix"
9
- "encoding/pem"
10
- "math/big"
11
- "net"
12
- "time"
13
-)
14
-
15
-func SelfSignedCertPEM(host string) (certPEM, keyPEM []byte, err error) {
16
- priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
17
- if err != nil {
18
- return nil, nil, err
19
- }
20
-
21
- serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
22
- serial, err := rand.Int(rand.Reader, serialLimit)
23
- if err != nil {
24
- return nil, nil, err
25
- }
26
-
27
- template := &x509.Certificate{
28
- SerialNumber: serial,
29
- Subject: pkix.Name{
30
- CommonName: host,
31
- },
32
- NotBefore: time.Now().Add(-time.Hour),
33
- NotAfter: time.Now().Add(24 * time.Hour),
34
- KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
35
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
36
- BasicConstraintsValid: true,
37
- }
38
-
39
- if ip := net.ParseIP(host); ip != nil {
40
- template.IPAddresses = []net.IP{ip}
41
- } else {
42
- template.DNSNames = []string{host}
43
- }
44
-
45
- der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
46
- if err != nil {
47
- return nil, nil, err
48
- }
49
-
50
- certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
51
- keyBytes, err := x509.MarshalECPrivateKey(priv)
52
- if err != nil {
53
- return nil, nil, err
54
- }
55
- keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
56
- return certPEM, keyPEM, nil
57
-}
portal/keyless/client.go
new
+188
@@ -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
+}
portal/keyless/signer.go
new
+110
@@ -0,0 +1,110 @@
1
+package keyless
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "net/http"
9
+ "os"
10
+ "strings"
11
+ "time"
12
+
13
+ ksigner "github.com/gosuda/keyless_tls/relay/signer"
14
+ "github.com/gosuda/keyless_tls/relay/signrpc"
15
+)
16
+
17
+const (
18
+ RelayKeyID = "relay-cert"
19
+ defaultAllowedSkew = 30 * time.Second
20
+)
21
+
22
+type Signer struct {
23
+ service *ksigner.Service
24
+ keyID string
25
+}
26
+
27
+func NewSigner(keyFile string) (*Signer, error) {
28
+ keyPEM, err := os.ReadFile(strings.TrimSpace(keyFile))
29
+ if err != nil {
30
+ return nil, fmt.Errorf("read keyless signing key: %w", err)
31
+ }
32
+ signingKey, err := ksigner.ParsePrivateKeyPEM(keyPEM)
33
+ if err != nil {
34
+ return nil, fmt.Errorf("parse keyless signing key: %w", err)
35
+ }
36
+
37
+ store := ksigner.NewStaticKeyStore()
38
+ if err := store.Put(RelayKeyID, signingKey); err != nil {
39
+ return nil, fmt.Errorf("register keyless signing key: %w", err)
40
+ }
41
+
42
+ return &Signer{
43
+ service: &ksigner.Service{
44
+ Store: store,
45
+ AllowedSkew: defaultAllowedSkew,
46
+ },
47
+ keyID: RelayKeyID,
48
+ }, nil
49
+}
50
+
51
+func (s *Signer) KeyID() string {
52
+ if s == nil {
53
+ return ""
54
+ }
55
+ return s.keyID
56
+}
57
+
58
+func (s *Signer) Sign(ctx context.Context, req *signrpc.SignRequest) (*signrpc.SignResponse, error) {
59
+ if s == nil || s.service == nil {
60
+ return nil, errors.New("keyless signer is disabled")
61
+ }
62
+ return s.service.Sign(ctx, req)
63
+}
64
+
65
+func (s *Signer) Handler() http.Handler {
66
+ mux := http.NewServeMux()
67
+ mux.HandleFunc(signrpc.SignPath, func(w http.ResponseWriter, r *http.Request) {
68
+ if r.Method != http.MethodPost {
69
+ w.Header().Set("Allow", http.MethodPost)
70
+ writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
71
+ return
72
+ }
73
+ if ct := r.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "application/json") {
74
+ writeJSONError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
75
+ return
76
+ }
77
+
78
+ r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
79
+ defer r.Body.Close()
80
+
81
+ var req signrpc.SignRequest
82
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
83
+ writeJSONError(w, http.StatusBadRequest, "invalid json body")
84
+ return
85
+ }
86
+
87
+ resp, err := s.Sign(r.Context(), &req)
88
+ if err != nil {
89
+ status := http.StatusInternalServerError
90
+ switch {
91
+ case errors.Is(err, ksigner.ErrInvalidArgument):
92
+ status = http.StatusBadRequest
93
+ case errors.Is(err, ksigner.ErrPermissionDenied):
94
+ status = http.StatusForbidden
95
+ }
96
+ writeJSONError(w, status, err.Error())
97
+ return
98
+ }
99
+
100
+ w.Header().Set("Content-Type", "application/json")
101
+ _ = json.NewEncoder(w).Encode(resp)
102
+ })
103
+ return mux
104
+}
105
+
106
+func writeJSONError(w http.ResponseWriter, status int, message string) {
107
+ w.Header().Set("Content-Type", "application/json")
108
+ w.WriteHeader(status)
109
+ _ = json.NewEncoder(w).Encode(signrpc.ErrorResponse{Error: message})
110
+}
portal/server.go
+5
-1
@@ -21,6 +21,7 @@ import (
21
22
type ServerConfig struct {
23
APIHandlerWrapper func(http.Handler) http.Handler
24
+ KeylessSignerHandler http.Handler
25
PortalURL string
26
APIListenAddr string
27
SNIListenAddr string
@@ -240,13 +241,16 @@ func (s *Server) ListLeases() []LeaseSnapshot {
241
242
func (s *Server) apiHandler() http.Handler {
243
mux := http.NewServeMux()
243
- mux.HandleFunc("/", s.handleRoot)
244
+ if s.cfg.KeylessSignerHandler != nil {
245
+ mux.Handle("/v1/sign", s.cfg.KeylessSignerHandler)
246
+ }
247
mux.HandleFunc("/healthz", s.handleHealthz)
248
mux.HandleFunc("/sdk/domain", s.handleDomain)
249
mux.HandleFunc("/sdk/register", s.handleRegister)
250
mux.HandleFunc("/sdk/renew", s.handleRenew)
251
mux.HandleFunc("/sdk/unregister", s.handleUnregister)
252
mux.HandleFunc("/sdk/connect", s.handleConnect)
253
+ mux.HandleFunc("/", s.handleRoot)
254
return mux
255
}
256
sdk/client.go
+5
-17
@@ -19,6 +19,7 @@ import (
19
"time"
20
21
"gosuda.org/portal/portal"
22
+ "gosuda.org/portal/portal/keyless"
23
)
24
25
type ClientConfig struct {
@@ -130,23 +131,10 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
131
return nil, err
132
}
133
133
- var (
134
- tlsConf *tls.Config
135
- tlsCloser io.Closer
136
- err error
137
- )
138
- if len(req.TLS.CertPEM) > 0 || len(req.TLS.KeyPEM) > 0 || req.TLS.Keyless != nil {
139
- tlsConf, tlsCloser, err = buildTenantTLSConfig(req.TLS)
140
- if err != nil {
141
- _ = c.unregisterLease(context.Background(), registerResp.LeaseID, reverseToken)
142
- return nil, err
143
- }
144
- } else {
145
- tlsConf, err = buildAutoTenantTLSConfig(registerResp.Hostnames)
146
- if err != nil {
147
- _ = c.unregisterLease(context.Background(), registerResp.LeaseID, reverseToken)
148
- return nil, err
149
- }
134
+ tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(c.baseURL.String(), registerResp.Hostnames)
135
+ if err != nil {
136
+ _ = c.unregisterLease(context.Background(), registerResp.LeaseID, reverseToken)
137
+ return nil, err
138
}
139
140
listenerCtx, cancel := context.WithCancel(ctx)
sdk/listener.go
-1
@@ -18,7 +18,6 @@ type LeaseMetadata = portal.LeaseMetadata
18
type ListenRequest struct {
19
Name string
20
ReverseToken string
21
- TLS portal.TLSMaterialConfig
21
Hostnames []string
22
Metadata LeaseMetadata
23
ReadyTarget int
sdk/listener_test.go
+89
-28
@@ -3,39 +3,90 @@ package sdk
3
import (
4
"bufio"
5
"context"
6
+ "crypto/ecdsa"
7
+ "crypto/elliptic"
8
+ "crypto/rand"
9
"crypto/tls"
10
+ "crypto/x509"
11
+ "crypto/x509/pkix"
12
+ "encoding/pem"
13
"errors"
14
"fmt"
15
"io"
16
+ "math/big"
17
"net"
18
"net/http"
19
+ "os"
20
"strings"
21
"testing"
22
"time"
23
16
- "gosuda.org/portal/internal/testutil"
24
"gosuda.org/portal/portal"
25
+ portalkeyless "gosuda.org/portal/portal/keyless"
26
)
27
28
+func selfSignedCertPEM(hosts ...string) (certPEM, keyPEM []byte, err error) {
29
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
30
+ if err != nil {
31
+ return nil, nil, err
32
+ }
33
+
34
+ serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
35
+ serial, err := rand.Int(rand.Reader, serialLimit)
36
+ if err != nil {
37
+ return nil, nil, err
38
+ }
39
+
40
+ template := &x509.Certificate{
41
+ SerialNumber: serial,
42
+ Subject: pkix.Name{
43
+ CommonName: hosts[0],
44
+ },
45
+ NotBefore: time.Now().Add(-time.Hour),
46
+ NotAfter: time.Now().Add(24 * time.Hour),
47
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
48
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
49
+ BasicConstraintsValid: true,
50
+ }
51
+
52
+ for _, host := range hosts {
53
+ if ip := net.ParseIP(host); ip != nil {
54
+ template.IPAddresses = append(template.IPAddresses, ip)
55
+ continue
56
+ }
57
+ template.DNSNames = append(template.DNSNames, host)
58
+ }
59
+
60
+ der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
61
+ if err != nil {
62
+ return nil, nil, err
63
+ }
64
+
65
+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
66
+ keyBytes, err := x509.MarshalECPrivateKey(priv)
67
+ if err != nil {
68
+ return nil, nil, err
69
+ }
70
+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
71
+ return certPEM, keyPEM, nil
72
+}
73
+
74
func TestListenerEndToEndTLSHTTP(t *testing.T) {
75
t.Parallel()
76
23
- apiCertPEM, apiKeyPEM, err := testutil.SelfSignedCertPEM("127.0.0.1")
77
+ apiCertPEM, apiKeyPEM, err := selfSignedCertPEM("127.0.0.1", "portal.test", "*.portal.test")
78
if err != nil {
25
- t.Fatalf("SelfSignedCertPEM(api) error = %v", err)
79
+ t.Fatalf("selfSignedCertPEM(api) error = %v", err)
80
}
81
tenantHost := "app.portal.test"
28
- tenantCertPEM, tenantKeyPEM, err := testutil.SelfSignedCertPEM(tenantHost)
29
- if err != nil {
30
- t.Fatalf("SelfSignedCertPEM(tenant) error = %v", err)
31
- }
82
83
relay, err := portal.NewServer(portal.ServerConfig{
34
- PortalURL: "https://127.0.0.1",
35
- APIListenAddr: "127.0.0.1:0",
36
- SNIListenAddr: "127.0.0.1:0",
37
- RootHost: "portal.test",
38
- RootFallbackAddr: "127.0.0.1:1",
84
+ PortalURL: "https://127.0.0.1",
85
+ APIListenAddr: "127.0.0.1:0",
86
+ SNIListenAddr: "127.0.0.1:0",
87
+ RootHost: "portal.test",
88
+ RootFallbackAddr: "127.0.0.1:1",
89
+ KeylessSignerHandler: newTestSignerHandler(t, apiKeyPEM),
90
APITLS: portal.TLSMaterialConfig{
91
CertPEM: apiCertPEM,
92
KeyPEM: apiKeyPEM,
@@ -78,10 +129,6 @@ func TestListenerEndToEndTLSHTTP(t *testing.T) {
129
Thumbnail: "https://example.test/thumb.png",
130
Hide: true,
131
},
81
- TLS: portal.TLSMaterialConfig{
82
- CertPEM: tenantCertPEM,
83
- KeyPEM: tenantKeyPEM,
84
- },
132
})
133
if err != nil {
134
t.Fatalf("Listen() error = %v", err)
@@ -145,21 +192,21 @@ func TestListenerEndToEndTLSHTTP(t *testing.T) {
192
}
193
}
194
148
-func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
195
+func TestListenerEndToEndTLSHTTP_AutoKeyless(t *testing.T) {
196
t.Parallel()
197
151
- apiCertPEM, apiKeyPEM, err := testutil.SelfSignedCertPEM("127.0.0.1")
198
+ apiCertPEM, apiKeyPEM, err := selfSignedCertPEM("127.0.0.1", "portal.test", "*.portal.test")
199
if err != nil {
153
- t.Fatalf("SelfSignedCertPEM(api) error = %v", err)
200
+ t.Fatalf("selfSignedCertPEM(api) error = %v", err)
201
}
202
tenantHost := "auto.portal.test"
156
-
203
relay, err := portal.NewServer(portal.ServerConfig{
158
- PortalURL: "https://127.0.0.1",
159
- APIListenAddr: "127.0.0.1:0",
160
- SNIListenAddr: "127.0.0.1:0",
161
- RootHost: "portal.test",
162
- RootFallbackAddr: "127.0.0.1:1",
204
+ PortalURL: "https://127.0.0.1",
205
+ APIListenAddr: "127.0.0.1:0",
206
+ SNIListenAddr: "127.0.0.1:0",
207
+ RootHost: "portal.test",
208
+ RootFallbackAddr: "127.0.0.1:1",
209
+ KeylessSignerHandler: newTestSignerHandler(t, apiKeyPEM),
210
APITLS: portal.TLSMaterialConfig{
211
CertPEM: apiCertPEM,
212
KeyPEM: apiKeyPEM,
@@ -204,7 +251,7 @@ func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
251
httpDone := make(chan error, 1)
252
server := &http.Server{
253
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207
- _, _ = io.WriteString(w, "auto tls ok\n")
254
+ _, _ = io.WriteString(w, "auto keyless ok\n")
255
}),
256
ReadHeaderTimeout: 5 * time.Second,
257
}
@@ -228,8 +275,8 @@ func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
275
for {
276
body, err := doTenantRequest(relay.SNIAddr(), tenantHost, "/")
277
if err == nil {
231
- if !strings.Contains(body, "auto tls ok") {
232
- t.Fatalf("body = %q, want auto tls payload", body)
278
+ if !strings.Contains(body, "auto keyless ok") {
279
+ t.Fatalf("body = %q, want auto keyless payload", body)
280
}
281
return
282
}
@@ -270,3 +317,17 @@ func doTenantRequest(addr, host, path string) (string, error) {
317
}
318
return string(body), nil
319
}
320
+
321
+func newTestSignerHandler(t *testing.T, keyPEM []byte) http.Handler {
322
+ t.Helper()
323
+
324
+ keyFile := t.TempDir() + "/relay-key.pem"
325
+ if err := os.WriteFile(keyFile, keyPEM, 0o600); err != nil {
326
+ t.Fatalf("WriteFile(key) error = %v", err)
327
+ }
328
+ signer, err := portalkeyless.NewSigner(keyFile)
329
+ if err != nil {
330
+ t.Fatalf("NewSigner() error = %v", err)
331
+ }
332
+ return signer.Handler()
333
+}
sdk/tls.go
deleted
-139
@@ -1,139 +0,0 @@
1
-package sdk
2
-
3
-import (
4
- "crypto/ecdsa"
5
- "crypto/elliptic"
6
- "crypto/rand"
7
- "crypto/tls"
8
- "crypto/x509"
9
- "crypto/x509/pkix"
10
- "encoding/pem"
11
- "errors"
12
- "fmt"
13
- "io"
14
- "math/big"
15
- "net"
16
- "strings"
17
- "time"
18
-
19
- keylesslib "github.com/gosuda/keyless_tls/keyless"
20
-
21
- "gosuda.org/portal/portal"
22
-)
23
-
24
-func buildTenantTLSConfig(cfg portal.TLSMaterialConfig) (*tls.Config, io.Closer, error) {
25
- if len(cfg.CertPEM) == 0 {
26
- return nil, nil, errors.New("tenant certificate is required")
27
- }
28
- if cfg.Keyless != nil {
29
- remoteSigner, err := keylesslib.NewRemoteSigner(keylesslib.RemoteSignerConfig{
30
- Endpoint: cfg.Keyless.Endpoint,
31
- ServerName: cfg.Keyless.ServerName,
32
- KeyID: cfg.Keyless.KeyID,
33
- ClientCertPEM: cfg.Keyless.ClientCertPEM,
34
- ClientKeyPEM: cfg.Keyless.ClientKeyPEM,
35
- RootCAPEM: cfg.Keyless.RootCAPEM,
36
- }, cfg.CertPEM)
37
- if err != nil {
38
- return nil, nil, err
39
- }
40
- tlsConf, err := keylesslib.NewServerTLSConfig(keylesslib.ServerTLSConfig{
41
- CertPEM: cfg.CertPEM,
42
- Signer: remoteSigner,
43
- NextProtos: []string{"http/1.1"},
44
- MinVersion: tls.VersionTLS12,
45
- })
46
- if err != nil {
47
- _ = remoteSigner.Close()
48
- return nil, nil, err
49
- }
50
- return tlsConf, remoteSigner, nil
51
- }
52
-
53
- cert, err := tls.X509KeyPair(cfg.CertPEM, cfg.KeyPEM)
54
- if err != nil {
55
- return nil, nil, fmt.Errorf("parse tenant tls key pair: %w", err)
56
- }
57
-
58
- return &tls.Config{
59
- MinVersion: tls.VersionTLS12,
60
- NextProtos: []string{"http/1.1"},
61
- Certificates: []tls.Certificate{cert},
62
- }, nil, nil
63
-}
64
-
65
-func buildAutoTenantTLSConfig(hostnames []string) (*tls.Config, error) {
66
- certPEM, keyPEM, err := selfSignedTenantCert(hostnames)
67
- if err != nil {
68
- return nil, err
69
- }
70
- tlsConf, _, err := buildTenantTLSConfig(portal.TLSMaterialConfig{
71
- CertPEM: certPEM,
72
- KeyPEM: keyPEM,
73
- })
74
- return tlsConf, err
75
-}
76
-
77
-func selfSignedTenantCert(hostnames []string) (certPEM, keyPEM []byte, err error) {
78
- priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
79
- if err != nil {
80
- return nil, nil, fmt.Errorf("generate self-signed tenant key: %w", err)
81
- }
82
-
83
- serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
84
- serial, err := rand.Int(rand.Reader, serialLimit)
85
- if err != nil {
86
- return nil, nil, fmt.Errorf("generate self-signed tenant serial: %w", err)
87
- }
88
-
89
- template := &x509.Certificate{
90
- SerialNumber: serial,
91
- Subject: pkix.Name{
92
- CommonName: firstHostname(hostnames),
93
- },
94
- NotBefore: time.Now().Add(-1 * time.Hour),
95
- NotAfter: time.Now().Add(30 * 24 * time.Hour),
96
- KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
97
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
98
- BasicConstraintsValid: true,
99
- }
100
-
101
- for _, host := range hostnames {
102
- host = strings.TrimSpace(host)
103
- if host == "" {
104
- continue
105
- }
106
- if ip := net.ParseIP(host); ip != nil {
107
- template.IPAddresses = append(template.IPAddresses, ip)
108
- continue
109
- }
110
- template.DNSNames = append(template.DNSNames, host)
111
- }
112
- if len(template.DNSNames) == 0 && len(template.IPAddresses) == 0 {
113
- template.DNSNames = []string{"localhost"}
114
- template.Subject.CommonName = "localhost"
115
- }
116
-
117
- der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
118
- if err != nil {
119
- return nil, nil, fmt.Errorf("create self-signed tenant certificate: %w", err)
120
- }
121
-
122
- certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
123
- keyDER, err := x509.MarshalECPrivateKey(priv)
124
- if err != nil {
125
- return nil, nil, fmt.Errorf("marshal self-signed tenant key: %w", err)
126
- }
127
- keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
128
- return certPEM, keyPEM, nil
129
-}
130
-
131
-func firstHostname(hostnames []string) string {
132
- for _, host := range hostnames {
133
- host = strings.TrimSpace(host)
134
- if host != "" {
135
- return host
136
- }
137
- }
138
- return "localhost"
139
-}