diff --git a/RULES.md b/RULES.md index 391c1e4..239554a 100644 --- a/RULES.md +++ b/RULES.md @@ -1,8 +1,9 @@ -1. Все настройки обязательно сохранять в config.yaml и восстанавливать оттуда при первом запуске бинарника. +1. Все настройки всех подсистем обязательно сохранять в config.yaml и восстанавливать оттуда при первом запуске бинарника, затрия то, что осталось в конфигах управляемых сервисов. 2. Функциональные разделы админки писать отдельными html страницами и добавлять в главное меню. +3. Документировать весь новый функционал в docs/ - -Установить пакеты: +Зависимости alpine: dnsmasq -nftables \ No newline at end of file +nftables +conntrack-tools diff --git a/alpine-router b/alpine-router index a7d1e6c..fdf7cbd 100755 Binary files a/alpine-router and b/alpine-router differ diff --git a/config/config.go b/config/config.go index 0219740..9f0a9a9 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,7 @@ import ( ) type InterfaceConfig struct { + Label string `yaml:"label,omitempty"` Auto bool `yaml:"auto"` Mode string `yaml:"mode"` Address string `yaml:"address,omitempty"` @@ -52,12 +53,32 @@ type MihomoConfig struct { Enabled bool `yaml:"enabled"` } +type FirewallRule struct { + ID string `yaml:"id" json:"id"` + Enabled bool `yaml:"enabled" json:"enabled"` + Action string `yaml:"action" json:"action"` + Protocol string `yaml:"protocol" json:"protocol"` + SrcAddr string `yaml:"src_addr" json:"src_addr"` + SrcPort string `yaml:"src_port" json:"src_port"` + DstAddr string `yaml:"dst_addr" json:"dst_addr"` + DstPort string `yaml:"dst_port" json:"dst_port"` + InIface string `yaml:"in_iface" json:"in_iface"` + OutIface string `yaml:"out_iface" json:"out_iface"` + Comment string `yaml:"comment" json:"comment"` +} + +type FirewallConfig struct { + Rules []FirewallRule `yaml:"rules" json:"rules"` + VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"` +} + type AppConfig struct { - Interfaces map[string]*InterfaceConfig `yaml:"interfaces"` - DHCP DHCPConfig `yaml:"dhcp"` - NAT NATConfig `yaml:"nat"` - KnownDevices []KnownDevice `yaml:"known_devices"` - Mihomo MihomoConfig `yaml:"mihomo"` + Interfaces map[string]*InterfaceConfig `yaml:"interfaces"` + DHCP DHCPConfig `yaml:"dhcp"` + NAT NATConfig `yaml:"nat"` + Firewall FirewallConfig `yaml:"firewall"` + KnownDevices []KnownDevice `yaml:"known_devices"` + Mihomo MihomoConfig `yaml:"mihomo"` } var ( @@ -176,6 +197,9 @@ func EnsureDefaults(cfg *AppConfig) { if cfg.NAT.Interfaces == nil { cfg.NAT.Interfaces = []string{} } + if cfg.Firewall.Rules == nil { + cfg.Firewall.Rules = []FirewallRule{} + } if cfg.KnownDevices == nil { cfg.KnownDevices = []KnownDevice{} } diff --git a/firewall/firewall.go b/firewall/firewall.go new file mode 100644 index 0000000..2801d24 --- /dev/null +++ b/firewall/firewall.go @@ -0,0 +1,201 @@ +package firewall + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +const tableName = "alpine-router" + +// Rule is a single stateless forward-filter rule. +type Rule struct { + ID string `yaml:"id" json:"id"` + Enabled bool `yaml:"enabled" json:"enabled"` + Action string `yaml:"action" json:"action"` // accept | drop | reject + Protocol string `yaml:"protocol" json:"protocol"` // tcp | udp | icmp | all + SrcAddr string `yaml:"src_addr" json:"src_addr"` // CIDR or IP, empty = any + SrcPort string `yaml:"src_port" json:"src_port"` // "80" | "80-443", empty = any + DstAddr string `yaml:"dst_addr" json:"dst_addr"` + DstPort string `yaml:"dst_port" json:"dst_port"` + InIface string `yaml:"in_iface" json:"in_iface"` // input interface, empty = any + OutIface string `yaml:"out_iface" json:"out_iface"` // output interface, empty = any + Comment string `yaml:"comment" json:"comment"` +} + +// Config is the top-level firewall config stored in config.yaml. +type Config struct { + Rules []Rule `yaml:"rules" json:"rules"` + VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"` +} + +// NATConfig holds NAT masquerade settings (passed to avoid a direct nat import). +type NATConfig struct { + Interfaces []string +} + +// IsInstalled reports whether the nft binary is available. +func IsInstalled() bool { + _, err := exec.LookPath("nft") + return err == nil +} + +// ApplyAll atomically regenerates the complete nftables ruleset: +// - NAT masquerade for natCfg.Interfaces +// - Blocked client IP drops +// - User rules from fwCfg (in order, enabled only) +// - LAN isolation (if fwCfg.VLANIsolation): blocks traffic between any two LAN interfaces +// (native + tagged VLANs). User rules placed above have priority. +// - Default accept from LAN interfaces to WAN +// +// lanIfaces is the union of NAT interfaces and all VLAN interfaces — every interface +// that serves a local subnet. Isolation prevents any two of them from talking directly. +func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) error { + if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil { + return fmt.Errorf("enable ip_forward: %w", err) + } + + // Remove both old and new table names to ensure clean state. + exec.Command("nft", "delete", "table", "ip", "alpine-router-nat").Run() + exec.Command("nft", "delete", "table", "ip", tableName).Run() + + var activeRules []Rule + for _, r := range fwCfg.Rules { + if r.Enabled { + activeRules = append(activeRules, r) + } + } + + hasNAT := len(natCfg.Interfaces) > 0 + hasBlocked := len(blockedIPs) > 0 + hasVLANIsolation := fwCfg.VLANIsolation && len(lanIfaces) >= 2 + + if !hasNAT && !hasBlocked && !hasVLANIsolation && len(activeRules) == 0 { + return nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "table ip %s {\n", tableName) + + // ── Forward chain ──────────────────────────────────────────────────────── + sb.WriteString(" chain forward {\n") + sb.WriteString(" type filter hook forward priority filter; policy drop;\n") + sb.WriteString(" ct state established,related accept\n") + + for _, ip := range blockedIPs { + fmt.Fprintf(&sb, " ip saddr %s drop\n", ip) + fmt.Fprintf(&sb, " ip daddr %s drop\n", ip) + } + + for _, rule := range activeRules { + line := buildRuleLine(rule) + if line == "" { + continue + } + if rule.Comment != "" { + fmt.Fprintf(&sb, " # %s\n", rule.Comment) + } + fmt.Fprintf(&sb, " %s\n", line) + } + + // LAN isolation — drop traffic between any two local (LAN) interfaces. + // Placed AFTER user rules so explicit allow rules above still take effect. + if hasVLANIsolation { + quoted := make([]string, len(lanIfaces)) + for i, v := range lanIfaces { + quoted[i] = fmt.Sprintf("%q", v) + } + set := "{ " + strings.Join(quoted, ", ") + " }" + fmt.Fprintf(&sb, " iifname %s oifname %s drop\n", set, set) + } + + // Allow from LAN/VLAN interfaces to WAN (non-VLAN, non-blocked traffic falls through above). + for _, iface := range natCfg.Interfaces { + fmt.Fprintf(&sb, " iifname %q accept\n", iface) + } + + sb.WriteString(" }\n") + + // ── Postrouting (masquerade) ───────────────────────────────────────────── + if hasNAT { + sb.WriteString(" chain postrouting {\n") + sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") + for _, iface := range natCfg.Interfaces { + fmt.Fprintf(&sb, " iifname %q masquerade\n", iface) + } + sb.WriteString(" }\n") + } + + sb.WriteString("}\n") + + cmd := exec.Command("nft", "-f", "-") + cmd.Stdin = strings.NewReader(sb.String()) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err) + } + + // Flush connection tracking table so existing sessions are re-evaluated + // against the new ruleset. Without this, traffic already tracked as + // "established/related" bypasses new drop rules until the session ends. + flushConntrack() + + return nil +} + +// flushConntrack clears the kernel connection tracking table so that all traffic +// is re-evaluated against the current nftables ruleset. This is necessary when +// adding new drop/reject rules to prevent previously-established sessions from +// continuing to bypass the new rules via ct state established,related accept. +func flushConntrack() { + // Preferred: conntrack utility (part of conntrack-tools package). + if err := exec.Command("conntrack", "-F").Run(); err == nil { + return + } + // Fallback: write to /proc (available when nf_conntrack module is loaded). + _ = os.WriteFile("/proc/sys/net/netfilter/nf_conntrack_flush", []byte("1"), 0644) +} + +// buildRuleLine converts a Rule to a single nftables match+action string. +// Returns "" if the rule has no valid action. +func buildRuleLine(r Rule) string { + if r.Action == "" { + return "" + } + + var parts []string + + if r.InIface != "" { + parts = append(parts, fmt.Sprintf("iifname %q", r.InIface)) + } + if r.OutIface != "" { + parts = append(parts, fmt.Sprintf("oifname %q", r.OutIface)) + } + if r.SrcAddr != "" { + parts = append(parts, "ip saddr "+r.SrcAddr) + } + if r.DstAddr != "" { + parts = append(parts, "ip daddr "+r.DstAddr) + } + + proto := strings.ToLower(r.Protocol) + switch proto { + case "tcp", "udp": + if r.SrcPort != "" || r.DstPort != "" { + if r.SrcPort != "" { + parts = append(parts, proto+" sport "+r.SrcPort) + } + if r.DstPort != "" { + parts = append(parts, proto+" dport "+r.DstPort) + } + } else { + parts = append(parts, "ip protocol "+proto) + } + case "icmp": + parts = append(parts, "ip protocol icmp") + } + + parts = append(parts, r.Action) + return strings.Join(parts, " ") +} diff --git a/handlers/api.go b/handlers/api.go index 3552f27..29edb14 100644 --- a/handlers/api.go +++ b/handlers/api.go @@ -49,7 +49,9 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) { } result := make([]iface, 0, len(names)) + existingNames := map[string]bool{} for _, name := range names { + existingNames[name] = true s, err := network.GetInterfaceStats(name) if err != nil { continue @@ -61,6 +63,25 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) { result = append(result, iface{s, hasPending}) } + // Also include pending VLAN configs not yet present in the system. + for name, cfg := range network.GetAllPending() { + if existingNames[name] || !network.IsVLAN(name) { + continue + } + s := &network.InterfaceStats{ + Name: name, + State: "unknown", + Mode: cfg.Mode, + IPv6: []string{}, + } + if cfg.Mode == "static" { + s.IPv4 = cfg.Address + s.IPv4Mask = cfg.Netmask + s.Gateway = cfg.Gateway + } + result = append(result, iface{s, true}) + } + ok(w, result) } @@ -100,6 +121,13 @@ func HandleInterfaceAction(w http.ResponseWriter, r *http.Request) { err = network.IfDown(name) case "restart": err = network.IfRestart(name) + case "delete": + if !network.IsVLAN(name) { + fail(w, http.StatusBadRequest, "delete is only supported for VLAN interfaces") + return + } + network.ClearPendingConfig(name) + err = network.DeleteVLAN(name) default: fail(w, http.StatusBadRequest, "unknown action: "+action) return diff --git a/handlers/clients.go b/handlers/clients.go index c4848dd..46f160f 100644 --- a/handlers/clients.go +++ b/handlers/clients.go @@ -9,7 +9,6 @@ import ( "alpine-router/clients" "alpine-router/config" "alpine-router/dhcp" - "alpine-router/nat" ) func HandleClients(w http.ResponseWriter, r *http.Request) { @@ -88,37 +87,6 @@ func updateClient(mac, hostname string, blocked bool, staticIP string) error { return config.Save(cfg) } -func applyBlockedFirewall() { - if !nat.IsInstalled() { - return - } - - cfg, err := config.Load() - if err != nil { - log.Printf("Warning: load config for blocked firewall: %v", err) - return - } - - var blockedIPs []string - for _, kd := range cfg.KnownDevices { - if kd.Blocked { - ip := kd.IP - if kd.StaticIP != "" { - ip = kd.StaticIP - } - if ip != "" { - blockedIPs = append(blockedIPs, ip) - } - } - } - - natCfg := &nat.Config{Interfaces: cfg.NAT.Interfaces} - if err := nat.ApplyRulesWithBlocked(natCfg, blockedIPs); err != nil { - log.Printf("Warning: apply blocked firewall rules: %v", err) - } else { - log.Printf("Applied firewall rules (%d blocked clients)", len(blockedIPs)) - } -} func applyDHCPStaticBindings() { if !dhcp.IsInstalled() { diff --git a/handlers/firewall.go b/handlers/firewall.go new file mode 100644 index 0000000..cd16953 --- /dev/null +++ b/handlers/firewall.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + + "alpine-router/config" + "alpine-router/nat" + "alpine-router/network" +) + +func HandleFirewall(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handleFirewallGet(w, r) + case http.MethodPost: + handleFirewallSave(w, r) + default: + fail(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func handleFirewallGet(w http.ResponseWriter, r *http.Request) { + cfg, err := config.Load() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + // Collect interface names for the UI dropdowns. + names, _ := network.GetInterfaces() + + ok(w, map[string]interface{}{ + "installed": nat.IsInstalled(), + "rules": cfg.Firewall.Rules, + "vlan_isolation": cfg.Firewall.VLANIsolation, + "interfaces": names, + }) +} + +func handleFirewallSave(w http.ResponseWriter, r *http.Request) { + var body struct { + Rules []config.FirewallRule `json:"rules"` + VLANIsolation bool `json:"vlan_isolation"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + + // Assign IDs to new rules that lack one. + for i := range body.Rules { + if body.Rules[i].ID == "" { + body.Rules[i].ID = fmt.Sprintf("%x", rand.Int63()) + } + } + + cfg, err := config.Load() + if err != nil { + fail(w, http.StatusInternalServerError, "load config: "+err.Error()) + return + } + + cfg.Firewall.Rules = body.Rules + cfg.Firewall.VLANIsolation = body.VLANIsolation + + if err := config.Save(cfg); err != nil { + fail(w, http.StatusInternalServerError, "save config: "+err.Error()) + return + } + + ok(w, map[string]string{"message": "saved"}) +} + +func HandleFirewallApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + if !nat.IsInstalled() { + fail(w, http.StatusServiceUnavailable, "nftables (nft) не установлен — выполните: apk add nftables") + return + } + + cfg, err := config.Load() + if err != nil { + fail(w, http.StatusInternalServerError, "load config: "+err.Error()) + return + } + + if err := applyAllRules(cfg); err != nil { + fail(w, http.StatusInternalServerError, "apply: "+err.Error()) + return + } + + ok(w, map[string]string{"message": "firewall applied"}) +} diff --git a/handlers/nat.go b/handlers/nat.go index cd49162..69914ed 100644 --- a/handlers/nat.go +++ b/handlers/nat.go @@ -62,10 +62,10 @@ func HandleNATSave(w http.ResponseWriter, r *http.Request) { return } - if err := nat.ApplyRules(&cfg); err != nil { + if err := applyAllRules(appCfg); err != nil { fail(w, http.StatusInternalServerError, "apply: "+err.Error()) return } ok(w, map[string]string{"message": "nat applied"}) -} \ No newline at end of file +} diff --git a/handlers/rules.go b/handlers/rules.go new file mode 100644 index 0000000..c33e8a2 --- /dev/null +++ b/handlers/rules.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "log" + + "alpine-router/config" + "alpine-router/firewall" + "alpine-router/nat" + "alpine-router/network" +) + +// applyAllRules rebuilds the complete nftables ruleset from the current config: +// NAT masquerade + user firewall rules + VLAN isolation + blocked clients. +func applyAllRules(cfg *config.AppConfig) error { + if !nat.IsInstalled() { + return nil + } + + // Collect blocked client IPs. + var blockedIPs []string + for _, kd := range cfg.KnownDevices { + if kd.Blocked { + ip := kd.IP + if kd.StaticIP != "" { + ip = kd.StaticIP + } + if ip != "" { + blockedIPs = append(blockedIPs, ip) + } + } + } + + // Build the LAN interface set for isolation: + // all NAT interfaces + all VLAN interfaces (active + pending). + // This ensures native interfaces (eth0) and their VLANs (eth0.100) are all + // mutually isolated when VLANIsolation is enabled. + seen := map[string]bool{} + var lanIfaces []string + addLAN := func(name string) { + if name != "" && !seen[name] { + lanIfaces = append(lanIfaces, name) + seen[name] = true + } + } + for _, name := range cfg.NAT.Interfaces { + addLAN(name) + } + names, _ := network.GetInterfaces() + for _, name := range names { + if network.IsVLAN(name) { + addLAN(name) + addLAN(network.VLANParent(name)) // include parent (native VLAN) too + } + } + for name := range network.GetAllPending() { + if network.IsVLAN(name) { + addLAN(name) + addLAN(network.VLANParent(name)) + } + } + + // Convert config.FirewallRule → firewall.Rule. + fwRules := make([]firewall.Rule, len(cfg.Firewall.Rules)) + for i, r := range cfg.Firewall.Rules { + fwRules[i] = firewall.Rule{ + ID: r.ID, + Enabled: r.Enabled, + Action: r.Action, + Protocol: r.Protocol, + SrcAddr: r.SrcAddr, + SrcPort: r.SrcPort, + DstAddr: r.DstAddr, + DstPort: r.DstPort, + InIface: r.InIface, + OutIface: r.OutIface, + Comment: r.Comment, + } + } + + return firewall.ApplyAll( + firewall.NATConfig{Interfaces: cfg.NAT.Interfaces}, + firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation}, + blockedIPs, + lanIfaces, + ) +} + +// applyBlockedFirewall is the async helper called after client updates. +func applyBlockedFirewall() { + cfg, err := config.Load() + if err != nil { + log.Printf("Warning: load config for firewall: %v", err) + return + } + if err := applyAllRules(cfg); err != nil { + log.Printf("Warning: apply firewall rules: %v", err) + } +} diff --git a/main.go b/main.go index 4111f85..aac88c7 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "alpine-router/config" "alpine-router/dhcp" + "alpine-router/firewall" "alpine-router/handlers" "alpine-router/mihomo" "alpine-router/nat" @@ -68,6 +69,9 @@ func main() { mux.HandleFunc("/api/config.yaml", handlers.HandleConfigYAML) + mux.HandleFunc("/api/firewall", handlers.HandleFirewall) + mux.HandleFunc("/api/firewall/apply", handlers.HandleFirewallApply) + mux.HandleFunc("/api/nat", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": @@ -227,13 +231,51 @@ func applyConfig(cfg *config.AppConfig) { } } - if err := nat.ApplyRulesWithBlocked(natCfg, blockedIPs); err != nil { - log.Printf("Warning: apply NAT: %v", err) + // Build LAN interface set: NAT interfaces + all VLAN interfaces + their parents. + seenLAN := map[string]bool{} + var lanIfaces []string + addLAN := func(name string) { + if name != "" && !seenLAN[name] { + lanIfaces = append(lanIfaces, name) + seenLAN[name] = true + } + } + for _, name := range cfg.NAT.Interfaces { + addLAN(name) + } + if names, err := network.GetInterfaces(); err == nil { + for _, name := range names { + if network.IsVLAN(name) { + addLAN(name) + addLAN(network.VLANParent(name)) + } + } + } + + // Convert config firewall rules. + fwRules := make([]firewall.Rule, len(cfg.Firewall.Rules)) + for i, r := range cfg.Firewall.Rules { + fwRules[i] = firewall.Rule{ + ID: r.ID, Enabled: r.Enabled, Action: r.Action, Protocol: r.Protocol, + SrcAddr: r.SrcAddr, SrcPort: r.SrcPort, DstAddr: r.DstAddr, DstPort: r.DstPort, + InIface: r.InIface, OutIface: r.OutIface, Comment: r.Comment, + } + } + + err := firewall.ApplyAll( + firewall.NATConfig{Interfaces: cfg.NAT.Interfaces}, + firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation}, + blockedIPs, + lanIfaces, + ) + if err != nil { + log.Printf("Warning: apply firewall/NAT rules: %v", err) } else { - log.Printf("NAT rules applied (%d interfaces, %d blocked clients)", len(cfg.NAT.Interfaces), len(blockedIPs)) + log.Printf("Firewall/NAT applied (%d NAT ifaces, %d fw rules, %d blocked, vlan_isolation=%v)", + len(cfg.NAT.Interfaces), len(fwRules), len(blockedIPs), cfg.Firewall.VLANIsolation) } } else { - log.Printf("nftables not installed — NAT unavailable (install with: apk add nftables)") + log.Printf("nftables not installed — NAT/firewall unavailable (install with: apk add nftables)") } if dhcp.IsInstalled() { diff --git a/nat/nat.go b/nat/nat.go index 20bc690..4f7ff72 100644 --- a/nat/nat.go +++ b/nat/nat.go @@ -6,19 +6,13 @@ import ( "os" "os/exec" "path/filepath" - "strings" ) -const tableName = "alpine-router-nat" - // Config holds NAT masquerade settings per interface. type Config struct { - // Interfaces is the list of LAN interface names for which masquerade is enabled. - // Traffic arriving on these interfaces will be NATted to the outgoing WAN interface. Interfaces []string `json:"interfaces"` } -// configPath returns the path to nat.json next to the running binary. func configPath() string { exe, err := os.Executable() if err != nil { @@ -34,7 +28,6 @@ func IsInstalled() bool { } // Load reads the NAT config from disk. -// Returns an empty config if the file does not exist yet. func Load() (*Config, error) { data, err := os.ReadFile(configPath()) if err != nil { @@ -53,7 +46,7 @@ func Load() (*Config, error) { return &cfg, nil } -// Save writes the NAT config to the configs/ directory next to the binary. +// Save writes the NAT config to the configs/ directory. func Save(cfg *Config) error { p := configPath() if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { @@ -65,59 +58,3 @@ func Save(cfg *Config) error { } return os.WriteFile(p, data, 0644) } - -// ApplyRules flushes the existing alpine-router NAT table and recreates it -// from the provided config. Called on every daemon startup and on config save. -// -// nftables is used instead of iptables because it applies all rules atomically -// in a single kernel call, which is faster and avoids partial-state issues. -func ApplyRules(cfg *Config) error { - return ApplyRulesWithBlocked(cfg, nil) -} - -// ApplyRulesWithBlocked is like ApplyRules but also installs drop rules for -// the given list of blocked client IPs. -func ApplyRulesWithBlocked(cfg *Config, blockedIPs []string) error { - if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil { - return fmt.Errorf("enable ip_forward: %w", err) - } - - exec.Command("nft", "delete", "table", "ip", tableName).Run() - - if len(cfg.Interfaces) == 0 && len(blockedIPs) == 0 { - return nil - } - - var sb strings.Builder - fmt.Fprintf(&sb, "table ip %s {\n", tableName) - - sb.WriteString(" chain forward {\n") - sb.WriteString(" type filter hook forward priority filter; policy drop;\n") - sb.WriteString(" ct state established,related accept\n") - - for _, ip := range blockedIPs { - fmt.Fprintf(&sb, " ip saddr %s drop\n", ip) - fmt.Fprintf(&sb, " ip daddr %s drop\n", ip) - } - - for _, iface := range cfg.Interfaces { - fmt.Fprintf(&sb, " iifname \"%s\" accept\n", iface) - } - sb.WriteString(" }\n") - - sb.WriteString(" chain postrouting {\n") - sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") - for _, iface := range cfg.Interfaces { - fmt.Fprintf(&sb, " iifname \"%s\" masquerade\n", iface) - } - sb.WriteString(" }\n") - sb.WriteString("}\n") - - cmd := exec.Command("nft", "-f", "-") - cmd.Stdin = strings.NewReader(sb.String()) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err) - } - return nil -} diff --git a/network/apply.go b/network/apply.go index 7845b1d..79a19d6 100644 --- a/network/apply.go +++ b/network/apply.go @@ -72,6 +72,13 @@ func ApplyPending() map[string]error { ClearPendingConfig(name) continue } + // For VLAN interfaces ensure the kernel interface exists before ifup. + if IsVLAN(name) { + if err := EnsureVLANExists(name); err != nil { + errs[name] = err + continue + } + } _ = IfDown(name) if cfg := configs[name]; cfg != nil && cfg.Auto { if err := IfUp(name); err != nil { diff --git a/network/config.go b/network/config.go index 59fabe6..b574e28 100644 --- a/network/config.go +++ b/network/config.go @@ -13,6 +13,7 @@ const ConfigFile = "/etc/network/interfaces" // InterfaceConfig represents one stanza in /etc/network/interfaces. type InterfaceConfig struct { Name string `json:"name"` + Label string `json:"label,omitempty"` // display name, stored in config.yaml only Auto bool `json:"auto"` Mode string `json:"mode"` // dhcp, static, loopback, manual Address string `json:"address,omitempty"` // static only @@ -154,12 +155,18 @@ func WriteConfig(configs map[string]*InterfaceConfig) error { } defer f.Close() - // loopback first + // loopback first, then physical interfaces, then VLANs (sorted within each group) if lo, ok := configs["lo"]; ok { writeStanza(f, lo) } for name, cfg := range configs { - if name == "lo" { + if name == "lo" || IsVLAN(name) { + continue + } + writeStanza(f, cfg) + } + for name, cfg := range configs { + if !IsVLAN(name) { continue } writeStanza(f, cfg) @@ -188,6 +195,12 @@ func writeStanza(f *os.File, c *InterfaceConfig) { fmt.Fprintf(f, "\tdns-nameservers %s\n", strings.Join(c.DNS, " ")) } } + // VLAN interfaces need vlan-raw-device unless already in Extra + if IsVLAN(c.Name) { + if _, ok := c.Extra["vlan-raw-device"]; !ok { + fmt.Fprintf(f, "\tvlan-raw-device %s\n", VLANParent(c.Name)) + } + } for k, v := range c.Extra { fmt.Fprintf(f, "\t%s %s\n", k, v) } diff --git a/network/vlan.go b/network/vlan.go new file mode 100644 index 0000000..c5f7cf6 --- /dev/null +++ b/network/vlan.go @@ -0,0 +1,80 @@ +package network + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// IsVLAN reports whether name is a VLAN interface (e.g. "eth0.100"). +func IsVLAN(name string) bool { + idx := strings.LastIndex(name, ".") + if idx <= 0 { + return false + } + suffix := name[idx+1:] + if suffix == "" { + return false + } + _, err := strconv.Atoi(suffix) + return err == nil +} + +// VLANParent returns the parent interface (e.g. "eth0.100" → "eth0"). +func VLANParent(name string) string { + idx := strings.LastIndex(name, ".") + if idx < 0 { + return "" + } + return name[:idx] +} + +// VLANId returns the VLAN ID (e.g. "eth0.100" → 100). +func VLANId(name string) int { + idx := strings.LastIndex(name, ".") + if idx < 0 { + return 0 + } + id, _ := strconv.Atoi(name[idx+1:]) + return id +} + +// EnsureVLANExists creates the VLAN interface in the kernel if it doesn't exist. +func EnsureVLANExists(name string) error { + if _, err := os.Stat("/sys/class/net/" + name); err == nil { + return nil + } + parent := VLANParent(name) + id := VLANId(name) + if parent == "" || id == 0 { + return fmt.Errorf("invalid VLAN name: %s", name) + } + out, err := exec.Command("ip", "link", "add", "link", parent, + "name", name, "type", "vlan", "id", strconv.Itoa(id)).CombinedOutput() + if err != nil { + return fmt.Errorf("ip link add %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + +// DeleteVLAN brings down and removes a VLAN interface and its /etc/network/interfaces stanza. +func DeleteVLAN(name string) error { + _ = IfDown(name) + if _, err := os.Stat("/sys/class/net/" + name); err == nil { + out, err2 := exec.Command("ip", "link", "delete", name).CombinedOutput() + if err2 != nil { + return fmt.Errorf("ip link delete %s: %s", name, strings.TrimSpace(string(out))) + } + } + configs, err := ParseConfig() + if err != nil { + return err + } + if _, ok := configs[name]; ok { + delete(configs, name) + return WriteConfig(configs) + } + return nil +} diff --git a/public/app.js b/public/app.js index 652e536..e2de8d3 100644 --- a/public/app.js +++ b/public/app.js @@ -5,7 +5,8 @@ const state = { interfaces: [], // latest data from /api/interfaces pending: [], // interface names with pending config - configModal: null, // name of interface being configured + configModal: null, // name of interface being configured (null = new VLAN) + configModalParent: null, // parent interface when creating a new VLAN nat: null, // {installed, interfaces} from /api/nat }; @@ -29,6 +30,19 @@ const get = (path) => api('GET', path); const post = (path, body) => api('POST', path, body); const del = (path) => api('DELETE', path); +// ── VLAN helpers ───────────────────────────────────────────────────────────── + +function isVLAN(name) { + return /\.\d+$/.test(name); +} +function vlanParent(name) { + return name.replace(/\.\d+$/, ''); +} +function vlanId(name) { + const m = name.match(/\.(\d+)$/); + return m ? parseInt(m[1]) : 0; +} + // ── Format helpers ─────────────────────────────────────────────────────────── function fmtBytes(n) { @@ -56,9 +70,24 @@ function renderAll() { const grid = document.getElementById('ifaceGrid'); grid.innerHTML = ''; - state.interfaces.forEach(iface => { - grid.appendChild(buildCard(iface)); - }); + // Group VLANs by parent + const vlansByParent = {}; + const physicals = []; + + for (const iface of state.interfaces) { + if (isVLAN(iface.name)) { + const p = vlanParent(iface.name); + if (!vlansByParent[p]) vlansByParent[p] = []; + vlansByParent[p].push(iface); + } else { + physicals.push(iface); + } + } + + for (const iface of physicals) { + const vlans = vlansByParent[iface.name] || []; + grid.appendChild(buildCard(iface, vlans)); + } document.getElementById('loading').classList.add('hidden'); grid.classList.remove('hidden'); @@ -66,9 +95,10 @@ function renderAll() { renderPendingBanner(); } -function buildCard(iface) { +function buildCard(iface, vlans) { const hasPending = state.pending.includes(iface.name); const sc = stateClass(iface.state); + const isLo = iface.name === 'lo' || iface.mode === 'loopback'; const card = document.createElement('div'); card.className = 'iface-card' + (hasPending ? ' has-pending' : ''); @@ -129,11 +159,56 @@ function buildCard(iface) { + + ${!isLo ? buildVLANSection(iface.name, vlans) : ''} `; return card; } +function buildVLANSection(parentName, vlans) { + const rows = vlans.map(v => { + const sc = stateClass(v.state); + const hasPending = state.pending.includes(v.name); + const ip = v.ipv4 + ? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '') + : ''; + return ` +
+
+ + ${v.name} + VLAN ${vlanId(v.name)} + ${modeLabel(v.mode)} + ${hasPending ? 'несохранено' : ''} +
+
${ip}
+
+ + + + +
+
`; + }).join(''); + + const empty = vlans.length === 0 + ? `
Нет тегированных VLAN
` + : ''; + + return ` +
+
+ Теговые VLAN + +
+
+ ${rows} + ${empty} +
+
`; +} + function renderPendingBanner() { const banner = document.getElementById('pendingBanner'); const list = document.getElementById('pendingList'); @@ -164,6 +239,18 @@ async function loadAll() { // ── Interface actions ───────────────────────────────────────────────────────── async function doAction(name, action) { + if (action === 'delete') { + if (!confirm(`Удалить VLAN ${name}?`)) return; + try { + await post(`/api/interfaces/${name}/delete`); + showToast(`${name}: удалён`, 'success'); + await loadAll(); + } catch (e) { + showToast(`${name} delete: ${e.message}`, 'error'); + } + return; + } + const btn = document.querySelector(`[data-action="${action}"][data-iface="${name}"]`); if (btn) { btn.disabled = true; btn.textContent = '...'; } @@ -182,8 +269,21 @@ async function doAction(name, action) { async function openConfig(name) { state.configModal = name; + state.configModalParent = null; document.getElementById('modalTitle').textContent = `Настройка: ${name}`; + // Show/hide VLAN ID field + const vlanSection = document.getElementById('vlanIdSection'); + const vlanInput = document.getElementById('cfgVLANId'); + if (isVLAN(name)) { + vlanSection.classList.remove('hidden'); + vlanInput.value = vlanId(name); + vlanInput.readOnly = true; + } else { + vlanSection.classList.add('hidden'); + vlanInput.readOnly = false; + } + try { const [{ config, pending }, natData] = await Promise.all([ get(`/api/config/${name}`), @@ -197,6 +297,26 @@ async function openConfig(name) { } } +async function openNewVLAN(parentName) { + state.configModal = null; + state.configModalParent = parentName; + document.getElementById('modalTitle').textContent = `Новый VLAN на ${parentName}`; + + const vlanSection = document.getElementById('vlanIdSection'); + const vlanInput = document.getElementById('cfgVLANId'); + vlanSection.classList.remove('hidden'); + vlanInput.readOnly = false; + vlanInput.value = ''; + + try { + const natData = await get('/api/nat').catch(() => null); + if (natData) state.nat = natData; + } catch (_) {} + + fillForm({ auto: true, mode: 'static' }, false, ''); + document.getElementById('modal').classList.remove('hidden'); +} + function fillForm(cfg, pending, name) { document.getElementById('cfgAuto').checked = !!cfg.auto; document.getElementById('cfgAddress').value = cfg.address || ''; @@ -208,9 +328,8 @@ function fillForm(cfg, pending, name) { setMode(mode); // Mark pending visually - const title = document.getElementById('modalTitle'); - if (pending) { - title.textContent = `Настройка: ${state.configModal} (несохранённые изменения)`; + if (pending && name) { + document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`; } // NAT section — show for all non-loopback interfaces @@ -244,11 +363,29 @@ function closeModal() { document.getElementById('modal').classList.add('hidden'); document.getElementById('configForm').reset(); state.configModal = null; + state.configModalParent = null; } async function saveConfig() { - const name = state.configModal; - if (!name) return; + let name = state.configModal; + + if (!name) { + // New VLAN — build name from parent + VLAN ID + const parent = state.configModalParent; + const id = parseInt(document.getElementById('cfgVLANId').value); + if (!parent) return; + if (!id || id < 1 || id > 4094) { + showToast('Укажите корректный VLAN ID (1–4094)', 'error'); + return; + } + name = `${parent}.${id}`; + + // Check for duplicate + if (state.interfaces.find(i => i.name === name)) { + showToast(`VLAN ${name} уже существует`, 'error'); + return; + } + } const mode = currentMode(); const cfg = { @@ -262,7 +399,6 @@ async function saveConfig() { extra: {}, }; - // Basic validation for static if (mode === 'static' && !cfg.address) { showToast('Укажите IP-адрес', 'error'); return; @@ -346,6 +482,8 @@ document.getElementById('ifaceGrid').addEventListener('click', e => { const { action, iface } = btn.dataset; if (action === 'config') { openConfig(iface); + } else if (action === 'addvlan') { + openNewVLAN(iface); } else { doAction(iface, action); } @@ -378,11 +516,5 @@ setInterval(loadAll, 10000); // ── Init ────────────────────────────────────────────────────────────────────── (async () => { - // Try to get hostname - try { - const res = await fetch('/api/interfaces'); - // hostname from Location header or just skip - } catch (_) {} - await loadAll(); })(); diff --git a/public/clients.html b/public/clients.html index 962b19b..fb677ab 100644 --- a/public/clients.html +++ b/public/clients.html @@ -50,9 +50,17 @@ Клиенты - + + + + Файрвол + + + + + Прокси diff --git a/public/dhcp.html b/public/dhcp.html index 1233874..bc42435 100644 --- a/public/dhcp.html +++ b/public/dhcp.html @@ -50,9 +50,17 @@ Клиенты - + + + + Файрвол + + + + + Прокси diff --git a/public/firewall.html b/public/firewall.html new file mode 100644 index 0000000..dfd24d8 --- /dev/null +++ b/public/firewall.html @@ -0,0 +1,234 @@ + + + + + + Файрвол — AlpineRouter + + + + +
+
+ +

AlpineRouter

+
+
+ +
+
+ + + +
+ + + + +
+
+ + — теговые VLAN не видят друг друга; NAT — только выход в интернет +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + +
#ВклДействиеПротоколВх. интерфейсВых. интерфейсИсточникНазначениеКомментарий
+ Правил нет. Нажмите «Добавить правило» для создания. +
+
+ +
+ + + + Политика по умолчанию: DROP — весь трафик запрещён, если не разрешён явно выше или через NAT. + Трафик established/related всегда разрешается. +
+
+ +
+ + + + + + + + + + + diff --git a/public/firewall.js b/public/firewall.js new file mode 100644 index 0000000..fcf3876 --- /dev/null +++ b/public/firewall.js @@ -0,0 +1,330 @@ +'use strict'; + +// ── State ──────────────────────────────────────────────────────────────────── + +const state = { + rules: [], // current rule list (order matters) + interfaces: [], // available interface names for autocomplete + editIdx: -1, // index in state.rules being edited (-1 = new) +}; + +// ── API helpers ────────────────────────────────────────────────────────────── + +async function api(method, path, body) { + const res = await fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + const json = await res.json(); + if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); + return json.data; +} +const get = p => api('GET', p); +const post = (p, b) => api('POST', p, b); + +// ── Load ───────────────────────────────────────────────────────────────────── + +async function loadAll() { + try { + const data = await get('/api/firewall'); + state.rules = data.rules || []; + state.interfaces = data.interfaces || []; + document.getElementById('vlanIsolation').checked = !!data.vlan_isolation; + + const notInstalled = document.getElementById('notInstalledBanner'); + if (!data.installed) { + notInstalled.classList.remove('hidden'); + } else { + notInstalled.classList.add('hidden'); + } + + // Populate datalist for interface autocomplete. + const dl = document.getElementById('ifaceList'); + dl.innerHTML = state.interfaces.map(n => `