package lxc import ( "encoding/binary" "fmt" "math/big" "net/netip" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "clicd/internal/config" ) const ipv6GatewayLinkLocal = "fe80::1" type IPv6PrefixInfo struct { Interface string `json:"interface"` Address string `json:"address"` Prefix string `json:"prefix"` PrefixLen int `json:"prefix_len"` Gateway string `json:"gateway"` IsTunnel bool `json:"is_tunnel"` Source string `json:"source"` } type PublicIPInfo struct { Address string `json:"address"` Interface string `json:"interface"` Prefix string `json:"prefix"` IsTunnel bool `json:"is_tunnel"` Source string `json:"source"` } type IPv6Status struct { Available bool `json:"available"` Reachable bool `json:"reachable"` Reason string `json:"reason"` Prefixes []IPv6PrefixInfo `json:"prefixes"` } func (m *Manager) DetectIPv6Status() IPv6Status { status := IPv6Status{} prefixes := DetectPublicIPv6Prefixes() status.Prefixes = prefixes if len(prefixes) == 0 { status.Reason = "no usable public IPv6 prefix found; /128 single-address IPv6 is not assignable" return status } status.Reachable = ipv6ConnectivityOK() if !status.Reachable { status.Reason = "host has an IPv6 prefix, but outbound IPv6 connectivity test failed" return status } status.Available = true status.Reason = "usable public IPv6 prefix detected" return status } func DetectPublicIPv6Prefixes() []IPv6PrefixInfo { return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes()) } func DetectPublicIPv4() PublicIPInfo { candidates := DetectPublicIPv4Candidates() if len(candidates) == 0 { return PublicIPInfo{} } return candidates[0] } func DetectPublicIPv4Candidates() []PublicIPInfo { out, err := exec.Command("ip", "-4", "-o", "addr", "show", "scope", "global").Output() if err != nil { return nil } defaultRoutes := detectIPv4DefaultRoutes() defaultIfaces := map[string]bool{} for _, route := range defaultRoutes { defaultIfaces[route.Interface] = true } type candidate struct { info PublicIPInfo score int } var candidates []candidate seen := map[string]bool{} for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) if len(fields) < 4 || fields[2] != "inet" { continue } iface := normalizeIface(fields[1]) if isContainerLikeInterface(iface) { continue } prefix, err := netip.ParsePrefix(fields[3]) if err != nil || !prefix.Addr().Is4() || !isPublicIPv4(prefix.Addr()) { continue } key := iface + "|" + prefix.Addr().String() if seen[key] { continue } seen[key] = true score := publicInterfaceScore(iface, defaultIfaces) candidates = append(candidates, candidate{ info: PublicIPInfo{ Address: prefix.Addr().String(), Interface: iface, Prefix: prefix.Masked().String(), IsTunnel: isTunnelLikeInterface(iface), Source: "local", }, score: score, }) } sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score }) result := make([]PublicIPInfo, 0, len(candidates)) for _, c := range candidates { result = append(result, c.info) } return result } func detectPublicIPv6Prefixes(defaultRoutes []routeInfo) []IPv6PrefixInfo { out, err := exec.Command("ip", "-6", "-o", "addr", "show", "scope", "global").Output() if err != nil { return nil } defaultIfaces := map[string]bool{} gateways := map[string]string{} for _, route := range defaultRoutes { defaultIfaces[route.Interface] = true if route.Gateway != "" && gateways[route.Interface] == "" { gateways[route.Interface] = route.Gateway } } type candidate struct { info IPv6PrefixInfo score int } var candidates []candidate seen := map[string]bool{} for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) if len(fields) < 4 || fields[2] != "inet6" { continue } iface := normalizeIface(fields[1]) if isContainerLikeInterface(iface) { continue } prefix, err := netip.ParsePrefix(fields[3]) if err != nil || !prefix.Addr().Is6() { continue } addr := prefix.Addr() if !isPublicIPv6(addr) { continue } // Require at least 8 host bits. /128 is a single address, not a usable segment. if prefix.Bits() > 120 { continue } masked := prefix.Masked() key := iface + "|" + masked.String() if seen[key] { continue } seen[key] = true score := publicInterfaceScore(iface, defaultIfaces) if masked.Bits() <= 64 { score += 20 } info := IPv6PrefixInfo{ Interface: iface, Address: addr.String(), Prefix: masked.String(), PrefixLen: masked.Bits(), Gateway: gateways[iface], IsTunnel: isTunnelLikeInterface(iface), Source: "local", } candidates = append(candidates, candidate{info: info, score: score}) } sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score }) result := make([]IPv6PrefixInfo, 0, len(candidates)) for _, c := range candidates { result = append(result, c.info) } return result } type routeInfo struct { Interface string Gateway string Metric int } func detectIPv4DefaultRoutes() []routeInfo { out, err := exec.Command("ip", "-4", "route", "show", "default").Output() if err != nil { return nil } return parseDefaultRoutes(string(out)) } func detectIPv6DefaultRoutes() []routeInfo { out, err := exec.Command("ip", "-6", "route", "show", "default").Output() if err != nil { return nil } return parseDefaultRoutes(string(out)) } func parseDefaultRoutes(output string) []routeInfo { var routes []routeInfo for _, line := range strings.Split(output, "\n") { fields := strings.Fields(line) if len(fields) == 0 || fields[0] != "default" { continue } route := routeInfo{Metric: 1024} for i := 1; i < len(fields)-1; i++ { switch fields[i] { case "dev": route.Interface = normalizeIface(fields[i+1]) case "via": route.Gateway = fields[i+1] case "metric": metric, err := strconv.Atoi(fields[i+1]) if err == nil { route.Metric = metric } } } if route.Interface != "" { routes = append(routes, route) } } sort.SliceStable(routes, func(i, j int) bool { return routes[i].Metric < routes[j].Metric }) return routes } func ipv6ConnectivityOK() bool { targets := [][]string{ {"ping", "-6", "-c", "1", "-W", "2", "2606:4700:4700::1111"}, {"ping", "-6", "-c", "1", "-W", "2", "2001:4860:4860::8888"}, {"ping6", "-c", "1", "-W", "2", "2606:4700:4700::1111"}, } for _, args := range targets { if exec.Command(args[0], args[1:]...).Run() == nil { return true } } return false } func isContainerLikeInterface(iface string) bool { prefixes := []string{ "lo", "lxc", "docker", "br-", "veth", "virbr", "cni", "flannel", "cali", "kube", "dummy", "ifb", "zt", "zerotier", } for _, prefix := range prefixes { if iface == prefix || strings.HasPrefix(iface, prefix) { return true } } return false } func normalizeIface(iface string) string { iface = strings.TrimSuffix(iface, ":") if at := strings.Index(iface, "@"); at >= 0 { iface = iface[:at] } return iface } func publicInterfaceScore(iface string, defaultIfaces map[string]bool) int { score := 0 if defaultIfaces[iface] { score += 100 } if isTunnelLikeInterface(iface) { score -= 120 } else { score += 80 } if isLikelyPhysicalInterface(iface) { score += 40 } if operState(iface) == "up" { score += 10 } return score } func isLikelyPhysicalInterface(iface string) bool { prefixes := []string{"eth", "ens", "eno", "enp", "em", "bond", "team"} for _, prefix := range prefixes { if strings.HasPrefix(iface, prefix) { return true } } return false } func isTunnelLikeInterface(iface string) bool { lower := strings.ToLower(iface) prefixes := []string{ "wg", "wgcf", "warp", "cloudflare", "tun", "tap", "tailscale", "ts", "vpn", "ppp", "ipsec", "gre", "gretap", "sit", "he-", "nebula", "zt", } for _, prefix := range prefixes { if lower == prefix || strings.HasPrefix(lower, prefix) { return true } } return strings.Contains(lower, "warp") || strings.Contains(lower, "cloudflare") } func operState(iface string) string { data, err := os.ReadFile("/sys/class/net/" + iface + "/operstate") if err != nil { return "" } return strings.TrimSpace(string(data)) } func isPublicIPv4(addr netip.Addr) bool { if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { return false } raw := addr.As4() if raw[0] == 100 && raw[1] >= 64 && raw[1] <= 127 { return false } if raw[0] == 192 && raw[1] == 0 && raw[2] == 0 { return false } return true } func isPublicIPv6(addr netip.Addr) bool { if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() { return false } return !strings.HasPrefix(addr.String(), "2001:db8:") } func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) { status := m.DetectIPv6Status() if !status.Available { return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: %s", status.Reason) } prefixInfo := status.Prefixes[0] prefix, err := netip.ParsePrefix(prefixInfo.Prefix) if err != nil { return "", 0, "", err } used := map[string]bool{} hostAddrs := map[string]bool{} for _, p := range status.Prefixes { hostAddrs[p.Address] = true } for _, c := range config.AppConfig.Containers { if c.IPv6 != "" { used[c.IPv6] = true } } for offset := uint64(0x1000 + id); offset < 0x100000; offset++ { addr, err := ipv6Add(prefix.Masked().Addr(), offset) if err != nil || !prefix.Contains(addr) { break } candidate := addr.String() if !used[candidate] && !hostAddrs[candidate] { return candidate, prefix.Bits(), prefixInfo.Interface, nil } } return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String()) } func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) { raw := base.As16() value := big.NewInt(0).SetBytes(raw[:]) add := make([]byte, 8) binary.BigEndian.PutUint64(add, offset) value.Add(value, big.NewInt(0).SetBytes(add)) bytes := value.Bytes() if len(bytes) > 16 { return netip.Addr{}, fmt.Errorf("IPv6 address overflow") } padded := make([]byte, 16) copy(padded[16-len(bytes):], bytes) var out [16]byte copy(out[:], padded) return netip.AddrFrom16(out), nil } func (m *Manager) AssignIPv6(id int) (*config.Container, error) { c := config.FindContainer(id) if c == nil { return nil, fmt.Errorf("container not found: %d", id) } if c.IPv6 == "" { addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id) if err != nil { return nil, err } c.IPv6 = addr c.IPv6PrefixLen = prefixLen c.IPv6Interface = iface config.SaveConfig() } if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil { return nil, err } if err := m.ApplyIPv6(id); err != nil { return nil, err } return c, nil } func (m *Manager) applyIPv6Config(lxcName, ipv6 string) error { configFile := filepath.Join(m.LxcPath, lxcName, "config") data, err := os.ReadFile(configFile) if err != nil { return fmt.Errorf("failed to read container config: %v", err) } lines := strings.Split(string(data), "\n") next := make([]string, 0, len(lines)+4) for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.Contains(trimmed, "# clicd managed: public IPv6") || strings.HasPrefix(trimmed, "lxc.net.0.ipv6.address") || strings.HasPrefix(trimmed, "lxc.net.0.ipv6.gateway") { continue } next = append(next, line) } if ipv6 != "" { next = append(next, "", "# clicd managed: public IPv6 routed /128") next = append(next, fmt.Sprintf("lxc.net.0.ipv6.address = %s/128", ipv6)) next = append(next, "lxc.net.0.ipv6.gateway = auto") } return os.WriteFile(configFile, []byte(strings.Join(next, "\n")), 0644) } func (m *Manager) ApplyIPv6(id int) error { c := config.FindContainer(id) if c == nil { return fmt.Errorf("container not found: %d", id) } if c.IPv6 == "" { return nil } if c.IPv6Interface == "" { status := m.DetectIPv6Status() if len(status.Prefixes) == 0 { return fmt.Errorf("failed to detect IPv6 uplink for %s", c.IPv6) } c.IPv6Interface = status.Prefixes[0].Interface c.IPv6PrefixLen = status.Prefixes[0].PrefixLen config.SaveConfig() } if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil { return err } status, _ := m.GetContainerStatus(c.LxcName()) if status != "running" { return nil } cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c", fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0", shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal))) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output)) } return nil } func ensureHostIPv6Routing(ipv6, uplink string) error { if uplink == "" { return fmt.Errorf("missing IPv6 uplink interface") } runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1") runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".accept_ra=2") runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".proxy_ndp=1") runQuiet("ip", "link", "set", "lxcbr0", "up") runQuiet("ip", "-6", "addr", "add", ipv6GatewayLinkLocal+"/64", "dev", "lxcbr0") if out, err := exec.Command("ip", "-6", "route", "replace", ipv6+"/128", "dev", "lxcbr0").CombinedOutput(); err != nil { return fmt.Errorf("failed to add IPv6 host route: %v, output: %s", err, string(out)) } if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", ipv6, "dev", uplink).CombinedOutput(); err != nil { return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out)) } ensureIPv6ForwardRules(ipv6) return nil } func ensureIPv6ForwardRules(ipv6 string) { rules := [][]string{ {"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"}, {"FORWARD", "-o", "lxcbr0", "-d", ipv6 + "/128", "-j", "ACCEPT"}, } for _, rule := range rules { check := append([]string{"-C"}, rule...) add := append([]string{"-A"}, rule...) if exec.Command("ip6tables", check...).Run() != nil { exec.Command("ip6tables", add...).Run() } } } func runQuiet(name string, args ...string) { _ = exec.Command(name, args...).Run() } func (m *Manager) AssignedIPv6Count() int { count := 0 for _, c := range config.AppConfig.Containers { if strings.TrimSpace(c.IPv6) != "" { count++ } } return count } func IPv6PrefixCapacity(prefixLen int) string { if prefixLen <= 0 || prefixLen > 128 { return "0" } hostBits := 128 - prefixLen if hostBits > 32 { return "large" } return strconv.FormatUint(uint64(1)<