4
"encoding/json"
5
"errors"
6
"fmt"
7
+ "net"
8
+ "net/url"
9
"os"
10
"strings"
11
200
return loaded, true, nil
201
}
202
201
-func ResolveListenerIdentity(identity types.Identity, identityPath, identityJSON string) (types.Identity, bool, error) {
203
+func ResolveListenerIdentity(identity types.Identity, target, identityPath, identityJSON string) (types.Identity, bool, error) {
204
identityPath = strings.TrimSpace(identityPath)
205
identityJSON = strings.TrimSpace(identityJSON)
204
- resolvedName, err := resolveExposeName(identity.Name, identityPath, identityJSON)
206
+ resolvedName, err := resolveExposeName(identity.Name, target, identityPath, identityJSON)
207
if err != nil {
208
return types.Identity{}, false, err
209
}
338
"whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone",
339
}
340
339
-func DefaultExposeName(rawSeed string) (string, error) {
341
+const (
342
+ defaultExposeTargetPort = "3000"
343
+ defaultExposeTargetHost = "127.0.0.1"
344
+)
345
+
346
+// DefaultExposeName generates a deterministic 3-word DNS label from a target
347
+// address and seed using FNV-1a hashing. The algorithm matches the frontend
348
+// implementation in frontend/src/lib/exposeName.ts:buildDefaultExposeName.
349
+func DefaultExposeName(target, rawSeed string) (string, error) {
350
seed := strings.TrimSpace(rawSeed)
351
if cut, ok := strings.CutPrefix(seed, "cli_"); ok {
352
seed = cut
355
seed = "portal"
356
}
357
348
- input := []byte(seed)
358
+ input := []byte(seed + "|" + normalizeExposeTarget(target))
359
first := fnv1a32(input, 0x811c9dc5)
360
second := fnv1a32(input, 0x9e3779b9)
361
third := fnv1a32(input, 0x85ebca6b)
369
return NormalizeDNSLabel(label)
370
}
371
362
-func resolveExposeName(name, identityPath, identityJSON string) (string, error) {
372
+// normalizeExposeTarget normalizes a target address for deterministic name
373
+// generation. Must match frontend/src/lib/exposeName.ts:normalizeExposeTarget.
374
+func normalizeExposeTarget(raw string) string {
375
+ trimmed := strings.TrimSpace(raw)
376
+ candidate := trimmed
377
+ if candidate == "" {
378
+ candidate = defaultExposeTargetPort
379
+ }
380
+
381
+ if isAllDigits(candidate) {
382
+ return defaultExposeTargetHost + ":" + candidate
383
+ }
384
+
385
+ if strings.Contains(candidate, "://") {
386
+ u, err := url.Parse(candidate)
387
+ if err != nil {
388
+ return candidate
389
+ }
390
+ if (u.Scheme == "http" || u.Scheme == "https") &&
391
+ u.Host != "" &&
392
+ (u.Path == "" || u.Path == "/") &&
393
+ u.RawQuery == "" &&
394
+ u.Fragment == "" {
395
+ return u.Host
396
+ }
397
+ return candidate
398
+ }
399
+
400
+ u, err := url.Parse("tcp://" + candidate)
401
+ if err != nil || u.Hostname() == "" {
402
+ return candidate
403
+ }
404
+ port := u.Port()
405
+ if port == "" {
406
+ port = "80"
407
+ }
408
+ return net.JoinHostPort(u.Hostname(), port)
409
+}
410
+
411
+func isAllDigits(s string) bool {
412
+ if s == "" {
413
+ return false
414
+ }
415
+ for _, c := range s {
416
+ if c < '0' || c > '9' {
417
+ return false
418
+ }
419
+ }
420
+ return true
421
+}
422
+
423
+func resolveExposeName(name, target, identityPath, identityJSON string) (string, error) {
424
if name = strings.TrimSpace(name); name != "" {
425
return name, nil
426
}
445
}
446
}
447
387
- return DefaultExposeName(RandomID("cli_"))
448
+ return DefaultExposeName(target, RandomID("cli_"))
449
}
450
451
func fnv1a32(data []byte, seed uint32) uint32 {