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 -}