| 1 | package utils |
| 2 | |
| 3 | import ( |
| 4 | "net" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | var exposeNameOpeners = []string{ |
| 10 | "arcade", "bouncy", "bravo", "bubble", "candy", "cosmic", "dapper", "electric", |
| 11 | "fancy", "fizzy", "flashy", "fuzzy", "gentle", "glitter", "golden", "happy", |
| 12 | "hyper", "jazzy", "jolly", "lively", "lucky", "magic", "mellow", "minty", |
| 13 | "misty", "moonlit", "mystic", "neon", "nova", "peppy", "pixel", "playful", |
| 14 | "poppy", "rapid", "rocket", "rowdy", "snappy", "snazzy", "sparkly", "spicy", |
| 15 | "sprightly", "starry", "sunny", "swift", "tangy", "tidy", "toasty", "turbo", |
| 16 | "velvet", "vivid", "wavy", "whimsy", "wild", "wonky", "zany", "zesty", |
| 17 | } |
| 18 | |
| 19 | var exposeNameCenters = []string{ |
| 20 | "alpaca", "badger", "banjo", "beacon", "biscuit", "capybara", "comet", "cricket", |
| 21 | "dragon", "falcon", "feather", "fjord", "fox", "gadget", "gecko", "gizmo", |
| 22 | "harbor", "heron", "iguana", "jelly", "koala", "lemur", "mango", "narwhal", |
| 23 | "nebula", "noodle", "octopus", "otter", "panda", "pepper", "phoenix", "pickle", |
| 24 | "puffin", "quokka", "radar", "ranger", "rocket", "scooter", "seahorse", "skylark", |
| 25 | "sprocket", "starling", "sunbeam", "taco", "thimble", "tiger", "toucan", "triton", |
| 26 | "walrus", "widget", "willow", "wombat", "yeti", "zeppelin", "zigzag", "zinnia", |
| 27 | } |
| 28 | |
| 29 | var exposeNameClosers = []string{ |
| 30 | "arcade", "beacon", "boogie", "bounce", "burst", "cascade", "chorus", "dash", |
| 31 | "disco", "drift", "echo", "fiesta", "flare", "flash", "flight", "flip", |
| 32 | "glow", "groove", "jam", "jive", "launch", "loop", "march", "orbit", |
| 33 | "parade", "party", "pulse", "quest", "rally", "riot", "ripple", "rodeo", |
| 34 | "roll", "rush", "serenade", "shuffle", "signal", "sketch", "spark", "sprint", |
| 35 | "starlight", "stride", "sway", "swoop", "twirl", "uplift", "vibe", "voyage", |
| 36 | "whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone", |
| 37 | } |
| 38 | |
| 39 | const ( |
| 40 | defaultExposeTargetPort = "3000" |
| 41 | defaultExposeTargetHost = "127.0.0.1" |
| 42 | ) |
| 43 | |
| 44 | // DefaultExposeName generates a deterministic 3-word DNS label from a target |
| 45 | // address and seed using FNV-1a hashing. The algorithm matches the frontend |
| 46 | // implementation in frontend/src/lib/exposeName.ts:buildDefaultExposeName. |
| 47 | func DefaultExposeName(target, rawSeed string) (string, error) { |
| 48 | seed := strings.TrimSpace(rawSeed) |
| 49 | if cut, ok := strings.CutPrefix(seed, "cli_"); ok { |
| 50 | seed = cut |
| 51 | } |
| 52 | if seed == "" { |
| 53 | seed = "portal" |
| 54 | } |
| 55 | |
| 56 | input := []byte(seed + "|" + normalizeExposeTarget(target)) |
| 57 | first := fnv1a32(input, 0x811c9dc5) |
| 58 | second := fnv1a32(input, 0x9e3779b9) |
| 59 | third := fnv1a32(input, 0x85ebca6b) |
| 60 | |
| 61 | label := strings.Join([]string{ |
| 62 | exposeNameOpeners[int(first&0xff)%len(exposeNameOpeners)], |
| 63 | exposeNameCenters[int(second&0xff)%len(exposeNameCenters)], |
| 64 | exposeNameClosers[int(third&0xff)%len(exposeNameClosers)], |
| 65 | }, "-") |
| 66 | |
| 67 | return NormalizeDNSLabel(label) |
| 68 | } |
| 69 | |
| 70 | // normalizeExposeTarget normalizes a target address for deterministic name |
| 71 | // generation. Must match frontend/src/lib/exposeName.ts:normalizeExposeTarget. |
| 72 | func normalizeExposeTarget(raw string) string { |
| 73 | trimmed := strings.TrimSpace(raw) |
| 74 | candidate := trimmed |
| 75 | if candidate == "" { |
| 76 | candidate = defaultExposeTargetPort |
| 77 | } |
| 78 | |
| 79 | if isAllDigits(candidate) { |
| 80 | return defaultExposeTargetHost + ":" + candidate |
| 81 | } |
| 82 | |
| 83 | if strings.Contains(candidate, "://") { |
| 84 | u, err := url.Parse(candidate) |
| 85 | if err != nil { |
| 86 | return candidate |
| 87 | } |
| 88 | if (u.Scheme == "http" || u.Scheme == "https") && |
| 89 | u.Host != "" && |
| 90 | (u.Path == "" || u.Path == "/") && |
| 91 | u.RawQuery == "" && |
| 92 | u.Fragment == "" { |
| 93 | return u.Host |
| 94 | } |
| 95 | return candidate |
| 96 | } |
| 97 | |
| 98 | u, err := url.Parse("tcp://" + candidate) |
| 99 | if err != nil || u.Hostname() == "" { |
| 100 | return candidate |
| 101 | } |
| 102 | port := u.Port() |
| 103 | if port == "" { |
| 104 | port = "80" |
| 105 | } |
| 106 | return net.JoinHostPort(u.Hostname(), port) |
| 107 | } |
| 108 | |
| 109 | func isAllDigits(s string) bool { |
| 110 | if s == "" { |
| 111 | return false |
| 112 | } |
| 113 | for _, c := range s { |
| 114 | if c < '0' || c > '9' { |
| 115 | return false |
| 116 | } |
| 117 | } |
| 118 | return true |
| 119 | } |
| 120 | |
| 121 | func fnv1a32(data []byte, seed uint32) uint32 { |
| 122 | h := seed |
| 123 | for _, b := range data { |
| 124 | h ^= uint32(b) |
| 125 | h *= 0x01000193 |
| 126 | } |
| 127 | return h |
| 128 | } |