Files
AetherForge/server/internal/api/server_info.go
AetherForge e65753ce49
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
fix: second-wave bug fixes, security hardening, visual polish, UX improvements
Bug fixes:
- SRV-B1 through B5: server config, router, server_info, syscheck corrections
- BA-03 through BA-06: builder path and universal build cleanup (BLD-D1 through D3)
- Frontend WS race condition and useVisibleInterval hook fixed
- Drive-root path bug in PathForge resolved
- Upload error handling improved

Security:
- PathForge path traversal guard added
- Script injection escaping applied throughout

Visual (DV-01 through DV-15):
- Cyan design tokens and page headers standardised
- Dead CSS removed from Pages.css, PathTracerPage.css, wealth-deck.css
- SacredPageHeader deleted (replaced inline); sidebar version display fixed

UX:
- Emberwake request storm fixed (EmberwakePage.tsx)
- Help blurbs UH-01, UH-02, UH-06 added
- HelpTips wired into Crucible, Fleet (FleetGroupsStrip, FleetToolbar), Settings

Build:
- vite.config.ts: webroot auto-sync on build
- build_universal.go: universal build artifact cleanup
- PROBLEMS.md tracking updated
2026-06-06 17:08:15 -07:00

270 lines
6.5 KiB
Go

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:])
}