add localhost test ( native, linux only )
Kim committed
Mar 6, 2026 at 15:07 UTC
a34c0bde05be45250eb7f436be40c4495a3ef368
5 files changed
+92
-343
cmd/relay-server/tunnel.go
+12
-2
@@ -35,6 +35,13 @@ BASE_URL="${BASE_URL:-%s}"
35
RELAYS="${RELAYS:-$BASE_URL}"
36
BIN_URL="${BIN_URL:-$BASE_URL/tunnel/bin/$TUNNEL_OS-$TUNNEL_ARCH}"
37
CHECKSUM_URL="${BIN_URL}.sha256"
38
+CURL_INSECURE_FLAG=""
39
+
40
+case "$BASE_URL" in
41
+ https://localhost|https://localhost:*|https://127.0.0.1|https://127.0.0.1:*|https://[::1]|https://[::1]:*|https://*.localhost|https://*.localhost:*)
42
+ CURL_INSECURE_FLAG="-k"
43
+ ;;
44
+esac
45
46
TMPDIR="${TMPDIR:-/tmp}"
47
WORKDIR="$(mktemp -d "$TMPDIR/portal-tunnel.XXXXXX" 2>/dev/null || mktemp -d -t portal-tunnel)"
@@ -43,10 +50,10 @@ cleanup() { rm -rf "$WORKDIR"; }
50
trap cleanup EXIT INT TERM
51
52
echo "Downloading portal-tunnel ($TUNNEL_OS/$TUNNEL_ARCH)..." >&2
46
-curl -fsSL "$BIN_URL" -o "$BIN_PATH"
53
+curl $CURL_INSECURE_FLAG -fsSL "$BIN_URL" -o "$BIN_PATH"
54
55
echo "Verifying SHA256 checksum..." >&2
49
-CHECKSUM_PAYLOAD="$(curl -fsSL "$CHECKSUM_URL")" || {
56
+CHECKSUM_PAYLOAD="$(curl $CURL_INSECURE_FLAG -fsSL "$CHECKSUM_URL")" || {
57
echo "Failed to download checksum from $CHECKSUM_URL. Aborting (fail-closed)." >&2
58
echo "Hint: verify relay artifact publishing or CDN cache freshness." >&2
59
exit 1
@@ -86,6 +93,8 @@ const tunnelPowerShellScriptTemplate = `$ErrorActionPreference = "Stop"
93
94
$BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "%s" }
95
$Relays = if ($env:RELAYS) { $env:RELAYS } else { $BaseUrl }
96
+$OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
97
+[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
98
99
$Arch = $env:PROCESSOR_ARCHITECTURE
100
if ($Arch -eq "AMD64") {
@@ -154,6 +163,7 @@ Write-Host "Starting portal-tunnel..."
163
try {
164
& $BinPath $ArgsList
165
} finally {
166
+ [System.Net.ServicePointManager]::SecurityProtocol = $OriginalSecurityProtocol
167
if (Test-Path $WorkDir) {
168
Remove-Item -Recurse -Force $WorkDir
169
}
frontend/src/components/TunnelCommandModal.tsx
+20
-2
@@ -74,14 +74,17 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
74
const relayUrlVal =
75
relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
76
const tunnelScriptURL = new URL(API_PATHS.tunnel, currentOrigin).toString();
77
+ const localhostRelay = isLocalRelayOrigin(currentOrigin);
78
79
if (os === "windows") {
80
const windowsScriptURL = new URL(tunnelScriptURL);
81
windowsScriptURL.searchParams.set("os", "windows");
81
- return `$ProgressPreference = 'SilentlyContinue'; $env:APP_HOST="${hostVal}"; $env:APP_NAME="${nameVal}"; $env:RELAYS="${relayUrlVal}"; irm ${windowsScriptURL.toString()} | iex`;
82
+ const downloadCommand = `& { $proto = [System.Net.ServicePointManager]::SecurityProtocol; try { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12; irm ${windowsScriptURL.toString()} } finally { [System.Net.ServicePointManager]::SecurityProtocol = $proto } }`;
83
+ return `$ProgressPreference = 'SilentlyContinue'; $env:APP_HOST="${hostVal}"; $env:APP_NAME="${nameVal}"; $env:RELAYS="${relayUrlVal}"; ${downloadCommand} | iex`;
84
}
85
84
- return `curl -fsSL ${tunnelScriptURL} | APP_HOST=${hostVal} APP_NAME=${nameVal} RELAYS="${relayUrlVal}" sh`;
86
+ const curlFlags = localhostRelay ? "-kfsSL" : "-fsSL";
87
+ return `curl ${curlFlags} ${tunnelScriptURL} | APP_HOST=${hostVal} APP_NAME=${nameVal} RELAYS="${relayUrlVal}" sh`;
88
}, [currentOrigin, host, name, relayUrls, os]);
89
90
const handleCopy = async () => {
@@ -268,3 +271,18 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
271
</Dialog>
272
);
273
}
274
+
275
+function isLocalRelayOrigin(origin: string): boolean {
276
+ try {
277
+ const parsed = new URL(origin);
278
+ const host = parsed.hostname.trim().toLowerCase();
279
+ return (
280
+ host === "localhost" ||
281
+ host === "127.0.0.1" ||
282
+ host === "::1" ||
283
+ host.endsWith(".localhost")
284
+ );
285
+ } catch {
286
+ return false;
287
+ }
288
+}
sdk/client.go
+28
-5
@@ -60,11 +60,6 @@ func NewClient(cfg ClientConfig) (*Client, error) {
60
baseURL.RawQuery = ""
61
baseURL.Fragment = ""
62
63
- rootCAs, err := buildRootCAs(cfg.RootCAPEM)
64
- if err != nil {
65
- return nil, err
66
- }
67
-
63
if cfg.DialTimeout <= 0 {
64
cfg.DialTimeout = 5 * time.Second
65
}
@@ -84,6 +79,22 @@ func NewClient(cfg ClientConfig) (*Client, error) {
79
cfg.ReadyTarget = 1
80
}
81
82
+ if len(cfg.RootCAPEM) == 0 && !cfg.InsecureSkipVerify && isLocalRelayHost(baseURL.Hostname()) {
83
+ bootstrapCtx, cancel := context.WithTimeout(context.Background(), cfg.DialTimeout+cfg.HandshakeTimeout)
84
+ defer cancel()
85
+
86
+ _, rootCAPEM, bootstrapErr := keyless.ResolveMaterials(bootstrapCtx, baseURL.String(), baseURL.Hostname())
87
+ if bootstrapErr != nil {
88
+ return nil, fmt.Errorf("bootstrap localhost relay trust: %w", bootstrapErr)
89
+ }
90
+ cfg.RootCAPEM = rootCAPEM
91
+ }
92
+
93
+ rootCAs, err := buildRootCAs(cfg.RootCAPEM)
94
+ if err != nil {
95
+ return nil, err
96
+ }
97
+
98
baseTLS := &tls.Config{
99
MinVersion: tls.VersionTLS12,
100
ServerName: baseURL.Hostname(),
@@ -329,6 +340,18 @@ func ensurePort(host string) string {
340
return net.JoinHostPort(host, "443")
341
}
342
343
+func isLocalRelayHost(host string) bool {
344
+ host = strings.TrimSpace(strings.ToLower(host))
345
+ switch host {
346
+ case "", "localhost":
347
+ return true
348
+ }
349
+ if ip := net.ParseIP(host); ip != nil {
350
+ return ip.IsLoopback()
351
+ }
352
+ return strings.HasSuffix(host, ".localhost")
353
+}
354
+
355
type bufferedConn struct {
356
net.Conn
357
reader *bytes.Reader
sdk/client_test.go
new
+32
@@ -0,0 +1,32 @@
1
+package sdk
2
+
3
+import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "testing"
7
+)
8
+
9
+func TestNewClientAutoTrustsLocalhostRelayCertificate(t *testing.T) {
10
+ t.Parallel()
11
+
12
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
13
+ w.WriteHeader(http.StatusOK)
14
+ }))
15
+ defer server.Close()
16
+
17
+ client, err := NewClient(ClientConfig{RelayURL: server.URL})
18
+ if err != nil {
19
+ t.Fatalf("NewClient() error = %v", err)
20
+ }
21
+ defer client.Close()
22
+
23
+ resp, err := client.httpClient.Get(server.URL)
24
+ if err != nil {
25
+ t.Fatalf("httpClient.Get() error = %v", err)
26
+ }
27
+ _ = resp.Body.Close()
28
+
29
+ if resp.StatusCode != http.StatusOK {
30
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
31
+ }
32
+}
sdk/listener_test.go
deleted
-334
@@ -1,334 +0,0 @@
1
-package sdk
2
-
3
-import (
4
- "bufio"
5
- "context"
6
- "crypto/ecdsa"
7
- "crypto/elliptic"
8
- "crypto/rand"
9
- "crypto/tls"
10
- "crypto/x509"
11
- "crypto/x509/pkix"
12
- "encoding/pem"
13
- "errors"
14
- "fmt"
15
- "io"
16
- "math/big"
17
- "net"
18
- "net/http"
19
- "os"
20
- "strings"
21
- "testing"
22
- "time"
23
-
24
- "gosuda.org/portal/portal"
25
- "gosuda.org/portal/portal/keyless"
26
- "gosuda.org/portal/types"
27
-)
28
-
29
-func selfSignedCertPEM(hosts ...string) (certPEM, keyPEM []byte, err error) {
30
- priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
31
- if err != nil {
32
- return nil, nil, err
33
- }
34
-
35
- serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
36
- serial, err := rand.Int(rand.Reader, serialLimit)
37
- if err != nil {
38
- return nil, nil, err
39
- }
40
-
41
- template := &x509.Certificate{
42
- SerialNumber: serial,
43
- Subject: pkix.Name{
44
- CommonName: hosts[0],
45
- },
46
- NotBefore: time.Now().Add(-time.Hour),
47
- NotAfter: time.Now().Add(24 * time.Hour),
48
- KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
49
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
50
- BasicConstraintsValid: true,
51
- }
52
-
53
- for _, host := range hosts {
54
- if ip := net.ParseIP(host); ip != nil {
55
- template.IPAddresses = append(template.IPAddresses, ip)
56
- continue
57
- }
58
- template.DNSNames = append(template.DNSNames, host)
59
- }
60
-
61
- der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
62
- if err != nil {
63
- return nil, nil, err
64
- }
65
-
66
- certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
67
- keyBytes, err := x509.MarshalECPrivateKey(priv)
68
- if err != nil {
69
- return nil, nil, err
70
- }
71
- keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
72
- return certPEM, keyPEM, nil
73
-}
74
-
75
-func TestListenerEndToEndTLSHTTP(t *testing.T) {
76
- t.Parallel()
77
-
78
- apiCertPEM, apiKeyPEM, err := selfSignedCertPEM("127.0.0.1", "portal.test", "*.portal.test")
79
- if err != nil {
80
- t.Fatalf("selfSignedCertPEM(api) error = %v", err)
81
- }
82
- tenantHost := "app.portal.test"
83
-
84
- relay, err := portal.NewServer(portal.ServerConfig{
85
- PortalURL: "https://127.0.0.1",
86
- APIListenAddr: "127.0.0.1:0",
87
- SNIListenAddr: "127.0.0.1:0",
88
- RootHost: "portal.test",
89
- RootFallbackAddr: "127.0.0.1:1",
90
- KeylessSignerHandler: newTestSignerHandler(t, apiKeyPEM),
91
- APITLS: keyless.TLSMaterialConfig{
92
- CertPEM: apiCertPEM,
93
- KeyPEM: apiKeyPEM,
94
- },
95
- })
96
- if err != nil {
97
- t.Fatalf("NewServer() error = %v", err)
98
- }
99
-
100
- ctx, cancel := context.WithCancel(context.Background())
101
- defer cancel()
102
- startErr := relay.Start(ctx)
103
- if startErr != nil {
104
- t.Fatalf("Start() error = %v", startErr)
105
- }
106
- t.Cleanup(func() {
107
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
108
- defer shutdownCancel()
109
- _ = relay.Shutdown(shutdownCtx)
110
- _ = relay.Wait()
111
- })
112
-
113
- client, err := NewClient(ClientConfig{
114
- RelayURL: "https://" + relay.APIAddr(),
115
- InsecureSkipVerify: true,
116
- ReadyTarget: 1,
117
- })
118
- if err != nil {
119
- t.Fatalf("NewClient() error = %v", err)
120
- }
121
- t.Cleanup(client.Close)
122
-
123
- listener, err := client.Listen(ctx, ListenRequest{
124
- Name: "demo",
125
- Hostnames: []string{tenantHost},
126
- Metadata: types.LeaseMetadata{
127
- Description: "demo description",
128
- Tags: []string{"demo", "test", "demo"},
129
- Owner: "portal",
130
- Thumbnail: "https://example.test/thumb.png",
131
- Hide: true,
132
- },
133
- })
134
- if err != nil {
135
- t.Fatalf("Listen() error = %v", err)
136
- }
137
- t.Cleanup(func() { _ = listener.Close() })
138
-
139
- if listener.Metadata().Description != "demo description" {
140
- t.Fatalf("listener.Metadata().Description = %q", listener.Metadata().Description)
141
- }
142
- if got, ok := relay.GetLease(listener.LeaseID()); !ok {
143
- t.Fatalf("GetLease(%q) = not found", listener.LeaseID())
144
- } else {
145
- if got.Metadata.Owner != "portal" {
146
- t.Fatalf("GetLease().Metadata.Owner = %q", got.Metadata.Owner)
147
- }
148
- if len(got.Metadata.Tags) != 2 {
149
- t.Fatalf("GetLease().Metadata.Tags = %v, want deduped tags", got.Metadata.Tags)
150
- }
151
- if !got.Metadata.Hide {
152
- t.Fatal("GetLease().Metadata.Hide = false, want true")
153
- }
154
- }
155
-
156
- httpDone := make(chan error, 1)
157
- server := &http.Server{
158
- Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159
- w.Header().Set("Content-Type", "text/plain")
160
- _, _ = io.WriteString(w, "hello over relay\n")
161
- }),
162
- ReadHeaderTimeout: 5 * time.Second,
163
- }
164
- go func() {
165
- httpDone <- server.Serve(listener)
166
- }()
167
- t.Cleanup(func() {
168
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
169
- defer shutdownCancel()
170
- _ = server.Shutdown(shutdownCtx)
171
- select {
172
- case err := <-httpDone:
173
- if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
174
- t.Fatalf("server.Serve() error = %v", err)
175
- }
176
- case <-time.After(2 * time.Second):
177
- }
178
- })
179
-
180
- deadline := time.Now().Add(10 * time.Second)
181
- for {
182
- body, err := doTenantRequest(relay.SNIAddr(), tenantHost, "/")
183
- if err == nil {
184
- if !strings.Contains(body, "hello over relay") {
185
- t.Fatalf("body = %q, want relay payload", body)
186
- }
187
- return
188
- }
189
- if time.Now().After(deadline) {
190
- t.Fatalf("doTenantRequest() last error = %v", err)
191
- }
192
- time.Sleep(100 * time.Millisecond)
193
- }
194
-}
195
-
196
-func TestListenerEndToEndTLSHTTP_AutoKeyless(t *testing.T) {
197
- t.Parallel()
198
-
199
- apiCertPEM, apiKeyPEM, err := selfSignedCertPEM("127.0.0.1", "portal.test", "*.portal.test")
200
- if err != nil {
201
- t.Fatalf("selfSignedCertPEM(api) error = %v", err)
202
- }
203
- tenantHost := "auto.portal.test"
204
- relay, err := portal.NewServer(portal.ServerConfig{
205
- PortalURL: "https://127.0.0.1",
206
- APIListenAddr: "127.0.0.1:0",
207
- SNIListenAddr: "127.0.0.1:0",
208
- RootHost: "portal.test",
209
- RootFallbackAddr: "127.0.0.1:1",
210
- KeylessSignerHandler: newTestSignerHandler(t, apiKeyPEM),
211
- APITLS: keyless.TLSMaterialConfig{
212
- CertPEM: apiCertPEM,
213
- KeyPEM: apiKeyPEM,
214
- },
215
- })
216
- if err != nil {
217
- t.Fatalf("NewServer() error = %v", err)
218
- }
219
-
220
- ctx, cancel := context.WithCancel(context.Background())
221
- defer cancel()
222
- startErr := relay.Start(ctx)
223
- if startErr != nil {
224
- t.Fatalf("Start() error = %v", startErr)
225
- }
226
- t.Cleanup(func() {
227
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
228
- defer shutdownCancel()
229
- _ = relay.Shutdown(shutdownCtx)
230
- _ = relay.Wait()
231
- })
232
-
233
- client, err := NewClient(ClientConfig{
234
- RelayURL: "https://" + relay.APIAddr(),
235
- InsecureSkipVerify: true,
236
- ReadyTarget: 1,
237
- })
238
- if err != nil {
239
- t.Fatalf("NewClient() error = %v", err)
240
- }
241
- t.Cleanup(client.Close)
242
-
243
- listener, err := client.Listen(ctx, ListenRequest{
244
- Name: "auto-demo",
245
- Hostnames: []string{tenantHost},
246
- })
247
- if err != nil {
248
- t.Fatalf("Listen() error = %v", err)
249
- }
250
- t.Cleanup(func() { _ = listener.Close() })
251
-
252
- httpDone := make(chan error, 1)
253
- server := &http.Server{
254
- Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
255
- _, _ = io.WriteString(w, "auto keyless ok\n")
256
- }),
257
- ReadHeaderTimeout: 5 * time.Second,
258
- }
259
- go func() {
260
- httpDone <- server.Serve(listener)
261
- }()
262
- t.Cleanup(func() {
263
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
264
- defer shutdownCancel()
265
- _ = server.Shutdown(shutdownCtx)
266
- select {
267
- case err := <-httpDone:
268
- if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
269
- t.Fatalf("server.Serve() error = %v", err)
270
- }
271
- case <-time.After(2 * time.Second):
272
- }
273
- })
274
-
275
- deadline := time.Now().Add(10 * time.Second)
276
- for {
277
- body, err := doTenantRequest(relay.SNIAddr(), tenantHost, "/")
278
- if err == nil {
279
- if !strings.Contains(body, "auto keyless ok") {
280
- t.Fatalf("body = %q, want auto keyless payload", body)
281
- }
282
- return
283
- }
284
- if time.Now().After(deadline) {
285
- t.Fatalf("doTenantRequest() last error = %v", err)
286
- }
287
- time.Sleep(100 * time.Millisecond)
288
- }
289
-}
290
-
291
-func doTenantRequest(addr, host, path string) (string, error) {
292
- conn, err := tls.Dial("tcp", addr, &tls.Config{
293
- ServerName: host,
294
- InsecureSkipVerify: true,
295
- NextProtos: []string{"http/1.1"},
296
- })
297
- if err != nil {
298
- return "", err
299
- }
300
- defer conn.Close()
301
-
302
- if _, writeErr := fmt.Fprintf(conn, "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host); writeErr != nil {
303
- return "", writeErr
304
- }
305
-
306
- resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
307
- if err != nil {
308
- return "", err
309
- }
310
- defer resp.Body.Close()
311
-
312
- body, err := io.ReadAll(resp.Body)
313
- if err != nil {
314
- return "", err
315
- }
316
- if resp.StatusCode != http.StatusOK {
317
- return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
318
- }
319
- return string(body), nil
320
-}
321
-
322
-func newTestSignerHandler(t *testing.T, keyPEM []byte) http.Handler {
323
- t.Helper()
324
-
325
- keyFile := t.TempDir() + "/relay-key.pem"
326
- if err := os.WriteFile(keyFile, keyPEM, 0o600); err != nil {
327
- t.Fatalf("WriteFile(key) error = %v", err)
328
- }
329
- signer, err := keyless.NewSigner(keyFile)
330
- if err != nil {
331
- t.Fatalf("NewSigner() error = %v", err)
332
- }
333
- return signer.Handler()
334
-}