refact: remove custom hostname inputs
Kim committed
Mar 11, 2026 at 12:21 UTC
6e3c58e239be18b94c9fba7adb527d2c661be9b1
20 files changed
+221
-177
cmd/demo-app/main.go
+1
-1
@@ -35,7 +35,7 @@ func main() {
35
36
flag.StringVar(&flagServerURLs, "server-urls", "https://localhost:4017", "relay API URLs (comma-separated; scheme omitted defaults to https)")
37
flag.StringVar(&flagAddr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
38
- flag.StringVar(&flagName, "name", "demo-app", "backend display name")
38
+ flag.StringVar(&flagName, "name", "demo-app", "public hostname prefix (single DNS label)")
39
flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
40
flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
41
flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
cmd/portal-tunnel/README.md
+2
-1
@@ -19,7 +19,7 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
19
```text
20
--relays Portal relay server API URLs (comma-separated, https only) [env: RELAYS]
21
--host Target host to proxy to (host:port or URL) [env: APP_HOST]
22
---name Service name [env: APP_NAME]
22
+--name Public hostname prefix (single DNS label) [env: APP_NAME]
23
--description Service description metadata [env: APP_DESCRIPTION]
24
--tags Service tags metadata (comma-separated) [env: APP_TAGS]
25
--thumbnail Service thumbnail URL metadata [env: APP_THUMBNAIL]
@@ -30,6 +30,7 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
30
## Notes
31
32
- Multiple relay URLs are registered independently. Each relay gets its own lease ID and public URLs.
33
+- Relay publishes each service at `<name>.<portal root host>`.
34
- Portal-tunnel now consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
35
- Startup is fail-fast: if any configured relay cannot register, the tunnel exits instead of partially publishing.
36
- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
cmd/portal-tunnel/main.go
+1
-1
@@ -43,7 +43,7 @@ func main() {
43
44
flag.StringVar(&flagRelayURLs, "relays", defaultRelayURLs, "Portal relay server API URLs (comma-separated; scheme omitted defaults to https) [env: RELAYS]")
45
flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
46
- flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
46
+ flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Public hostname prefix (single DNS label) [env: APP_NAME]")
47
flag.StringVar(&flagDesc, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
48
flag.StringVar(&flagTags, "tags", os.Getenv("APP_TAGS"), "Service tags metadata (comma-separated) [env: APP_TAGS]")
49
flag.StringVar(&flagThumbnail, "thumbnail", os.Getenv("APP_THUMBNAIL"), "Service thumbnail URL metadata [env: APP_THUMBNAIL]")
docs/architecture.md
+2
-3
@@ -92,10 +92,9 @@ Result: the relay decides routing, but tenant TLS termination still happens at t
92
- Caller provides:
93
- `name`
94
- `reverse_token`
95
- - optional `hostnames`
95
- optional `metadata`
96
- optional `ttl`
98
-- If no hostname is supplied, relay derives one from `name + root host`
97
+- `name` must be a valid single DNS label and relay publishes the lease at `<name>.<root host>`
98
- Registration reserves the hostname and publishes the route immediately; if no reverse session is ready yet, inbound SNI claims wait up to `ClaimTimeout`
99
- `PORTAL_URL` is normalized to its host component only; path/query segments are ignored for routing
100
@@ -133,7 +132,7 @@ Notes:
132
133
- Wildcards are one level only.
134
- The exact root host is never served by the wildcard route.
136
-- For non-apex `PORTAL_URL` values such as `https://portal.example.com:8443/admin`, public lease hosts become `<lease>.portal.example.com`.
135
+- For non-apex `PORTAL_URL` values such as `https://portal.example.com:8443/admin`, a lease named `demo` is published at `demo.portal.example.com`.
136
137
## Admin and Frontend Surface
138
docs/deployment.md
+1
-1
@@ -57,7 +57,7 @@ If you deploy on a non-apex host (for example, `PORTAL_URL=https://portal.exampl
57
- `portal.example.com -> <server-ip>`
58
- `*.portal.example.com -> <server-ip>`
59
60
-Portal derives public lease hostnames from the normalized `PORTAL_URL` host.
60
+Portal's relay DNS and certificates cover the normalized `PORTAL_URL` host and its wildcard, and public lease hostnames are published as `<name>.<root host>`.
61
Requests to the exact root host are not served by the wildcard route; they fall back to the admin/API listener.
62
63
#### Create Cloudflare API token
docs/glossary.md
+1
-1
@@ -39,7 +39,7 @@ Each lease has an ID, display name, hostnames, metadata, expiry, reverse token,
39
40
## Lease Name
41
42
-The human-readable identifier used to derive a hostname (for example, `myapp` -> `myapp.example.com`) when no explicit hostname is supplied.
42
+The canonical single DNS label for a lease. Portal publishes the lease at `<name>.<root host>` and also uses the same value for admin/UI display.
43
44
## Lease Broker
45
portal/admin/rows.go
+1
-4
@@ -60,10 +60,7 @@ func BuildLeaseRows(serv *portal.Server, includeAdmin bool, portalURL string) []
60
}
61
62
metadataJSON, _ := json.Marshal(snapshot.Metadata)
63
- host := ""
64
- if len(snapshot.Hostnames) > 0 {
65
- host = snapshot.Hostnames[0]
66
- }
63
+ host := snapshot.Hostname
64
65
rows = append(rows, LeaseRow{
66
TTL: formatDuration(time.Until(snapshot.ExpiresAt)),
portal/api_server.go
+10
-13
@@ -94,11 +94,8 @@ func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
94
return
95
}
96
97
- name := r.URL.Query().Get("name")
97
utils.WriteAPIData(w, http.StatusOK, types.DomainResponse{
99
- RootHost: s.rootHost,
100
- SuggestedHostname: suggestHostname(name, s.rootHost),
101
- Version: types.SDKProtocolVersion,
98
+ Version: types.SDKProtocolVersion,
99
})
100
}
101
@@ -277,8 +274,9 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
274
}
275
276
func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (types.RegisterResponse, error) {
280
- if strings.TrimSpace(req.Name) == "" {
281
- return types.RegisterResponse{}, errors.New("name is required")
277
+ name, err := utils.NormalizeDNSLabel(req.Name)
278
+ if err != nil {
279
+ return types.RegisterResponse{}, err
280
}
281
if strings.TrimSpace(req.ReverseToken) == "" {
282
return types.RegisterResponse{}, errors.New("reverse token is required")
@@ -286,10 +284,9 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
284
if s.isClientIPBanned(clientIP) {
285
return types.RegisterResponse{}, errIPBanned
286
}
289
-
290
- hostnames := normalizeHostnames(req.Hostnames)
291
- if len(hostnames) == 0 {
292
- hostnames = []string{suggestHostname(req.Name, s.rootHost)}
287
+ hostname, err := utils.LeaseHostname(name, s.rootHost)
288
+ if err != nil {
289
+ return types.RegisterResponse{}, err
290
}
291
292
ttl := s.cfg.LeaseTTL
@@ -302,8 +299,8 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
299
expiresAt := now.Add(ttl)
300
record := &leaseRecord{
301
ID: leaseID,
305
- Name: strings.TrimSpace(req.Name),
306
- Hostnames: hostnames,
302
+ Name: name,
303
+ Hostname: hostname,
304
Metadata: req.Metadata,
305
ReverseToken: req.ReverseToken,
306
ExpiresAt: expiresAt,
@@ -319,7 +316,7 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
316
317
return types.RegisterResponse{
318
LeaseID: leaseID,
322
- Hostnames: append([]string(nil), hostnames...),
319
+ Hostname: hostname,
320
Metadata: record.Metadata,
321
ExpiresAt: expiresAt,
322
ConnectURL: s.connectURL(),
portal/keyless/client.go
+1
@@ -13,6 +13,7 @@ import (
13
"time"
14
15
keylesstls "github.com/gosuda/keyless_tls/keyless"
16
+
17
"github.com/gosuda/portal/v2/utils"
18
)
19
portal/lease.go
+14
-20
@@ -115,26 +115,22 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
115
if leaseID == "" {
116
return errors.New("lease id is required")
117
}
118
- hostnames := normalizeHostnames(record.Hostnames)
119
- if len(hostnames) == 0 {
120
- return errors.New("lease hostnames are required")
118
+ hostname := utils.NormalizeHostname(record.Hostname)
119
+ if hostname == "" {
120
+ return errors.New("lease hostname is required")
121
}
122
123
r.mu.Lock()
124
defer r.mu.Unlock()
125
126
- for _, host := range hostnames {
127
- if ownerLeaseID, ok := r.routes.LookupExact(host); ok && ownerLeaseID != leaseID {
128
- return fmt.Errorf("%w: %s", errHostnameConflict, host)
129
- }
126
+ if ownerLeaseID, ok := r.routes.LookupExact(hostname); ok && ownerLeaseID != leaseID {
127
+ return fmt.Errorf("%w: %s", errHostnameConflict, hostname)
128
}
129
130
record.ID = leaseID
133
- record.Hostnames = hostnames
131
+ record.Hostname = hostname
132
r.leaseByID[leaseID] = record
135
- for _, host := range hostnames {
136
- r.routes.Set(host, leaseID)
137
- }
133
+ r.routes.Set(hostname, leaseID)
134
if strings.TrimSpace(record.ClientIP) != "" {
135
r.policy.IPFilter().RegisterLeaseIP(leaseID, record.ClientIP)
136
}
@@ -176,7 +172,7 @@ func (r *leaseRegistry) Unregister(leaseID, reverseToken string) (*leaseRecord,
172
}
173
174
delete(r.leaseByID, record.ID)
179
- r.routes.DeleteLease(record.Hostnames)
175
+ r.routes.Delete(record.Hostname)
176
r.policy.ForgetLease(record.ID)
177
return record, nil
178
}
@@ -223,7 +219,7 @@ func (r *leaseRegistry) removeExpired(now time.Time) []*leaseRecord {
219
if now.After(record.ExpiresAt) {
220
expired = append(expired, record)
221
delete(r.leaseByID, leaseID)
226
- r.routes.DeleteLease(record.Hostnames)
222
+ r.routes.Delete(record.Hostname)
223
r.policy.ForgetLease(record.ID)
224
}
225
}
@@ -251,7 +247,7 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) LeaseSnapshot {
247
ID: record.ID,
248
Name: record.Name,
249
ClientIP: clientIP,
254
- Hostnames: append([]string(nil), record.Hostnames...),
250
+ Hostname: record.Hostname,
251
Metadata: record.Metadata,
252
ExpiresAt: record.ExpiresAt,
253
FirstSeenAt: record.FirstSeenAt,
@@ -273,7 +269,7 @@ type leaseRecord struct {
269
Name string
270
ReverseToken string
271
ClientIP string
276
- Hostnames []string
272
+ Hostname string
273
Metadata types.LeaseMetadata
274
}
275
@@ -284,7 +280,7 @@ type LeaseSnapshot struct {
280
ID string
281
Name string
282
ClientIP string
287
- Hostnames []string
283
+ Hostname string
284
Metadata types.LeaseMetadata
285
Ready int
286
IsApproved bool
@@ -309,10 +305,8 @@ func (t *routeTable) Set(host, leaseID string) {
305
t.exact[host] = leaseID
306
}
307
312
-func (t *routeTable) DeleteLease(hosts []string) {
313
- for _, host := range hosts {
314
- delete(t.exact, utils.NormalizeHostname(host))
315
- }
308
+func (t *routeTable) Delete(host string) {
309
+ delete(t.exact, utils.NormalizeHostname(host))
310
}
311
312
func (t *routeTable) LookupExact(host string) (string, bool) {
portal/lease_test.go
+5
-5
@@ -16,7 +16,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
16
registry := newLeaseRegistry(runtime)
17
record := &leaseRecord{
18
ID: "lease_1",
19
- Hostnames: []string{"demo.example.com"},
19
+ Hostname: "demo.example.com",
20
ReverseToken: "tok_1",
21
ExpiresAt: time.Now().Add(30 * time.Second),
22
}
@@ -63,7 +63,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
63
registry := newLeaseRegistry(policy.NewRuntime())
64
wildcardLease := &leaseRecord{
65
ID: "lease_wildcard",
66
- Hostnames: []string{"*.example.com"},
66
+ Hostname: "*.example.com",
67
ReverseToken: "tok_wildcard",
68
ExpiresAt: time.Now().Add(30 * time.Second),
69
}
@@ -80,7 +80,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
80
81
conflict := &leaseRecord{
82
ID: "lease_conflict",
83
- Hostnames: []string{"*.example.com"},
83
+ Hostname: "*.example.com",
84
ReverseToken: "tok_conflict",
85
ExpiresAt: time.Now().Add(30 * time.Second),
86
}
@@ -102,7 +102,7 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
102
record := &leaseRecord{
103
ID: "lease_policy",
104
Name: "demo",
105
- Hostnames: []string{"demo.example.com"},
105
+ Hostname: "demo.example.com",
106
ReverseToken: "tok_policy",
107
ExpiresAt: time.Now().Add(30 * time.Second),
108
ClientIP: "203.0.113.20",
@@ -141,7 +141,7 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
141
registry := newLeaseRegistry(policy.NewRuntime())
142
record := &leaseRecord{
143
ID: "lease_expired",
144
- Hostnames: []string{"expired.example.com"},
144
+ Hostname: "expired.example.com",
145
ReverseToken: "tok_expired",
146
ExpiresAt: time.Now().Add(-time.Second),
147
Broker: newLeaseBroker("lease_expired", time.Minute, 1),
portal/server_test.go
+54
@@ -111,3 +111,57 @@ func TestNewServerRejectsInvalidTrustedProxyCIDRs(t *testing.T) {
111
t.Fatalf("NewServer() error = %v, want trusted proxy parse error", err)
112
}
113
}
114
+
115
+func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
116
+ t.Parallel()
117
+
118
+ server, err := NewServer(ServerConfig{
119
+ PortalURL: "https://portal.example.com",
120
+ })
121
+ if err != nil {
122
+ t.Fatalf("NewServer() error = %v", err)
123
+ }
124
+
125
+ resp, err := server.registerLease(types.RegisterRequest{
126
+ Name: "Demo-App",
127
+ ReverseToken: "tok_1",
128
+ }, "203.0.113.10")
129
+ if err != nil {
130
+ t.Fatalf("registerLease() error = %v", err)
131
+ }
132
+
133
+ wantHostname := "demo-app.portal.example.com"
134
+ if resp.Hostname != wantHostname {
135
+ t.Fatalf("registerLease() hostname = %q, want %q", resp.Hostname, wantHostname)
136
+ }
137
+
138
+ snapshot, ok := server.GetLease(resp.LeaseID)
139
+ if !ok {
140
+ t.Fatal("GetLease() = false, want registered lease")
141
+ }
142
+ if snapshot.Name != "demo-app" {
143
+ t.Fatalf("GetLease().Name = %q, want %q", snapshot.Name, "demo-app")
144
+ }
145
+ if snapshot.Hostname != wantHostname {
146
+ t.Fatalf("GetLease().Hostname = %q, want %q", snapshot.Hostname, wantHostname)
147
+ }
148
+}
149
+
150
+func TestRegisterLeaseRejectsInvalidName(t *testing.T) {
151
+ t.Parallel()
152
+
153
+ server, err := NewServer(ServerConfig{
154
+ PortalURL: "https://portal.example.com",
155
+ })
156
+ if err != nil {
157
+ t.Fatalf("NewServer() error = %v", err)
158
+ }
159
+
160
+ _, err = server.registerLease(types.RegisterRequest{
161
+ Name: "demo app",
162
+ ReverseToken: "tok_1",
163
+ }, "203.0.113.10")
164
+ if err == nil {
165
+ t.Fatal("registerLease() error = nil, want invalid name error")
166
+ }
167
+}
portal/utils.go
-54
@@ -7,12 +7,10 @@ import (
7
"io"
8
"net"
9
"net/http"
10
- "strings"
10
11
"golang.org/x/sync/errgroup"
12
13
"github.com/gosuda/portal/v2/portal/keyless"
15
- "github.com/gosuda/portal/v2/utils"
14
)
15
16
func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
@@ -21,23 +19,6 @@ func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
19
return json.NewDecoder(r.Body).Decode(dst)
20
}
21
24
-func normalizeHostnames(hosts []string) []string {
25
- seen := make(map[string]struct{}, len(hosts))
26
- out := make([]string, 0, len(hosts))
27
- for _, host := range hosts {
28
- host = utils.NormalizeHostname(host)
29
- if host == "" {
30
- continue
31
- }
32
- if _, ok := seen[host]; ok {
33
- continue
34
- }
35
- seen[host] = struct{}{}
36
- out = append(out, host)
37
- }
38
- return out
39
-}
40
-
22
func tokenMatches(expected, actual string) bool {
23
if len(expected) == 0 || len(actual) == 0 {
24
return false
@@ -55,41 +36,6 @@ func validateAPITLS(apiTLS keyless.TLSMaterialConfig) error {
36
return nil
37
}
38
58
-func sanitizeLabel(name string) string {
59
- name = strings.ToLower(strings.TrimSpace(name))
60
- var b strings.Builder
61
- lastHyphen := false
62
- for _, r := range name {
63
- switch {
64
- case r >= 'a' && r <= 'z':
65
- b.WriteRune(r)
66
- lastHyphen = false
67
- case r >= '0' && r <= '9':
68
- b.WriteRune(r)
69
- lastHyphen = false
70
- default:
71
- if b.Len() == 0 || lastHyphen {
72
- continue
73
- }
74
- b.WriteByte('-')
75
- lastHyphen = true
76
- }
77
- }
78
- s := strings.Trim(b.String(), "-")
79
- if s == "" {
80
- return "app"
81
- }
82
- return s
83
-}
84
-
85
-func suggestHostname(name, rootHost string) string {
86
- label := sanitizeLabel(name)
87
- if rootHost == "" {
88
- return label
89
- }
90
- return label + "." + rootHost
91
-}
92
-
39
func bridgeConns(left, right net.Conn) {
40
defer left.Close()
41
defer right.Close()
sdk/api_client.go
+4
-11
@@ -38,15 +38,14 @@ type apiClient struct {
38
rawTLSConfig *tls.Config
39
dialTimeout time.Duration
40
name string
41
- hostnames []string
41
reverseToken string
42
metadata types.LeaseMetadata
43
}
44
45
func newApiClient(ctx context.Context, relayURL string, cfg ListenerConfig) (*apiClient, error) {
47
- name := strings.TrimSpace(cfg.Name)
48
- if name == "" {
49
- return nil, errors.New("listener name is required")
46
+ name, err := utils.NormalizeDNSLabel(cfg.Name)
47
+ if err != nil {
48
+ return nil, err
49
}
50
51
reverseToken := strings.TrimSpace(cfg.ReverseToken)
@@ -106,7 +105,6 @@ func newApiClient(ctx context.Context, relayURL string, cfg ListenerConfig) (*ap
105
rawTLSConfig: baseTLS,
106
dialTimeout: dialTimeout,
107
name: name,
109
- hostnames: append([]string(nil), cfg.Hostnames...),
108
reverseToken: reverseToken,
109
metadata: cloneMetadata(cfg.Metadata),
110
}
@@ -127,15 +125,10 @@ func (a *apiClient) close() {
125
}
126
}
127
130
-func (a *apiClient) registerLease(ctx context.Context, hostnames []string, ttl time.Duration) (types.RegisterResponse, error) {
131
- if len(hostnames) == 0 {
132
- hostnames = a.hostnames
133
- }
134
-
128
+func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration) (types.RegisterResponse, error) {
129
var resp types.RegisterResponse
130
if err := a.doJSON(ctx, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
131
Name: a.name,
138
- Hostnames: append([]string(nil), hostnames...),
132
Metadata: cloneMetadata(a.metadata),
133
ReverseToken: a.reverseToken,
134
TTL: int(ttl / time.Second),
sdk/expose.go
+9
-8
@@ -79,8 +79,7 @@ func Expose(ctx context.Context, relayUrls []string, name string, metadata types
79
relays: relays,
80
}
81
82
- logger := log.With().Str("component", "sdk-exposure").Logger()
83
- logger.Info().
82
+ log.Info().
83
Int("relay_count", len(exposure.relays)).
84
Strs("relays", exposure.RelayURLs()).
85
Strs("public_urls", exposure.PublicURLs()).
@@ -156,13 +155,15 @@ func (e *Exposure) PublicURLs() []string {
155
if relay.listener == nil {
156
continue
157
}
159
- for _, rawURL := range relay.listener.PublicURLs() {
160
- if _, ok := seen[rawURL]; ok {
161
- continue
162
- }
163
- seen[rawURL] = struct{}{}
164
- out = append(out, rawURL)
158
+ rawURL := relay.listener.PublicURL()
159
+ if rawURL == "" {
160
+ continue
161
+ }
162
+ if _, ok := seen[rawURL]; ok {
163
+ continue
164
}
165
+ seen[rawURL] = struct{}{}
166
+ out = append(out, rawURL)
167
}
168
if len(out) == 0 {
169
return nil
sdk/listener.go
+16
-21
@@ -20,7 +20,6 @@ import (
20
type ListenerConfig struct {
21
Name string
22
ReverseToken string
23
- Hostnames []string
23
Metadata types.LeaseMetadata
24
RootCAPEM []byte
25
DialTimeout time.Duration
@@ -47,7 +46,7 @@ type Listener struct {
46
api *apiClient
47
accepted chan net.Conn
48
leaseID string
50
- hostnames []string
49
+ hostname string
50
metadata types.LeaseMetadata
51
52
closeOnce sync.Once
@@ -86,14 +85,14 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
85
handshakeTimeout: handshakeTimeout,
86
}
87
89
- resp, err := api.registerLease(listenerCtx, cfg.Hostnames, leaseTTL)
88
+ resp, err := api.registerLease(listenerCtx, leaseTTL)
89
if err != nil {
90
api.close()
91
cancel()
92
return nil, err
93
}
94
96
- tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(api.baseURL.String(), resp.Hostnames)
95
+ tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(api.baseURL.String(), []string{resp.Hostname})
96
if err != nil {
97
_ = api.unregisterLease(context.Background(), resp.LeaseID)
98
api.close()
@@ -111,7 +110,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
110
111
l.mu.Lock()
112
l.leaseID = resp.LeaseID
114
- l.hostnames = append([]string(nil), resp.Hostnames...)
113
+ l.hostname = resp.Hostname
114
l.metadata = cloneMetadata(resp.Metadata)
115
l.tlsConfig = tlsConf
116
l.tlsCloser = tlsCloser
@@ -145,6 +144,7 @@ func (l *Listener) Close() error {
144
tlsCloser := l.tlsCloser
145
api := l.api
146
l.leaseID = ""
147
+ l.hostname = ""
148
l.tlsConfig = nil
149
l.tlsCloser = nil
150
l.mu.Unlock()
@@ -181,10 +181,10 @@ func (l *Listener) LeaseID() string {
181
return l.leaseID
182
}
183
184
-func (l *Listener) Hostnames() []string {
184
+func (l *Listener) Hostname() string {
185
l.mu.Lock()
186
defer l.mu.Unlock()
187
- return append([]string(nil), l.hostnames...)
187
+ return l.hostname
188
}
189
190
func (l *Listener) Metadata() types.LeaseMetadata {
@@ -193,16 +193,15 @@ func (l *Listener) Metadata() types.LeaseMetadata {
193
return cloneMetadata(l.metadata)
194
}
195
196
-func (l *Listener) PublicURLs() []string {
196
+func (l *Listener) PublicURL() string {
197
l.mu.Lock()
198
- hostnames := append([]string(nil), l.hostnames...)
198
+ hostname := l.hostname
199
l.mu.Unlock()
200
201
- urls := make([]string, 0, len(hostnames))
202
- for _, host := range hostnames {
203
- urls = append(urls, "https://"+host)
201
+ if hostname == "" {
202
+ return ""
203
}
205
- return urls
204
+ return "https://" + hostname
205
}
206
207
func (l *Listener) runSessionLoop(ctx context.Context) {
@@ -347,7 +346,7 @@ func (l *Listener) renewLease(ctx context.Context) error {
346
log.Info().
347
Str("component", "sdk-listener").
348
Str("lease_id", l.LeaseID()).
350
- Strs("hostnames", l.Hostnames()).
349
+ Str("hostname", l.Hostname()).
350
Msg("lease re-registered successfully")
351
return nil
352
}
@@ -356,16 +355,12 @@ func (l *Listener) reregister(ctx context.Context) error {
355
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
356
defer cancel()
357
359
- l.mu.Lock()
360
- hostnames := append([]string(nil), l.hostnames...)
361
- l.mu.Unlock()
362
-
363
- resp, err := l.api.registerLease(requestCtx, hostnames, l.leaseTTL)
358
+ resp, err := l.api.registerLease(requestCtx, l.leaseTTL)
359
if err != nil {
360
return err
361
}
362
368
- tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(l.api.baseURL.String(), resp.Hostnames)
363
+ tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(l.api.baseURL.String(), []string{resp.Hostname})
364
if err != nil {
365
_ = l.api.unregisterLease(requestCtx, resp.LeaseID)
366
return err
@@ -380,7 +375,7 @@ func (l *Listener) reregister(ctx context.Context) error {
375
l.mu.Lock()
376
oldCloser := l.tlsCloser
377
l.leaseID = resp.LeaseID
383
- l.hostnames = append([]string(nil), resp.Hostnames...)
378
+ l.hostname = resp.Hostname
379
l.metadata = cloneMetadata(resp.Metadata)
380
l.tlsConfig = tlsConf
381
l.tlsCloser = tlsCloser
sdk/sdk_test.go
+38
-28
@@ -5,7 +5,6 @@ import (
5
"encoding/json"
6
"net/http"
7
"net/http/httptest"
8
- "reflect"
8
"sync/atomic"
9
"testing"
10
"time"
@@ -22,8 +21,7 @@ func TestNewListenerFailsFastOnRegisterError(t *testing.T) {
21
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
22
OK: true,
23
Data: types.DomainResponse{
25
- RootHost: "localhost",
26
- Version: types.SDKProtocolVersion,
24
+ Version: types.SDKProtocolVersion,
25
},
26
})
27
case types.PathSDKRegister:
@@ -38,7 +36,9 @@ func TestNewListenerFailsFastOnRegisterError(t *testing.T) {
36
}))
37
defer server.Close()
38
41
- listener, err := NewListener(context.Background(), server.URL, ListenerConfig{Name: "demo"})
39
+ listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
40
+ Name: "demo",
41
+ })
42
if err == nil {
43
t.Fatal("NewListener() error = nil, want register failure")
44
}
@@ -47,6 +47,18 @@ func TestNewListenerFailsFastOnRegisterError(t *testing.T) {
47
}
48
}
49
50
+func TestNewListenerRejectsInvalidName(t *testing.T) {
51
+ t.Parallel()
52
+
53
+ listener, err := NewListener(context.Background(), "https://relay.example.com", ListenerConfig{Name: "demo app"})
54
+ if err == nil {
55
+ t.Fatal("NewListener() error = nil, want invalid name error")
56
+ }
57
+ if listener != nil {
58
+ t.Fatalf("NewListener() listener = %#v, want nil", listener)
59
+ }
60
+}
61
+
62
func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
63
t.Parallel()
64
@@ -57,8 +69,7 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
69
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
70
OK: true,
71
Data: types.DomainResponse{
60
- RootHost: "localhost",
61
- Version: types.SDKProtocolVersion,
72
+ Version: types.SDKProtocolVersion,
73
},
74
})
75
case types.PathSDKRegister:
@@ -68,9 +79,9 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
79
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
80
OK: true,
81
Data: types.RegisterResponse{
71
- LeaseID: "lease-1",
72
- Hostnames: []string{"127.0.0.1"},
73
- Metadata: registerReq.Metadata,
82
+ LeaseID: "lease-1",
83
+ Hostname: "127.0.0.1",
84
+ Metadata: registerReq.Metadata,
85
},
86
})
87
case types.PathSDKConnect:
@@ -92,7 +103,7 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
103
defer server.Close()
104
105
listener, err := NewListener(context.Background(), server.URL, ListenerConfig{
95
- Name: "demo",
106
+ Name: "Demo-App",
107
Metadata: types.LeaseMetadata{Owner: "alice"},
108
LeaseTTL: 42 * time.Second,
109
})
@@ -104,14 +115,17 @@ func TestNewListenerRegistersLeaseWithMainContract(t *testing.T) {
115
if registerReq.TTL != 42 {
116
t.Fatalf("register request TTL = %d, want 42", registerReq.TTL)
117
}
118
+ if registerReq.Name != "demo-app" {
119
+ t.Fatalf("register request Name = %q, want %q", registerReq.Name, "demo-app")
120
+ }
121
if listener.LeaseID() != "lease-1" {
122
t.Fatalf("LeaseID() = %q, want %q", listener.LeaseID(), "lease-1")
123
}
110
- if got := listener.Hostnames(); !reflect.DeepEqual(got, []string{"127.0.0.1"}) {
111
- t.Fatalf("Hostnames() = %v, want %v", got, []string{"127.0.0.1"})
124
+ if got := listener.Hostname(); got != "127.0.0.1" {
125
+ t.Fatalf("Hostname() = %q, want %q", got, "127.0.0.1")
126
}
113
- if got := listener.PublicURLs(); !reflect.DeepEqual(got, []string{"https://127.0.0.1"}) {
114
- t.Fatalf("PublicURLs() = %v, want %v", got, []string{"https://127.0.0.1"})
127
+ if got := listener.PublicURL(); got != "https://127.0.0.1" {
128
+ t.Fatalf("PublicURL() = %q, want %q", got, "https://127.0.0.1")
129
}
130
if got := listener.Metadata(); got.Owner != "alice" {
131
t.Fatalf("Metadata().Owner = %q, want %q", got.Owner, "alice")
@@ -128,8 +142,7 @@ func TestNewListenerReregistersOnLeaseNotFound(t *testing.T) {
142
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
143
OK: true,
144
Data: types.DomainResponse{
131
- RootHost: "localhost",
132
- Version: types.SDKProtocolVersion,
145
+ Version: types.SDKProtocolVersion,
146
},
147
})
148
case types.PathSDKRegister:
@@ -141,8 +154,8 @@ func TestNewListenerReregistersOnLeaseNotFound(t *testing.T) {
154
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
155
OK: true,
156
Data: types.RegisterResponse{
144
- LeaseID: leaseID,
145
- Hostnames: []string{"127.0.0.1"},
157
+ LeaseID: leaseID,
158
+ Hostname: "127.0.0.1",
159
},
160
})
161
case types.PathSDKRenew:
@@ -200,16 +213,15 @@ func TestNewListenerClosesAfterReverseSessionRetryBudgetExhausted(t *testing.T)
213
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
214
OK: true,
215
Data: types.DomainResponse{
203
- RootHost: "localhost",
204
- Version: types.SDKProtocolVersion,
216
+ Version: types.SDKProtocolVersion,
217
},
218
})
219
case types.PathSDKRegister:
220
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
221
OK: true,
222
Data: types.RegisterResponse{
211
- LeaseID: "lease-1",
212
- Hostnames: []string{"127.0.0.1"},
223
+ LeaseID: "lease-1",
224
+ Hostname: "127.0.0.1",
225
},
226
})
227
case types.PathSDKConnect:
@@ -258,16 +270,15 @@ func TestExposeFailsFastWhenAnyRelayCannotRegister(t *testing.T) {
270
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
271
OK: true,
272
Data: types.DomainResponse{
261
- RootHost: "localhost",
262
- Version: types.SDKProtocolVersion,
273
+ Version: types.SDKProtocolVersion,
274
},
275
})
276
case types.PathSDKRegister:
277
writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
278
OK: true,
279
Data: types.RegisterResponse{
269
- LeaseID: "lease-good",
270
- Hostnames: []string{"127.0.0.1"},
280
+ LeaseID: "lease-good",
281
+ Hostname: "127.0.0.1",
282
},
283
})
284
case types.PathSDKConnect:
@@ -295,8 +306,7 @@ func TestExposeFailsFastWhenAnyRelayCannotRegister(t *testing.T) {
306
writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
307
OK: true,
308
Data: types.DomainResponse{
298
- RootHost: "localhost",
299
- Version: types.SDKProtocolVersion,
309
+ Version: types.SDKProtocolVersion,
310
},
311
})
312
case types.PathSDKRegister:
types/api.go
+2
-5
@@ -71,7 +71,6 @@ type LeaseMetadata struct {
71
type RegisterRequest struct {
72
Name string `json:"name"`
73
ReverseToken string `json:"reverse_token"`
74
- Hostnames []string `json:"hostnames,omitempty"`
74
Metadata LeaseMetadata `json:"metadata"`
75
TTL int `json:"ttl,omitempty"`
76
}
@@ -80,7 +79,7 @@ type RegisterResponse struct {
79
ExpiresAt time.Time `json:"expires_at"`
80
LeaseID string `json:"lease_id"`
81
ConnectURL string `json:"connect_url"`
83
- Hostnames []string `json:"hostnames"`
82
+ Hostname string `json:"hostname"`
83
Metadata LeaseMetadata `json:"metadata"`
84
}
85
@@ -101,9 +100,7 @@ type UnregisterRequest struct {
100
}
101
102
type DomainResponse struct {
104
- RootHost string `json:"root_host"`
105
- SuggestedHostname string `json:"suggested_hostname"`
106
- Version string `json:"version"`
103
+ Version string `json:"version"`
104
}
105
106
type AdminLoginRequest struct {
utils/utils.go
+35
@@ -51,6 +51,41 @@ func NormalizeRelayURLs(inputs []string) ([]string, error) {
51
return out, nil
52
}
53
54
+func NormalizeDNSLabel(raw string) (string, error) {
55
+ label := NormalizeHostname(raw)
56
+ if label == "" {
57
+ return "", errors.New("name is required")
58
+ }
59
+ if strings.Contains(label, ".") {
60
+ return "", errors.New("name must be a single dns label")
61
+ }
62
+ if len(label) > 63 {
63
+ return "", errors.New("name must be 63 characters or fewer")
64
+ }
65
+ if label[0] == '-' || label[len(label)-1] == '-' {
66
+ return "", errors.New("name must not start or end with hyphen")
67
+ }
68
+ for _, r := range label {
69
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
70
+ continue
71
+ }
72
+ return "", errors.New("name must contain only letters, numbers, or hyphen")
73
+ }
74
+ return label, nil
75
+}
76
+
77
+func LeaseHostname(name, rootHost string) (string, error) {
78
+ label, err := NormalizeDNSLabel(name)
79
+ if err != nil {
80
+ return "", err
81
+ }
82
+ rootHost = NormalizeHostname(rootHost)
83
+ if rootHost == "" {
84
+ return "", errors.New("root host is required")
85
+ }
86
+ return label + "." + rootHost, nil
87
+}
88
+
89
func NormalizeRelayURL(raw string) (string, error) {
90
trimmed := strings.TrimSpace(raw)
91
if trimmed == "" {
utils/utils_test.go
+24
@@ -38,6 +38,30 @@ func TestNormalizeTargetAddr(t *testing.T) {
38
}
39
}
40
41
+func TestNormalizeDNSLabel(t *testing.T) {
42
+ t.Parallel()
43
+
44
+ got, err := NormalizeDNSLabel("Demo-App")
45
+ if err != nil {
46
+ t.Fatalf("NormalizeDNSLabel() error = %v", err)
47
+ }
48
+ if got != "demo-app" {
49
+ t.Fatalf("NormalizeDNSLabel() = %q, want %q", got, "demo-app")
50
+ }
51
+}
52
+
53
+func TestLeaseHostname(t *testing.T) {
54
+ t.Parallel()
55
+
56
+ got, err := LeaseHostname("Demo-App", "portal.example.com")
57
+ if err != nil {
58
+ t.Fatalf("LeaseHostname() error = %v", err)
59
+ }
60
+ if got != "demo-app.portal.example.com" {
61
+ t.Fatalf("LeaseHostname() = %q, want %q", got, "demo-app.portal.example.com")
62
+ }
63
+}
64
+
65
func TestRandomID(t *testing.T) {
66
t.Parallel()
67