fix lint error, implement zerolog

Kim committed Mar 6, 2026 at 12:50 UTC 9bfb760945eb96dd09559d2c38af97407549350b
23 files changed +468 -273
cmd/demo-app/main.go
+23 -3
@@ -5,6 +5,7 @@ import (
5 "embed"
6 "encoding/base64"
7 "encoding/json"
8 + "errors"
9 "flag"
10 "fmt"
11 "io/fs"
@@ -15,6 +16,8 @@ import (
16 "syscall"
17 "time"
18
19 + "github.com/rs/zerolog"
20 + "github.com/rs/zerolog/log"
21 "golang.org/x/net/websocket"
22
23 "gosuda.org/portal/sdk"
@@ -37,6 +40,10 @@ var (
40 )
41
42 func main() {
43 + zerolog.TimeFieldFormat = time.RFC3339
44 + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
45 + logger := log.With().Str("component", "demo-app").Logger()
46 +
47 flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URL (https only)")
48 flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
49 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
@@ -47,12 +54,14 @@ func main() {
54 flag.Parse()
55
56 if err := runDemo(); err != nil {
50 - fmt.Fprintf(os.Stderr, "demo command failed: %v\n", err)
57 + logger.Error().Err(err).Msg("demo command failed")
58 os.Exit(1)
59 }
60 }
61
62 func runDemo() error {
63 + logger := log.With().Str("component", "demo-app").Logger()
64 +
65 sdkClient, err := sdk.NewClient(sdk.ClientConfig{RelayURL: flagServerURL})
66 if err != nil {
67 return fmt.Errorf("new client: %w", err)
@@ -79,6 +88,12 @@ func runDemo() error {
88 }
89 defer listener.Close()
90
91 + logger.Info().
92 + Str("lease_id", listener.LeaseID()).
93 + Strs("public_urls", listener.PublicURLs()).
94 + Int("local_port", flagPort).
95 + Msg("demo app registered with relay")
96 +
97 mux := http.NewServeMux()
98
99 staticFS, err := fs.Sub(staticFiles, "static")
@@ -131,7 +146,10 @@ func runDemo() error {
146 Handler: mux,
147 ReadHeaderTimeout: 5 * time.Second,
148 }
134 - _ = localSrv.ListenAndServe()
149 + logger.Info().Str("addr", localAddr).Msg("demo app listening locally")
150 + if err := localSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
151 + logger.Error().Err(err).Str("addr", localAddr).Msg("demo local server stopped")
152 + }
153 }()
154
155 relaySrv := &http.Server{
@@ -149,12 +167,14 @@ func runDemo() error {
167
168 select {
169 case <-sig:
170 + logger.Info().Msg("demo app shutting down")
171 case err := <-errCh:
153 - if err != nil && err != http.ErrServerClosed {
172 + if err != nil && !errors.Is(err, http.ErrServerClosed) {
173 return err
174 }
175 }
176
177 + logger.Info().Msg("demo app shutdown complete")
178 return nil
179 }
180
cmd/demo-app/static/index.html
+126 -52
@@ -1,57 +1,131 @@
1 -<!doctype html>
1 +<!DOCTYPE html>
2 <html lang="en">
3 +
4 <head>
4 - <meta charset="utf-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1">
6 - <title>Portal Demo App</title>
7 - <link rel="stylesheet" href="/style.css">
5 + <meta charset="UTF-8">
6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
7 + <meta name="apple-mobile-web-app-capable" content="yes">
8 + <meta name="mobile-web-app-capable" content="yes">
9 + <title>Portal Demo Connectivity</title>
10 + <link rel="stylesheet" href="style.css">
11 </head>
12 +
13 <body>
10 - <main class="shell">
11 - <header>
12 - <p class="eyebrow">Portal Demo</p>
13 - <h1>Relay-backed demo application</h1>
14 - <p>Use this page to verify static assets, cookies, fetches, and websocket echo.</p>
15 - </header>
16 -
17 - <section class="card">
18 - <h2>HTTP</h2>
19 - <button id="ping">Fetch /api/ping</button>
20 - <button id="cookies">Set cookies</button>
21 - <pre id="output">Ready.</pre>
22 - </section>
23 -
24 - <section class="card">
25 - <h2>WebSocket</h2>
26 - <input id="ws-input" value="hello portal">
27 - <button id="ws-send">Send</button>
28 - <pre id="ws-output">Disconnected.</pre>
29 - </section>
30 - </main>
31 -
32 - <script>
33 - const output = document.getElementById('output');
34 - const wsOutput = document.getElementById('ws-output');
35 -
36 - document.getElementById('ping').addEventListener('click', async () => {
37 - const res = await fetch('/api/ping');
38 - output.textContent = JSON.stringify(await res.json(), null, 2);
39 - });
40 -
41 - document.getElementById('cookies').addEventListener('click', async () => {
42 - const res = await fetch('/api/test-cookies');
43 - output.textContent = JSON.stringify(await res.json(), null, 2);
44 - });
45 -
46 - const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
47 - const ws = new WebSocket(proto + location.host + '/ws');
48 - ws.onopen = () => { wsOutput.textContent = 'Connected.'; };
49 - ws.onmessage = (event) => { wsOutput.textContent = event.data; };
50 - ws.onclose = () => { wsOutput.textContent = 'Disconnected.'; };
51 -
52 - document.getElementById('ws-send').addEventListener('click', () => {
53 - ws.send(document.getElementById('ws-input').value);
54 - });
55 - </script>
14 + <div class="container">
15 + <h1>Portal Demo Connectivity</h1>
16 +
17 + <div class="toolbar">
18 + <button id="httpPingBtn">HTTP Ping</button>
19 + <button id="testCookiesBtn">Test Cookies</button>
20 + <button id="wsConnectBtn">WS Connect</button>
21 + <button id="wsSendBtn" disabled>WS Send "hello"</button>
22 + </div>
23 +
24 + <div class="canvas-wrapper">
25 + <pre id="log" class="log"></pre>
26 + </div>
27 +
28 + <div id="status" class="status">Idle</div>
29 + </div>
30 +
31 + <script>
32 + const statusEl = document.getElementById('status');
33 + const logEl = document.getElementById('log');
34 + const httpPingBtn = document.getElementById('httpPingBtn');
35 + const wsConnectBtn = document.getElementById('wsConnectBtn');
36 + const wsSendBtn = document.getElementById('wsSendBtn');
37 +
38 + let ws = null;
39 +
40 + function log(message) {
41 + const time = new Date().toISOString();
42 + logEl.textContent += `[${time}] ${message}\n`;
43 + logEl.scrollTop = logEl.scrollHeight;
44 + }
45 +
46 + httpPingBtn.addEventListener('click', async () => {
47 + statusEl.textContent = 'HTTP: Pinging...';
48 + try {
49 + const res = await fetch('/api/ping');
50 + const json = await res.json();
51 + statusEl.textContent = 'HTTP: OK';
52 + log(`HTTP /api/ping -> ${JSON.stringify(json)}`);
53 + } catch (err) {
54 + statusEl.textContent = 'HTTP: Error';
55 + log(`HTTP error: ${err}`);
56 + }
57 + });
58 +
59 + document.getElementById('testCookiesBtn').addEventListener('click', async () => {
60 + statusEl.textContent = 'Cookies: Testing...';
61 + try {
62 + const res = await fetch('/api/test-cookies');
63 + const json = await res.json();
64 + statusEl.textContent = 'Cookies: OK';
65 + log(`HTTP /api/test-cookies -> ${JSON.stringify(json)}`);
66 +
67 + // Log response headers
68 + log(`Response headers:`);
69 + for (const [key, value] of res.headers.entries()) {
70 + log(` ${key}: ${value}`);
71 + }
72 +
73 + // Log current cookies
74 + log(`Current document.cookie: ${document.cookie || '(empty)'}`);
75 + } catch (err) {
76 + statusEl.textContent = 'Cookies: Error';
77 + log(`HTTP error: ${err}`);
78 + }
79 + });
80 +
81 + wsConnectBtn.addEventListener('click', () => {
82 + if (ws && ws.readyState === WebSocket.OPEN) {
83 + ws.close();
84 + return;
85 + }
86 +
87 + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
88 + const basePath = location.pathname.endsWith('/') ? location.pathname : (location.pathname + '/');
89 + const url = protocol + '//' + window.location.host + basePath + 'ws';
90 +
91 + statusEl.textContent = 'WS: Connecting...';
92 + log(`WS connecting to ${url}`);
93 +
94 + ws = new WebSocket(url);
95 +
96 + ws.onopen = () => {
97 + statusEl.textContent = 'WS: Connected';
98 + log('WS connected');
99 + wsSendBtn.disabled = false;
100 + wsConnectBtn.textContent = 'WS Disconnect';
101 + };
102 +
103 + ws.onclose = () => {
104 + statusEl.textContent = 'WS: Disconnected';
105 + log('WS disconnected');
106 + wsSendBtn.disabled = true;
107 + wsConnectBtn.textContent = 'WS Connect';
108 + };
109 +
110 + ws.onerror = (err) => {
111 + log(`WS error: ${err.message || err}`);
112 + };
113 +
114 + ws.onmessage = (event) => {
115 + log(`WS recv: ${event.data}`);
116 + };
117 + });
118 +
119 + wsSendBtn.addEventListener('click', () => {
120 + if (!ws || ws.readyState !== WebSocket.OPEN) {
121 + log('WS send skipped: not connected');
122 + return;
123 + }
124 + const msg = 'hello';
125 + ws.send(msg);
126 + log(`WS send: ${msg}`);
127 + });
128 + </script>
129 </body>
57 -</html>
130 +
131 +</html>
\ No newline at end of file
cmd/demo-app/static/style.css
+70 -31
@@ -1,46 +1,85 @@
1 -:root {
2 - color-scheme: light;
3 - font-family: "Georgia", serif;
4 - background: #f6f1e8;
5 - color: #1f1a17;
1 +* {
2 + margin: 0;
3 + padding: 0;
4 + box-sizing: border-box;
5 }
6
7 body {
9 - margin: 0;
8 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
9 + background: #f5f5f5;
10 + min-height: 100vh;
11 + display: flex;
12 + align-items: center;
13 + justify-content: center;
14 + padding: 16px;
15 }
16
12 -.shell {
13 - max-width: 960px;
14 - margin: 0 auto;
15 - padding: 48px 20px 80px;
17 +.container {
18 + width: 100%;
19 + max-width: 520px;
20 + background: #ffffff;
21 + border-radius: 8px;
22 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
23 + padding: 16px;
24 + display: flex;
25 + flex-direction: column;
26 }
27
18 -.eyebrow {
19 - text-transform: uppercase;
20 - letter-spacing: 0.18em;
21 - font-size: 12px;
22 - color: #8f5e3b;
28 +h1 {
29 + font-size: 18px;
30 + margin-bottom: 12px;
31 + color: #333333;
32 + text-align: center;
33 +}
34 +
35 +.toolbar {
36 + display: flex;
37 + flex-wrap: wrap;
38 + gap: 8px;
39 + margin-bottom: 12px;
40 + justify-content: center;
41 }
42
25 -.card {
26 - background: #fff;
27 - border: 1px solid #dccdb8;
28 - padding: 20px;
29 - margin-top: 18px;
30 - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.06);
43 +.toolbar button {
44 + padding: 6px 12px;
45 + font-size: 13px;
46 + border-radius: 4px;
47 + border: 1px solid #d0d7de;
48 + background: #ffffff;
49 + cursor: pointer;
50 + transition: background 0.15s ease, border-color 0.15s ease;
51 }
52
33 -button,
34 -input {
35 - font: inherit;
36 - margin-right: 8px;
37 - margin-bottom: 8px;
38 - padding: 10px 12px;
53 +.toolbar button:hover {
54 + background: #f3f4f6;
55 + border-color: #c3ccd6;
56 }
57
41 -pre {
42 - background: #201a17;
43 - color: #f7efe4;
44 - padding: 14px;
58 +.canvas-wrapper {
59 + border-radius: 4px;
60 + border: 1px solid #e1e4e8;
61 + background: #111827;
62 + padding: 8px;
63 + height: 220px;
64 overflow: auto;
65 }
66 +
67 +.log {
68 + width: 100%;
69 + height: 100%;
70 + border: none;
71 + background: transparent;
72 + color: #e5e7eb;
73 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
74 + "Liberation Mono", "Courier New", monospace;
75 + font-size: 12px;
76 + white-space: pre-wrap;
77 + word-break: break-all;
78 +}
79 +
80 +.status {
81 + margin-top: 10px;
82 + font-size: 13px;
83 + color: #4b5563;
84 + text-align: right;
85 +}
\ No newline at end of file
cmd/portal-tunnel/main.go
+33 -25
@@ -6,7 +6,6 @@ import (
6 "flag"
7 "fmt"
8 "io"
9 - "log"
9 "net"
10 "os"
11 "os/signal"
@@ -15,6 +14,9 @@ import (
14 "syscall"
15 "time"
16
17 + "github.com/rs/zerolog"
18 + "github.com/rs/zerolog/log"
19 +
20 "gosuda.org/portal/portal"
21 "gosuda.org/portal/sdk"
22 )
@@ -31,6 +33,10 @@ var (
33 )
34
35 func main() {
36 + zerolog.TimeFieldFormat = time.RFC3339
37 + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
38 + logger := log.With().Str("component", "portal-tunnel").Logger()
39 +
40 defaultRelayURLs := os.Getenv("RELAYS")
41 if defaultRelayURLs == "" {
42 defaultRelayURLs = "https://localhost:4017"
@@ -47,12 +53,14 @@ func main() {
53 flag.Parse()
54
55 if err := runTunnel(); err != nil {
50 - log.Printf("Exited with error: %v", err)
56 + logger.Error().Err(err).Msg("portal tunnel exited with error")
57 os.Exit(1)
58 }
59 }
60
61 func runTunnel() error {
62 + logger := log.With().Str("component", "portal-tunnel").Logger()
63 +
64 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
65 defer stop()
66
@@ -65,12 +73,12 @@ func runTunnel() error {
73 return err
74 }
75
68 - log.Printf("Local service is reachable at %s", flagHost)
69 - log.Printf("Starting Portal Tunnel...")
70 - log.Printf(" Local: %s", flagHost)
71 - log.Printf(" Relays: %s", strings.Join(relayURLs, ", "))
76 + logger.Info().
77 + Str("local", flagHost).
78 + Strs("relays", relayURLs).
79 + Msg("starting portal tunnel")
80 if len(relayURLs) > 1 {
73 - log.Printf(" Note: current runtime uses the first relay URL only: %s", relayURL)
81 + logger.Warn().Str("selected_relay", relayURL).Msg("multiple relays configured; using first relay only")
82 }
83
84 sdkClient, err := sdk.NewClient(sdk.ClientConfig{RelayURL: relayURL})
@@ -99,14 +107,11 @@ func runTunnel() error {
107 _ = listener.Close()
108 }()
109
102 - log.Printf("")
103 - log.Printf("Access via:")
104 - log.Printf("- Relay: %s", relayURL)
105 - log.Printf("- Lease ID: %s", listener.LeaseID())
106 - for _, publicURL := range listener.PublicURLs() {
107 - log.Printf("- Public: %s", publicURL)
108 - }
109 - log.Printf("")
110 + logger.Info().
111 + Str("relay", relayURL).
112 + Str("lease_id", listener.LeaseID()).
113 + Strs("public_urls", listener.PublicURLs()).
114 + Msg("tunnel ready")
115
116 connCount := 0
117 var connWG sync.WaitGroup
@@ -115,7 +120,7 @@ loop:
120 for {
121 select {
122 case <-ctx.Done():
118 - log.Printf("[tunnel] shutting down...")
123 + logger.Info().Msg("tunnel shutting down")
124 break loop
125 default:
126 }
@@ -123,29 +128,32 @@ loop:
128 relayConn, err := listener.Accept()
129 if err != nil {
130 if errors.Is(err, net.ErrClosed) {
126 - log.Printf("[tunnel] listener closed")
131 + logger.Info().Msg("tunnel listener closed")
132 break loop
133 }
134 select {
135 case <-ctx.Done():
136 break loop
137 default:
133 - log.Printf("Failed to accept connection: %v", err)
138 + logger.Error().Err(err).Msg("failed to accept relay connection")
139 continue
140 }
141 }
142
143 connCount++
139 - log.Printf("[#%d] New connection from %s", connCount, relayConn.RemoteAddr())
144 + logger.Info().
145 + Int("conn_id", connCount).
146 + Str("remote_addr", relayConn.RemoteAddr().String()).
147 + Msg("accepted relay connection")
148
149 connWG.Add(1)
142 - go func(relayConn net.Conn) {
150 + go func(connID int, relayConn net.Conn) {
151 defer connWG.Done()
152 if err := proxyConnection(ctx, flagHost, relayConn); err != nil {
145 - log.Printf("Proxy error: %v", err)
153 + logger.Error().Err(err).Int("conn_id", connID).Msg("proxy connection failed")
154 }
147 - log.Printf("Connection closed")
148 - }(relayConn)
155 + logger.Info().Int("conn_id", connID).Msg("proxy connection closed")
156 + }(connCount, relayConn)
157 }
158
159 done := make(chan struct{})
@@ -157,10 +165,10 @@ loop:
165 select {
166 case <-done:
167 case <-time.After(5 * time.Second):
160 - log.Printf("[tunnel] shutdown timeout, some connections still active")
168 + logger.Warn().Msg("tunnel shutdown timeout; connections still active")
169 }
170
163 - log.Printf("[tunnel] shutdown complete")
171 + logger.Info().Msg("tunnel shutdown complete")
172 return nil
173 }
174
cmd/relay-server/admin.go
+2 -2
@@ -10,10 +10,10 @@ import (
10 )
11
12 type Admin struct {
13 - secret string
14 - trustProxy bool
13 frontend *Frontend
14 server *portal.Server
15 + secret string
16 + trustProxy bool
17 }
18
19 func NewAdmin(secret string, trustProxy bool, frontend *Frontend) *Admin {
cmd/relay-server/http_helpers.go
-18
@@ -3,7 +3,6 @@ package main
3 import (
4 "crypto/subtle"
5 "encoding/json"
6 - "html"
6 "mime"
7 "net/http"
8 "strings"
@@ -15,19 +14,6 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
14 _ = json.NewEncoder(w).Encode(data)
15 }
16
18 -func isSecureRequestWithPolicy(r *http.Request, trustProxyHeaders bool) bool {
19 - if r == nil {
20 - return false
21 - }
22 - if r.TLS != nil {
23 - return true
24 - }
25 - if !trustProxyHeaders {
26 - return false
27 - }
28 - return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
29 -}
30 -
17 func hasPathPrefix(path, prefix string) bool {
18 return strings.HasPrefix(strings.TrimSpace(path), prefix)
19 }
@@ -43,10 +29,6 @@ func subtleValueMatch(left, right string) bool {
29 return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
30 }
31
46 -func escapeHTML(value string) string {
47 - return html.EscapeString(value)
48 -}
49 -
32 func getContentType(ext string) string {
33 ext = strings.TrimSpace(ext)
34 if ext == "" {
cmd/relay-server/lease_rows.go
+9 -9
@@ -10,15 +10,15 @@ import (
10 )
11
12 type leaseRow struct {
13 - TTL string `json:"ttl"`
14 - Metadata string `json:"metadata"`
15 - Kind string `json:"kind"`
16 - DNS string `json:"dns"`
17 - Name string `json:"name"`
18 - Peer string `json:"peer"`
19 - Link string `json:"link"`
20 - Hide bool `json:"hide"`
21 - Connected bool `json:"connected"`
13 + TTL string
14 + Metadata string
15 + Kind string
16 + DNS string
17 + Name string
18 + Peer string
19 + Link string
20 + Hide bool
21 + Connected bool
22 }
23
24 func convertLeaseEntriesToRows(serv *portal.Server, includeHidden bool, portalURL string) []leaseRow {
cmd/relay-server/main.go
+13 -4
@@ -3,9 +3,12 @@ package main
3 import (
4 "flag"
5 "fmt"
6 - "log"
6 "os"
7 "strings"
8 + "time"
9 +
10 + "github.com/rs/zerolog"
11 + "github.com/rs/zerolog/log"
12 )
13
14 const (
@@ -28,6 +31,10 @@ type relayServerConfig struct {
31 }
32
33 func main() {
34 + zerolog.TimeFieldFormat = time.RFC3339
35 + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
36 + logger := log.With().Str("component", "relay-server").Logger()
37 +
38 cfg := relayServerConfig{}
39
40 portalURL := strings.TrimSuffix(trimmedEnv("PORTAL_URL"), "/")
@@ -67,11 +74,13 @@ func main() {
74 cfg.PortalURL = cfg.Bootstraps[0]
75 }
76
70 - log.Printf("[server] portal base url %s", cfg.PortalURL)
71 - log.Printf("[server] bootstraps %s", strings.Join(cfg.Bootstraps, ", "))
77 + logger.Info().
78 + Str("portal_url", cfg.PortalURL).
79 + Strs("bootstraps", cfg.Bootstraps).
80 + Msg("configured relay server")
81
82 if err := runServer(cfg); err != nil {
74 - log.Fatalf("execute root command: %v", err)
83 + logger.Fatal().Err(err).Msg("execute root command")
84 }
85 }
86
cmd/relay-server/serve.go
+12 -6
@@ -3,7 +3,6 @@ package main
3 import (
4 "context"
5 "fmt"
6 - "log"
6 "net"
7 "net/http"
8 "os"
@@ -11,11 +10,15 @@ import (
10 "strings"
11 "syscall"
12
13 + "github.com/rs/zerolog/log"
14 +
15 "gosuda.org/portal/portal"
16 "gosuda.org/portal/portal/acme"
17 )
18
19 func runServer(cfg relayServerConfig) error {
20 + logger := log.With().Str("component", "relay-server").Logger()
21 +
22 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
23 defer stop()
24
@@ -68,9 +71,12 @@ func runServer(cfg relayServerConfig) error {
71 acmeManager.Start(ctx)
72 defer acmeManager.Stop()
73
71 - log.Printf("[server] https api enabled via ACME/self-signed on %s", loopbackAddr(server.APIAddr()))
72 - log.Printf("[server] sni router listening on %s", server.SNIAddr())
73 - log.Printf("[server] root host %s", rootHost)
74 + logger.Info().
75 + Str("api_addr", loopbackAddr(server.APIAddr())).
76 + Str("sni_addr", server.SNIAddr()).
77 + Str("root_host", rootHost).
78 + Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1").
79 + Msg("relay server started")
80
81 return server.Wait()
82 }
@@ -134,11 +140,11 @@ func loopbackAddr(addr string) string {
140
141 func mustRead(path string) []byte {
142 if path == "" {
137 - log.Fatal("missing required PEM path")
143 + log.Fatal().Msg("missing required PEM path")
144 }
145 data, err := os.ReadFile(path)
146 if err != nil {
141 - log.Fatalf("read %s: %v", path, err)
147 + log.Fatal().Err(err).Str("path", path).Msg("read pem file")
148 }
149 return data
150 }
go.mod
+3
@@ -5,6 +5,7 @@ go 1.26.0
5 require (
6 github.com/go-acme/lego/v4 v4.32.0
7 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
8 + github.com/rs/zerolog v1.34.0
9 golang.org/x/net v0.50.0
10 golang.org/x/sync v0.19.0
11 )
@@ -12,6 +13,8 @@ require (
13 require (
14 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
15 github.com/go-jose/go-jose/v4 v4.1.3 // indirect
16 + github.com/mattn/go-colorable v0.1.13 // indirect
17 + github.com/mattn/go-isatty v0.0.20 // indirect
18 github.com/miekg/dns v1.1.72 // indirect
19 golang.org/x/crypto v0.48.0 // indirect
20 golang.org/x/mod v0.32.0 // indirect
go.sum
+15
@@ -1,19 +1,31 @@
1 github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
2 github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
3 +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
4 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
5 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6 github.com/go-acme/lego/v4 v4.32.0 h1:z7Ss7aa1noabhKj+DBzhNCO2SM96xhE3b0ucVW3x8Tc=
7 github.com/go-acme/lego/v4 v4.32.0/go.mod h1:lI2fZNdgeM/ymf9xQ9YKbgZm6MeDuf91UrohMQE4DhI=
8 github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
9 github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
10 +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
11 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
12 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
13 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc h1:aS9LQ35x6EtrGKCmOWRj6Y9aQ2l5hP8dVva4oxB9VEg=
14 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
15 +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
16 +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
17 +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
18 +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
19 +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
20 +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
21 github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
22 github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
23 +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
24 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
25 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
26 +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
27 +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
28 +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
29 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
30 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
31 golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -24,6 +36,9 @@ golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
36 golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
37 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
38 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
39 +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
40 +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
41 +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
42 golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
43 golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
44 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
portal/acme/acme.go
+9 -6
@@ -41,13 +41,12 @@ type Config struct {
41 }
42
43 type Manager struct {
44 - cfg Config
45 -
44 + stopCh chan struct{}
45 + cfg Config
46 + wg sync.WaitGroup
47 mu sync.RWMutex
48 startOnce sync.Once
49 stopOnce sync.Once
49 - stopCh chan struct{}
50 - wg sync.WaitGroup
50 }
51
52 type provisionConfig struct {
@@ -262,18 +261,22 @@ func certCoversDomains(certFile string, domains []string) (bool, error) {
261 }
262 for _, domain := range domains {
263 if wildcardDomain, ok := strings.CutPrefix(domain, "*."); ok {
265 - if err := cert.VerifyHostname("probe." + wildcardDomain); err != nil {
264 + if !certificateCoversHostname(cert, "probe."+wildcardDomain) {
265 return false, nil
266 }
267 continue
268 }
270 - if err := cert.VerifyHostname(domain); err != nil {
269 + if !certificateCoversHostname(cert, domain) {
270 return false, nil
271 }
272 }
273 return true, nil
274 }
275
276 +func certificateCoversHostname(cert *x509.Certificate, hostname string) bool {
277 + return cert != nil && cert.VerifyHostname(hostname) == nil
278 +}
279 +
280 func newClient(cfg provisionConfig) (*lego.Client, *acmeUser, error) {
281 accountKey, err := loadOrCreateAccountKey(cfg.AccountKeyFile)
282 if err != nil {
portal/acme/dnsrecord.go
+2 -2
@@ -51,8 +51,8 @@ type cfRecordsResult struct {
51 }
52
53 type cfRecordResult struct {
54 - Errors []cfError `json:"errors"`
54 Result cfDNSRecord `json:"result"`
55 + Errors []cfError `json:"errors"`
56 Success bool `json:"success"`
57 }
58
@@ -113,7 +113,7 @@ func detectPublicIP(ctx context.Context) (string, error) {
113
114 func findZoneID(ctx context.Context, token, domain string) (string, error) {
115 parts := strings.Split(domain, ".")
116 - for i := 0; i < len(parts)-1; i++ {
116 + for i := range len(parts) - 1 {
117 candidate := strings.Join(parts[i:], ".")
118 zones, err := cfListZones(ctx, token, candidate)
119 if err != nil {
portal/api.go
+7 -7
@@ -13,9 +13,9 @@ const (
13 )
14
15 type APIEnvelope struct {
16 - OK bool `json:"ok"`
16 Data any `json:"data,omitempty"`
17 Error *APIError `json:"error,omitempty"`
18 + OK bool `json:"ok"`
19 }
20
21 type APIError struct {
@@ -25,27 +25,27 @@ type APIError struct {
25
26 type LeaseMetadata struct {
27 Description string `json:"description,omitempty"`
28 - Tags []string `json:"tags,omitempty"`
28 Owner string `json:"owner,omitempty"`
29 Thumbnail string `json:"thumbnail,omitempty"`
30 + Tags []string `json:"tags,omitempty"`
31 Hide bool `json:"hide,omitempty"`
32 }
33
34 type RegisterRequest struct {
35 Name string `json:"name"`
36 + ReverseToken string `json:"reverse_token"`
37 Hostnames []string `json:"hostnames,omitempty"`
38 Metadata LeaseMetadata `json:"metadata,omitempty"`
38 - ReverseToken string `json:"reverse_token"`
39 - TLS bool `json:"tls"`
39 TTLSeconds int `json:"ttl_seconds,omitempty"`
40 + TLS bool `json:"tls"`
41 }
42
43 type RegisterResponse struct {
44 + ExpiresAt time.Time `json:"expires_at"`
45 LeaseID string `json:"lease_id"`
46 + ConnectURL string `json:"connect_url"`
47 Hostnames []string `json:"hostnames"`
48 Metadata LeaseMetadata `json:"metadata,omitempty"`
47 - ExpiresAt time.Time `json:"expires_at"`
48 - ConnectURL string `json:"connect_url"`
49 }
50
51 type RenewRequest struct {
@@ -55,8 +55,8 @@ type RenewRequest struct {
55 }
56
57 type RenewResponse struct {
58 - LeaseID string `json:"lease_id"`
58 ExpiresAt time.Time `json:"expires_at"`
59 + LeaseID string `json:"lease_id"`
60 }
61
62 type UnregisterRequest struct {
portal/broker.go
+9 -11
@@ -24,14 +24,13 @@ const (
24 )
25
26 type leaseBroker struct {
27 + notify chan struct{}
28 leaseID string
29 + ready []*reverseSession
30 idleInterval time.Duration
31 readyLimit int
30 -
31 - mu sync.Mutex
32 - ready []*reverseSession
33 - state brokerState
34 - notify chan struct{}
32 + state brokerState
33 + mu sync.Mutex
34 }
35
36 func newLeaseBroker(leaseID string, idleInterval time.Duration, readyLimit int) *leaseBroker {
@@ -171,15 +170,14 @@ const (
170 )
171
172 type reverseSession struct {
174 - conn net.Conn
175 - idleInterval time.Duration
176 -
177 - mu sync.Mutex
178 - state reverseSessionState
173 + conn net.Conn
174 keepaliveStop chan struct{}
175 keepaliveDone chan struct{}
176 done chan struct{}
177 + idleInterval time.Duration
178 + state reverseSessionState
179 closeOnce sync.Once
180 + mu sync.Mutex
181 }
182
183 func newReverseSession(conn net.Conn, idleInterval time.Duration) *reverseSession {
@@ -254,7 +252,7 @@ func (s *reverseSession) Activate() error {
252 _, err := s.conn.Write([]byte{MarkerTLSStart})
253 _ = s.conn.SetWriteDeadline(time.Time{})
254 if err != nil {
257 - go s.Close()
255 + _ = s.Close()
256 }
257 return err
258 }
portal/helpers.go
+1 -17
@@ -16,7 +16,7 @@ const (
16 defaultIdleKeepalive = 15 * time.Second
17 defaultReadyQueueLimit = 8
18 defaultClientHelloWait = 2 * time.Second
19 - defaultControlBodyLimit = 32 << 10
19 + defaultControlBodyLimit = 4 << 20
20 defaultSessionWriteLimit = 5 * time.Second
21 )
22
@@ -86,18 +86,6 @@ func suggestHostname(name, rootHost string) string {
86 return label + "." + rootHost
87 }
88
89 -func hostnameMatchesWildcard(pattern, host string) bool {
90 - if !strings.HasPrefix(pattern, "*.") {
91 - return false
92 - }
93 - suffix := strings.TrimPrefix(pattern, "*.")
94 - parts := strings.Split(host, ".")
95 - if len(parts) < 2 {
96 - return false
97 - }
98 - return normalizeHostname("*."+strings.Join(parts[1:], ".")) == normalizeHostname(pattern) && strings.Count(suffix, ".")+1 == len(parts)-1
99 -}
100 -
89 func randomID(prefix string) string {
90 buf := make([]byte, 8)
91 if _, err := rand.Read(buf); err != nil {
@@ -106,10 +94,6 @@ func randomID(prefix string) string {
94 return prefix + hex.EncodeToString(buf)
95 }
96
109 -func randomToken() string {
110 - return randomID("tok_")
111 -}
112 -
97 func durationOrDefault(v, fallback time.Duration) time.Duration {
98 if v > 0 {
99 return v
portal/routing.go
+1 -1
@@ -6,8 +6,8 @@ import (
6 )
7
8 type routeTable struct {
9 - mu sync.RWMutex
9 exact map[string]string
10 + mu sync.RWMutex
11 }
12
13 func newRouteTable() *routeTable {
portal/server.go
+59 -32
@@ -14,61 +14,58 @@ import (
14 "sync"
15 "time"
16
17 - "github.com/gosuda/keyless_tls/relay/l4"
17 "golang.org/x/sync/errgroup"
18 +
19 + "github.com/gosuda/keyless_tls/relay/l4"
20 )
21
22 type ServerConfig struct {
23 + APIHandlerWrapper func(http.Handler) http.Handler
24 PortalURL string
25 APIListenAddr string
26 SNIListenAddr string
27 RootHost string
28 RootFallbackAddr string
29 + APITLS TLSMaterialConfig
30 LeaseTTL time.Duration
31 ClaimTimeout time.Duration
32 IdleKeepaliveInterval time.Duration
33 ReadyQueueLimit int
34 ClientHelloTimeout time.Duration
32 - APITLS TLSMaterialConfig
33 - APIHandlerWrapper func(http.Handler) http.Handler
35 }
36
37 type Server struct {
37 - cfg ServerConfig
38 -
39 - apiServer *http.Server
40 - apiListener net.Listener
41 - sniListener net.Listener
42 - apiTLSClose io.Closer
43 -
44 - ctx context.Context
45 - cancel context.CancelFunc
46 - group *errgroup.Group
47 -
48 - routes *routeTable
49 -
50 - mu sync.RWMutex
51 - leases map[string]*leaseRecord
52 -
38 + sniListener net.Listener
39 + apiTLSClose io.Closer
40 + apiListener net.Listener
41 + apiServer *http.Server
42 + ctxDone <-chan struct{}
43 + baseContext func() context.Context
44 + cancel context.CancelFunc
45 + group *errgroup.Group
46 + routes *routeTable
47 + leases map[string]*leaseRecord
48 + cfg ServerConfig
49 + mu sync.RWMutex
50 shutdownOnce sync.Once
51 }
52
53 type leaseRecord struct {
54 + ExpiresAt time.Time
55 + Broker *leaseBroker
56 ID string
57 Name string
58 + ReverseToken string
59 Hostnames []string
60 Metadata LeaseMetadata
61 - ReverseToken string
62 - ExpiresAt time.Time
63 - Broker *leaseBroker
61 }
62
63 type LeaseSnapshot struct {
64 + ExpiresAt time.Time
65 ID string
66 Name string
67 Hostnames []string
68 Metadata LeaseMetadata
71 - ExpiresAt time.Time
69 Ready int
70 }
71
@@ -109,17 +106,21 @@ func (s *Server) Start(ctx context.Context) error {
106 return errors.New("server already started")
107 }
108
112 - apiListener, err := net.Listen("tcp", s.cfg.APIListenAddr)
109 + serverCtx, cancel := context.WithCancel(ctx)
110 + var listenConfig net.ListenConfig
111 +
112 + apiListener, err := listenConfig.Listen(serverCtx, "tcp", s.cfg.APIListenAddr)
113 if err != nil {
114 + cancel()
115 return fmt.Errorf("listen api: %w", err)
116 }
116 - sniListener, err := net.Listen("tcp", s.cfg.SNIListenAddr)
117 + sniListener, err := listenConfig.Listen(serverCtx, "tcp", s.cfg.SNIListenAddr)
118 if err != nil {
119 _ = apiListener.Close()
120 + cancel()
121 return fmt.Errorf("listen sni: %w", err)
122 }
123
122 - serverCtx, cancel := context.WithCancel(ctx)
124 group, groupCtx := errgroup.WithContext(serverCtx)
125
126 apiServer := &http.Server{
@@ -139,7 +140,8 @@ func (s *Server) Start(ctx context.Context) error {
140 s.sniListener = sniListener
141 s.apiServer = apiServer
142 s.apiTLSClose = apiCloser
142 - s.ctx = groupCtx
143 + s.baseContext = func() context.Context { return groupCtx }
144 + s.ctxDone = groupCtx.Done()
145 s.cancel = cancel
146 s.group = group
147
@@ -527,7 +529,7 @@ func (s *Server) runSNIListener() error {
529 for {
530 conn, err := s.sniListener.Accept()
531 if err != nil {
530 - if s.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
532 + if errors.Is(err, net.ErrClosed) || s.isClosed() {
533 return nil
534 }
535 return err
@@ -568,7 +570,7 @@ func (s *Server) handleSNIConn(conn net.Conn) {
570 return
571 }
572
571 - claimCtx, cancel := context.WithTimeout(s.ctx, s.cfg.ClaimTimeout)
573 + claimCtx, cancel := context.WithTimeout(s.context(), s.cfg.ClaimTimeout)
574 defer cancel()
575
576 session, err := record.Broker.Claim(claimCtx)
@@ -581,7 +583,8 @@ func (s *Server) handleSNIConn(conn net.Conn) {
583 }
584
585 func (s *Server) bridgeToFallback(conn net.Conn) {
584 - upstream, err := net.DialTimeout("tcp", hostPortOrLoopback(s.cfg.RootFallbackAddr), 5*time.Second)
586 + dialer := &net.Dialer{Timeout: 5 * time.Second}
587 + upstream, err := dialer.DialContext(s.context(), "tcp", hostPortOrLoopback(s.cfg.RootFallbackAddr))
588 if err != nil {
589 _ = conn.Close()
590 return
@@ -595,7 +598,7 @@ func (s *Server) runLeaseJanitor() error {
598
599 for {
600 select {
598 - case <-s.ctx.Done():
601 + case <-s.ctxDone:
602 return nil
603 case <-ticker.C:
604 s.cleanupExpiredLeases()
@@ -623,7 +626,10 @@ func (s *Server) cleanupExpiredLeases() {
626 }
627
628 func (s *Server) watchContext() error {
626 - <-s.ctx.Done()
629 + if s.ctxDone == nil {
630 + return nil
631 + }
632 + <-s.ctxDone
633 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
634 defer cancel()
635 return s.Shutdown(shutdownCtx)
@@ -706,3 +712,24 @@ func closeWrite(conn net.Conn) {
712 _ = cw.CloseWrite()
713 }
714 }
715 +
716 +func (s *Server) context() context.Context {
717 + if s.baseContext != nil {
718 + if ctx := s.baseContext(); ctx != nil {
719 + return ctx
720 + }
721 + }
722 + return context.Background()
723 +}
724 +
725 +func (s *Server) isClosed() bool {
726 + if s.ctxDone == nil {
727 + return false
728 + }
729 + select {
730 + case <-s.ctxDone:
731 + return true
732 + default:
733 + return false
734 + }
735 +}
portal/tls.go
+3 -2
@@ -2,6 +2,7 @@ package portal
2
3 import (
4 "crypto/tls"
5 + "errors"
6 "fmt"
7 "io"
8 "net/http"
@@ -10,9 +11,9 @@ import (
11 )
12
13 type TLSMaterialConfig struct {
14 + Keyless *RemoteSignerConfig
15 CertPEM []byte
16 KeyPEM []byte
15 - Keyless *RemoteSignerConfig
17 }
18
19 type RemoteSignerConfig struct {
@@ -26,7 +27,7 @@ type RemoteSignerConfig struct {
27
28 func attachAPITLS(server *http.Server, cfg TLSMaterialConfig) (io.Closer, error) {
29 if server == nil {
29 - return nil, fmt.Errorf("http server is required")
30 + return nil, errors.New("http server is required")
31 }
32 if cfg.Keyless != nil {
33 remoteSigner, err := keylesslib.AttachToHTTPServer(server, keylesslib.HTTPServerAttachConfig{
sdk/client.go
+9 -7
@@ -9,6 +9,7 @@ import (
9 "crypto/x509"
10 "encoding/hex"
11 "encoding/json"
12 + "errors"
13 "fmt"
14 "io"
15 "net"
@@ -104,7 +105,7 @@ func (c *Client) Close() {
105
106 func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, error) {
107 if strings.TrimSpace(req.Name) == "" {
107 - return nil, fmt.Errorf("listener name is required")
108 + return nil, errors.New("listener name is required")
109 }
110
111 reverseToken := strings.TrimSpace(req.ReverseToken)
@@ -151,7 +152,8 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
152 listenerCtx, cancel := context.WithCancel(ctx)
153 l := &Listener{
154 client: c,
154 - ctx: listenerCtx,
155 + baseContext: func() context.Context { return listenerCtx },
156 + ctxDone: listenerCtx.Done(),
157 cancel: cancel,
158 leaseID: registerResp.LeaseID,
159 hostnames: append([]string(nil), registerResp.Hostnames...),
@@ -253,9 +255,9 @@ func (c *Client) openReverseSession(ctx context.Context, leaseID, reverseToken s
255 req.Header.Set(portal.HeaderReverseToken, reverseToken)
256 req.Header.Set("Connection", "keep-alive")
257
256 - if err := req.Write(conn); err != nil {
258 + if writeErr := req.Write(conn); writeErr != nil {
259 _ = conn.Close()
258 - return nil, err
260 + return nil, writeErr
261 }
262
263 reader := bufio.NewReader(conn)
@@ -281,9 +283,9 @@ func (c *Client) resolve(path string) string {
283 }
284
285 type apiEnvelope struct {
284 - OK bool `json:"ok"`
285 - Data json.RawMessage `json:"data"`
286 Error *portal.APIError `json:"error"`
287 + Data json.RawMessage `json:"data"`
288 + OK bool `json:"ok"`
289 }
290
291 func buildRootCAs(rootCAPEM []byte) (*x509.CertPool, error) {
@@ -292,7 +294,7 @@ func buildRootCAs(rootCAPEM []byte) (*x509.CertPool, error) {
294 }
295 pool := x509.NewCertPool()
296 if !pool.AppendCertsFromPEM(rootCAPEM) {
295 - return nil, fmt.Errorf("failed to parse relay root ca")
297 + return nil, errors.New("failed to parse relay root ca")
298 }
299 return pool, nil
300 }
sdk/listener.go
+50 -29
@@ -17,38 +17,37 @@ type LeaseMetadata = portal.LeaseMetadata
17
18 type ListenRequest struct {
19 Name string
20 + ReverseToken string
21 + TLS portal.TLSMaterialConfig
22 Hostnames []string
23 Metadata LeaseMetadata
22 - ReverseToken string
24 ReadyTarget int
25 LeaseTTL time.Duration
25 - TLS portal.TLSMaterialConfig
26 }
27
28 type Listener struct {
29 - client *Client
30 - ctx context.Context
31 - cancel context.CancelFunc
32 - leaseID string
33 - hostnames []string
34 - metadata LeaseMetadata
35 - reverseToken string
36 - leaseTTL time.Duration
37 - readyTarget int
38 - tlsConfig *tls.Config
39 - tlsCloser io.Closer
40 -
41 - accepted chan net.Conn
42 - signal chan struct{}
43 -
44 - mu sync.Mutex
29 + tlsCloser io.Closer
30 + tlsConfig *tls.Config
31 + baseContext func() context.Context
32 + ctxDone <-chan struct{}
33 + cancel context.CancelFunc
34 + client *Client
35 + signal chan struct{}
36 + accepted chan net.Conn
37 + leaseID string
38 + reverseToken string
39 + hostnames []string
40 + metadata LeaseMetadata
41 + readyTarget int
42 + leaseTTL time.Duration
43 activeSessions int
44 closeOnce sync.Once
45 + mu sync.Mutex
46 }
47
48 func (l *Listener) Accept() (net.Conn, error) {
49 select {
51 - case <-l.ctx.Done():
50 + case <-l.ctxDone:
51 return nil, net.ErrClosed
52 case conn := <-l.accepted:
53 if conn == nil {
@@ -102,7 +101,7 @@ func (l *Listener) PublicURLs() []string {
101 func (l *Listener) runSupervisor() {
102 for {
103 select {
105 - case <-l.ctx.Done():
104 + case <-l.ctxDone:
105 return
106 case <-l.signal:
107 }
@@ -130,10 +129,10 @@ func (l *Listener) runRenewLoop() {
129
130 for {
131 select {
133 - case <-l.ctx.Done():
132 + case <-l.ctxDone:
133 return
134 case <-ticker.C:
136 - ctx, cancel := context.WithTimeout(l.ctx, 10*time.Second)
135 + ctx, cancel := context.WithTimeout(l.context(), 10*time.Second)
136 _ = l.client.renewLease(ctx, l.leaseID, l.reverseToken, l.leaseTTL)
137 cancel()
138 }
@@ -143,16 +142,17 @@ func (l *Listener) runRenewLoop() {
142 func (l *Listener) runSession() {
143 defer l.releaseSessionSlot()
144
146 - conn, err := l.client.openReverseSession(l.ctx, l.leaseID, l.reverseToken)
145 + sessionCtx := l.context()
146 + conn, err := l.client.openReverseSession(sessionCtx, l.leaseID, l.reverseToken)
147 if err != nil {
148 - sleepOrDone(l.ctx, time.Second)
148 + sleepOrDone(sessionCtx, time.Second)
149 return
150 }
151
152 if err := l.awaitActivation(conn); err != nil {
153 _ = conn.Close()
154 if !errors.Is(err, context.Canceled) && !errors.Is(err, net.ErrClosed) {
155 - sleepOrDone(l.ctx, time.Second)
155 + sleepOrDone(sessionCtx, time.Second)
156 }
157 }
158 }
@@ -179,16 +179,16 @@ func (l *Listener) awaitActivation(conn net.Conn) error {
179
180 func (l *Listener) activate(conn net.Conn) error {
181 tlsConn := tls.Server(conn, l.tlsConfig.Clone())
182 - handshakeCtx, cancel := context.WithTimeout(l.ctx, l.client.handshakeTimeout)
182 + handshakeCtx, cancel := context.WithTimeout(l.context(), l.client.handshakeTimeout)
183 defer cancel()
184 if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
185 return err
186 }
187
188 select {
189 - case <-l.ctx.Done():
189 + case <-l.ctxDone:
190 _ = tlsConn.Close()
191 - return l.ctx.Err()
191 + return l.context().Err()
192 case l.accepted <- tlsConn:
193 return nil
194 }
@@ -197,7 +197,7 @@ func (l *Listener) activate(conn net.Conn) error {
197 func (l *Listener) reserveSessionSlot() bool {
198 l.mu.Lock()
199 defer l.mu.Unlock()
200 - if l.ctx.Err() != nil {
200 + if l.isClosed() {
201 return false
202 }
203 if l.activeSessions >= l.readyTarget {
@@ -234,3 +234,24 @@ type listenerAddr string
234
235 func (a listenerAddr) Network() string { return "portal" }
236 func (a listenerAddr) String() string { return string(a) }
237 +
238 +func (l *Listener) context() context.Context {
239 + if l.baseContext != nil {
240 + if ctx := l.baseContext(); ctx != nil {
241 + return ctx
242 + }
243 + }
244 + return context.Background()
245 +}
246 +
247 +func (l *Listener) isClosed() bool {
248 + if l.ctxDone == nil {
249 + return false
250 + }
251 + select {
252 + case <-l.ctxDone:
253 + return true
254 + default:
255 + return false
256 + }
257 +}
sdk/listener_test.go
+10 -8
@@ -47,8 +47,9 @@ func TestListenerEndToEndTLSHTTP(t *testing.T) {
47
48 ctx, cancel := context.WithCancel(context.Background())
49 defer cancel()
50 - if err := relay.Start(ctx); err != nil {
51 - t.Fatalf("Start() error = %v", err)
50 + startErr := relay.Start(ctx)
51 + if startErr != nil {
52 + t.Fatalf("Start() error = %v", startErr)
53 }
54 t.Cleanup(func() {
55 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -121,7 +122,7 @@ func TestListenerEndToEndTLSHTTP(t *testing.T) {
122 _ = server.Shutdown(shutdownCtx)
123 select {
124 case err := <-httpDone:
124 - if err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) {
125 + if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
126 t.Fatalf("server.Serve() error = %v", err)
127 }
128 case <-time.After(2 * time.Second):
@@ -170,8 +171,9 @@ func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
171
172 ctx, cancel := context.WithCancel(context.Background())
173 defer cancel()
173 - if err := relay.Start(ctx); err != nil {
174 - t.Fatalf("Start() error = %v", err)
174 + startErr := relay.Start(ctx)
175 + if startErr != nil {
176 + t.Fatalf("Start() error = %v", startErr)
177 }
178 t.Cleanup(func() {
179 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -215,7 +217,7 @@ func TestListenerEndToEndTLSHTTP_AutoSelfSigned(t *testing.T) {
217 _ = server.Shutdown(shutdownCtx)
218 select {
219 case err := <-httpDone:
218 - if err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) {
220 + if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
221 t.Fatalf("server.Serve() error = %v", err)
222 }
223 case <-time.After(2 * time.Second):
@@ -249,8 +251,8 @@ func doTenantRequest(addr, host, path string) (string, error) {
251 }
252 defer conn.Close()
253
252 - if _, err := fmt.Fprintf(conn, "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host); err != nil {
253 - return "", err
254 + if _, writeErr := fmt.Fprintf(conn, "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host); writeErr != nil {
255 + return "", writeErr
256 }
257
258 resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
sdk/tls.go
+2 -1
@@ -8,6 +8,7 @@ import (
8 "crypto/x509"
9 "crypto/x509/pkix"
10 "encoding/pem"
11 + "errors"
12 "fmt"
13 "io"
14 "math/big"
@@ -22,7 +23,7 @@ import (
23
24 func buildTenantTLSConfig(cfg portal.TLSMaterialConfig) (*tls.Config, io.Closer, error) {
25 if len(cfg.CertPEM) == 0 {
25 - return nil, nil, fmt.Errorf("tenant certificate is required")
26 + return nil, nil, errors.New("tenant certificate is required")
27 }
28 if cfg.Keyless != nil {
29 remoteSigner, err := keylesslib.NewRemoteSigner(keylesslib.RemoteSignerConfig{