Files
2026-10-04 11:15:58 +02:00

193 lines
4.5 KiB
Go

package main
import (
"context"
"io"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
)
func validIP(raw string) string {
ip := net.ParseIP(strings.TrimSpace(raw))
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() {
return ""
}
return ip.String()
}
func publicIPFromURL(ctx context.Context, rawURL string) string {
if rawURL == "" {
return ""
}
client := &http.Client{Timeout: 2 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return ""
}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 128))
if err != nil {
return ""
}
return validIP(string(body))
}
func routeIP() string {
conn, err := net.DialTimeout("udp", "1.1.1.1:53", time.Second)
if err != nil {
return ""
}
defer conn.Close()
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
return validIP(addr.IP.String())
}
return ""
}
func ipScope(ip net.IP) string {
switch {
case ip.IsPrivate():
return "private"
case ip.IsLinkLocalUnicast():
return "link_local"
default:
return "public"
}
}
func skipInterface(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
return name == "docker0" || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "veth") || strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "virbr")
}
func localHostAddresses() []hostAddress {
interfaces, err := net.Interfaces()
if err != nil {
return nil
}
out := make([]hostAddress, 0)
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 || skipInterface(iface.Name) {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
raw := addr.String()
if host, _, err := net.ParseCIDR(raw); err == nil {
raw = host.String()
} else if host, _, err := net.SplitHostPort(raw); err == nil {
raw = host
}
clean := validIP(raw)
if clean == "" {
continue
}
ip := net.ParseIP(clean)
out = append(out, hostAddress{IP: clean, Source: "interface", Interface: iface.Name, Scope: ipScope(ip)})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Interface != out[j].Interface {
return out[i].Interface < out[j].Interface
}
return out[i].IP < out[j].IP
})
return out
}
type ipDetector struct {
mu sync.Mutex
publicIP string
expires time.Time
}
func (d *ipDetector) public(ctx context.Context, rawURL string) string {
if rawURL == "" {
return ""
}
d.mu.Lock()
defer d.mu.Unlock()
if time.Now().Before(d.expires) {
return d.publicIP
}
d.publicIP = publicIPFromURL(ctx, rawURL)
d.expires = time.Now().Add(5 * time.Minute)
if d.publicIP == "" {
d.expires = time.Now().Add(30 * time.Second)
}
return d.publicIP
}
func mergeHostAddress(out *[]hostAddress, index map[string]int, candidate hostAddress) {
candidate.IP = validIP(candidate.IP)
if candidate.IP == "" {
return
}
if candidate.Scope == "" {
candidate.Scope = ipScope(net.ParseIP(candidate.IP))
}
if pos, ok := index[candidate.IP]; ok {
existing := &(*out)[pos]
if candidate.Interface != "" && existing.Interface == "" {
existing.Interface = candidate.Interface
}
if candidate.Source != "" && !strings.Contains(existing.Source, candidate.Source) {
if existing.Source == "" {
existing.Source = candidate.Source
} else {
existing.Source += "," + candidate.Source
}
}
return
}
index[candidate.IP] = len(*out)
*out = append(*out, candidate)
}
func detectedAddresses(ctx context.Context, cfg config, detector *ipDetector) ([]hostAddress, string, string) {
out := make([]hostAddress, 0, 8)
index := make(map[string]int)
for _, item := range localHostAddresses() {
mergeHostAddress(&out, index, item)
}
routed := routeIP()
if routed != "" {
mergeHostAddress(&out, index, hostAddress{IP: routed, Source: "route"})
}
public := detector.public(ctx, cfg.PublicIPURL)
if public != "" {
mergeHostAddress(&out, index, hostAddress{IP: public, Source: "public_api"})
}
advertised := validIP(cfg.AdvertiseIP)
if advertised != "" {
mergeHostAddress(&out, index, hostAddress{IP: advertised, Source: "override"})
}
switch {
case advertised != "":
return out, advertised, "override"
case routed != "":
return out, routed, "route"
case public != "":
return out, public, "public_api"
case len(out) > 0:
return out, out[0].IP, out[0].Source
default:
return out, "", "unavailable"
}
}