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