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