refact: enforce https-only relay APIs and remove localhost HTTP proxy path

Kim committed Mar 4, 2026 at 12:01 UTC 1f9776558cc7c5b4f7d0fe705d7fc1c466cdb7a7
14 files changed +314 -52
Dockerfile
+2 -2
@@ -38,8 +38,8 @@ FROM gcr.io/distroless/static-debian12:nonroot
38
39 COPY --from=go-builder /src/bin/relay-server /usr/bin/relay-server
40
41 -ENV PORTAL_URL=http://localhost:4017
42 -ENV BOOTSTRAP_URIS=http://localhost:4017
41 +ENV PORTAL_URL=https://localhost:4017
42 +ENV BOOTSTRAP_URIS=https://localhost:4017
43 ENV ADMIN_SECRET_KEY=
44 ENV SNI_PORT=:443
45 ENV KEYLESS_DIR=/etc/portal/keyless
cmd/demo-app/main.go
+1 -1
@@ -38,7 +38,7 @@ var (
38 )
39
40 func main() {
41 - flag.StringVar(&flagServerURL, "server-url", "http://localhost:4017", "relay API URL (http/https)")
41 + flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URL (https only)")
42 flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
43 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
44 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
cmd/portal-tunnel/README.md
+1 -1
@@ -45,7 +45,7 @@ Usage:
45 portal-tunnel [OPTIONS] [ARGUMENTS]
46
47 Options:
48 - --relay Portal relay server API URLs (comma-separated, http/https) [default: http://localhost:4017] [env: RELAYS]
48 + --relay Portal relay server API URLs (comma-separated, https only) [default: https://localhost:4017] [env: RELAYS]
49 --host Target host to proxy to (host:port or URL) [env: APP_HOST]
50 --name Service name [env: APP_NAME]
51 --tls Enable keyless TLS mode [env: TLS]
cmd/relay-server/frontend/src/components/TunnelCommandModal.tsx
+1 -1
@@ -26,7 +26,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
26 if (typeof window !== "undefined") {
27 return window.location.origin;
28 }
29 - return "http://localhost:4017";
29 + return "https://localhost:4017";
30 }, []);
31
32 const [host, setHost] = useState(defaultHost);
cmd/relay-server/main.go
+1 -1
@@ -23,7 +23,7 @@ import (
23 const (
24 defaultAPIPort = 4017
25 defaultSNIPort = 443
26 - defaultPortalURL = "http://localhost:4017"
26 + defaultPortalURL = "https://localhost:4017"
27 defaultKeylessDir = "/etc/portal/keyless"
28 )
29
docker-compose.yml
+3 -4
@@ -9,17 +9,16 @@ services:
9 - "${ADMIN_PORT:-4017}"
10 environment:
11 # Core configuration
12 - PORTAL_URL: ${PORTAL_URL:-http://localhost:${ADMIN_PORT:-4017}}
13 - BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-http://localhost:${ADMIN_PORT:-4017}}
12 + PORTAL_URL: ${PORTAL_URL:-https://localhost:${ADMIN_PORT:-4017}}
13 + BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-https://localhost:${ADMIN_PORT:-4017}}
14 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
15
16 - # TLS/SNI and keyless configurationa
16 + # TLS/SNI and keyless configuration
17 SNI_PORT: ${SNI_PORT:-443}
18 KEYLESS_DIR: ${KEYLESS_DIR:-/etc/portal/keyless}
19 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
20 ports:
21 - "${ADMIN_PORT:-4017}:${ADMIN_PORT:-4017}"
22 - - "80:80"
22 - "443:443"
23 volumes:
24 - ./data/keyless:/etc/portal/keyless
portal/acme/acme.go
+59 -2
@@ -23,6 +23,8 @@ import (
23 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
24 "github.com/go-acme/lego/v4/registration"
25 "github.com/rs/zerolog/log"
26 +
27 + "gosuda.org/portal/types"
28 )
29
30 const (
@@ -66,8 +68,12 @@ type Manager struct {
68 stopOnce sync.Once
69 }
70
69 -func NewManager(cfg Config) *Manager {
70 - return &Manager{
71 +func NewManager(ctx context.Context, cfg Config) (*Manager, string, error) {
72 + if strings.TrimSpace(cfg.KeyDir) == "" {
73 + return nil, "", nil
74 + }
75 +
76 + manager := &Manager{
77 cfg: Config{
78 BaseDomain: cfg.BaseDomain,
79 KeyDir: cfg.KeyDir,
@@ -75,6 +81,57 @@ func NewManager(cfg Config) *Manager {
81 },
82 stopCh: make(chan struct{}),
83 }
84 +
85 + generated, err := EnsureLocalDevelopmentCertificate(manager.cfg.KeyDir, manager.cfg.BaseDomain)
86 + if err != nil {
87 + return nil, "", fmt.Errorf("ensure local development certificate: %w", err)
88 + }
89 + if generated {
90 + log.Info().
91 + Str("base_host", manager.cfg.BaseDomain).
92 + Str("key_file", manager.SigningKeyFile()).
93 + Msg("[signer] generated self-signed localhost development certificate")
94 + }
95 +
96 + keyFile, err := manager.PrepareSigningKey(ctx)
97 + if err != nil {
98 + return nil, "", err
99 + }
100 + return manager, keyFile, nil
101 +}
102 +
103 +// PrepareSigningKey resolves the signer key path and runs ACME provisioning when required.
104 +// It encapsulates local-host detection and ACME enable/disable policy.
105 +func (m *Manager) PrepareSigningKey(ctx context.Context) (string, error) {
106 + if m == nil {
107 + return "", errors.New("acme manager is nil")
108 + }
109 +
110 + keyDir := strings.TrimSpace(m.cfg.KeyDir)
111 + baseDomain := strings.TrimSpace(m.cfg.BaseDomain)
112 + cloudflareToken := strings.TrimSpace(m.cfg.CloudflareToken)
113 + isLocalBaseHost := types.IsLocalhost(baseDomain)
114 +
115 + shouldEnsureWithACME := keyDir != "" && cloudflareToken != "" && baseDomain != "" && !isLocalBaseHost
116 + if shouldEnsureWithACME {
117 + keyFile, err := m.EnsureSigningKey(ctx)
118 + if err != nil {
119 + return "", fmt.Errorf("ensure keyless signing key: %w", err)
120 + }
121 + return keyFile, nil
122 + }
123 +
124 + log.Info().
125 + Bool("has_key_dir", keyDir != "").
126 + Bool("has_cloudflare_token", cloudflareToken != "").
127 + Bool("has_base_domain", baseDomain != "").
128 + Bool("is_local_base_host", isLocalBaseHost).
129 + Msg("[signer] ACME issuance disabled (requires key directory, Cloudflare token, and base domain)")
130 +
131 + if keyDir == "" {
132 + return "", nil
133 + }
134 + return m.SigningKeyFile(), nil
135 }
136
137 func (m *Manager) keyDir() string {
portal/acme/local.go new
+154
@@ -0,0 +1,154 @@
1 +package acme
2 +
3 +import (
4 + "crypto/ecdsa"
5 + "crypto/elliptic"
6 + "crypto/rand"
7 + "crypto/x509"
8 + "crypto/x509/pkix"
9 + "encoding/pem"
10 + "fmt"
11 + "math/big"
12 + "net"
13 + "strings"
14 + "time"
15 +
16 + "gosuda.org/portal/types"
17 +)
18 +
19 +const localDevelopmentCertificateTTL = 3650 * 24 * time.Hour
20 +
21 +// EnsureLocalDevelopmentCertificate ensures keyless TLS materials exist for localhost-style development.
22 +// It only acts when baseHost points to localhost/loopback semantics.
23 +func EnsureLocalDevelopmentCertificate(keyDir, baseHost string) (bool, error) {
24 + keyDir = strings.TrimSpace(keyDir)
25 + if keyDir == "" {
26 + return false, nil
27 + }
28 +
29 + baseHost = normalizeLocalDevelopmentHost(baseHost)
30 + if !types.IsLocalhost(baseHost) {
31 + return false, nil
32 + }
33 +
34 + domains := localDevelopmentDomains(baseHost)
35 + keyFile := keyPath(keyDir)
36 + certFile := fullChainPath(keyDir)
37 +
38 + if fileExists(keyFile) && fileExists(certFile) {
39 + covered, err := certCoversDomains(certFile, domains)
40 + if err == nil && covered {
41 + return false, nil
42 + }
43 + }
44 +
45 + if err := ensureParentDir(keyFile); err != nil {
46 + return false, err
47 + }
48 + if err := ensureParentDir(certFile); err != nil {
49 + return false, err
50 + }
51 +
52 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
53 + if err != nil {
54 + return false, fmt.Errorf("generate local development signing key: %w", err)
55 + }
56 +
57 + serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
58 + serialNumber, err := rand.Int(rand.Reader, serialLimit)
59 + if err != nil {
60 + return false, fmt.Errorf("generate local development certificate serial: %w", err)
61 + }
62 +
63 + now := time.Now().UTC()
64 + template := &x509.Certificate{
65 + SerialNumber: serialNumber,
66 + Subject: pkix.Name{
67 + CommonName: baseHost,
68 + Organization: []string{"Portal Local Development"},
69 + },
70 + NotBefore: now.Add(-1 * time.Hour),
71 + NotAfter: now.Add(localDevelopmentCertificateTTL),
72 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageKeyAgreement | x509.KeyUsageCertSign,
73 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
74 + BasicConstraintsValid: true,
75 + IsCA: true,
76 + }
77 +
78 + dnsNames := make(map[string]struct{}, len(domains))
79 + ipAddresses := make(map[string]net.IP)
80 + for _, domain := range domains {
81 + if ip := net.ParseIP(domain); ip != nil {
82 + ipAddresses[ip.String()] = ip
83 + continue
84 + }
85 + domain = strings.TrimSpace(domain)
86 + if domain == "" {
87 + continue
88 + }
89 + dnsNames[domain] = struct{}{}
90 + }
91 +
92 + for dnsName := range dnsNames {
93 + template.DNSNames = append(template.DNSNames, dnsName)
94 + }
95 + for _, ipAddress := range ipAddresses {
96 + template.IPAddresses = append(template.IPAddresses, ipAddress)
97 + }
98 +
99 + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
100 + if err != nil {
101 + return false, fmt.Errorf("create local development certificate: %w", err)
102 + }
103 +
104 + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
105 + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
106 + if err != nil {
107 + return false, fmt.Errorf("marshal local development private key: %w", err)
108 + }
109 + privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyDER})
110 +
111 + if err := writeFileAtomic(keyFile, privateKeyPEM, 0o600); err != nil {
112 + return false, fmt.Errorf("write local development private key: %w", err)
113 + }
114 + if err := writeFileAtomic(certFile, certPEM, 0o644); err != nil {
115 + return false, fmt.Errorf("write local development certificate: %w", err)
116 + }
117 +
118 + return true, nil
119 +}
120 +
121 +func normalizeLocalDevelopmentHost(host string) string {
122 + host = strings.ToLower(strings.TrimSpace(host))
123 + host = strings.TrimPrefix(strings.TrimSuffix(host, "."), "*.")
124 + return host
125 +}
126 +
127 +func localDevelopmentDomains(baseHost string) []string {
128 + baseHost = normalizeLocalDevelopmentHost(baseHost)
129 + if baseHost == "" {
130 + return []string{"localhost", "*.localhost", "127.0.0.1", "::1"}
131 + }
132 +
133 + domains := []string{"localhost", "*.localhost", "127.0.0.1", "::1"}
134 + domains = append(domains, baseHost)
135 +
136 + if net.ParseIP(baseHost) == nil {
137 + domains = append(domains, "*."+baseHost)
138 + }
139 +
140 + seen := make(map[string]struct{}, len(domains))
141 + out := make([]string, 0, len(domains))
142 + for _, domain := range domains {
143 + domain = strings.TrimSpace(domain)
144 + if domain == "" {
145 + continue
146 + }
147 + if _, ok := seen[domain]; ok {
148 + continue
149 + }
150 + seen[domain] = struct{}{}
151 + out = append(out, domain)
152 + }
153 + return out
154 +}
portal/keyless/client.go
+5 -2
@@ -15,6 +15,8 @@ import (
15 "github.com/rs/zerolog/log"
16
17 keylesstls "github.com/gosuda/keyless_tls/keyless"
18 +
19 + "gosuda.org/portal/types"
20 )
21
22 // BuildClientTLSConfig builds a keyless TLS server config for tunnel-side TLS termination.
@@ -181,8 +183,9 @@ func FetchEndpointCertificateChain(ctx context.Context, endpoint string, serverN
183 }
184
185 tlsConn := tls.Client(rawConn, &tls.Config{
184 - MinVersion: tls.VersionTLS12,
185 - ServerName: serverName,
186 + MinVersion: tls.VersionTLS12,
187 + ServerName: serverName,
188 + InsecureSkipVerify: types.IsLocalhost(host),
189 })
190 defer tlsConn.Close()
191 if err := tlsConn.HandshakeContext(ctx); err != nil {
portal/relay.go
+8 -23
@@ -44,30 +44,15 @@ func NewRelayServer(
44 sniRouter: sni.NewRouter(sniPort),
45 }
46
47 - keyFile := ""
48 - if keylessDir != "" {
49 - server.acmeManager = acme.NewManager(acme.Config{
50 - BaseDomain: baseHost,
51 - KeyDir: keylessDir,
52 - CloudflareToken: cloudflareToken,
53 - })
54 - keyFile = server.acmeManager.SigningKeyFile()
55 - }
56 -
57 - shouldEnsureWithACME := keylessDir != "" && cloudflareToken != "" && baseHost != ""
58 - if shouldEnsureWithACME {
59 - var err error
60 - keyFile, err = server.acmeManager.EnsureSigningKey(ctx)
61 - if err != nil {
62 - return nil, fmt.Errorf("ensure keyless signing key: %w", err)
63 - }
64 - } else {
65 - log.Info().
66 - Bool("has_key_dir", keylessDir != "").
67 - Bool("has_cloudflare_token", cloudflareToken != "").
68 - Bool("has_base_domain", baseHost != "").
69 - Msg("[signer] ACME issuance disabled (requires key directory, Cloudflare token, and base domain)")
47 + acmeManager, keyFile, err := acme.NewManager(ctx, acme.Config{
48 + BaseDomain: baseHost,
49 + KeyDir: keylessDir,
50 + CloudflareToken: cloudflareToken,
51 + })
52 + if err != nil {
53 + return nil, err
54 }
55 + server.acmeManager = acmeManager
56
57 signer, err := keyless.NewSigner(keyless.Config{
58 KeyFile: keyFile,
sdk/listener.go
+12 -3
@@ -120,6 +120,13 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
120 if err != nil {
121 return nil, err
122 }
123 + host := types.PortalRootHost(apiURL)
124 + clientTransport := http.DefaultTransport.(*http.Transport).Clone()
125 + clientTransport.TLSClientConfig = &tls.Config{
126 + MinVersion: tls.VersionTLS12,
127 + ServerName: host,
128 + InsecureSkipVerify: types.IsLocalhost(host),
129 + }
130
131 if reverseWorkers <= 0 {
132 reverseWorkers = defaultReverseWorkers
@@ -133,7 +140,8 @@ func NewListener(relayAddr string, lease *portal.Lease, tlsConfig *tls.Config, r
140 relayAddr: apiURL,
141 lease: lease,
142 httpClient: &http.Client{
136 - Timeout: 10 * time.Second,
143 + Timeout: 10 * time.Second,
144 + Transport: clientTransport,
145 },
146 tlsConfig: tlsConfig,
147 closeFns: closeFns,
@@ -388,8 +396,9 @@ func (l *Listener) openReverseConnection() (net.Conn, error) {
396 return nil, errors.New("reverse connect URL missing TLS server name")
397 }
398 tlsConn := tls.Client(rawConn, &tls.Config{
391 - MinVersion: tls.VersionTLS12,
392 - ServerName: serverName,
399 + MinVersion: tls.VersionTLS12,
400 + ServerName: serverName,
401 + InsecureSkipVerify: types.IsLocalhost(serverName),
402 })
403 err = tlsConn.HandshakeContext(ctx)
404 if err != nil {
sdk/listener_test.go
+4 -4
@@ -27,13 +27,13 @@ func TestNormalizeRelayAPIURL(t *testing.T) {
27 want string
28 wantErr bool
29 }{
30 - {name: "localhost subdomain to localhost", in: "http://demo-app.localhost:4017", want: "http://localhost:4017"},
31 - {name: "http base", in: "http://example.com", want: "http://example.com"},
30 + {name: "localhost subdomain to localhost", in: "https://demo-app.localhost:4017", want: "https://localhost:4017"},
31 + {name: "http base rejected", in: "http://example.com", wantErr: true},
32 {name: "https base", in: "https://example.com/", want: "https://example.com"},
33 - {name: "bare host", in: "localhost:4017", want: "http://localhost:4017"},
33 + {name: "bare host", in: "localhost:4017", want: "https://localhost:4017"},
34 {name: "invalid ws scheme", in: "ws://localhost:4017", wantErr: true},
35 {name: "invalid wss scheme", in: "wss://example.com", wantErr: true},
36 - {name: "invalid relay path", in: "http://localhost:4017/relay", wantErr: true},
36 + {name: "invalid relay path", in: "https://localhost:4017/relay", wantErr: true},
37 {name: "invalid scheme", in: "ftp://example.com", wantErr: true},
38 {name: "empty", in: "", wantErr: true},
39 }
types/netutil.go
+32 -7
@@ -10,7 +10,7 @@ import (
10 "strings"
11 )
12
13 -const defaultBootstrapURL = "http://localhost:4017"
13 +const defaultBootstrapURL = "https://localhost:4017"
14
15 func normalizeRootHost(raw string) string {
16 normalized := strings.ToLower(strings.TrimSpace(raw))
@@ -18,6 +18,30 @@ func normalizeRootHost(raw string) string {
18 return normalized
19 }
20
21 +// IsLocalhost reports whether host resolves to localhost/loopback semantics.
22 +// It accepts bare hosts, host:port forms, and bracketed IPv6 literals.
23 +func IsLocalhost(host string) bool {
24 + normalized := strings.ToLower(strings.TrimSpace(host))
25 + if normalized == "" {
26 + return false
27 + }
28 +
29 + if parsedHost, _, err := net.SplitHostPort(normalized); err == nil {
30 + normalized = parsedHost
31 + }
32 + normalized = strings.TrimPrefix(strings.TrimSuffix(normalized, "."), "*.")
33 + normalized = strings.TrimPrefix(strings.TrimSuffix(normalized, "]"), "[")
34 +
35 + if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
36 + return true
37 + }
38 +
39 + if ip := net.ParseIP(normalized); ip != nil {
40 + return ip.IsLoopback()
41 + }
42 + return false
43 +}
44 +
45 // StripScheme removes http:// or https:// prefix from a string.
46 func StripScheme(s string) string {
47 s = strings.TrimSpace(s)
@@ -166,7 +190,7 @@ func ServicePublicURL(portalURL, serviceName string) string {
190 return ""
191 }
192
169 - scheme, rootHost, _, ok := parsePortalAddress(portalURL, "http")
193 + scheme, rootHost, _, ok := parsePortalAddress(portalURL, "https")
194 if !ok || rootHost == "" {
195 return ""
196 }
@@ -199,7 +223,7 @@ func DefaultBootstrapFrom(base string) string {
223 }
224
225 if !strings.Contains(base, "://") {
202 - base = "http://" + base
226 + base = "https://" + base
227 }
228
229 u, err := url.Parse(strings.TrimSuffix(base, "/"))
@@ -213,6 +237,7 @@ func DefaultBootstrapFrom(base string) string {
237 return defaultBootstrapURL
238 }
239
240 + u.Scheme = "https"
241 u.Path = ""
242 u.RawQuery = ""
243 u.Fragment = ""
@@ -358,7 +383,7 @@ func NormalizeTargetAddr(raw string) (string, error) {
383 }
384
385 // NormalizeRelayAPIURL normalizes a relay API URL.
361 -// It accepts host:port input (defaults to http), validates the scheme,
386 +// It accepts host:port input (defaults to https), validates the scheme,
387 // normalizes localhost hostnames, and removes path/query/fragment.
388 func NormalizeRelayAPIURL(raw string) (string, error) {
389 raw = strings.TrimSpace(raw)
@@ -368,7 +393,7 @@ func NormalizeRelayAPIURL(raw string) (string, error) {
393
394 // Accept host:port input.
395 if !strings.Contains(raw, "://") {
371 - raw = "http://" + raw
396 + raw = "https://" + raw
397 }
398
399 u, err := url.Parse(raw)
@@ -389,9 +414,9 @@ func NormalizeRelayAPIURL(raw string) (string, error) {
414 }
415
416 switch u.Scheme {
392 - case "http", "https":
417 + case "https":
418 default:
394 - return "", fmt.Errorf("unsupported relay URL scheme: %q (use http/https)", u.Scheme)
419 + return "", fmt.Errorf("unsupported relay URL scheme: %q (use https)", u.Scheme)
420 }
421
422 if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
types/netutil_test.go
+31 -1
@@ -134,7 +134,7 @@ func TestServicePublicURL(t *testing.T) {
134 name: "default scheme for host-only portal URL",
135 portalURL: "portal.example.com",
136 service: "My-App",
137 - want: "http://my-app.portal.example.com",
137 + want: "https://my-app.portal.example.com",
138 },
139 {
140 name: "invalid service returns empty",
@@ -278,3 +278,33 @@ func TestBuildSNINameFallbackNormalizesRootHost(t *testing.T) {
278 t.Fatalf("BuildSNIName fallback=%q, want %q", got, want)
279 }
280 }
281 +
282 +func TestIsLocalhost(t *testing.T) {
283 + t.Parallel()
284 +
285 + tests := []struct {
286 + name string
287 + host string
288 + want bool
289 + }{
290 + {name: "localhost", host: "localhost", want: true},
291 + {name: "localhost with port", host: "localhost:4017", want: true},
292 + {name: "subdomain localhost", host: "portal.localhost", want: true},
293 + {name: "ipv4 loopback", host: "127.0.0.1", want: true},
294 + {name: "ipv4 loopback with port", host: "127.0.0.1:4017", want: true},
295 + {name: "ipv6 loopback", host: "::1", want: true},
296 + {name: "ipv6 loopback with port", host: "[::1]:4017", want: true},
297 + {name: "public host", host: "example.com", want: false},
298 + {name: "public ip", host: "8.8.8.8", want: false},
299 + }
300 +
301 + for _, tt := range tests {
302 + t.Run(tt.name, func(t *testing.T) {
303 + t.Parallel()
304 +
305 + if got := IsLocalhost(tt.host); got != tt.want {
306 + t.Fatalf("IsLocalhostHost(%q)=%v, want %v", tt.host, got, tt.want)
307 + }
308 + })
309 + }
310 +}