mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
first commit
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// IsExpired checks if a container has passed its expiration date
|
||||
func IsExpired(c config.Container) bool {
|
||||
return isContainerExpired(c, time.Now())
|
||||
}
|
||||
|
||||
// StopExpiredContainers stops running containers whose expiration date has passed.
|
||||
func (m *Manager) StopExpiredContainers(now time.Time) {
|
||||
for _, container := range config.AppConfig.Containers {
|
||||
if !isContainerExpired(container, now) {
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := m.GetContainerStatus(container.LxcName())
|
||||
if err != nil {
|
||||
status = container.Status
|
||||
}
|
||||
if status != "running" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Container %s (ID=%d) expired at %s, stopping...\n", container.Name, container.ID, container.ExpiresAt)
|
||||
if err := m.StopContainer(container.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to stop expired container %s: %v\n", container.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartExpiryScanner runs a background loop that tracks traffic & stops expired/over-traffic containers every 30 seconds
|
||||
func (m *Manager) StartExpiryScanner() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
now := time.Now()
|
||||
m.AccumulateTraffic() // track network traffic deltas
|
||||
m.StopExpiredContainers(now)
|
||||
m.StopTrafficExceededContainers(now)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopTrafficExceededContainers stops running containers that have exceeded their monthly traffic limit
|
||||
func (m *Manager) StopTrafficExceededContainers(now time.Time) {
|
||||
currentMonth := now.Format("2006-01")
|
||||
saved := false
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset traffic if new month
|
||||
if c.TrafficResetDate != currentMonth {
|
||||
c.TrafficUsedRX = 0
|
||||
c.TrafficUsedTX = 0
|
||||
c.TrafficResetDate = currentMonth
|
||||
saved = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check traffic limits
|
||||
if isTrafficExceeded(*c) {
|
||||
fmt.Printf("Container %s (ID=%d) exceeded traffic limit, stopping...\n", c.Name, c.ID)
|
||||
if err := m.StopContainer(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to stop traffic-exceeded container %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if saved {
|
||||
config.SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func isTrafficExceeded(c config.Container) bool {
|
||||
if c.TrafficMode == "in_out" {
|
||||
inLimit := int64(c.TrafficInGB) * 1073741824
|
||||
outLimit := int64(c.TrafficOutGB) * 1073741824
|
||||
if inLimit > 0 && c.TrafficUsedRX >= inLimit {
|
||||
return true
|
||||
}
|
||||
if outLimit > 0 && c.TrafficUsedTX >= outLimit {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
totalLimit := int64(c.MonthlyTrafficGB) * 1073741824
|
||||
return totalLimit > 0 && (c.TrafficUsedRX+c.TrafficUsedTX) >= totalLimit
|
||||
}
|
||||
|
||||
// ResetTraffic resets traffic counters for a container
|
||||
func (m *Manager) ResetTraffic(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
c.TrafficUsedRX = 0
|
||||
c.TrafficUsedTX = 0
|
||||
c.TrafficResetDate = time.Now().Format("2006-01")
|
||||
config.SaveConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTrafficExceeded checks if a container has exceeded its traffic limit
|
||||
func IsTrafficExceeded(c config.Container) bool {
|
||||
return isTrafficExceeded(c)
|
||||
}
|
||||
|
||||
func isContainerExpired(container config.Container, now time.Time) bool {
|
||||
expiresAt, ok := ParseExpiration(container.ExpiresAt)
|
||||
return ok && !now.Before(expiresAt)
|
||||
}
|
||||
|
||||
// ParseExpiration parses an expiration string. A YYYY-MM-DD value expires at the
|
||||
// end of that local day, while RFC3339 values are treated as exact timestamps.
|
||||
func ParseExpiration(value string) (time.Time, bool) {
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
if parsed, err := time.ParseInLocation("2006-01-02", value, time.Local); err == nil {
|
||||
return parsed.Add(24 * time.Hour), true
|
||||
}
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
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)<<uint(hostBits), 10)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
|
||||
func (m *Manager) ApplyPortMappings(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if c.IP == "" {
|
||||
return fmt.Errorf("container has no IP")
|
||||
}
|
||||
tag := clicdTag(id)
|
||||
|
||||
EnsureForwardRules()
|
||||
m.CleanPortMappings(id)
|
||||
|
||||
for _, pm := range c.PortMappings {
|
||||
cmd := exec.Command("iptables",
|
||||
"-t", "nat",
|
||||
"-I", "PREROUTING", "1",
|
||||
"-p", pm.Protocol,
|
||||
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
||||
"-j", "DNAT",
|
||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
|
||||
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
||||
}
|
||||
|
||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
|
||||
func EnsureForwardRules() {
|
||||
rules := [][]string{
|
||||
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
|
||||
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
|
||||
}
|
||||
for _, args := range rules {
|
||||
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
|
||||
if exec.Command("iptables", checkArgs...).Run() != nil {
|
||||
exec.Command("iptables", args...).Run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanPortMappings removes all iptables rules for a container
|
||||
func (m *Manager) CleanPortMappings(id int) error {
|
||||
tag := clicdTag(id)
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
|
||||
cmd.Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupDefaultPortMappings creates default port mappings
|
||||
func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
|
||||
return []config.PortMapping{
|
||||
{ContainerPort: 22, HostPort: sshPort, Protocol: "tcp", Description: "SSH"},
|
||||
}
|
||||
}
|
||||
|
||||
// AddPortMapping adds a NAT rule to a container
|
||||
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
|
||||
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
|
||||
}
|
||||
normalized, err := normalizePortMapping(c, -1, pm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.PortMappings = append(c.PortMappings, normalized)
|
||||
if err := persistAndReloadMappings(m, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
// UpdatePortMapping updates an existing NAT rule
|
||||
func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([]config.PortMapping, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if index < 0 || index >= len(c.PortMappings) {
|
||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||
}
|
||||
normalized, err := normalizePortMapping(c, index, pm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.PortMappings[index] = normalized
|
||||
if err := persistAndReloadMappings(m, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
// DeletePortMapping removes a NAT rule
|
||||
func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if index < 0 || index >= len(c.PortMappings) {
|
||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||
}
|
||||
if c.PortMappings[index].Description == "SSH" {
|
||||
return nil, fmt.Errorf("SSH default mapping cannot be deleted")
|
||||
}
|
||||
c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...)
|
||||
if err := persistAndReloadMappings(m, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.PortMappings, nil
|
||||
}
|
||||
|
||||
func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||
config.SaveConfig()
|
||||
if c.Status == "running" && c.IP != "" {
|
||||
return m.ApplyPortMappings(c.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
|
||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||
return pm, fmt.Errorf("container port must be 1-65535")
|
||||
}
|
||||
if pm.Protocol == "" {
|
||||
pm.Protocol = "tcp"
|
||||
}
|
||||
if pm.Description == "" {
|
||||
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
||||
}
|
||||
if pm.HostPort <= 0 {
|
||||
pm.HostPort = pm.ContainerPort
|
||||
}
|
||||
for i, existing := range c.PortMappings {
|
||||
if i == skipIndex {
|
||||
continue
|
||||
}
|
||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
||||
return pm, fmt.Errorf("host port %d/%s already mapped", pm.HostPort, pm.Protocol)
|
||||
}
|
||||
}
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
used := map[int]bool{}
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
used[pm.ContainerPort] = true
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
next := 20000
|
||||
for len(ports) < count {
|
||||
if !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
next++
|
||||
if next > 65535 || len(ports) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package lxc
|
||||
|
||||
// Template represents an LXC image template
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Distro string `json:"distro"`
|
||||
Release string `json:"release"`
|
||||
Arch string `json:"arch"`
|
||||
Variant string `json:"variant"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// GetTemplates returns available LXC image templates (only verified working ones)
|
||||
func GetTemplates() []Template {
|
||||
return []Template{
|
||||
{
|
||||
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
||||
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
||||
Description: "Ubuntu 24.04 LTS",
|
||||
},
|
||||
{
|
||||
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
|
||||
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
||||
Description: "Ubuntu 22.04 LTS",
|
||||
},
|
||||
{
|
||||
ID: "debian-bookworm", Name: "Debian 12",
|
||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||
Description: "Debian 12 (Bookworm)",
|
||||
},
|
||||
{
|
||||
ID: "debian-bullseye", Name: "Debian 11",
|
||||
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
||||
Description: "Debian 11 (Bullseye)",
|
||||
},
|
||||
{
|
||||
ID: "alpine-3.21", Name: "Alpine 3.21",
|
||||
Distro: "alpine", Release: "3.21", Arch: "amd64",
|
||||
Description: "Alpine Linux 3.21",
|
||||
},
|
||||
{
|
||||
ID: "centos-9-stream", Name: "CentOS 9 Stream",
|
||||
Distro: "centos", Release: "9-Stream", Arch: "amd64",
|
||||
Description: "CentOS 9 Stream",
|
||||
},
|
||||
{
|
||||
ID: "archlinux-current", Name: "Arch Linux",
|
||||
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
|
||||
Description: "Arch Linux (Rolling)",
|
||||
},
|
||||
{
|
||||
ID: "fedora-44", Name: "Fedora 44",
|
||||
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
|
||||
Description: "Fedora 44",
|
||||
},
|
||||
{
|
||||
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
||||
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
|
||||
Description: "Rocky Linux 10",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// FindTemplate finds a template by ID
|
||||
func FindTemplate(id string) *Template {
|
||||
templates := GetTemplates()
|
||||
for _, t := range templates {
|
||||
if t.ID == id {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user