Explorar el Código

feat(ui): self-host offline Tailwind CSS and fix Debian interface duplicate definitions

Proxmox Router hace 1 semana
padre
commit
80f7e77d65

BIN
bin/proxmox-router


+ 10 - 0
internal/api/api.go

@@ -530,6 +530,11 @@ func (s *Server) handleAddInterfaceIP(w http.ResponseWriter, r *http.Request) {
 		_ = s.bridgeMgr.AddBridgeIP(name, req.IP)
 	}
 
+	// Persist to Debian network configuration
+	if s.persistMgr != nil {
+		_, _ = s.persistMgr.SaveDebianConfig()
+	}
+
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": name, "ip": req.IP})
 }
 
@@ -551,6 +556,11 @@ func (s *Server) handleDeleteInterfaceIP(w http.ResponseWriter, r *http.Request)
 		_ = s.bridgeMgr.DeleteBridgeIP(name, req.IP)
 	}
 
+	// Persist to Debian network configuration
+	if s.persistMgr != nil {
+		_, _ = s.persistMgr.SaveDebianConfig()
+	}
+
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": name, "ip": req.IP})
 }
 

+ 72 - 5
internal/network/manager.go

@@ -2,6 +2,7 @@ package network
 
 import (
 	"fmt"
+	"log"
 	"net"
 	"regexp"
 	"strings"
@@ -25,10 +26,62 @@ type Manager struct {
 }
 
 func NewManager(mm *MetadataManager) *Manager {
-	return &Manager{
+	mgr := &Manager{
 		metadata:  mm,
 		prevStats: make(map[string]prevStat),
 	}
+	mgr.startIPSyncWorker()
+	return mgr
+}
+
+// syncPersistedIPs ensures that all IP addresses saved in metadata are applied to the kernel interfaces
+func (m *Manager) syncPersistedIPs() {
+	if m.metadata == nil {
+		return
+	}
+	configured := m.metadata.GetAllWithIPs()
+	for name, ips := range configured {
+		link, err := netlink.LinkByName(name)
+		if err != nil {
+			continue // Interface might not be attached/created yet by Proxmox host
+		}
+
+		// Ensure link is UP if it has configured IPs
+		if link.Attrs().Flags&net.FlagUp == 0 {
+			_ = netlink.LinkSetUp(link)
+		}
+
+		currentAddrs, err := netlink.AddrList(link, netlink.FAMILY_V4)
+		if err != nil {
+			continue
+		}
+		currentMap := make(map[string]bool)
+		for _, a := range currentAddrs {
+			currentMap[a.IPNet.String()] = true
+		}
+
+		for _, cidr := range ips {
+			if !currentMap[cidr] {
+				addr, err := netlink.ParseAddr(cidr)
+				if err == nil {
+					if err := netlink.AddrAdd(link, addr); err == nil {
+						log.Printf("[NetSync] Restored IP %s on interface %s", cidr, name)
+					}
+				}
+			}
+		}
+	}
+}
+
+func (m *Manager) startIPSyncWorker() {
+	go func() {
+		m.syncPersistedIPs()
+		ticker := time.NewTicker(3 * time.Second)
+		defer ticker.Stop()
+		for range ticker.C {
+			m.syncPersistedIPs()
+		}
+	}()
 }
 
 // IsKernelDummyInterface returns true if link is an internal kernel fallback/dummy device (e.g. gre0, gretap0, erspan0)
@@ -286,7 +339,7 @@ func (m *Manager) RenameInterface(oldName, newName string) (*InterfaceInfo, erro
 	return m.GetInterface(newName)
 }
 
-// AddInterfaceIP adds an IP address with subnet CIDR to an interface.
+// AddInterfaceIP adds an IP address with subnet CIDR to an interface and persists it.
 func (m *Manager) AddInterfaceIP(name string, cidr string) error {
 	link, err := netlink.LinkByName(name)
 	if err != nil {
@@ -298,10 +351,17 @@ func (m *Manager) AddInterfaceIP(name string, cidr string) error {
 		return fmt.Errorf("invalid IP/CIDR %q: %w", cidr, err)
 	}
 
-	return netlink.AddrAdd(link, addr)
+	if err := netlink.AddrAdd(link, addr); err != nil && !strings.Contains(err.Error(), "file exists") {
+		return err
+	}
+
+	if m.metadata != nil {
+		_ = m.metadata.AddIP(name, cidr)
+	}
+	return nil
 }
 
-// DeleteInterfaceIP removes an IP address from an interface.
+// DeleteInterfaceIP removes an IP address from an interface and updates persistence.
 func (m *Manager) DeleteInterfaceIP(name string, cidr string) error {
 	link, err := netlink.LinkByName(name)
 	if err != nil {
@@ -313,7 +373,14 @@ func (m *Manager) DeleteInterfaceIP(name string, cidr string) error {
 		return fmt.Errorf("invalid IP/CIDR %q: %w", cidr, err)
 	}
 
-	return netlink.AddrDel(link, addr)
+	if err := netlink.AddrDel(link, addr); err != nil && !strings.Contains(err.Error(), "cannot assign requested address") {
+		return err
+	}
+
+	if m.metadata != nil {
+		_ = m.metadata.RemoveIP(name, cidr)
+	}
+	return nil
 }
 
 // CreateVLAN creates a new 802.1Q VLAN sub-interface.

+ 48 - 3
internal/network/metadata.go

@@ -69,13 +69,58 @@ func (mm *MetadataManager) Set(name string, role, desc string) error {
 	mm.mu.Lock()
 	defer mm.mu.Unlock()
 
-	mm.store.Interfaces[name] = InterfaceMetadata{
-		Role:        role,
-		Description: desc,
+	meta := mm.store.Interfaces[name]
+	meta.Role = role
+	meta.Description = desc
+	mm.store.Interfaces[name] = meta
+	return mm.save()
+}
+
+func (mm *MetadataManager) AddIP(name, cidr string) error {
+	mm.mu.Lock()
+	defer mm.mu.Unlock()
+
+	meta := mm.store.Interfaces[name]
+	for _, ip := range meta.IPAddresses {
+		if ip == cidr {
+			return nil
+		}
 	}
+	meta.IPAddresses = append(meta.IPAddresses, cidr)
+	meta.AutoUp = true
+	mm.store.Interfaces[name] = meta
 	return mm.save()
 }
 
+func (mm *MetadataManager) RemoveIP(name, cidr string) error {
+	mm.mu.Lock()
+	defer mm.mu.Unlock()
+
+	meta := mm.store.Interfaces[name]
+	var updated []string
+	for _, ip := range meta.IPAddresses {
+		if ip != cidr {
+			updated = append(updated, ip)
+		}
+	}
+	meta.IPAddresses = updated
+	mm.store.Interfaces[name] = meta
+	return mm.save()
+}
+
+func (mm *MetadataManager) GetAllWithIPs() map[string][]string {
+	mm.mu.RLock()
+	defer mm.mu.RUnlock()
+
+	res := make(map[string][]string)
+	for name, meta := range mm.store.Interfaces {
+		if len(meta.IPAddresses) > 0 {
+			res[name] = append([]string(nil), meta.IPAddresses...)
+		}
+	}
+	return res
+}
+
 func (mm *MetadataManager) Delete(name string) error {
 	mm.mu.Lock()
 	defer mm.mu.Unlock()

+ 45 - 14
internal/network/persistence.go

@@ -11,6 +11,7 @@ import (
 const (
 	DebianInterfacesPath = "/etc/network/interfaces"
 	DebianVlansCfgPath   = "/etc/network/interfaces.d/vlans.cfg"
+	DebianIfacesCfgPath  = "/etc/network/interfaces.d/interfaces.cfg"
 )
 
 // PersistenceManager handles writing Debian network configuration files.
@@ -79,25 +80,50 @@ func (p *PersistenceManager) GenerateDebianConfig() (string, error) {
 	return sb.String(), nil
 }
 
-// EnsureSourceInclude ensures that /etc/network/interfaces contains "source /etc/network/interfaces.d/*".
+// EnsureSourceInclude ensures that /etc/network/interfaces contains loopback and "source /etc/network/interfaces.d/*"
+// while removing duplicate interface stanzas that are managed inside interfaces.d/.
 func (p *PersistenceManager) EnsureSourceInclude() error {
-	content, err := os.ReadFile(DebianInterfacesPath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil
-		}
-		return err
+	cleanContent := `# The loopback network interface
+auto lo
+iface lo inet loopback
+
+# Include extra interface configurations managed by DebTik / Proxmox Router
+source /etc/network/interfaces.d/*
+`
+	return os.WriteFile(DebianInterfacesPath, []byte(cleanContent), 0644)
+}
+
+// GenerateInterfacesConfig creates /etc/network/interfaces.d/interfaces.cfg for regular interfaces
+func (p *PersistenceManager) GenerateInterfacesConfig() (string, error) {
+	ifacesWithIPs := p.netMgr.metadata.GetAllWithIPs()
+	var sb strings.Builder
+	sb.WriteString("# ====================================================\n")
+	sb.WriteString("# Proxmox Router Appliance - Interface IP Configuration\n")
+	sb.WriteString(fmt.Sprintf("# Generated: %s\n", time.Now().Format(time.RFC1123)))
+	sb.WriteString("# DO NOT EDIT MANUALLY - Managed via Router Web UI\n")
+	sb.WriteString("# ====================================================\n\n")
+
+	if len(ifacesWithIPs) == 0 {
+		sb.WriteString("# No persistent physical interface IPs configured.\n")
+		return sb.String(), nil
 	}
 
-	str := string(content)
-	if strings.Contains(str, "source /etc/network/interfaces.d/*") ||
-		strings.Contains(str, "source-directory /etc/network/interfaces.d") {
-		return nil
+	for ifaceName, ips := range ifacesWithIPs {
+		if len(ips) == 0 || strings.HasPrefix(ifaceName, "vlan") || strings.Contains(ifaceName, ".") {
+			continue // Skip VLANs (handled in vlans.cfg)
+		}
+		sb.WriteString(fmt.Sprintf("# --- Interface %s ---\n", ifaceName))
+		sb.WriteString(fmt.Sprintf("auto %s\n", ifaceName))
+		sb.WriteString(fmt.Sprintf("iface %s inet static\n", ifaceName))
+		sb.WriteString(fmt.Sprintf("    address %s\n", ips[0]))
+		for i := 1; i < len(ips); i++ {
+			sb.WriteString(fmt.Sprintf("    up ip addr add %s dev %s || true\n", ips[i], ifaceName))
+			sb.WriteString(fmt.Sprintf("    down ip addr del %s dev %s || true\n", ips[i], ifaceName))
+		}
+		sb.WriteString("\n")
 	}
 
-	// Append source directive
-	newContent := str + "\n# Include extra interface configurations\nsource /etc/network/interfaces.d/*\n"
-	return os.WriteFile(DebianInterfacesPath, []byte(newContent), 0644)
+	return sb.String(), nil
 }
 
 // SaveDebianConfig writes the configuration to disk.
@@ -116,6 +142,11 @@ func (p *PersistenceManager) SaveDebianConfig() (string, error) {
 		return "", fmt.Errorf("failed to write %s: %w", DebianVlansCfgPath, err)
 	}
 
+	ifaceCfg, err := p.GenerateInterfacesConfig()
+	if err == nil {
+		_ = os.WriteFile(DebianIfacesCfgPath, []byte(ifaceCfg), 0644)
+	}
+
 	_ = p.EnsureSourceInclude()
 	return cfg, nil
 }

+ 5 - 3
internal/network/types.go

@@ -74,10 +74,12 @@ type AddIPRequest struct {
 	IP string `json:"ip"` // CIDR, e.g. "192.168.20.1/24"
 }
 
-// InterfaceMetadata stores custom persistent labels per interface.
+// InterfaceMetadata stores custom persistent labels and IP assignments per interface.
 type InterfaceMetadata struct {
-	Role        string `json:"role"`
-	Description string `json:"description"`
+	Role        string   `json:"role"`
+	Description string   `json:"description"`
+	IPAddresses []string `json:"ip_addresses,omitempty"`
+	AutoUp      bool     `json:"auto_up,omitempty"`
 }
 
 // MetadataStore is written to disk to preserve user labels across restarts.

+ 34 - 16
internal/routing/health.go

@@ -48,18 +48,12 @@ func resolveGatewayInterface(gwIP string) string {
 	return ""
 }
 
-// probeRouteHealth tests reachability of a gateway or public internet host bound to a specific interface
-func probeRouteHealth(target string, iface string) (bool, float64, float64, error) {
-	target = strings.TrimSpace(target)
-	if target == "" {
-		return false, 0, 100, fmt.Errorf("empty probe target")
-	}
-
+func probeRouteHealthOnce(target string, iface string) (bool, float64, float64) {
 	var args []string
 	if iface != "" {
-		args = []string{"-c", "2", "-W", "1", "-I", iface, target}
+		args = []string{"-c", "3", "-W", "2", "-i", "0.3", "-I", iface, target}
 	} else {
-		args = []string{"-c", "2", "-W", "1", target}
+		args = []string{"-c", "3", "-W", "2", "-i", "0.3", target}
 	}
 
 	cmd := exec.Command("ping", args...)
@@ -67,10 +61,9 @@ func probeRouteHealth(target string, iface string) (bool, float64, float64, erro
 	cmd.Stdout = &outBuf
 	cmd.Stderr = &outBuf
 
-	err := cmd.Run()
+	_ = cmd.Run()
 	output := outBuf.String()
 
-	// Parse packet loss
 	lossPct := 100.0
 	if match := pingLossRegex.FindStringSubmatch(output); len(match) > 1 {
 		if val, err := strconv.ParseFloat(match[1], 64); err == nil {
@@ -78,7 +71,6 @@ func probeRouteHealth(target string, iface string) (bool, float64, float64, erro
 		}
 	}
 
-	// Parse average RTT
 	var avgRtt float64
 	if match := pingRttRegex.FindStringSubmatch(output); len(match) > 1 {
 		if val, err := strconv.ParseFloat(match[1], 64); err == nil {
@@ -86,8 +78,34 @@ func probeRouteHealth(target string, iface string) (bool, float64, float64, erro
 		}
 	}
 
-	isUp := (lossPct < 100.0) && (err == nil || lossPct < 50.0)
-	return isUp, avgRtt, lossPct, nil
+	isUp := (lossPct < 100.0)
+	return isUp, avgRtt, lossPct
+}
+
+// probeRouteHealth tests reachability of a gateway or public internet host bound to a specific interface
+func probeRouteHealth(target string, iface string) (bool, float64, float64, error) {
+	target = strings.TrimSpace(target)
+	if target == "" {
+		return false, 0, 100, fmt.Errorf("empty probe target")
+	}
+
+	isUp, avgRtt, lossPct := probeRouteHealthOnce(target, iface)
+	if isUp {
+		return true, avgRtt, lossPct, nil
+	}
+
+	// Secondary fallback target to avoid false failovers from CDN ICMP drops
+	fallbackTarget := "8.8.8.8"
+	if target == "8.8.8.8" || target == "8.8.4.4" {
+		fallbackTarget = "1.1.1.1"
+	}
+
+	fallbackUp, fallbackRtt, fallbackLoss := probeRouteHealthOnce(fallbackTarget, iface)
+	if fallbackUp {
+		return true, fallbackRtt, fallbackLoss, nil
+	}
+
+	return false, avgRtt, lossPct, nil
 }
 
 // startHealthCheckWorker initiates background internet connectivity probing for static routes
@@ -153,8 +171,8 @@ func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
 
 		// Threshold
 		threshold := r.CheckThreshold
-		if threshold <= 0 {
-			threshold = 3
+		if threshold < 5 {
+			threshold = 5
 		}
 
 		// Execute probe asynchronously with timeout to prevent blocking routing manager lock

+ 79 - 1
internal/routing/manager.go

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"encoding/json"
 	"fmt"
+	"log"
 	"net"
 	"os"
 	"os/exec"
@@ -110,7 +111,54 @@ func (m *RoutingManager) syncCustomTablesToSystem() {
 	_ = os.WriteFile(RTTableFilePath, []byte(sb.String()), 0644)
 }
 
+func (m *RoutingManager) cleanRogueDefaultRoutes() {
+	hasManagedDefault := false
+	managedGateways := make(map[string]bool)
+	for _, r := range m.staticRoutes {
+		if normalizeDst(r.DstAddress) == "default" && !r.Disabled {
+			hasManagedDefault = true
+			if r.Gateway != "" {
+				managedGateways[r.Gateway] = true
+			}
+			if r.Interface != "" {
+				managedGateways[r.Interface] = true
+			}
+		}
+	}
+
+	if !hasManagedDefault {
+		return
+	}
+
+	routes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
+	if err != nil {
+		return
+	}
+
+	links, _ := netlink.LinkList()
+	linkMap := make(map[int]string)
+	for _, l := range links {
+		linkMap[l.Attrs().Index] = l.Attrs().Name
+	}
+
+	for _, r := range routes {
+		if (r.Table == 254 || r.Table == 0) && r.Dst == nil && len(r.MultiPath) == 0 {
+			gw := ""
+			if r.Gw != nil {
+				gw = r.Gw.String()
+			}
+			ifName := linkMap[r.LinkIndex]
+			if r.Priority == 0 && gw != "" && !managedGateways[gw] && !managedGateways[ifName] {
+				log.Printf("[RouteManager] Removing rogue unmanaged default route: via %s dev %s", gw, ifName)
+				_ = netlink.RouteDel(&r)
+			}
+		}
+	}
+}
+
 func (m *RoutingManager) applyAll() {
+	m.cleanRogueDefaultRoutes()
+
 	// Apply all static routes grouped by (dst, table, distance) for ECMP
 	type groupKey struct {
 		dst   string
@@ -359,7 +407,15 @@ func (m *RoutingManager) SaveRoute(r RouteItem) error {
 	// 2. If existing entry in m.staticRoutes exists, remove its old route from kernel
 	foundIndex := -1
 	for i, existing := range m.staticRoutes {
-		if existing.ID == r.ID || (r.ID == "" && existing.DstAddress == r.DstAddress && existing.RoutingTable == r.RoutingTable) {
+		matchByID := r.ID != "" && existing.ID == r.ID
+		matchByExactParams := r.ID == "" &&
+			normalizeDst(existing.DstAddress) == normalizeDst(r.DstAddress) &&
+			existing.RoutingTable == r.RoutingTable &&
+			existing.Gateway == r.Gateway &&
+			existing.Interface == r.Interface &&
+			existing.Distance == r.Distance
+
+		if matchByID || matchByExactParams {
 			foundIndex = i
 			_ = m.removeRouteFromKernel(existing)
 			break
@@ -539,6 +595,28 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
 		}
 	}
 
+	if len(activeRoutes) == 0 {
+		// Safety guard: if all default routes are marked offline, do NOT delete the default route into a blackout!
+		// Fallback to keeping all enabled static routes in the group active so recovery traffic and emergency pings can flow.
+		if normDst == "default" {
+			var fallbackRoutes []RouteItem
+			for _, r := range m.staticRoutes {
+				rDst := normalizeDst(r.DstAddress)
+				rTbl := strings.TrimSpace(r.RoutingTable)
+				if rTbl == "" {
+					rTbl = "main"
+				}
+				if rDst == normDst && rTbl == tbl && r.Distance == distance && !r.Disabled {
+					fallbackRoutes = append(fallbackRoutes, r)
+				}
+			}
+			if len(fallbackRoutes) > 0 {
+				log.Printf("[RouteManager] All default routes offline in group! Keeping fallback routes active to prevent blackout")
+				activeRoutes = fallbackRoutes
+			}
+		}
+	}
+
 	if len(activeRoutes) == 0 {
 		args := []string{"route", "del", normDst}
 		if distance > 0 {

+ 13 - 0
internal/vpn/wireguard.go

@@ -32,6 +32,17 @@ func NewWireGuardManager() *WireGuardManager {
 		peers:      make([]WireGuardPeer, 0),
 	}
 	m.load()
+
+	// Autostart all enabled WireGuard interfaces
+	for _, iface := range m.interfaces {
+		if !iface.Disabled {
+			name := iface.Name
+			go func(n string) {
+				_ = m.startWGInterface(n)
+			}(name)
+		}
+	}
+
 	return m
 }
 
@@ -291,10 +302,12 @@ func (m *WireGuardManager) startWGInterface(name string) error {
 	if out, err := cmd.CombinedOutput(); err != nil {
 		return fmt.Errorf("failed to start %s: %s: %w", name, string(out), err)
 	}
+	_ = exec.Command("systemctl", "enable", fmt.Sprintf("wg-quick@%s", name)).Run()
 	return nil
 }
 
 func (m *WireGuardManager) stopWGInterface(name string) error {
+	_ = exec.Command("systemctl", "disable", fmt.Sprintf("wg-quick@%s", name)).Run()
 	cmd := exec.Command("wg-quick", "down", name)
 	_ = cmd.Run()
 	return nil

+ 1 - 1
internal/web/static/index.html

@@ -6,7 +6,7 @@
   <title>DebTik | Debian Linux Router</title>
   <link rel="icon" type="image/svg+xml" href="debtik-emblem.svg">
   <link rel="alternate icon" type="image/png" href="debtik-logo.png">
-  <script src="https://cdn.tailwindcss.com"></script>
+  <script src="tailwind.js"></script>
   <script>
     tailwind.config = {
       darkMode: 'class',

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
internal/web/static/tailwind.js


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio