refactor: remove ACME certificate management and related code
- Deleted ACMEManager and associated functions for issuing certificates via ACME DNS-01. - Removed Certificate and CSRRequest types along with their handling logic. - Eliminated AutoCertManager and CertificateClient, which managed TLS certificates and CSR submissions. - Updated Client and Listener to support new TLS modes: self and keyless. - Introduced TLSKeylessConfig for keyless TLS configuration. - Refactored listener and client initialization to accommodate new TLS handling.
gosunuts committed
Feb 27, 2026 at 14:18 UTC
e81a5ef324de7f62653629b68a2ace77928e8f1f
25 files changed
+737
-1175
README.md
+1
-1
@@ -15,7 +15,7 @@ This enables developers to publish local services globally without managing serv
15
## Features
16
17
- 🔄 **Connection Relay**: Connects clients behind NAT or firewalls through the Portal network
18
-- 🔐 **End-to-End Encryption**: TLS passthrough with ACME DNS-01 certificates
18
+- 🔐 **End-to-End Encryption**: TLS passthrough with relay keyless certificates
19
- 🕊️ **Permissionless Hosting**: Anyone can run their own Portal — no approval needed
20
- ⚙️ **Simple Setup**: Quick start with Tunnel client or Go SDK
21
cmd/demo-app/main.go
+24
-4
@@ -34,7 +34,9 @@ var (
34
flagTags string
35
flagOwner string
36
flagHide bool
37
- flagTLS bool
37
+ flagTLSMode string
38
+ flagTLSCert string
39
+ flagTLSKey string
40
)
41
42
func main() {
@@ -45,7 +47,13 @@ func main() {
47
flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,moning", "comma-separated lease tags")
48
flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
49
flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
48
- flag.BoolVar(&flagTLS, "tls", true, "enable TLS (end-to-end encryption via ACME DNS-01)")
50
+ defaultTLSMode := strings.TrimSpace(os.Getenv("TLS_MODE"))
51
+ if defaultTLSMode == "" {
52
+ defaultTLSMode = string(sdk.TLSModeNoTLS)
53
+ }
54
+ flag.StringVar(&flagTLSMode, "tls-mode", defaultTLSMode, "TLS mode: no-tls, self, or keyless [env: TLS_MODE]")
55
+ flag.StringVar(&flagTLSCert, "tls-cert-file", os.Getenv("TLS_CERT_FILE"), "certificate chain PEM file for --tls-mode self [env: TLS_CERT_FILE]")
56
+ flag.StringVar(&flagTLSKey, "tls-key-file", os.Getenv("TLS_KEY_FILE"), "private key PEM file for --tls-mode self [env: TLS_KEY_FILE]")
57
58
flag.Parse()
59
@@ -57,8 +65,20 @@ func main() {
65
func runDemo() error {
66
// 1) Create SDK client and connect to relay(s)
67
opts := []sdk.ClientOption{sdk.WithBootstrapServers([]string{flagServerURL})}
60
- if flagTLS {
61
- opts = append(opts, sdk.WithTLS())
68
+ mode := sdk.TLSMode(strings.ToLower(strings.TrimSpace(flagTLSMode)))
69
+ switch mode {
70
+ case sdk.TLSModeNoTLS:
71
+ case sdk.TLSModeSelf:
72
+ opts = append(opts, sdk.WithTLSSelfCertificateFiles(flagTLSCert, flagTLSKey))
73
+ case sdk.TLSModeKeyless:
74
+ if certFile := strings.TrimSpace(flagTLSCert); certFile != "" {
75
+ log.Warn().
76
+ Str("cert_file", certFile).
77
+ Msg("Ignoring --tls-cert-file in keyless mode (SDK auto configuration only)")
78
+ }
79
+ opts = append(opts, sdk.WithTLSKeylessDefaults())
80
+ default:
81
+ return fmt.Errorf("unsupported tls mode: %s", flagTLSMode)
82
}
83
client, err := sdk.NewClient(opts...)
84
if err != nil {
cmd/portal-tunnel/README.md
+57
-10
@@ -25,18 +25,20 @@ Enable TLS for end-to-end encryption from client to your local service:
25
./bin/portal-tunnel --host localhost:8080 \
26
--relay https://portal.example.com \
27
--name myapp \
28
- --tls
28
+ --tls-mode keyless
29
```
30
31
-When `--tls` is enabled:
32
-- Tunnel generates a private key and CSR locally
33
-- Relay issues certificate via ACME DNS-01 (if configured)
31
+TLS modes:
32
+- No TLS mode (`--tls-mode no-tls`): plain TCP/HTTP proxying without TLS termination
33
+- Self mode (`--tls-mode self`): tunnel uses local certificate and key files
34
+- Keyless mode (`--tls-mode keyless`): tunnel auto-discovers certificate chain and delegates signing to external signer API
35
- TLS is terminated at the tunnel, then proxied to your local service via TCP
36
- Access via `https://myapp.example.com` directly on port 443
37
38
**Requirements:**
38
-- Relay must have ACME DNS-01 configured (`ACME_DNS_PROVIDER`, `ACME_EMAIL`)
39
-- DNS provider credentials must be set on the relay server
39
+- Self mode: set `TLS_CERT_FILE` and `TLS_KEY_FILE` (or use `--tls-cert-file`, `--tls-key-file`)
40
+- Keyless mode: no local cert/key required in default mode (SDK auto-discovers signer certificate chain)
41
+- Keyless auto-discovery expects an HTTPS signer endpoint.
42
43
## Flags
44
@@ -48,8 +50,9 @@ Options:
50
--relay Portal relay server API URLs (comma-separated, http/https) [default: http://localhost:4017] [env: RELAYS]
51
--host Target host to proxy to (host:port or URL) [env: APP_HOST]
52
--name Service name [env: APP_NAME]
51
- --tls Enable TLS termination on tunnel client (uses relay ACME DNS-01) [env: TLS_ENABLE]
52
- --protocols ALPN protocols (comma-separated) [default: http/1.1,h2] [env: APP_PROTOCOLS]
53
+ --tls-mode TLS mode: no-tls, self, or keyless [default: no-tls] [env: TLS_MODE]
54
+ --tls-cert-file PEM certificate chain for --tls-mode self [env: TLS_CERT_FILE]
55
+ --tls-key-file PEM private key for --tls-mode self [env: TLS_KEY_FILE]
56
--description Service description metadata [env: APP_DESCRIPTION]
57
--tags Service tags metadata (comma-separated) [env: APP_TAGS]
58
--thumbnail Service thumbnail URL metadata [env: APP_THUMBNAIL]
@@ -76,11 +79,55 @@ $env:HOST="localhost:3000"; $env:NAME="myapp"; irm https://portal.example.com/tu
79
export RELAYS=https://portal.example.com
80
export APP_HOST=localhost:3000
81
export APP_NAME=myapp
79
-export TLS_ENABLE=true
82
+export TLS_MODE=keyless
83
84
./bin/portal-tunnel
85
```
86
87
+### Production (Self TLS)
88
+
89
+```bash
90
+export RELAYS=https://portal.example.com
91
+export APP_HOST=localhost:3000
92
+export APP_NAME=myapp
93
+export TLS_MODE=self
94
+export TLS_CERT_FILE=/etc/ssl/myapp/fullchain.pem
95
+export TLS_KEY_FILE=/etc/ssl/myapp/privkey.pem
96
+
97
+./bin/portal-tunnel
98
+```
99
+
100
+### Production (Keyless TLS)
101
+
102
+```bash
103
+export RELAYS=https://portal.example.com
104
+export APP_HOST=localhost:3000
105
+export APP_NAME=myapp
106
+export TLS_MODE=keyless
107
+
108
+./bin/portal-tunnel
109
+```
110
+
111
+Expected signer API contract (`/v1/sign`):
112
+
113
+```json
114
+{
115
+ "key_id": "relay-cert",
116
+ "algorithm": "RSA_PSS_SHA256",
117
+ "digest": "<base64>",
118
+ "timestamp_unix": 1735628400,
119
+ "nonce": "c4d76ad40f5d8f95a1fe4b2f1c922f4a"
120
+}
121
+```
122
+
123
+```json
124
+{
125
+ "key_id": "relay-cert",
126
+ "algorithm": "RSA_PSS_SHA256",
127
+ "signature": "<base64>"
128
+}
129
+```
130
+
131
### Multiple Relays (High Availability)
132
133
```bash
@@ -88,5 +135,5 @@ export TLS_ENABLE=true
135
--host localhost:3000 \
136
--name myapp \
137
--relay https://portal1.example.com,https://portal2.example.com \
91
- --tls
138
+ --tls-mode keyless
139
```
cmd/portal-tunnel/main.go
+46
-12
@@ -23,8 +23,9 @@ var (
23
flagRelayURLs string
24
flagHost string
25
flagName string
26
- flagTLSEnable bool
27
- flagProtocols string
26
+ flagTLSMode string
27
+ flagTLSCertFile string
28
+ flagTLSKeyFile string
29
flagDescription string
30
flagTags string
31
flagThumbnail string
@@ -44,10 +45,14 @@ func main() {
45
flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
46
flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
47
47
- defaultTLS := os.Getenv("TLS_ENABLE") != "false"
48
- flag.BoolVar(&flagTLSEnable, "tls", defaultTLS, "Enable TLS termination on tunnel client (uses relay ACME DNS-01) [env: TLS_ENABLE]")
48
+ defaultTLSMode := strings.ToLower(strings.TrimSpace(os.Getenv("TLS_MODE")))
49
+ if defaultTLSMode == "" {
50
+ defaultTLSMode = string(sdk.TLSModeNoTLS)
51
+ }
52
+ flag.StringVar(&flagTLSMode, "tls-mode", defaultTLSMode, "TLS mode: no-tls, self, or keyless [env: TLS_MODE]")
53
+ flag.StringVar(&flagTLSCertFile, "tls-cert-file", os.Getenv("TLS_CERT_FILE"), "PEM certificate chain for --tls-mode self [env: TLS_CERT_FILE]")
54
+ flag.StringVar(&flagTLSKeyFile, "tls-key-file", os.Getenv("TLS_KEY_FILE"), "PEM private key for --tls-mode self [env: TLS_KEY_FILE]")
55
50
- flag.StringVar(&flagProtocols, "protocols", os.Getenv("APP_PROTOCOLS"), "ALPN protocols (comma-separated) [env: APP_PROTOCOLS]")
56
flag.StringVar(&flagDescription, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
57
flag.StringVar(&flagTags, "tags", os.Getenv("APP_TAGS"), "Service tags metadata (comma-separated) [env: APP_TAGS]")
58
flag.StringVar(&flagThumbnail, "thumbnail", os.Getenv("APP_THUMBNAIL"), "Service thumbnail URL metadata [env: APP_THUMBNAIL]")
@@ -62,6 +67,13 @@ func main() {
67
flag.Usage()
68
os.Exit(1)
69
}
70
+ flagTLSMode = strings.ToLower(strings.TrimSpace(flagTLSMode))
71
+ if flagTLSMode != string(sdk.TLSModeNoTLS) &&
72
+ flagTLSMode != string(sdk.TLSModeSelf) &&
73
+ flagTLSMode != string(sdk.TLSModeKeyless) {
74
+ log.Error().Str("tls_mode", flagTLSMode).Msg("--tls-mode must be one of: no-tls, self, keyless")
75
+ os.Exit(1)
76
+ }
77
78
relayURLs := parseURLs(flagRelayURLs)
79
if len(relayURLs) == 0 {
@@ -99,14 +111,36 @@ func runServiceTunnel(ctx context.Context, relayURLs []string) error {
111
log.Info().Msg("Starting Portal Tunnel...")
112
log.Info().Msgf(" Local: %s", flagHost)
113
log.Info().Msgf(" Relays: %s", strings.Join(relayURLs, ", "))
102
- log.Info().Msgf(" TLS Mode: %v", flagTLSEnable)
114
+ tlsEnabled := flagTLSMode != string(sdk.TLSModeNoTLS)
115
+ log.Info().Msgf(" TLS: %v", tlsEnabled)
116
+ if tlsEnabled {
117
+ log.Info().Msgf(" TLS Mode: %s", flagTLSMode)
118
+ }
119
120
var clientOpts []sdk.ClientOption
121
clientOpts = append(clientOpts, sdk.WithBootstrapServers(relayURLs))
122
107
- if flagTLSEnable {
108
- clientOpts = append(clientOpts, sdk.WithTLS())
109
- log.Info().Msg("TLS: Using relay ACME DNS-01 (E2EE)")
123
+ if tlsEnabled {
124
+ if flagTLSMode == string(sdk.TLSModeSelf) {
125
+ certFile := strings.TrimSpace(flagTLSCertFile)
126
+ keyFile := strings.TrimSpace(flagTLSKeyFile)
127
+ clientOpts = append(clientOpts, sdk.WithTLSSelfCertificateFiles(certFile, keyFile))
128
+ log.Info().
129
+ Str("cert_file", certFile).
130
+ Str("key_file", keyFile).
131
+ Msg("TLS: Using self-managed local certificate")
132
+ } else if flagTLSMode == string(sdk.TLSModeKeyless) {
133
+ certFile := strings.TrimSpace(flagTLSCertFile)
134
+ if certFile != "" {
135
+ log.Warn().
136
+ Str("cert_file", certFile).
137
+ Msg("Ignoring --tls-cert-file in keyless mode (SDK auto configuration only)")
138
+ }
139
+ clientOpts = append(clientOpts, sdk.WithTLSKeylessDefaults())
140
+ log.Info().Msg("TLS: Using keyless remote signer (SDK auto configuration)")
141
+ } else {
142
+ return fmt.Errorf("unsupported TLS mode: %s", flagTLSMode)
143
+ }
144
}
145
146
client, err := sdk.NewClient(clientOpts...)
@@ -140,7 +174,7 @@ func runServiceTunnel(ctx context.Context, relayURLs []string) error {
174
if leaseAware, ok := listener.(interface{ LeaseID() string }); ok {
175
log.Info().Msgf("- Lease ID: %s", leaseAware.LeaseID())
176
}
143
- if flagTLSEnable {
177
+ if tlsEnabled {
178
log.Info().Msg("- TLS: Enabled")
179
}
180
@@ -174,10 +208,10 @@ func runServiceTunnel(ctx context.Context, relayURLs []string) error {
208
go func(relayConn net.Conn) {
209
defer connWG.Done()
210
proxyType := "TCP"
177
- if flagTLSEnable {
211
+ if tlsEnabled {
212
proxyType = "TLS→TCP"
213
}
180
- if err := proxyConnection(ctx, flagHost, relayConn, flagTLSEnable); err != nil {
214
+ if err := proxyConnection(ctx, flagHost, relayConn, tlsEnabled); err != nil {
215
log.Error().Str("proxy", proxyType).Err(err).Msg("Proxy error")
216
}
217
log.Info().Str("proxy", proxyType).Msg("Connection closed")
cmd/relay-server/frontend/src/components/TunnelCommandModal.tsx
+130
-20
@@ -16,6 +16,8 @@ interface TunnelCommandModalProps {
16
trigger?: React.ReactNode;
17
}
18
19
+type TLSMode = "no-tls" | "self" | "keyless";
20
+
21
export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
22
const defaultHost = "localhost:3000";
23
const defaultName = "your-app-name";
@@ -34,7 +36,9 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
36
const [urlInput, setUrlInput] = useState("");
37
const [copied, setCopied] = useState(false);
38
const [os, setOs] = useState<"unix" | "windows">("unix");
37
- const [tlsEnabled, setTlsEnabled] = useState(true);
39
+ const [tlsMode, setTlsMode] = useState<TLSMode>("keyless");
40
+ const [tlsCertFile, setTlsCertFile] = useState("");
41
+ const [tlsKeyFile, setTlsKeyFile] = useState("");
42
const urlInputRef = useRef<HTMLInputElement>(null);
43
44
const addRelayUrl = (url: string) => {
@@ -76,13 +80,34 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
80
relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
81
82
if (os === "windows") {
79
- const tlsEnv = tlsEnabled ? "$env:TLS_ENABLE=\"true\"; " : "$env:TLS_ENABLE=\"false\"; ";
83
+ let tlsEnv = `$env:TLS_MODE="${tlsMode}"; `;
84
+ if (tlsMode === "self" && tlsCertFile.trim() !== "") {
85
+ tlsEnv += `$env:TLS_CERT_FILE="${tlsCertFile}"; `;
86
+ }
87
+ if (tlsMode === "self" && tlsKeyFile.trim() !== "") {
88
+ tlsEnv += `$env:TLS_KEY_FILE="${tlsKeyFile}"; `;
89
+ }
90
return `$ProgressPreference = 'SilentlyContinue'; ${tlsEnv}$env:HOST="${hostVal}"; $env:NAME="${nameVal}"; $env:RELAY_URL="${relayUrlVal}"; irm ${currentOrigin}/tunnel?os=windows | iex`;
91
}
92
83
- const tlsEnv = tlsEnabled ? "TLS_ENABLE=true " : "TLS_ENABLE=false ";
93
+ let tlsEnv = `TLS_MODE=${tlsMode} `;
94
+ if (tlsMode === "self" && tlsCertFile.trim() !== "") {
95
+ tlsEnv += `TLS_CERT_FILE="${tlsCertFile}" `;
96
+ }
97
+ if (tlsMode === "self" && tlsKeyFile.trim() !== "") {
98
+ tlsEnv += `TLS_KEY_FILE="${tlsKeyFile}" `;
99
+ }
100
return `curl -fsSL ${currentOrigin}/tunnel | ${tlsEnv}HOST=${hostVal} NAME=${nameVal} RELAY_URL="${relayUrlVal}" sh`;
85
- }, [currentOrigin, host, name, relayUrls, os, tlsEnabled]);
101
+ }, [
102
+ currentOrigin,
103
+ host,
104
+ name,
105
+ relayUrls,
106
+ os,
107
+ tlsMode,
108
+ tlsCertFile,
109
+ tlsKeyFile,
110
+ ]);
111
112
const handleCopy = async () => {
113
try {
@@ -103,15 +128,14 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
128
</Button>
129
)}
130
</DialogTrigger>
106
- <DialogContent className="sm:max-w-[500px] rounded-sm">
131
+ <DialogContent className="sm:max-w-[500px] rounded-sm max-h-[85vh] overflow-y-auto">
132
<DialogHeader>
133
<DialogTitle className="flex items-center gap-2">
134
<Terminal className="w-5 h-5" />
135
Tunnel Setup Command
136
</DialogTitle>
137
<DialogDescription>
113
- Configure your tunnel settings and copy the command to start
114
- exposing your local server.
138
+ Set options and copy the command.
139
</DialogDescription>
140
</DialogHeader>
141
@@ -234,21 +258,107 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
258
259
{/* TLS Mode */}
260
<div className="space-y-2">
237
- <label className="flex items-center gap-2 cursor-pointer">
238
- <input
239
- type="checkbox"
240
- checked={tlsEnabled}
241
- onChange={(e) => setTlsEnabled(e.target.checked)}
242
- className="w-4 h-4 rounded border-input cursor-pointer"
243
- />
244
- <span className="text-sm font-medium text-foreground">
245
- TLS Mode (End-to-End Encryption)
246
- </span>
261
+ <label className="text-sm font-medium text-foreground">
262
+ TLS Mode
263
</label>
264
+ <div className="flex p-1 bg-border rounded-md">
265
+ <button
266
+ type="button"
267
+ onClick={() => setTlsMode("keyless")}
268
+ className={cn(
269
+ "flex-1 px-3 py-1.5 text-sm font-medium rounded-sm transition-all",
270
+ tlsMode === "keyless"
271
+ ? "bg-background text-foreground shadow-sm"
272
+ : "text-muted-foreground hover:text-foreground"
273
+ )}
274
+ >
275
+ keyless
276
+ </button>
277
+ <button
278
+ type="button"
279
+ onClick={() => setTlsMode("no-tls")}
280
+ className={cn(
281
+ "flex-1 px-3 py-1.5 text-sm font-medium rounded-sm transition-all",
282
+ tlsMode === "no-tls"
283
+ ? "bg-background text-foreground shadow-sm"
284
+ : "text-muted-foreground hover:text-foreground"
285
+ )}
286
+ >
287
+ no tls
288
+ </button>
289
+ <button
290
+ type="button"
291
+ onClick={() => setTlsMode("self")}
292
+ className={cn(
293
+ "flex-1 px-3 py-1.5 text-sm font-medium rounded-sm transition-all",
294
+ tlsMode === "self"
295
+ ? "bg-background text-foreground shadow-sm"
296
+ : "text-muted-foreground hover:text-foreground"
297
+ )}
298
+ >
299
+ self tls
300
+ </button>
301
+ </div>
302
+ <p className="text-xs text-text-muted">
303
+ {tlsMode === "no-tls"
304
+ ? "No TLS"
305
+ : tlsMode === "keyless"
306
+ ? "Keyless (auto)"
307
+ : "Self TLS (manual cert/key)"}
308
+ </p>
309
+ </div>
310
+
311
+ <div
312
+ className={cn(
313
+ "space-y-3 rounded-md border border-input/80 p-3 transition-opacity",
314
+ tlsMode === "self" ? "opacity-100" : "opacity-60"
315
+ )}
316
+ >
317
+ <p className="text-xs font-medium text-foreground">Self TLS Files</p>
318
+ <div className="space-y-2">
319
+ <label
320
+ htmlFor="tls-cert-file"
321
+ className="text-sm font-medium text-foreground"
322
+ >
323
+ TLS Cert File
324
+ </label>
325
+ <div className="flex items-center rounded-md bg-border">
326
+ <span className="px-3 text-sm text-text-muted">TLS_CERT_FILE=</span>
327
+ <Input
328
+ id="tls-cert-file"
329
+ type="text"
330
+ value={tlsCertFile}
331
+ onChange={(e) => setTlsCertFile(e.target.value)}
332
+ placeholder="/path/to/fullchain.pem"
333
+ className="rounded-l-none"
334
+ disabled={tlsMode !== "self"}
335
+ />
336
+ </div>
337
+ </div>
338
+ <div className="space-y-2">
339
+ <label
340
+ htmlFor="tls-key-file"
341
+ className="text-sm font-medium text-foreground"
342
+ >
343
+ TLS Key File
344
+ </label>
345
+ <div className="flex items-center rounded-md bg-border">
346
+ <span className="px-3 text-sm text-text-muted">TLS_KEY_FILE=</span>
347
+ <Input
348
+ id="tls-key-file"
349
+ type="text"
350
+ value={tlsKeyFile}
351
+ onChange={(e) => setTlsKeyFile(e.target.value)}
352
+ placeholder="/path/to/privkey.pem"
353
+ className="rounded-l-none"
354
+ disabled={tlsMode !== "self"}
355
+ />
356
+ </div>
357
+ </div>
358
<p className="text-xs text-text-muted">
249
- {tlsEnabled
250
- ? "End-to-end encryption with relay ACME DNS-01 certificates"
251
- : "HTTP proxy mode without TLS termination"}
359
+ {tlsMode === "self"
360
+ ? "Self TLS selected. These files are included in the command."
361
+ : "This section is used only when TLS mode is self tls."}
362
</p>
363
</div>
364
cmd/relay-server/main.go
+7
-8
@@ -31,10 +31,7 @@ var (
31
flagBootstraps []string
32
33
// TLS
34
- flagSNIPort string
35
- flagACMEDNSProvider string
36
- flagACMEEmail string
37
- flagACMEDirectory string
34
+ flagSNIPort string
35
)
36
37
func main() {
@@ -67,9 +64,6 @@ func main() {
64
65
// TLS flags
66
flag.StringVar(&flagSNIPort, "sni-port", defaultSNIPort, "SNI router port (env: SNI_PORT)")
70
- flag.StringVar(&flagACMEDNSProvider, "acme-dns-provider", os.Getenv("ACME_DNS_PROVIDER"), "ACME DNS provider (env: ACME_DNS_PROVIDER)")
71
- flag.StringVar(&flagACMEEmail, "acme-email", os.Getenv("ACME_EMAIL"), "ACME account email (env: ACME_EMAIL)")
72
- flag.StringVar(&flagACMEDirectory, "acme-directory", os.Getenv("ACME_DIRECTORY"), "ACME directory URL (env: ACME_DIRECTORY)")
67
68
flag.Parse()
69
@@ -88,7 +82,12 @@ func runServer() error {
82
Strs("bootstrap_uris", flagBootstraps).
83
Msg("[server] frontend configuration")
84
91
- serv := portal.NewRelayServer(ctx, flagBootstraps, flagSNIPort, flagPortalURL, flagACMEDNSProvider, flagACMEEmail, flagACMEDirectory)
85
+ serv := portal.NewRelayServer(
86
+ ctx,
87
+ flagBootstraps,
88
+ flagSNIPort,
89
+ flagPortalURL,
90
+ )
91
92
frontend := NewFrontend()
93
authManager := manager.NewAuthManager(flagAdminSecretKey)
cmd/relay-server/registry.go
+21
-126
@@ -12,7 +12,6 @@ import (
12
"golang.org/x/net/websocket"
13
14
"gosuda.org/portal/portal"
15
- "gosuda.org/portal/portal/utils/cert"
15
"gosuda.org/portal/sdk"
16
)
17
@@ -30,8 +29,6 @@ func (r *SDKRegistry) HandleSDKRequest(w http.ResponseWriter, req *http.Request,
29
r.handleUnregister(w, req, serv)
30
case "renew":
31
r.handleRenew(w, req, serv)
33
- case "csr":
34
- r.handleCSR(w, req, serv)
32
case "domain":
33
r.handleDomain(w, req, serv)
34
case "connect":
@@ -91,6 +88,16 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
88
})
89
return
90
}
91
+ tlsMode := normalizeTLSMode(registerReq.TLSMode)
92
+ switch tlsMode {
93
+ case sdk.TLSModeNoTLS, sdk.TLSModeSelf, sdk.TLSModeKeyless:
94
+ default:
95
+ writeJSON(w, sdk.RegisterResponse{
96
+ Success: false,
97
+ Message: "tls_mode must be one of: no-tls, self, keyless",
98
+ })
99
+ return
100
+ }
101
102
// Create lease
103
lease := &portal.Lease{
@@ -98,7 +105,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
105
Name: registerReq.Name,
106
Metadata: registerReq.Metadata,
107
Expires: time.Now().Add(30 * time.Second),
101
- TLSEnabled: registerReq.TLSEnabled,
108
+ TLSMode: string(tlsMode),
109
ReverseToken: strings.TrimSpace(registerReq.ReverseToken),
110
}
111
@@ -114,8 +121,8 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
121
// Clear dropped state in case this is a re-registration after disconnect
122
serv.GetReverseHub().ClearDropped(registerReq.LeaseID)
123
117
- // Only register SNI route for TLS-enabled leases
118
- if registerReq.TLSEnabled {
124
+ // Only register SNI route for TLS leases.
125
+ if normalizeTLSMode(tlsMode) != sdk.TLSModeNoTLS {
126
if err := registerSNIRoute(serv, registerReq.LeaseID, registerReq.Name); err != nil {
127
// Keep lease and route state consistent on partial failure.
128
serv.GetLeaseManager().DeleteLease(registerReq.LeaseID)
@@ -130,7 +137,7 @@ func (r *SDKRegistry) handleRegister(w http.ResponseWriter, req *http.Request, s
137
log.Info().
138
Str("lease_id", registerReq.LeaseID).
139
Str("name", registerReq.Name).
133
- Bool("tls_enabled", registerReq.TLSEnabled).
140
+ Str("tls_mode", string(tlsMode)).
141
Msg("[Registry] Lease registered")
142
143
// Build public URL
@@ -251,8 +258,8 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
258
}
259
260
// Re-register route if needed (e.g., router restarted while lease remained active).
254
- // Only TLS-enabled leases need SNI routes.
255
- if entry.Lease.TLSEnabled {
261
+ // Only TLS leases need SNI routes.
262
+ if normalizeTLSMode(sdk.TLSMode(entry.Lease.TLSMode)) != sdk.TLSModeNoTLS {
263
if err := registerSNIRoute(serv, entry.Lease.ID, entry.Lease.Name); err != nil {
264
log.Warn().
265
Err(err).
@@ -287,124 +294,12 @@ func unregisterSNIRoute(serv *portal.RelayServer, leaseID string) {
294
sniRouter.UnregisterRouteByLeaseID(leaseID)
295
}
296
290
-// handleCSR handles Certificate Signing Request submissions
291
-// The tunnel client submits a CSR, and the relay issues a certificate via ACME DNS-01
292
-func (r *SDKRegistry) handleCSR(w http.ResponseWriter, req *http.Request, serv *portal.RelayServer) {
293
- if req.Method != http.MethodPost {
294
- w.Header().Set("Allow", http.MethodPost)
295
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
296
- return
297
- }
298
-
299
- // Check if certificate manager is available
300
- if serv.GetCertManager() == nil {
301
- writeJSON(w, sdk.CSRResponse{
302
- Success: false,
303
- Message: "certificate issuance not configured on this relay",
304
- })
305
- return
306
- }
307
-
308
- var csrReq sdk.CSRRequest
309
- if err := json.NewDecoder(req.Body).Decode(&csrReq); err != nil {
310
- log.Error().Err(err).Msg("[Registry] Failed to decode CSR request")
311
- writeJSON(w, sdk.CSRResponse{
312
- Success: false,
313
- Message: "invalid request body",
314
- })
315
- return
297
+func normalizeTLSMode(mode sdk.TLSMode) sdk.TLSMode {
298
+ normalized := sdk.TLSMode(strings.ToLower(strings.TrimSpace(string(mode))))
299
+ if normalized == "" {
300
+ return sdk.TLSModeNoTLS
301
}
317
-
318
- // Validate request
319
- if csrReq.LeaseID == "" {
320
- writeJSON(w, sdk.CSRResponse{
321
- Success: false,
322
- Message: "lease_id is required",
323
- })
324
- return
325
- }
326
- if csrReq.ReverseToken == "" {
327
- writeJSON(w, sdk.CSRResponse{
328
- Success: false,
329
- Message: "reverse_token is required",
330
- })
331
- return
332
- }
333
- if len(csrReq.CSR) == 0 {
334
- writeJSON(w, sdk.CSRResponse{
335
- Success: false,
336
- Message: "csr is required",
337
- })
338
- return
339
- }
340
-
341
- // Authenticate via lease
342
- entry, ok := serv.GetLeaseManager().GetLeaseByID(csrReq.LeaseID)
343
- if !ok {
344
- writeJSON(w, sdk.CSRResponse{
345
- Success: false,
346
- Message: "lease not found",
347
- })
348
- return
349
- }
350
- if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(entry.Lease.ReverseToken)), []byte(strings.TrimSpace(csrReq.ReverseToken))) != 1 {
351
- writeJSON(w, sdk.CSRResponse{
352
- Success: false,
353
- Message: "unauthorized",
354
- })
355
- return
356
- }
357
-
358
- // Parse CSR to extract and validate domain
359
- csrDomain, err := cert.ParseCSRDomain(csrReq.CSR)
360
- if err != nil {
361
- writeJSON(w, sdk.CSRResponse{
362
- Success: false,
363
- Message: fmt.Sprintf("invalid CSR: %v", err),
364
- })
365
- return
366
- }
367
-
368
- // Validate domain matches lease name + base host
369
- expectedDomain := strings.ToLower(entry.Lease.Name) + "." + serv.BaseHost
370
- if strings.ToLower(csrDomain) != expectedDomain {
371
- writeJSON(w, sdk.CSRResponse{
372
- Success: false,
373
- Message: fmt.Sprintf("domain mismatch: expected %s", expectedDomain),
374
- })
375
- return
376
- }
377
-
378
- // Issue certificate
379
- certReq := &cert.CSRRequest{
380
- Domain: csrDomain,
381
- CSR: csrReq.CSR,
382
- }
383
-
384
- cert, err := serv.GetCertManager().IssueCertificate(req.Context(), certReq)
385
- if err != nil {
386
- log.Error().Err(err).
387
- Str("lease_id", csrReq.LeaseID).
388
- Str("domain", csrDomain).
389
- Msg("[Registry] Failed to issue certificate")
390
- writeJSON(w, sdk.CSRResponse{
391
- Success: false,
392
- Message: fmt.Sprintf("certificate issuance failed: %v", err),
393
- })
394
- return
395
- }
396
-
397
- log.Info().
398
- Str("lease_id", csrReq.LeaseID).
399
- Str("domain", csrDomain).
400
- Time("expires", cert.ExpiresAt).
401
- Msg("[Registry] Certificate issued")
402
-
403
- writeJSON(w, sdk.CSRResponse{
404
- Success: true,
405
- Certificate: cert.Certificate,
406
- ExpiresAt: cert.ExpiresAt.Format(time.RFC3339),
407
- })
302
+ return normalized
303
}
304
305
// handleDomain returns the relay's base domain for TLS certificate construction.
cmd/relay-server/serve.go
+7
-7
@@ -13,6 +13,7 @@ import (
13
"github.com/rs/zerolog/log"
14
15
"gosuda.org/portal/portal"
16
+ "gosuda.org/portal/sdk"
17
)
18
19
//go:embed dist/*
@@ -133,9 +134,8 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fr
134
return srv
135
}
136
136
-// shouldProxyHTTP checks if the request should be proxied via HTTP
137
-// based on the lease's TLSEnabled setting.
138
-// Returns true if TLS is NOT enabled (can proxy via HTTP).
137
+// shouldProxyHTTP checks if the request should be proxied via HTTP.
138
+// Returns true if TLS mode is no-tls.
139
func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
140
leaseName, ok := leaseNameFromHost(host, defaultAppPattern(flagPortalURL))
141
if !ok {
@@ -149,11 +149,11 @@ func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
149
return true
150
}
151
152
- // If TLS is NOT enabled, we can proxy via HTTP
153
- shouldProxy := !entry.Lease.TLSEnabled
152
+ // If TLS mode is no-tls, we can proxy via HTTP.
153
+ shouldProxy := normalizeTLSMode(sdk.TLSMode(entry.Lease.TLSMode)) == sdk.TLSModeNoTLS
154
log.Debug().
155
Str("lease_name", leaseName).
156
- Bool("tls_enabled", entry.Lease.TLSEnabled).
156
+ Str("tls_mode", entry.Lease.TLSMode).
157
Msg("[proxy] shouldProxyHTTP")
158
return shouldProxy
159
}
@@ -171,7 +171,7 @@ func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServe
171
return
172
}
173
174
- if entry.Lease.TLSEnabled {
174
+ if normalizeTLSMode(sdk.TLSMode(entry.Lease.TLSMode)) != sdk.TLSModeNoTLS {
175
http.Error(w, "TLS enabled requires HTTPS access", http.StatusBadRequest)
176
return
177
}
cmd/relay-server/tunnel.go
+9
-3
@@ -54,8 +54,10 @@ set -- "$BIN_PATH" --relay "$RELAY_URL" --host "${HOST:-localhost:3000}"
54
if [ "${HIDE:-}" = "1" ] || [ "${HIDE:-}" = "true" ]; then
55
set -- "$@" --hide
56
fi
57
-if [ "${TLS_ENABLE:-}" = "0" ] || [ "${TLS_ENABLE:-}" = "false" ]; then
58
- set -- "$@" --tls=false
57
+[ -n "${TLS_MODE:-}" ] && set -- "$@" --tls-mode "$TLS_MODE"
58
+if [ "${TLS_MODE:-}" = "self" ]; then
59
+ [ -n "${TLS_CERT_FILE:-}" ] && set -- "$@" --tls-cert-file "$TLS_CERT_FILE"
60
+ [ -n "${TLS_KEY_FILE:-}" ] && set -- "$@" --tls-key-file "$TLS_KEY_FILE"
61
fi
62
63
echo "Starting portal-tunnel..." >&2
@@ -101,7 +103,11 @@ if ($env:TAGS) { $ArgsList += "--tags", $env:TAGS }
103
if ($env:THUMBNAIL) { $ArgsList += "--thumbnail", $env:THUMBNAIL }
104
if ($env:OWNER) { $ArgsList += "--owner", $env:OWNER }
105
if ($env:HIDE -eq "1" -or $env:HIDE -eq "true") { $ArgsList += "--hide" }
104
-if ($env:TLS_ENABLE -eq "0" -or $env:TLS_ENABLE -eq "false") { $ArgsList += "--tls=false" }
106
+if ($env:TLS_MODE) { $ArgsList += "--tls-mode", $env:TLS_MODE }
107
+if ($env:TLS_MODE -eq "self") {
108
+ if ($env:TLS_CERT_FILE) { $ArgsList += "--tls-cert-file", $env:TLS_CERT_FILE }
109
+ if ($env:TLS_KEY_FILE) { $ArgsList += "--tls-key-file", $env:TLS_KEY_FILE }
110
+}
111
112
Write-Host "Starting portal-tunnel..."
113
try {
cmd/relay-server/utils.go
+2
-1
@@ -13,6 +13,7 @@ import (
13
14
"gosuda.org/portal/cmd/relay-server/manager"
15
"gosuda.org/portal/portal"
16
+ "gosuda.org/portal/sdk"
17
)
18
19
// parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
@@ -307,7 +308,7 @@ func (r *leaseRow) fromLeaseEntry(entry *portal.LeaseEntry, admin *Admin, portal
308
}
309
310
kind := "http"
310
- if lease.TLSEnabled {
311
+ if normalizeTLSMode(sdk.TLSMode(lease.TLSMode)) != sdk.TLSModeNoTLS {
312
kind = "https"
313
}
314
docker-compose.yml
-8
@@ -17,14 +17,6 @@ services:
17
18
# SNI router port (for TLS passthrough)
19
SNI_PORT: ${SNI_PORT:-:443}
20
-
21
- # ACME certificate management (optional)
22
- ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
23
- ACME_EMAIL: ${ACME_EMAIL:-}
24
- ACME_DIRECTORY: ${ACME_DIRECTORY:-}
25
-
26
- # DNS provider credentials (set as needed)
27
- CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
20
ports:
21
- "${PORTAL_PORT:-4017}:${PORTAL_PORT:-4017}"
22
- "80:80"
docs/architecture.md
+2
-2
@@ -46,14 +46,14 @@ Portal connects local applications to web users through a secure relay layer wit
46
47
## Security
48
49
-- **E2EE**: TLS passthrough with ACME certificates - relay routes TLS by SNI without termination
49
+- **E2EE**: TLS passthrough with keyless wildcard certificates - relay routes TLS by SNI without termination
50
- **Tokens**: Per-lease reverse connection tokens
51
- **SNI Routing**: TLS passthrough without decryption
52
53
## Protocol Stack
54
55
```
56
-TLS (E2EE via ACME certificate)
56
+TLS (E2EE via keyless certificate)
57
└── TCP
58
└── Application Data
59
```
docs/development.md
+1
-1
@@ -46,6 +46,6 @@ utils/ # Shared utilities
46
47
## Architecture Decisions
48
49
-- **E2EE**: TLS passthrough with ACME certificates
49
+- **E2EE**: TLS passthrough with keyless certificates
50
- **SNI Routing**: TLS routed by hostname without termination
51
- **No CGO**: pure Go for cross-platform builds
docs/portal-deploy-guide.md
+69
-47
@@ -1,15 +1,19 @@
1
# Portal Deploy Guide
2
3
-How to run a public Portal relay with TLS Passthrough and ACME DNS-01.
3
+How to run a public Portal relay with TLS passthrough.
4
5
## Overview
6
7
-Portal uses SNI-based TLS passthrough: the relay routes TLS connections by SNI to tunnel backends, and each tunnel gets its own certificate via ACME DNS-01.
7
+Portal uses SNI-based TLS passthrough: the relay routes TLS by SNI to tunnel backends.
8
+
9
+TLS certificate mode:
10
+- `self`: tunnel uses locally managed certificate and key files.
11
+- `keyless`: tunnel uses a local certificate chain and delegates signing to an external signer API (relay does not hold private key).
12
13
```
14
Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Backend (TLS mode)
15
│
12
- └── ACME DNS-01 issues cert for each tunnel
16
+ └── Self or keyless mode at tunnel
17
```
18
19
## Prerequisites
@@ -20,7 +24,7 @@ Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Back
24
- DNS A/AAAA records pointing to your server:
25
- `example.com -> <server IP>`
26
- `*.example.com -> <server IP>`
23
-- DNS provider API credentials (Cloudflare or Route53) for ACME DNS-01
27
+- Wildcard TLS certificate and private key for `*.example.com`
28
29
## Quick Start
30
@@ -42,11 +46,6 @@ example.com. AAAA 2001:db8::1
46
# Core
47
PORTAL_URL=https://example.com
48
ADMIN_SECRET_KEY=your-secure-key-here
45
-
46
-# ACME (Cloudflare example)
47
-ACME_DNS_PROVIDER=cloudflare
48
-ACME_EMAIL=admin@example.com
49
-CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
49
```
50
51
### 3. Run Portal
@@ -58,7 +57,7 @@ docker compose up -d
57
### 4. Run Tunnel
58
59
```bash
61
-portal-tunnel --host localhost:3000 --name myapp --relay https://example.com --tls
60
+portal-tunnel --host localhost:3000 --name myapp --relay https://example.com --tls-mode keyless
61
```
62
63
### 5. Access
@@ -78,28 +77,6 @@ https://myapp.example.com
77
| `ADMIN_SECRET_KEY` | (auto-generated) | Admin authentication key |
78
| `SNI_PORT` | `:443` | SNI router port |
79
81
-### ACME Certificate Management
82
-
83
-| Variable | Description |
84
-|----------|-------------|
85
-| `ACME_DNS_PROVIDER` | `cloudflare` or `route53` |
86
-| `ACME_EMAIL` | Email for ACME registration |
87
-| `ACME_DIRECTORY` | ACME directory URL (default: Let's Encrypt) |
88
-
89
-### DNS Provider Credentials
90
-
91
-**Cloudflare:**
92
-```bash
93
-CLOUDFLARE_API_TOKEN=your-api-token
94
-```
95
-
96
-**Route53:**
97
-```bash
98
-AWS_ACCESS_KEY_ID=your-access-key
99
-AWS_SECRET_ACCESS_KEY=your-secret-key
100
-AWS_REGION=us-east-1
101
-```
102
-
80
## docker-compose.yml
81
82
```yaml
@@ -109,9 +86,6 @@ services:
86
environment:
87
PORTAL_URL: ${PORTAL_URL}
88
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY}
112
- ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER}
113
- ACME_EMAIL: ${ACME_EMAIL}
114
- CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
89
ports:
90
- "4017:4017"
91
- "443:443"
@@ -124,20 +98,69 @@ services:
98
### TLS Mode (Production)
99
100
```bash
127
-portal-tunnel --host localhost:3000 --name myapp --relay https://example.com --tls
101
+portal-tunnel --host localhost:3000 --name myapp --relay https://example.com --tls-mode keyless
102
```
103
104
- `--host`: Local service address
105
- `--name`: Subdomain name (becomes `myapp.example.com`)
106
- `--relay`: Portal relay URL
133
-- `--tls`: Enable TLS
107
+- `--tls-mode`: `no-tls`, `self`, or `keyless`
108
109
How it works:
136
-1. Tunnel generates private key and CSR locally
137
-2. Sends CSR to relay via `/sdk/csr`
138
-3. Relay issues certificate via ACME DNS-01
110
+1. Tunnel performs TLS handshake on reverse tunnel connections
111
+2. In `self` mode, tunnel signs locally with certificate key
112
+3. In `keyless` mode, tunnel requests signatures from external signer API
113
4. TLS connections go directly to tunnel on port 443
114
115
+### TLS Self Mode (Tunnel-Managed Certificate)
116
+
117
+```bash
118
+portal-tunnel \
119
+ --host localhost:3000 \
120
+ --name myapp \
121
+ --relay https://example.com \
122
+ --tls-mode self \
123
+ --tls-cert-file /etc/ssl/myapp/fullchain.pem \
124
+ --tls-key-file /etc/ssl/myapp/privkey.pem
125
+```
126
+
127
+- Relay keyless endpoints are not used in self mode.
128
+- Tunnel must have direct access to certificate and key files.
129
+
130
+### TLS Keyless Mode (External Signer)
131
+
132
+```bash
133
+portal-tunnel \
134
+ --host localhost:3000 \
135
+ --name myapp \
136
+ --relay https://example.com \
137
+ --tls-mode keyless
138
+```
139
+
140
+- Keyless signer endpoint, key id, trust roots, and certificate chain are auto-configured by SDK defaults.
141
+- Auto-discovery expects an HTTPS signer endpoint.
142
+- External signer API must return TLS signature responses for the requested digest.
143
+
144
+Signer API request/response example:
145
+
146
+```json
147
+{
148
+ "key_id": "relay-cert",
149
+ "algorithm": "RSA_PSS_SHA256",
150
+ "digest": "<base64>",
151
+ "timestamp_unix": 1735628400,
152
+ "nonce": "c4d76ad40f5d8f95a1fe4b2f1c922f4a"
153
+}
154
+```
155
+
156
+```json
157
+{
158
+ "key_id": "relay-cert",
159
+ "algorithm": "RSA_PSS_SHA256",
160
+ "signature": "<base64>"
161
+}
162
+```
163
+
164
### Non-TLS Mode (Development Only)
165
166
```bash
@@ -181,7 +204,7 @@ curl -fsSL https://example.com/tunnel | HOST=localhost:3000 NAME=test sh
204
│ │
205
:443 TLS ──────►│ ┌─────────────┐ ┌─────────────────────────────┐ │
206
│ │ SNI Router │───►│ Tunnel Backend │ │
184
- │ │ │ │ w/ ACME certificate │ │
207
+ │ │ │ │ w/ keyless certificate │ │
208
│ └─────────────┘ └─────────────────────────────┘ │
209
│ │
210
├─────────────────────────────────────────────────────┤
@@ -212,15 +235,14 @@ sudo netstat -tlnp | grep :443
235
sudo setcap 'cap_net_bind_service=+ep' ./bin/relay-server
236
```
237
215
-### ACME Certificate Issuance Fails
238
+### TLS Certificate Load Fails
239
240
```bash
218
-# Verify credentials
219
-echo $CLOUDFLARE_API_TOKEN
220
-echo $ACME_EMAIL
241
+# Verify self TLS certificate files
242
+ls -l /etc/ssl/myapp/fullchain.pem /etc/ssl/myapp/privkey.pem
243
244
# Check logs
223
-docker compose logs portal | grep -i acme
245
+docker compose logs portal
246
```
247
248
### Tunnel Cannot Connect
@@ -230,5 +252,5 @@ docker compose logs portal | grep -i acme
252
curl https://example.com/healthz
253
254
# Run tunnel with verbose logging
233
-portal-tunnel --host localhost:3000 --name test --relay https://example.com --tls
255
+portal-tunnel --host localhost:3000 --name test --relay https://example.com --tls-mode keyless
256
```
go.mod
+2
-25
@@ -1,38 +1,15 @@
1
module gosuda.org/portal
2
3
-go 1.25.3
3
+go 1.26.0
4
5
require (
6
- github.com/go-acme/lego/v4 v4.32.0
6
+ github.com/gosuda/keyless_tls v0.0.0-20260227040929-641e56ad6610
7
github.com/rs/zerolog v1.34.0
8
golang.org/x/net v0.50.0
9
)
10
11
require (
12
- github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
13
- github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect
14
- github.com/aws/aws-sdk-go-v2/credentials v1.19.8 // indirect
15
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
16
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
17
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
18
- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
19
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
20
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
21
- github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 // indirect
22
- github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
23
- github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
24
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
25
- github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
26
- github.com/aws/smithy-go v1.24.0 // indirect
27
- github.com/cenkalti/backoff/v5 v5.0.3 // indirect
28
- github.com/go-jose/go-jose/v4 v4.1.3 // indirect
12
github.com/mattn/go-colorable v0.1.14 // indirect
13
github.com/mattn/go-isatty v0.0.20 // indirect
31
- github.com/miekg/dns v1.1.72 // indirect
32
- golang.org/x/crypto v0.48.0 // indirect
33
- golang.org/x/mod v0.32.0 // indirect
34
- golang.org/x/sync v0.19.0 // indirect
14
golang.org/x/sys v0.41.0 // indirect
36
- golang.org/x/text v0.34.0 // indirect
37
- golang.org/x/tools v0.41.0 // indirect
15
)
go.sum
+2
-58
@@ -1,45 +1,7 @@
1
-github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
2
-github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
3
-github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
4
-github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI=
5
-github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0=
6
-github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY=
7
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
8
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
9
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
10
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
11
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
12
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
13
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
14
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
15
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
16
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
17
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
18
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
19
-github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
20
-github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
21
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
22
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
23
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
24
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
25
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
26
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
27
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
28
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
29
-github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
30
-github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
31
-github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
32
-github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
1
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
34
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
35
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
36
-github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
37
-github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
38
-github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
39
-github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
2
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
41
-github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
42
-github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
3
+github.com/gosuda/keyless_tls v0.0.0-20260227040929-641e56ad6610 h1:P29C9M4t/LHIiEkwTZ96ohlPb3qmzwxLdhNai878LMQ=
4
+github.com/gosuda/keyless_tls v0.0.0-20260227040929-641e56ad6610/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
5
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
6
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
7
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -47,32 +9,14 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
9
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
10
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
11
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
50
-github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
51
-github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
12
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
53
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
54
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
13
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
14
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
15
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
58
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
59
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
60
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
61
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
62
-golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
63
-golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
16
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
17
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
66
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
67
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
18
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
19
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
21
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
22
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
73
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
74
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
75
-golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
76
-golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
77
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
78
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
portal/lease.go
+2
-2
@@ -23,8 +23,8 @@ type Lease struct {
23
Name string `json:"name"`
24
Metadata Metadata `json:"metadata"`
25
Expires time.Time `json:"expires"`
26
- TLSEnabled bool `json:"tls_enabled"` // Whether the tunnel client handles TLS termination
27
- ReverseToken string `json:"-"` // shared secret for reverse connect authentication
26
+ TLSMode string `json:"tls_mode"` // no-tls, self, keyless
27
+ ReverseToken string `json:"-"` // shared secret for reverse connect authentication
28
}
29
30
// Metadata holds service metadata
portal/relay.go
-41
@@ -10,7 +10,6 @@ import (
10
11
"github.com/rs/zerolog/log"
12
13
- "gosuda.org/portal/portal/utils/cert"
13
"gosuda.org/portal/portal/utils/sni"
14
)
15
@@ -21,7 +20,6 @@ type RelayServer struct {
20
leaseManager *LeaseManager
21
reverseHub *ReverseHub
22
sniRouter *sni.Router
24
- certManager cert.Manager
23
24
stopch chan struct{}
25
waitgroup sync.WaitGroup
@@ -33,37 +31,18 @@ func NewRelayServer(
31
address []string,
32
sniPort string,
33
portalURL string,
36
- acmeDNSProvider string,
37
- acmeEmail string,
38
- acmeDirectory string,
34
) *RelayServer {
40
- var certManager cert.Manager
35
baseDomain := extractBaseDomain(portalURL)
36
if baseDomain == "" {
37
log.Warn().Msg("[RelayServer] Could not extract base domain from portal URL")
38
}
39
46
- acmeCfg := buildACMEConfig(baseDomain, acmeDNSProvider, acmeEmail, acmeDirectory)
47
- if acmeCfg != nil {
48
- var err error
49
- certManager, err = cert.NewACMEManager(ctx, acmeCfg)
50
- if err != nil {
51
- log.Error().Err(err).Msg("[RelayServer] Failed to create ACME manager, TLSAuto disabled")
52
- } else {
53
- log.Info().
54
- Str("dns_provider", acmeCfg.DNSProviderType).
55
- Str("base_domain", acmeCfg.BaseDomain).
56
- Msg("[RelayServer] ACME certificate manager initialized")
57
- }
58
- }
59
-
40
server := &RelayServer{
41
BaseHost: baseDomain,
42
address: address,
43
leaseManager: NewLeaseManager(30 * time.Second),
44
reverseHub: NewReverseHub(),
45
sniRouter: sni.NewRouter(sniPort),
66
- certManager: certManager,
46
stopch: make(chan struct{}),
47
}
48
server.leaseManager.SetOnLeaseDeleted(server.reverseHub.DropLease)
@@ -81,21 +60,6 @@ func NewRelayServer(
60
return server
61
}
62
84
-func buildACMEConfig(baseDomain, dnsProvider, email, directory string) *cert.ACMEConfig {
85
- dnsProvider = strings.TrimSpace(dnsProvider)
86
- email = strings.TrimSpace(email)
87
- if dnsProvider == "" || email == "" {
88
- return nil
89
- }
90
-
91
- return &cert.ACMEConfig{
92
- BaseDomain: baseDomain,
93
- DNSProviderType: dnsProvider,
94
- Email: email,
95
- DirectoryURL: strings.TrimSpace(directory),
96
- }
97
-}
98
-
63
func extractBaseDomain(rawURL string) string {
64
trimmed := strings.TrimSpace(rawURL)
65
if trimmed == "" {
@@ -133,11 +97,6 @@ func (g *RelayServer) GetSNIRouter() *sni.Router {
97
return g.sniRouter
98
}
99
136
-// GetCertManager returns the certificate manager instance.
137
-func (g *RelayServer) GetCertManager() cert.Manager {
138
- return g.certManager
139
-}
140
-
100
// Start starts the relay server.
101
func (g *RelayServer) Start() error {
102
g.leaseManager.Start()
portal/utils/cert/acme.go
deleted
-305
@@ -1,305 +0,0 @@
1
-package cert
2
-
3
-import (
4
- "context"
5
- "crypto"
6
- "crypto/ecdsa"
7
- "crypto/elliptic"
8
- "crypto/rand"
9
- "crypto/x509"
10
- "encoding/pem"
11
- "fmt"
12
- "os"
13
- "strings"
14
- "sync"
15
- "time"
16
-
17
- "github.com/go-acme/lego/v4/certificate"
18
- "github.com/go-acme/lego/v4/lego"
19
- "github.com/go-acme/lego/v4/providers/dns/cloudflare"
20
- "github.com/go-acme/lego/v4/providers/dns/route53"
21
- "github.com/go-acme/lego/v4/registration"
22
- "github.com/rs/zerolog/log"
23
-)
24
-
25
-// ACMEManager implements Manager using LEGO ACME client with DNS-01 challenge.
26
-type ACMEManager struct {
27
- client *lego.Client
28
- dnsProvider DNSProvider
29
- baseDomain string
30
- mu sync.Mutex
31
-}
32
-
33
-// ACMEConfig contains configuration for the ACME manager.
34
-type ACMEConfig struct {
35
- // BaseDomain is the base domain for subdomains (e.g., "portal.com")
36
- BaseDomain string
37
-
38
- // DNSProviderType specifies which DNS provider to use (cloudflare, route53)
39
- DNSProviderType string
40
-
41
- // DirectoryURL is the ACME directory URL (defaults to Let's Encrypt production)
42
- DirectoryURL string
43
-
44
- // Email is the email for ACME account registration
45
- Email string
46
-}
47
-
48
-// NewACMEManager creates a new ACME certificate manager.
49
-func NewACMEManager(ctx context.Context, cfg *ACMEConfig) (*ACMEManager, error) {
50
- if cfg.BaseDomain == "" {
51
- return nil, fmt.Errorf("base domain is required")
52
- }
53
- if cfg.Email == "" {
54
- return nil, fmt.Errorf("email is required for ACME registration")
55
- }
56
-
57
- dnsProvider, err := createDNSProvider(cfg.DNSProviderType)
58
- if err != nil {
59
- return nil, fmt.Errorf("create DNS provider: %w", err)
60
- }
61
-
62
- // Generate ACME account key
63
- accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
64
- if err != nil {
65
- return nil, fmt.Errorf("generate account key: %w", err)
66
- }
67
-
68
- user := &acmeUser{
69
- email: cfg.Email,
70
- key: accountKey,
71
- }
72
-
73
- legoCfg := lego.NewConfig(user)
74
- legoCfg.CADirURL = getDirectoryURL(cfg.DirectoryURL)
75
-
76
- client, err := lego.NewClient(legoCfg)
77
- if err != nil {
78
- return nil, fmt.Errorf("create lego client: %w", err)
79
- }
80
-
81
- // Set up DNS-01 challenge
82
- if err := client.Challenge.SetDNS01Provider(dnsProvider); err != nil {
83
- return nil, fmt.Errorf("set DNS01 provider: %w", err)
84
- }
85
-
86
- // Register account
87
- reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
88
- if err != nil {
89
- return nil, fmt.Errorf("register ACME account: %w", err)
90
- }
91
- user.registration = reg
92
-
93
- log.Info().
94
- Str("base_domain", cfg.BaseDomain).
95
- Str("dns_provider", cfg.DNSProviderType).
96
- Str("email", cfg.Email).
97
- Msg("[cert] ACME manager initialized")
98
-
99
- return &ACMEManager{
100
- client: client,
101
- dnsProvider: dnsProvider,
102
- baseDomain: cfg.BaseDomain,
103
- }, nil
104
-}
105
-
106
-// IssueCertificate issues a certificate for the given CSR.
107
-// The CSR must be PEM-encoded and already contains the public key.
108
-// The private key remains with the caller; only the cert chain is returned.
109
-func (m *ACMEManager) IssueCertificate(ctx context.Context, req *CSRRequest) (*Certificate, error) {
110
- if req.Domain == "" {
111
- return nil, fmt.Errorf("domain is required")
112
- }
113
- if len(req.CSR) == 0 {
114
- return nil, fmt.Errorf("CSR is required")
115
- }
116
-
117
- m.mu.Lock()
118
- defer m.mu.Unlock()
119
-
120
- // Decode PEM-encoded CSR and parse to x509.CertificateRequest
121
- csr, err := parsePEMCSR(req.CSR)
122
- if err != nil {
123
- return nil, fmt.Errorf("parse CSR: %w", err)
124
- }
125
-
126
- certReq := certificate.ObtainForCSRRequest{
127
- CSR: csr,
128
- Bundle: true,
129
- }
130
-
131
- cert, err := m.client.Certificate.ObtainForCSR(certReq)
132
- if err != nil {
133
- return nil, fmt.Errorf("obtain certificate: %w", err)
134
- }
135
-
136
- issuedAt, expiresAt := parseCertValidity(cert.Certificate)
137
-
138
- log.Info().
139
- Str("domain", req.Domain).
140
- Time("expires", expiresAt).
141
- Msg("[cert] Certificate issued")
142
-
143
- return &Certificate{
144
- Domain: req.Domain,
145
- Certificate: append(cert.Certificate, cert.IssuerCertificate...),
146
- IssuedAt: issuedAt,
147
- ExpiresAt: expiresAt,
148
- }, nil
149
-}
150
-
151
-// GetCACertificate returns the CA certificate.
152
-func (m *ACMEManager) GetCACertificate(ctx context.Context) ([]byte, error) {
153
- // Return Let's Encrypt root certificate
154
- // For production: ISRG Root X1
155
- return []byte(`-----BEGIN CERTIFICATE-----
156
-MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
157
-TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
158
-cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
159
-WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
160
-ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
161
-MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfE3SZL46XH
162
-FY3uLK8C2RZ8i6W4L3H9J8S7t3z3pVlZfXK5L8U2K9yB3N0P4Z1vX8m8k3pW1oG
163
------END CERTIFICATE-----`), nil
164
-}
165
-
166
-// parsePEMCSR decodes a PEM-encoded CSR and returns the parsed CertificateRequest.
167
-func parsePEMCSR(pemData []byte) (*x509.CertificateRequest, error) {
168
- block, _ := pem.Decode(pemData)
169
- if block == nil {
170
- return nil, fmt.Errorf("failed to decode PEM block")
171
- }
172
- if block.Type != "CERTIFICATE REQUEST" {
173
- return nil, fmt.Errorf("expected CERTIFICATE REQUEST, got %s", block.Type)
174
- }
175
- return x509.ParseCertificateRequest(block.Bytes)
176
-}
177
-
178
-// ParseCSRDomain extracts the domain (CommonName or first DNSNames) from a PEM-encoded CSR.
179
-func ParseCSRDomain(pemData []byte) (string, error) {
180
- csr, err := parsePEMCSR(pemData)
181
- if err != nil {
182
- return "", err
183
- }
184
-
185
- // Prefer CommonName, fall back to first DNSName
186
- if csr.Subject.CommonName != "" {
187
- return csr.Subject.CommonName, nil
188
- }
189
- if len(csr.DNSNames) > 0 {
190
- return csr.DNSNames[0], nil
191
- }
192
- return "", fmt.Errorf("CSR has no CommonName or DNSNames")
193
-}
194
-
195
-func createDNSProvider(providerType string) (DNSProvider, error) {
196
- switch strings.ToLower(providerType) {
197
- case "cloudflare":
198
- return newCloudflareProvider()
199
- case "route53":
200
- return newRoute53Provider()
201
- default:
202
- return nil, fmt.Errorf("unsupported DNS provider: %s", providerType)
203
- }
204
-}
205
-
206
-func newCloudflareProvider() (DNSProvider, error) {
207
- apiToken := os.Getenv("CLOUDFLARE_API_TOKEN")
208
- if apiToken == "" {
209
- return nil, fmt.Errorf("CLOUDFLARE_API_TOKEN environment variable not set")
210
- }
211
-
212
- cfg := cloudflare.NewDefaultConfig()
213
- cfg.AuthToken = apiToken
214
-
215
- provider, err := cloudflare.NewDNSProviderConfig(cfg)
216
- if err != nil {
217
- return nil, fmt.Errorf("create cloudflare provider: %w", err)
218
- }
219
-
220
- return &cloudflareProviderAdapter{provider: provider}, nil
221
-}
222
-
223
-func newRoute53Provider() (DNSProvider, error) {
224
- provider, err := route53.NewDNSProvider()
225
- if err != nil {
226
- return nil, fmt.Errorf("create route53 provider: %w", err)
227
- }
228
-
229
- return &route53ProviderAdapter{provider: provider}, nil
230
-}
231
-
232
-func getDirectoryURL(url string) string {
233
- if url == "" {
234
- return lego.LEDirectoryProduction
235
- }
236
- return url
237
-}
238
-
239
-func parseCertValidity(certPEM []byte) (issuedAt, expiresAt time.Time) {
240
- // Decode PEM block
241
- block, _ := pem.Decode(certPEM)
242
- if block == nil {
243
- return time.Now(), time.Now().Add(90 * 24 * time.Hour)
244
- }
245
-
246
- // Parse certificate to extract validity period
247
- cert, err := x509.ParseCertificate(block.Bytes)
248
- if err != nil {
249
- return time.Now(), time.Now().Add(90 * 24 * time.Hour)
250
- }
251
- return cert.NotBefore, cert.NotAfter
252
-}
253
-
254
-// acmeUser implements lego's registration.User interface
255
-type acmeUser struct {
256
- email string
257
- key *ecdsa.PrivateKey
258
- registration *registration.Resource
259
-}
260
-
261
-func (u *acmeUser) GetEmail() string {
262
- return u.email
263
-}
264
-
265
-func (u *acmeUser) GetRegistration() *registration.Resource {
266
- return u.registration
267
-}
268
-
269
-func (u *acmeUser) GetPrivateKey() crypto.PrivateKey {
270
- return u.key
271
-}
272
-
273
-// cloudflareProviderAdapter adapts cloudflare provider to our DNSProvider interface
274
-type cloudflareProviderAdapter struct {
275
- provider *cloudflare.DNSProvider
276
-}
277
-
278
-func (a *cloudflareProviderAdapter) Present(domain, token, keyAuth string) error {
279
- return a.provider.Present(domain, token, keyAuth)
280
-}
281
-
282
-func (a *cloudflareProviderAdapter) CleanUp(domain, token, keyAuth string) error {
283
- return a.provider.CleanUp(domain, token, keyAuth)
284
-}
285
-
286
-func (a *cloudflareProviderAdapter) Timeout() (timeout, interval time.Duration) {
287
- return a.provider.Timeout()
288
-}
289
-
290
-// route53ProviderAdapter adapts route53 provider to our DNSProvider interface
291
-type route53ProviderAdapter struct {
292
- provider *route53.DNSProvider
293
-}
294
-
295
-func (a *route53ProviderAdapter) Present(domain, token, keyAuth string) error {
296
- return a.provider.Present(domain, token, keyAuth)
297
-}
298
-
299
-func (a *route53ProviderAdapter) CleanUp(domain, token, keyAuth string) error {
300
- return a.provider.CleanUp(domain, token, keyAuth)
301
-}
302
-
303
-func (a *route53ProviderAdapter) Timeout() (timeout, interval time.Duration) {
304
- return a.provider.Timeout()
305
-}
portal/utils/cert/cert.go
deleted
-43
@@ -1,43 +0,0 @@
1
-package cert
2
-
3
-import (
4
- "context"
5
- "time"
6
-)
7
-
8
-// Certificate represents an issued certificate chain.
9
-type Certificate struct {
10
- Domain string // The domain name (e.g., "app1.portal.com")
11
- Certificate []byte // PEM-encoded certificate chain
12
- IssuedAt time.Time // When the certificate was issued
13
- ExpiresAt time.Time // When the certificate expires
14
-}
15
-
16
-// CSRRequest contains the data needed to issue a certificate.
17
-type CSRRequest struct {
18
- Domain string // The domain to issue for (e.g., "app1.portal.com")
19
- CSR []byte // PEM-encoded Certificate Signing Request
20
-}
21
-
22
-// Manager handles certificate issuance via ACME with DNS-01 challenge.
23
-type Manager interface {
24
- // IssueCertificate issues a certificate for the given CSR.
25
- // The private key remains with the caller; only the cert chain is returned.
26
- IssueCertificate(ctx context.Context, req *CSRRequest) (*Certificate, error)
27
-
28
- // GetCACertificate returns the CA certificate for verification.
29
- GetCACertificate(ctx context.Context) ([]byte, error)
30
-}
31
-
32
-// DNSProvider handles DNS-01 challenge lifecycle.
33
-// Signature matches lego's challenge.Provider.
34
-type DNSProvider interface {
35
- // Present creates a TXT record for the DNS-01 challenge.
36
- Present(domain, token, keyAuth string) error
37
-
38
- // CleanUp removes the TXT record after the challenge is complete.
39
- CleanUp(domain, token, keyAuth string) error
40
-
41
- // Timeout returns the timeout and interval for DNS propagation checking.
42
- Timeout() (timeout time.Duration, interval time.Duration)
43
-}
sdk/autocert.go
deleted
-226
@@ -1,226 +0,0 @@
1
-package sdk
2
-
3
-import (
4
- "context"
5
- "crypto/tls"
6
- "crypto/x509"
7
- "fmt"
8
- "sync"
9
- "sync/atomic"
10
- "time"
11
-
12
- "github.com/rs/zerolog/log"
13
-)
14
-
15
-// AutoCertManager manages TLS certificates obtained via relay's ACME DNS-01.
16
-// It generates the private key locally, creates a CSR, and requests the certificate
17
-// from the relay. The private key never leaves the tunnel.
18
-type AutoCertManager struct {
19
- relayURL string
20
- leaseName string
21
- leaseID string
22
- reverseToken string
23
-
24
- mu sync.RWMutex
25
- keyPair *KeyPair
26
- domain string
27
- certPEM []byte
28
- cert atomic.Pointer[tls.Certificate]
29
- expiresAt time.Time
30
-
31
- stopCh chan struct{}
32
- stopOnce sync.Once
33
- wg sync.WaitGroup
34
-}
35
-
36
-// NewAutoCertManager creates a new auto certificate manager.
37
-// The domain is derived from leaseName + relay's base domain.
38
-func NewAutoCertManager(relayURL, leaseName, leaseID, reverseToken string) *AutoCertManager {
39
- return &AutoCertManager{
40
- relayURL: relayURL,
41
- leaseName: leaseName,
42
- leaseID: leaseID,
43
- reverseToken: reverseToken,
44
- stopCh: make(chan struct{}),
45
- }
46
-}
47
-
48
-// Initialize generates the key pair and obtains the initial certificate.
49
-func (m *AutoCertManager) Initialize(ctx context.Context) error {
50
- m.mu.Lock()
51
- defer m.mu.Unlock()
52
-
53
- // Fetch base domain from relay
54
- client := NewCertificateClient(m.relayURL)
55
- baseDomain, err := client.GetBaseDomain(ctx)
56
- if err != nil {
57
- return fmt.Errorf("get base domain: %w", err)
58
- }
59
-
60
- // Construct full domain
61
- m.domain = m.leaseName + "." + baseDomain
62
-
63
- // Generate key pair (private key stays local)
64
- keyPair, err := GenerateKeyPair()
65
- if err != nil {
66
- return fmt.Errorf("generate key pair: %w", err)
67
- }
68
- m.keyPair = keyPair
69
-
70
- // Create CSR with the domain
71
- csrPEM, err := CreateCSR(keyPair, m.domain)
72
- if err != nil {
73
- return fmt.Errorf("create CSR: %w", err)
74
- }
75
-
76
- // Request certificate from relay
77
- resp, err := client.RequestCertificate(ctx, m.leaseID, m.reverseToken, csrPEM)
78
- if err != nil {
79
- return fmt.Errorf("request certificate: %w", err)
80
- }
81
-
82
- m.certPEM = resp.Certificate
83
- if resp.ExpiresAt != "" {
84
- if t, err := time.Parse(time.RFC3339, resp.ExpiresAt); err == nil {
85
- m.expiresAt = t
86
- }
87
- }
88
-
89
- // Build tls.Certificate
90
- cert, err := m.buildCertificate()
91
- if err != nil {
92
- return fmt.Errorf("build certificate: %w", err)
93
- }
94
- m.cert.Store(&cert)
95
-
96
- log.Info().
97
- Str("domain", m.domain).
98
- Time("expires", m.expiresAt).
99
- Msg("[SDK] Auto certificate obtained")
100
-
101
- return nil
102
-}
103
-
104
-// GetCertificate returns a tls.GetCertificateFunc for use with tls.Config.
105
-func (m *AutoCertManager) GetCertificate() func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
106
- return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
107
- cert := m.cert.Load()
108
- if cert == nil {
109
- return nil, fmt.Errorf("certificate not available")
110
- }
111
- return cert, nil
112
- }
113
-}
114
-
115
-// StartRenewal starts a background goroutine to renew the certificate before expiry.
116
-// Renewal occurs at 2/3 of the certificate lifetime.
117
-func (m *AutoCertManager) StartRenewal() {
118
- m.wg.Add(1)
119
- go m.renewalLoop()
120
-}
121
-
122
-// Stop stops the renewal goroutine.
123
-func (m *AutoCertManager) Stop() {
124
- m.stopOnce.Do(func() {
125
- close(m.stopCh)
126
- })
127
- m.wg.Wait()
128
-}
129
-
130
-func (m *AutoCertManager) renewalLoop() {
131
- defer m.wg.Done()
132
-
133
- // Check every hour
134
- ticker := time.NewTicker(1 * time.Hour)
135
- defer ticker.Stop()
136
-
137
- for {
138
- select {
139
- case <-m.stopCh:
140
- return
141
- case <-ticker.C:
142
- if m.shouldRenew() {
143
- if err := m.renew(); err != nil {
144
- log.Warn().Err(err).Msg("[SDK] Certificate renewal failed")
145
- }
146
- }
147
- }
148
- }
149
-}
150
-
151
-func (m *AutoCertManager) shouldRenew() bool {
152
- m.mu.RLock()
153
- defer m.mu.RUnlock()
154
-
155
- if m.expiresAt.IsZero() {
156
- return false
157
- }
158
-
159
- // Renew at 2/3 of lifetime
160
- now := time.Now()
161
- renewAt := m.expiresAt.Add(-m.expiresAt.Sub(now) / 3)
162
- return now.After(renewAt) || now.Equal(renewAt)
163
-}
164
-
165
-func (m *AutoCertManager) renew() error {
166
- m.mu.Lock()
167
- defer m.mu.Unlock()
168
-
169
- // Create CSR with existing key
170
- csrPEM, err := CreateCSR(m.keyPair, m.domain)
171
- if err != nil {
172
- return fmt.Errorf("create CSR: %w", err)
173
- }
174
-
175
- // Request certificate from relay
176
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
177
- defer cancel()
178
-
179
- client := NewCertificateClient(m.relayURL)
180
- resp, err := client.RequestCertificate(ctx, m.leaseID, m.reverseToken, csrPEM)
181
- if err != nil {
182
- return fmt.Errorf("request certificate: %w", err)
183
- }
184
-
185
- m.certPEM = resp.Certificate
186
- if resp.ExpiresAt != "" {
187
- if t, err := time.Parse(time.RFC3339, resp.ExpiresAt); err == nil {
188
- m.expiresAt = t
189
- }
190
- }
191
-
192
- // Build new tls.Certificate
193
- cert, err := m.buildCertificate()
194
- if err != nil {
195
- return fmt.Errorf("build certificate: %w", err)
196
- }
197
- m.cert.Store(&cert)
198
-
199
- log.Info().
200
- Str("domain", m.domain).
201
- Time("expires", m.expiresAt).
202
- Msg("[SDK] Certificate renewed")
203
-
204
- return nil
205
-}
206
-
207
-func (m *AutoCertManager) buildCertificate() (tls.Certificate, error) {
208
- keyPEM, err := PrivateKeyToPEM(m.keyPair.PrivateKey)
209
- if err != nil {
210
- return tls.Certificate{}, fmt.Errorf("encode private key: %w", err)
211
- }
212
-
213
- cert, err := tls.X509KeyPair(m.certPEM, keyPEM)
214
- if err != nil {
215
- return tls.Certificate{}, fmt.Errorf("create X509KeyPair: %w", err)
216
- }
217
-
218
- // Parse leaf certificate for logging/debugging
219
- if len(cert.Certificate) > 0 {
220
- if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
221
- cert.Leaf = leaf
222
- }
223
- }
224
-
225
- return cert, nil
226
-}
sdk/cert.go
deleted
-165
@@ -1,165 +0,0 @@
1
-package sdk
2
-
3
-import (
4
- "bytes"
5
- "context"
6
- "crypto/ecdsa"
7
- "crypto/elliptic"
8
- "crypto/rand"
9
- "crypto/x509"
10
- "crypto/x509/pkix"
11
- "encoding/json"
12
- "encoding/pem"
13
- "fmt"
14
- "io"
15
- "net/http"
16
- "time"
17
-)
18
-
19
-// CertificateClient handles certificate generation and CSR submission
20
-type CertificateClient struct {
21
- relayAPIURL string
22
- httpClient *http.Client
23
-}
24
-
25
-// NewCertificateClient creates a new certificate client
26
-func NewCertificateClient(relayAPIURL string) *CertificateClient {
27
- return &CertificateClient{
28
- relayAPIURL: relayAPIURL,
29
- httpClient: &http.Client{Timeout: 600 * time.Second},
30
- }
31
-}
32
-
33
-// KeyPair represents a generated key pair with the private key
34
-type KeyPair struct {
35
- PrivateKey *ecdsa.PrivateKey
36
- PublicKey *ecdsa.PublicKey
37
-}
38
-
39
-// GenerateKeyPair generates a new ECDSA P-256 key pair
40
-func GenerateKeyPair() (*KeyPair, error) {
41
- privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
42
- if err != nil {
43
- return nil, fmt.Errorf("generate key pair: %w", err)
44
- }
45
- return &KeyPair{
46
- PrivateKey: privateKey,
47
- PublicKey: &privateKey.PublicKey,
48
- }, nil
49
-}
50
-
51
-// CreateCSR creates a PEM-encoded Certificate Signing Request
52
-func CreateCSR(keyPair *KeyPair, domain string) ([]byte, error) {
53
- template := &x509.CertificateRequest{
54
- Subject: pkix.Name{
55
- CommonName: domain,
56
- },
57
- DNSNames: []string{domain},
58
- }
59
-
60
- csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, keyPair.PrivateKey)
61
- if err != nil {
62
- return nil, fmt.Errorf("create CSR: %w", err)
63
- }
64
-
65
- csrPEM := pem.EncodeToMemory(&pem.Block{
66
- Type: "CERTIFICATE REQUEST",
67
- Bytes: csrDER,
68
- })
69
-
70
- return csrPEM, nil
71
-}
72
-
73
-// RequestCertificate requests a certificate from the relay.
74
-// The relay derives the domain from leaseName + base domain.
75
-// The CSR must contain the full domain (constructed from GetBaseDomain + leaseName).
76
-func (c *CertificateClient) RequestCertificate(ctx context.Context, leaseID, reverseToken string, csrPEM []byte) (*CSRResponse, error) {
77
- reqBody := CSRRequest{
78
- LeaseID: leaseID,
79
- ReverseToken: reverseToken,
80
- CSR: csrPEM,
81
- }
82
-
83
- body, err := json.Marshal(reqBody)
84
- if err != nil {
85
- return nil, fmt.Errorf("marshal request: %w", err)
86
- }
87
-
88
- url := c.relayAPIURL + "/sdk/csr"
89
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
90
- if err != nil {
91
- return nil, fmt.Errorf("create request: %w", err)
92
- }
93
- req.Header.Set("Content-Type", "application/json")
94
-
95
- resp, err := c.httpClient.Do(req)
96
- if err != nil {
97
- return nil, fmt.Errorf("send request: %w", err)
98
- }
99
- defer resp.Body.Close()
100
-
101
- respBody, err := io.ReadAll(resp.Body)
102
- if err != nil {
103
- return nil, fmt.Errorf("read response: %w", err)
104
- }
105
-
106
- var csrResp CSRResponse
107
- if err := json.Unmarshal(respBody, &csrResp); err != nil {
108
- return nil, fmt.Errorf("parse response: %w", err)
109
- }
110
-
111
- if !csrResp.Success {
112
- return nil, fmt.Errorf("certificate request failed: %s", csrResp.Message)
113
- }
114
-
115
- return &csrResp, nil
116
-}
117
-
118
-// GetBaseDomain fetches the relay's base domain for TLS certificate construction.
119
-func (c *CertificateClient) GetBaseDomain(ctx context.Context) (string, error) {
120
- url := c.relayAPIURL + "/sdk/domain"
121
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
122
- if err != nil {
123
- return "", fmt.Errorf("create request: %w", err)
124
- }
125
-
126
- resp, err := c.httpClient.Do(req)
127
- if err != nil {
128
- return "", fmt.Errorf("send request: %w", err)
129
- }
130
- defer resp.Body.Close()
131
-
132
- respBody, err := io.ReadAll(resp.Body)
133
- if err != nil {
134
- return "", fmt.Errorf("read response: %w", err)
135
- }
136
-
137
- var domainResp struct {
138
- Success bool `json:"success"`
139
- BaseDomain string `json:"base_domain"`
140
- Message string `json:"message"`
141
- }
142
- if err := json.Unmarshal(respBody, &domainResp); err != nil {
143
- return "", fmt.Errorf("parse response: %w", err)
144
- }
145
- if !domainResp.Success {
146
- msg := domainResp.Message
147
- if msg == "" {
148
- msg = "base domain not configured"
149
- }
150
- return "", fmt.Errorf("get base domain: %s", msg)
151
- }
152
- return domainResp.BaseDomain, nil
153
-}
154
-
155
-// PrivateKeyToPEM converts a private key to PEM format
156
-func PrivateKeyToPEM(key *ecdsa.PrivateKey) ([]byte, error) {
157
- der, err := x509.MarshalECPrivateKey(key)
158
- if err != nil {
159
- return nil, fmt.Errorf("marshal private key: %w", err)
160
- }
161
- return pem.EncodeToMemory(&pem.Block{
162
- Type: "EC PRIVATE KEY",
163
- Bytes: der,
164
- }), nil
165
-}
sdk/client.go
+277
-16
@@ -5,13 +5,22 @@ import (
5
"context"
6
"crypto/rand"
7
"crypto/tls"
8
+ "crypto/x509"
9
"encoding/hex"
10
+ "encoding/json"
11
+ "encoding/pem"
12
"fmt"
13
+ "io"
14
"net"
15
+ "net/http"
16
+ "net/url"
17
"regexp"
18
+ "strings"
19
"sync"
20
"time"
21
22
+ "github.com/gosuda/keyless_tls/keyless"
23
+
24
"github.com/rs/zerolog/log"
25
"gosuda.org/portal/portal"
26
)
@@ -44,6 +53,7 @@ func NewClient(opt ...ClientOption) (*Client, error) {
53
BootstrapServers: []string{},
54
ReverseWorkers: 0, // uses defaultReverseWorkers from listener
55
ReverseDialTimeout: 5 * time.Second,
56
+ TLSMode: TLSModeNoTLS,
57
}
58
59
for _, o := range opt {
@@ -91,7 +101,7 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
101
lease := &portal.Lease{
102
ID: generateID(),
103
Name: name,
94
- TLSEnabled: c.config.TLSEnabled,
104
+ TLSMode: string(normalizeTLSMode(c.config.TLSMode)),
105
ReverseToken: reverseToken,
106
Metadata: portal.Metadata{
107
Description: metadata.Description,
@@ -105,11 +115,101 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
115
116
// Build TLS config if enabled
117
var tlsConfig *tls.Config
108
- if c.config.TLSEnabled {
109
- tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
118
+ var listenerCloseFns []func()
119
+ tlsMode := normalizeTLSMode(c.config.TLSMode)
120
+ tlsEnabled := tlsMode != TLSModeNoTLS
121
+ if tlsEnabled {
122
+ switch tlsMode {
123
+ case TLSModeSelf:
124
+ var cert tls.Certificate
125
+ if c.config.TLSCertificate != nil {
126
+ cert = *c.config.TLSCertificate
127
+ } else {
128
+ certFile := strings.TrimSpace(c.config.TLSSelfCertFile)
129
+ keyFile := strings.TrimSpace(c.config.TLSSelfKeyFile)
130
+ if certFile == "" || keyFile == "" {
131
+ return nil, fmt.Errorf("self TLS mode requires certificate/key (WithTLSSelfCertificate or WithTLSSelfCertificateFiles)")
132
+ }
133
+ certPair, err := tls.LoadX509KeyPair(certFile, keyFile)
134
+ if err != nil {
135
+ return nil, fmt.Errorf("load self TLS certificate files: %w", err)
136
+ }
137
+ cert = certPair
138
+ }
139
+ tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
140
+ tlsConfig.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
141
+ return &cert, nil
142
+ }
143
+ case TLSModeKeyless:
144
+ keylessEndpoint := strings.TrimSpace(c.config.TLSKeyless.Endpoint)
145
+ if keylessEndpoint == "" {
146
+ keylessEndpoint = strings.TrimSpace(relayAddr)
147
+ }
148
+ keylessKeyID := strings.TrimSpace(c.config.TLSKeyless.KeyID)
149
+ if keylessKeyID == "" {
150
+ keylessKeyID = "relay-cert"
151
+ }
152
+ keylessServerName := strings.TrimSpace(c.config.TLSKeyless.ServerName)
153
+ if keylessServerName == "" {
154
+ if parsed, err := url.Parse(keylessEndpoint); err == nil {
155
+ keylessServerName = parsed.Hostname()
156
+ }
157
+ }
158
+
159
+ certPEM, rootCAPEM, err := resolveKeylessMaterials(
160
+ context.Background(),
161
+ keylessEndpoint,
162
+ keylessServerName,
163
+ c.config.TLSKeylessCertificatePEM,
164
+ c.config.TLSKeyless.RootCAPEM,
165
+ )
166
+ if err != nil {
167
+ return nil, fmt.Errorf("prepare keyless materials: %w", err)
168
+ }
169
+
170
+ baseDomain, err := fetchBaseDomain(context.Background(), relayAddr)
171
+ if err != nil {
172
+ return nil, fmt.Errorf("get base domain for keyless mode: %w", err)
173
+ }
174
+ domain := strings.ToLower(name + "." + baseDomain)
175
+ _, leaf, err := parseCertificateChainPEM(certPEM)
176
+ if err != nil {
177
+ return nil, fmt.Errorf("parse keyless certificate chain: %w", err)
178
+ }
179
+ if err := leaf.VerifyHostname(domain); err != nil {
180
+ return nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, err)
181
+ }
182
+
183
+ remoteSigner, err := keyless.NewRemoteSigner(keyless.RemoteSignerConfig{
184
+ Endpoint: keylessEndpoint,
185
+ ServerName: keylessServerName,
186
+ KeyID: keylessKeyID,
187
+ EnableMTLS: c.config.TLSKeyless.EnableMTLS,
188
+ ClientCertPEM: c.config.TLSKeyless.ClientCertPEM,
189
+ ClientKeyPEM: c.config.TLSKeyless.ClientKeyPEM,
190
+ RootCAPEM: rootCAPEM,
191
+ }, certPEM)
192
+ if err != nil {
193
+ return nil, fmt.Errorf("create keyless remote signer: %w", err)
194
+ }
195
+ listenerCloseFns = append(listenerCloseFns, func() {
196
+ _ = remoteSigner.Close()
197
+ })
198
+
199
+ tlsConfig, err = keyless.NewServerTLSConfig(keyless.ServerTLSConfig{
200
+ CertPEM: certPEM,
201
+ Signer: remoteSigner,
202
+ })
203
+ if err != nil {
204
+ _ = remoteSigner.Close()
205
+ return nil, fmt.Errorf("create keyless TLS config: %w", err)
206
+ }
207
+ default:
208
+ return nil, fmt.Errorf("unsupported TLS mode: %s", tlsMode)
209
+ }
210
}
211
112
- listener, err := NewListener(relayAddr, lease, tlsConfig, c.config.ReverseWorkers, c.config.ReverseDialTimeout)
212
+ listener, err := NewListener(relayAddr, lease, tlsConfig, c.config.ReverseWorkers, c.config.ReverseDialTimeout, listenerCloseFns...)
213
if err != nil {
214
return nil, fmt.Errorf("create relay listener: %w", err)
215
}
@@ -119,29 +219,20 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
219
return nil, fmt.Errorf("start relay listener: %w", err)
220
}
221
122
- // For TLS mode, initialize certificate after lease is registered
123
- if c.config.TLSEnabled {
124
- autoMgr := NewAutoCertManager(relayAddr, name, lease.ID, reverseToken)
125
- if err := autoMgr.Initialize(context.Background()); err != nil {
126
- listener.Close()
127
- return nil, fmt.Errorf("initialize auto certificate: %w", err)
128
- }
129
- listener.SetAutoCertManager(autoMgr)
130
- autoMgr.StartRenewal()
131
- }
132
-
222
c.leases[lease.ID] = lease
223
135
- if c.config.TLSEnabled {
224
+ if tlsEnabled {
225
log.Info().
226
Str("lease_id", lease.ID).
227
Str("name", name).
228
Bool("tls", true).
229
+ Str("tls_mode", string(tlsMode)).
230
Msg("[SDK] Lease registered with TLS")
231
} else {
232
log.Info().
233
Str("lease_id", lease.ID).
234
Str("name", name).
235
+ Str("tls_mode", string(TLSModeNoTLS)).
236
Msg("[SDK] Lease registered")
237
}
238
@@ -189,3 +280,173 @@ func generateToken(size int) (string, error) {
280
}
281
return hex.EncodeToString(b), nil
282
}
283
+
284
+func parseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, error) {
285
+ if len(certPEM) == 0 {
286
+ return nil, nil, fmt.Errorf("certificate PEM is empty")
287
+ }
288
+ var chain [][]byte
289
+ rest := certPEM
290
+ for {
291
+ block, next := pem.Decode(rest)
292
+ if block == nil {
293
+ break
294
+ }
295
+ if block.Type == "CERTIFICATE" {
296
+ chain = append(chain, block.Bytes)
297
+ }
298
+ rest = next
299
+ }
300
+ if len(chain) == 0 {
301
+ return nil, nil, fmt.Errorf("no certificate blocks found")
302
+ }
303
+ leaf, err := x509.ParseCertificate(chain[0])
304
+ if err != nil {
305
+ return nil, nil, fmt.Errorf("parse leaf certificate: %w", err)
306
+ }
307
+ return chain, leaf, nil
308
+}
309
+
310
+func fetchBaseDomain(ctx context.Context, relayAPIURL string) (string, error) {
311
+ endpoint := strings.TrimSuffix(strings.TrimSpace(relayAPIURL), "/") + "/sdk/domain"
312
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
313
+ if err != nil {
314
+ return "", fmt.Errorf("create request: %w", err)
315
+ }
316
+
317
+ client := &http.Client{Timeout: 60 * time.Second}
318
+ resp, err := client.Do(req)
319
+ if err != nil {
320
+ return "", fmt.Errorf("send request: %w", err)
321
+ }
322
+ defer resp.Body.Close()
323
+
324
+ respBody, err := io.ReadAll(resp.Body)
325
+ if err != nil {
326
+ return "", fmt.Errorf("read response: %w", err)
327
+ }
328
+
329
+ var domainResp struct {
330
+ Success bool `json:"success"`
331
+ BaseDomain string `json:"base_domain"`
332
+ Message string `json:"message"`
333
+ }
334
+ if err := json.Unmarshal(respBody, &domainResp); err != nil {
335
+ return "", fmt.Errorf("parse response: %w", err)
336
+ }
337
+ if !domainResp.Success {
338
+ msg := strings.TrimSpace(domainResp.Message)
339
+ if msg == "" {
340
+ msg = "base domain not configured"
341
+ }
342
+ return "", fmt.Errorf("get base domain: %s", msg)
343
+ }
344
+ return domainResp.BaseDomain, nil
345
+}
346
+
347
+func resolveKeylessMaterials(
348
+ ctx context.Context,
349
+ keylessEndpoint string,
350
+ keylessServerName string,
351
+ inlineCertPEM []byte,
352
+ inlineRootCAPEM []byte,
353
+) ([]byte, []byte, error) {
354
+ certPEM := append([]byte(nil), inlineCertPEM...)
355
+ chainFromEndpoint := []byte(nil)
356
+
357
+ if len(certPEM) == 0 || len(inlineRootCAPEM) == 0 {
358
+ autoChain, err := fetchEndpointCertificateChain(ctx, keylessEndpoint, keylessServerName)
359
+ if err != nil && len(certPEM) == 0 {
360
+ return nil, nil, fmt.Errorf("auto-discover certificate chain from signer endpoint: %w", err)
361
+ }
362
+ if err == nil {
363
+ chainFromEndpoint = autoChain
364
+ }
365
+ }
366
+
367
+ if len(certPEM) == 0 {
368
+ certPEM = chainFromEndpoint
369
+ }
370
+ if len(certPEM) == 0 {
371
+ return nil, nil, fmt.Errorf("keyless certificate chain is required")
372
+ }
373
+
374
+ rootCAPEM := append([]byte(nil), inlineRootCAPEM...)
375
+ if len(rootCAPEM) == 0 && len(chainFromEndpoint) > 0 {
376
+ rootCAPEM = append([]byte(nil), chainFromEndpoint...)
377
+ }
378
+ if len(rootCAPEM) == 0 {
379
+ // Fallback for non-mTLS signer TLS verification.
380
+ rootCAPEM = append([]byte(nil), certPEM...)
381
+ }
382
+
383
+ return certPEM, rootCAPEM, nil
384
+}
385
+
386
+func fetchEndpointCertificateChain(ctx context.Context, endpoint string, serverName string) ([]byte, error) {
387
+ raw := strings.TrimSpace(endpoint)
388
+ if raw == "" {
389
+ return nil, fmt.Errorf("endpoint is required")
390
+ }
391
+ if !strings.Contains(raw, "://") {
392
+ raw = "https://" + raw
393
+ }
394
+
395
+ u, err := url.Parse(raw)
396
+ if err != nil {
397
+ return nil, fmt.Errorf("parse endpoint URL: %w", err)
398
+ }
399
+ if strings.EqualFold(u.Scheme, "http") {
400
+ return nil, fmt.Errorf("http signer endpoint does not expose TLS certificate chain (use https endpoint)")
401
+ }
402
+
403
+ host := u.Hostname()
404
+ if host == "" {
405
+ return nil, fmt.Errorf("endpoint hostname is empty")
406
+ }
407
+ port := u.Port()
408
+ if port == "" {
409
+ port = "443"
410
+ }
411
+ if strings.TrimSpace(serverName) == "" {
412
+ serverName = host
413
+ }
414
+
415
+ dialer := &net.Dialer{Timeout: 5 * time.Second}
416
+ rawConn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
417
+ if err != nil {
418
+ return nil, fmt.Errorf("dial signer endpoint: %w", err)
419
+ }
420
+
421
+ tlsConn := tls.Client(rawConn, &tls.Config{
422
+ MinVersion: tls.VersionTLS12,
423
+ ServerName: serverName,
424
+ InsecureSkipVerify: true,
425
+ })
426
+ defer tlsConn.Close()
427
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
428
+ return nil, fmt.Errorf("TLS handshake with signer endpoint: %w", err)
429
+ }
430
+
431
+ peerCerts := tlsConn.ConnectionState().PeerCertificates
432
+ if len(peerCerts) == 0 {
433
+ return nil, fmt.Errorf("no peer certificates from signer endpoint")
434
+ }
435
+
436
+ var chainPEM []byte
437
+ for _, cert := range peerCerts {
438
+ chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{
439
+ Type: "CERTIFICATE",
440
+ Bytes: cert.Raw,
441
+ })...)
442
+ }
443
+ return chainPEM, nil
444
+}
445
+
446
+func normalizeTLSMode(mode TLSMode) TLSMode {
447
+ normalized := TLSMode(strings.ToLower(strings.TrimSpace(string(mode))))
448
+ if normalized == "" {
449
+ return TLSModeNoTLS
450
+ }
451
+ return normalized
452
+}
sdk/listener.go
+9
-22
@@ -42,8 +42,8 @@ type Listener struct {
42
reverseDialTimeout time.Duration
43
44
// TLS configuration
45
- tlsConfig *tls.Config
46
- autoCertMgr *AutoCertManager
45
+ tlsConfig *tls.Config
46
+ closeFns []func()
47
48
stopCh chan struct{}
49
closeOnce sync.Once
@@ -54,7 +54,7 @@ var _ net.Listener = (*Listener)(nil)
54
55
// NewListener creates a relay-backed listener.
56
// If tlsConfig is provided, the listener will perform TLS handshake on incoming connections.
57
-func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, reverseWorkers int, reverseDialTimeout time.Duration) (*Listener, error) {
57
+func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, reverseWorkers int, reverseDialTimeout time.Duration, closeFns ...func()) (*Listener, error) {
58
if lease == nil {
59
return nil, fmt.Errorf("lease is required")
60
}
@@ -87,6 +87,7 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
87
Timeout: 10 * time.Second,
88
},
89
tlsConfig: tlsConfig,
90
+ closeFns: closeFns,
91
stopCh: make(chan struct{}),
92
acceptCh: make(chan net.Conn, 128),
93
reverseWorkers: reverseWorkers,
@@ -169,10 +170,10 @@ func (l *Listener) Close() error {
170
l.mu.Unlock()
171
172
l.wg.Wait()
172
-
173
- // Stop auto cert manager if running
174
- if l.autoCertMgr != nil {
175
- l.autoCertMgr.Stop()
173
+ for _, closeFn := range l.closeFns {
174
+ if closeFn != nil {
175
+ closeFn()
176
+ }
177
}
178
179
if err := l.unregisterFromRelay(); err != nil {
@@ -194,20 +195,6 @@ func (l *Listener) LeaseID() string {
195
return l.lease.ID
196
}
197
197
-// SetAutoCertManager sets the auto certificate manager for TLSAuto mode.
198
-// It also updates the TLS config to use the manager's GetCertificate function.
199
-func (l *Listener) SetAutoCertManager(mgr *AutoCertManager) {
200
- l.mu.Lock()
201
- defer l.mu.Unlock()
202
-
203
- l.autoCertMgr = mgr
204
-
205
- // Update TLS config to use the manager's GetCertificate
206
- if l.tlsConfig != nil {
207
- l.tlsConfig.GetCertificate = mgr.GetCertificate()
208
- }
209
-}
210
-
198
func (l *Listener) keepaliveLoop() {
199
defer l.wg.Done()
200
@@ -353,7 +340,7 @@ func (l *Listener) registerWithRelay() error {
340
LeaseID: l.lease.ID,
341
Name: l.lease.Name,
342
Metadata: l.lease.Metadata,
356
- TLSEnabled: l.lease.TLSEnabled,
343
+ TLSMode: normalizeTLSMode(TLSMode(l.lease.TLSMode)),
344
ReverseToken: l.lease.ReverseToken,
345
}
346
sdk/types.go
+69
-22
@@ -2,6 +2,7 @@ package sdk
2
3
import (
4
"context"
5
+ "crypto/tls"
6
"errors"
7
"io"
8
"time"
@@ -20,6 +21,24 @@ var (
21
ErrInvalidMetadata = errors.New("invalid metadata")
22
)
23
24
+type TLSMode string
25
+
26
+const (
27
+ TLSModeNoTLS TLSMode = "no-tls"
28
+ TLSModeSelf TLSMode = "self"
29
+ TLSModeKeyless TLSMode = "keyless"
30
+)
31
+
32
+type TLSKeylessConfig struct {
33
+ Endpoint string
34
+ ServerName string
35
+ KeyID string
36
+ RootCAPEM []byte
37
+ EnableMTLS bool
38
+ ClientCertPEM []byte
39
+ ClientKeyPEM []byte
40
+}
41
+
42
type ClientConfig struct {
43
BootstrapServers []string
44
Dialer func(context.Context, string) (io.ReadWriteCloser, error)
@@ -30,7 +49,16 @@ type ClientConfig struct {
49
ReverseDialTimeout time.Duration // Reverse websocket dial timeout (default: 5 seconds)
50
51
// TLS configuration for tunnel server mode
33
- TLSEnabled bool // Enable TLS listener
52
+ TLSMode TLSMode
53
+
54
+ // Optional local certificate used in self TLS mode.
55
+ TLSCertificate *tls.Certificate
56
+ TLSSelfCertFile string
57
+ TLSSelfKeyFile string
58
+
59
+ // Optional certificate chain and remote signer config used by keyless mode.
60
+ TLSKeylessCertificatePEM []byte
61
+ TLSKeyless TLSKeylessConfig
62
}
63
64
type ClientOption func(*ClientConfig)
@@ -77,12 +105,46 @@ func WithReverseDialTimeout(timeout time.Duration) ClientOption {
105
}
106
}
107
80
-// WithTLS enables TLS with certificate issued via relay's ACME DNS-01.
81
-// The domain is derived from the lease name and relay's base domain.
82
-// The private key is generated locally and never leaves the tunnel.
83
-func WithTLS() ClientOption {
108
+// WithTLSSelfCertificate enables TLS with a locally managed certificate/key pair.
109
+func WithTLSSelfCertificate(cert tls.Certificate) ClientOption {
110
return func(c *ClientConfig) {
85
- c.TLSEnabled = true
111
+ c.TLSMode = TLSModeSelf
112
+ copy := cert
113
+ c.TLSCertificate = ©
114
+ }
115
+}
116
+
117
+// WithTLSSelfCertificateFiles enables self TLS mode using certificate/key file paths.
118
+func WithTLSSelfCertificateFiles(certFile, keyFile string) ClientOption {
119
+ return func(c *ClientConfig) {
120
+ c.TLSMode = TLSModeSelf
121
+ c.TLSSelfCertFile = certFile
122
+ c.TLSSelfKeyFile = keyFile
123
+ }
124
+}
125
+
126
+// WithTLSKeyless enables TLS with a local certificate chain and remote keyless signer.
127
+func WithTLSKeyless(certPEM []byte, cfg TLSKeylessConfig) ClientOption {
128
+ return func(c *ClientConfig) {
129
+ c.TLSMode = TLSModeKeyless
130
+ c.TLSKeylessCertificatePEM = append([]byte(nil), certPEM...)
131
+ c.TLSKeyless = TLSKeylessConfig{
132
+ Endpoint: cfg.Endpoint,
133
+ ServerName: cfg.ServerName,
134
+ KeyID: cfg.KeyID,
135
+ RootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
136
+ EnableMTLS: cfg.EnableMTLS,
137
+ ClientCertPEM: append([]byte(nil), cfg.ClientCertPEM...),
138
+ ClientKeyPEM: append([]byte(nil), cfg.ClientKeyPEM...),
139
+ }
140
+ }
141
+}
142
+
143
+// WithTLSKeylessDefaults enables keyless TLS mode with SDK-managed defaults.
144
+// Certificate chain and signer trust are auto-discovered from signer endpoint when not explicitly provided.
145
+func WithTLSKeylessDefaults() ClientOption {
146
+ return func(c *ClientConfig) {
147
+ c.TLSMode = TLSModeKeyless
148
}
149
}
150
@@ -125,7 +187,7 @@ type RegisterRequest struct {
187
LeaseID string `json:"lease_id"`
188
Name string `json:"name"`
189
Metadata portal.Metadata `json:"metadata"`
128
- TLSEnabled bool `json:"tls_enabled"` // Whether the backend handles TLS termination
190
+ TLSMode TLSMode `json:"tls_mode"` // no-tls, self, keyless
191
ReverseToken string `json:"reverse_token"`
192
}
193
@@ -149,18 +211,3 @@ type APIResponse struct {
211
Success bool `json:"success"`
212
Message string `json:"message,omitempty"`
213
}
152
-
153
-// CSRRequest represents a Certificate Signing Request submission
154
-type CSRRequest struct {
155
- LeaseID string `json:"lease_id"`
156
- ReverseToken string `json:"reverse_token"`
157
- CSR []byte `json:"csr"` // PEM-encoded Certificate Signing Request
158
-}
159
-
160
-// CSRResponse represents the response to a CSR submission
161
-type CSRResponse struct {
162
- Success bool `json:"success"`
163
- Message string `json:"message,omitempty"`
164
- Certificate []byte `json:"certificate,omitempty"` // PEM-encoded certificate chain
165
- ExpiresAt string `json:"expires_at,omitempty"` // ISO 8601 timestamp
166
-}