package api import ( "net" "net/http" "net/url" "strings" "sync" "time" ) var ( cachedLocalIPs []string cachedLocalIPsAt time.Time cachedLocalIPsMu sync.Mutex localIPsCacheTTL = 30 * time.Second ) type ServerInfo struct { Port int `json:"port"` Host string `json:"host"` LocalIPs []string `json:"local_ips"` LANURL string `json:"lan_url"` TunnelURL string `json:"tunnel_url,omitempty"` CloudflaredConfigured bool `json:"cloudflared_configured"` SuggestedURL string `json:"suggested_url"` DashboardURL string `json:"dashboard_url"` WebSocketURL string `json:"websocket_url"` Version string `json:"version,omitempty"` } // GetServerInfo returns URLs workers and droppers should use to reach this deck. // When the dashboard is opened via HTTPS reverse proxy (e.g. Cloudflare tunnel), // suggested_url uses https and omits :443 — not the local listen port (8989). // lan_url is always the LAN http endpoint for workers on the same network. func GetServerInfo(w http.ResponseWriter, r *http.Request, publicURLOverride string, listenPort int, cloudflaredConfigured bool, version ...string) { if listenPort <= 0 { listenPort = 8989 } localIPs := listLocalIPv4Cached() lanURL := lanURLFromIPs(localIPs, listenPort) tunnelURL := tunnelURLFromRequest(r) suggestedURL := resolveSuggestedURL(r, publicURLOverride, listenPort, localIPs) host := r.Host port := listenPort if h, p, err := net.SplitHostPort(r.Host); err == nil { host = h if parsed := parsePort(p); parsed > 0 { port = parsed } } info := ServerInfo{ Port: port, Host: host, LocalIPs: localIPs, LANURL: lanURL, TunnelURL: tunnelURL, CloudflaredConfigured: cloudflaredConfigured, SuggestedURL: suggestedURL, DashboardURL: suggestedURL, WebSocketURL: httpToWS(suggestedURL) + "/ws/agent", } if len(version) > 0 { info.Version = version[0] } writeJSON(w, info) } func lanURLFromIPs(localIPs []string, listenPort int) string { if len(localIPs) == 0 { return "" } return formatBaseURL("http", localIPs[0], listenPort) } func tunnelURLFromRequest(r *http.Request) string { scheme := requestScheme(r) if scheme != "https" { return "" } host := requestHost(r) hostOnly, port := hostAndPort(host, scheme) if isLoopbackHost(hostOnly) { return "" } return formatBaseURL(scheme, hostOnly, port) } func resolveSuggestedURL(r *http.Request, publicOverride string, listenPort int, localIPs []string) string { if norm := normalizePublicURL(publicOverride); norm != "" { return norm } return externalBaseFromRequest(r, listenPort, localIPs) } func externalBaseFromRequest(r *http.Request, listenPort int, localIPs []string) string { scheme := requestScheme(r) host := requestHost(r) hostOnly, port := hostAndPort(host, scheme) if isLoopbackHost(hostOnly) && len(localIPs) > 0 { hostOnly = localIPs[0] scheme = "http" port = listenPort } return formatBaseURL(scheme, hostOnly, port) } func requestScheme(r *http.Request) string { if r.TLS != nil { return "https" } if p := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); p != "" { return strings.ToLower(p) } return "http" } func requestHost(r *http.Request) string { if h := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); h != "" { return h } return r.Host } func hostAndPort(host string, scheme string) (hostOnly string, port int) { if h, p, err := net.SplitHostPort(host); err == nil { return strings.Trim(h, "[]"), parsePort(p) } if strings.Count(host, ":") == 1 && !strings.Contains(host, "]") { parts := strings.SplitN(host, ":", 2) return parts[0], parsePort(parts[1]) } hostOnly = strings.Trim(host, "[]") if scheme == "https" { return hostOnly, 443 } return hostOnly, 80 } func formatBaseURL(scheme, host string, port int) string { host = strings.Trim(host, "[]") if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) { return scheme + "://" + host } return scheme + "://" + net.JoinHostPort(host, itoa(port)) } func normalizePublicURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return strings.TrimRight(raw, "/") } _, port := hostAndPort(u.Host, u.Scheme) u.Host = formatURLHost(u.Hostname(), port, u.Scheme) u.Path = "" u.RawPath = "" u.RawQuery = "" u.Fragment = "" return strings.TrimRight(u.String(), "/") } func formatURLHost(hostname string, port int, scheme string) string { if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) { return hostname } return net.JoinHostPort(hostname, itoa(port)) } func httpToWS(base string) string { if strings.HasPrefix(base, "https://") { return "wss://" + strings.TrimPrefix(base, "https://") } return "ws://" + strings.TrimPrefix(base, "http://") } func listLocalIPv4Cached() []string { cachedLocalIPsMu.Lock() defer cachedLocalIPsMu.Unlock() if cachedLocalIPs != nil && time.Since(cachedLocalIPsAt) < localIPsCacheTTL { out := make([]string, len(cachedLocalIPs)) copy(out, cachedLocalIPs) return out } cachedLocalIPs = listLocalIPv4() cachedLocalIPsAt = time.Now() out := make([]string, len(cachedLocalIPs)) copy(out, cachedLocalIPs) return out } func listLocalIPv4() []string { var ips []string ifaces, err := net.Interfaces() if err != nil { return ips } for _, iface := range ifaces { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { continue } addrs, err := iface.Addrs() if err != nil { continue } for _, addr := range addrs { var ip net.IP switch v := addr.(type) { case *net.IPNet: ip = v.IP case *net.IPAddr: ip = v.IP } if ip == nil || ip.IsLoopback() { continue } ip = ip.To4() if ip == nil { continue } ips = append(ips, ip.String()) } } return ips } func isLoopbackHost(host string) bool { return host == "localhost" || host == "127.0.0.1" || host == "::1" } func parsePort(s string) int { n := 0 for _, ch := range s { if ch < '0' || ch > '9' { return 8989 } n = n*10 + int(ch-'0') if n > 65535 { return 8989 } } if n == 0 { return 8989 } return n } func itoa(n int) string { if n == 0 { return "0" } var buf [16]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) }