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 = &copy
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 -}