14.04.2026 Update

This commit is contained in:
2026-04-15 11:38:26 +03:00
parent 6aa0349f5d
commit f50d79fab3
45 changed files with 5645 additions and 751 deletions

View File

@@ -5,8 +5,8 @@ import (
"net/http"
"strings"
"alpine-router/config"
"alpine-router/network"
"nano-router/config"
"nano-router/network"
)
type apiResp struct {
@@ -47,10 +47,16 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
*network.InterfaceStats
Pending bool `json:"pending"`
Label string `json:"label,omitempty"`
NAT bool `json:"nat"`
}
appCfg, _ := config.Load()
var natIfaces []string
if appCfg != nil {
natIfaces = appCfg.NAT.Interfaces
}
result := make([]iface, 0, len(names))
existingNames := map[string]bool{}
for _, name := range names {
@@ -64,15 +70,30 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
}
hasPending := network.GetPendingConfig(name) != nil
label := ""
ifaceType := ""
if appCfg != nil && appCfg.Interfaces != nil {
if ic, ok := appCfg.Interfaces[name]; ok {
label = ic.Label
ifaceType = ic.Type
}
}
result = append(result, iface{s, hasPending, label})
s.Type = ifaceType
if s.Type == "" && s.Gateway != "" {
s.Type = "wan"
}
if s.Type == "" && s.Mode != "loopback" {
s.Type = "lan"
}
isNAT := false
for _, ni := range natIfaces {
if ni == name {
isNAT = true
break
}
}
result = append(result, iface{s, hasPending, label, isNAT})
}
// Also include pending VLAN configs not yet present in the system.
for name, cfg := range network.GetAllPending() {
if existingNames[name] || !network.IsVLAN(name) {
continue
@@ -81,6 +102,7 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
Name: name,
State: "unknown",
Mode: cfg.Mode,
Type: cfg.Type,
IPv6: []string{},
}
if cfg.Mode == "static" {
@@ -88,13 +110,26 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
s.IPv4Mask = cfg.Netmask
s.Gateway = cfg.Gateway
}
if s.Type == "" && s.Gateway != "" {
s.Type = "wan"
}
if s.Type == "" {
s.Type = "lan"
}
label := cfg.Label
if label == "" && appCfg != nil && appCfg.Interfaces != nil {
if ic, ok := appCfg.Interfaces[name]; ok {
label = ic.Label
}
}
result = append(result, iface{s, true, label})
isNAT := false
for _, ni := range natIfaces {
if ni == name {
isNAT = true
break
}
}
result = append(result, iface{s, true, label, isNAT})
}
ok(w, result)
@@ -131,9 +166,9 @@ func HandleInterfaceAction(w http.ResponseWriter, r *http.Request) {
var err error
switch action {
case "up":
err = network.IfUp(name)
err = network.LinkUp(name)
case "down":
err = network.IfDown(name)
err = network.LinkDown(name)
case "restart":
err = network.IfRestart(name)
case "delete":
@@ -166,16 +201,21 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
appCfg, _ := config.Load()
label := ""
ifaceType := ""
if appCfg != nil && appCfg.Interfaces != nil {
if ic, ok2 := appCfg.Interfaces[name]; ok2 {
label = ic.Label
ifaceType = ic.Type
}
}
if cfg := network.GetPendingConfig(name); cfg != nil {
if cfg.Label != "" {
label = cfg.Label
}
ok(w, map[string]interface{}{"config": cfg, "pending": true, "label": label})
if cfg.Type != "" {
ifaceType = cfg.Type
}
ok(w, map[string]interface{}{"config": cfg, "pending": true, "label": label, "type": ifaceType})
return
}
fileCfg, err := network.ParseConfig()
@@ -184,12 +224,17 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
return
}
if cfg, exists := fileCfg[name]; exists {
ok(w, map[string]interface{}{"config": cfg, "pending": false, "label": label})
if ifaceType == "" {
ifaceType = cfg.Type
}
ok(w, map[string]interface{}{"config": cfg, "pending": false, "label": label, "type": ifaceType})
} else {
defaultType := "lan"
ok(w, map[string]interface{}{
"config": &network.InterfaceConfig{Name: name, Auto: true, Mode: "dhcp", Extra: map[string]string{}},
"config": &network.InterfaceConfig{Name: name, Auto: true, Mode: "dhcp", Type: defaultType, Extra: map[string]string{}},
"pending": false,
"label": label,
"type": defaultType,
})
}
@@ -200,6 +245,49 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
return
}
cfg.Name = name
if cfg.Type != "wan" && cfg.Type != "lan" {
fail(w, http.StatusBadRequest, "type must be 'wan' or 'lan'")
return
}
if network.IsVLAN(name) && cfg.Type != "lan" {
fail(w, http.StatusBadRequest, "VLAN interface must be type 'lan'")
return
}
if cfg.Type == "lan" {
if cfg.Mode == "dhcp" {
fail(w, http.StatusBadRequest, "LAN interface cannot use DHCP mode")
return
}
if cfg.Gateway != "" {
fail(w, http.StatusBadRequest, "LAN interface cannot have a gateway")
return
}
if len(cfg.DNS) > 0 {
fail(w, http.StatusBadRequest, "LAN interface cannot have DNS servers")
return
}
}
if cfg.Type == "wan" && cfg.Mode == "static" && cfg.Address == "" {
fail(w, http.StatusBadRequest, "WAN interface in static mode requires an IP address")
return
}
if network.IsVLAN(name) {
parent := network.VLANParent(name)
appCfgCheck, _ := config.Load()
if appCfgCheck != nil && appCfgCheck.Interfaces != nil {
if pic, ok := appCfgCheck.Interfaces[parent]; ok && pic.Type == "wan" {
fail(w, http.StatusBadRequest, "VLAN cannot be created on a WAN interface ("+parent+")")
return
}
}
}
if msg, overlaps := checkInterfaceOverlap(&cfg); overlaps {
fail(w, http.StatusConflict, msg)
return
}
if cfg.Extra == nil {
cfg.Extra = map[string]string{}
}
@@ -215,6 +303,7 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
}
appCfg.Interfaces[name] = &config.InterfaceConfig{
Label: cfg.Label,
Type: cfg.Type,
Auto: cfg.Auto,
Mode: cfg.Mode,
Address: cfg.Address,

211
handlers/auth.go Normal file
View File

@@ -0,0 +1,211 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"nano-router/auth"
"nano-router/config"
)
func HandleAuthChallenge(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
nonce := auth.Global.CreateChallenge()
ok(w, map[string]string{"nonce": nonce})
}
func HandleAuthLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Nonce string `json:"nonce"`
Response string `json:"response"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "invalid request")
return
}
clientIP := auth.GetClientIP(r)
sessionID, loginOk := auth.Global.Login(req.Nonce, req.Response, clientIP)
if !loginOk {
fail(w, http.StatusUnauthorized, "invalid credentials or expired challenge")
return
}
auth.SetSessionCookie(w, sessionID)
username, _, isDefault := auth.Global.GetCredentials()
ok(w, map[string]interface{}{
"message": "authenticated",
"default_password": isDefault,
"username": username,
})
}
func HandleAuthLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
token := auth.GetSessionToken(r)
if token != "" {
auth.Global.DestroySession(token)
}
auth.ClearSessionCookie(w)
ok(w, map[string]string{"message": "logged out"})
}
func HandleAuthStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
username, apiKey, isDefault := auth.Global.GetCredentials()
ok(w, map[string]interface{}{
"authenticated": true,
"username": username,
"default_password": isDefault,
"has_api_key": apiKey != "",
})
}
func HandleAuthProfile(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
username, apiKey, isDefault := auth.Global.GetCredentials()
ok(w, map[string]interface{}{
"username": username,
"default_password": isDefault,
"has_api_key": apiKey != "",
"api_key_prefix": apiKeyPrefix(apiKey),
})
case http.MethodPost:
var req struct {
Username string `json:"username"`
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
NewPassword2 string `json:"new_password2"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "invalid request")
return
}
if req.Username == "" && req.NewPassword == "" {
fail(w, http.StatusBadRequest, "nothing to update")
return
}
usernameChanged := false
passwordChanged := false
if req.NewPassword != "" {
if req.OldPassword == "" {
fail(w, http.StatusBadRequest, "current password required")
return
}
oldHash := auth.HashPassword(req.OldPassword)
if !auth.Global.VerifyPassword(oldHash) {
fail(w, http.StatusUnauthorized, "current password incorrect")
return
}
if req.NewPassword != req.NewPassword2 {
fail(w, http.StatusBadRequest, "passwords do not match")
return
}
if len(req.NewPassword) < 4 {
fail(w, http.StatusBadRequest, "password too short (min 4 chars)")
return
}
newHash := auth.HashPassword(req.NewPassword)
clientIP := auth.GetClientIP(r)
newSessionID := auth.Global.UpdatePassword(newHash, clientIP)
auth.SetSessionCookie(w, newSessionID)
passwordChanged = true
cfg, _ := config.Load()
cfg.Auth.PasswordHash = newHash
if req.Username != "" {
cfg.Auth.Username = req.Username
auth.Global.UpdateUsername(req.Username)
usernameChanged = true
}
config.Save(cfg)
} else if req.Username != "" {
auth.Global.UpdateUsername(req.Username)
usernameChanged = true
cfg, _ := config.Load()
cfg.Auth.Username = req.Username
config.Save(cfg)
}
username, apiKey, isDefault := auth.Global.GetCredentials()
result := map[string]interface{}{
"username": username,
"default_password": isDefault,
"has_api_key": apiKey != "",
"api_key_prefix": apiKeyPrefix(apiKey),
}
if passwordChanged {
result["password_changed"] = true
}
if usernameChanged {
result["username_changed"] = true
}
ok(w, result)
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func HandleAuthAPIKey(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
key := generateAPIKey()
auth.Global.SetAPIKey(key)
cfg, _ := config.Load()
cfg.Auth.APIKey = key
config.Save(cfg)
ok(w, map[string]interface{}{
"api_key": key,
})
case http.MethodDelete:
auth.Global.SetAPIKey("")
cfg, _ := config.Load()
cfg.Auth.APIKey = ""
config.Save(cfg)
ok(w, map[string]string{"message": "api key revoked"})
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func generateAPIKey() string {
b := make([]byte, 32)
rand.Read(b)
return "ar_" + hex.EncodeToString(b)
}
func apiKeyPrefix(key string) string {
if len(key) > 8 {
return key[:8] + "..."
}
return ""
}

View File

@@ -6,9 +6,9 @@ import (
"net/http"
"strings"
"alpine-router/clients"
"alpine-router/config"
"alpine-router/dhcp"
"nano-router/clients"
"nano-router/config"
"nano-router/dhcp"
)
func HandleClients(w http.ResponseWriter, r *http.Request) {
@@ -42,13 +42,14 @@ func HandleClientUpdate(w http.ResponseWriter, r *http.Request) {
Hostname string `json:"hostname"`
Blocked bool `json:"blocked"`
StaticIP string `json:"static_ip"`
Policy string `json:"policy"` // "disabled" | "direct" | "vpn" | ""
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if err := updateClient(mac, req.Hostname, req.Blocked, req.StaticIP); err != nil {
if err := updateClient(mac, req.Hostname, req.Blocked, req.StaticIP, req.Policy); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
@@ -58,7 +59,88 @@ func HandleClientUpdate(w http.ResponseWriter, r *http.Request) {
ok(w, map[string]string{"message": "updated"})
}
func updateClient(mac, hostname string, blocked bool, staticIP string) error {
// HandleClientPolicyDefault handles GET/POST for the default client routing policy.
func HandleClientPolicyDefault(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]string{"default": cfg.ClientPolicy.Default})
case http.MethodPost:
var req struct {
Default string `json:"default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if req.Default != "disabled" && req.Default != "direct" && req.Default != "vpn" {
fail(w, http.StatusBadRequest, "invalid policy: must be disabled, direct, or vpn")
return
}
cfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
cfg.ClientPolicy.Default = req.Default
if err := config.Save(cfg); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
go applyBlockedFirewall()
ok(w, map[string]string{"default": req.Default})
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// HandleClientPolicyApplyAll sets the given policy on every known device.
func HandleClientPolicyApplyAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Policy string `json:"policy"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if req.Policy != "disabled" && req.Policy != "direct" && req.Policy != "vpn" {
fail(w, http.StatusBadRequest, "invalid policy: must be disabled, direct, or vpn")
return
}
cfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
for i := range cfg.KnownDevices {
cfg.KnownDevices[i].Policy = req.Policy
// Keep Blocked flag consistent: disabled policy means blocked.
cfg.KnownDevices[i].Blocked = req.Policy == "disabled"
}
if err := config.Save(cfg); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
go applyBlockedFirewall()
ok(w, map[string]int{"updated": len(cfg.KnownDevices)})
}
func updateClient(mac, hostname string, blocked bool, staticIP, policy string) error {
cfg, err := config.Load()
if err != nil {
return err
@@ -67,9 +149,11 @@ func updateClient(mac, hostname string, blocked bool, staticIP string) error {
found := false
for i := range cfg.KnownDevices {
if cfg.KnownDevices[i].MAC == mac {
cfg.KnownDevices[i].Blocked = blocked
cfg.KnownDevices[i].Hostname = hostname
cfg.KnownDevices[i].StaticIP = staticIP
cfg.KnownDevices[i].Policy = policy
// Derive Blocked from policy for backward compatibility.
cfg.KnownDevices[i].Blocked = policy == "disabled"
found = true
break
}
@@ -79,15 +163,15 @@ func updateClient(mac, hostname string, blocked bool, staticIP string) error {
cfg.KnownDevices = append(cfg.KnownDevices, config.KnownDevice{
MAC: mac,
Hostname: hostname,
Blocked: blocked,
StaticIP: staticIP,
Policy: policy,
Blocked: policy == "disabled",
})
}
return config.Save(cfg)
}
func applyDHCPStaticBindings() {
if !dhcp.IsInstalled() {
return

16
handlers/dashboard.go Normal file
View File

@@ -0,0 +1,16 @@
package handlers
import (
"net/http"
"nano-router/monitor"
)
func HandleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
data := monitor.GetData()
ok(w, data)
}

View File

@@ -2,11 +2,12 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"alpine-router/config"
"alpine-router/dhcp"
"alpine-router/network"
"nano-router/config"
"nano-router/dhcp"
"nano-router/network"
)
func HandleDHCPStatus(w http.ResponseWriter, r *http.Request) {
@@ -90,6 +91,14 @@ func HandleDHCPConfigSave(w http.ResponseWriter, r *http.Request) {
if cfg.Pools == nil {
cfg.Pools = []dhcp.Pool{}
}
for _, pool := range cfg.Pools {
if pool.Subnet != "" && pool.Netmask != "" {
if msg, overlaps := checkDHCPPoolOverlap(pool.Subnet, pool.Netmask, pool.Interface); overlaps {
fail(w, http.StatusConflict, fmt.Sprintf("Пул интерфейса %s: %s", pool.Interface, msg))
return
}
}
}
if err := dhcp.Save(&cfg); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return

View File

@@ -6,9 +6,9 @@ import (
"math/rand"
"net/http"
"alpine-router/config"
"alpine-router/nat"
"alpine-router/network"
"nano-router/config"
"nano-router/nat"
"nano-router/network"
)
func HandleFirewall(w http.ResponseWriter, r *http.Request) {

View File

@@ -4,12 +4,14 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"alpine-router/mihomo"
"nano-router/config"
"nano-router/mihomo"
)
func HandleMihomoStatus(w http.ResponseWriter, r *http.Request) {
@@ -29,6 +31,7 @@ func HandleMihomoStart(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, err.Error())
return
}
saveMihomoEnabled(true)
ok(w, map[string]string{"message": "mihomo started"})
}
@@ -41,6 +44,7 @@ func HandleMihomoStop(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, err.Error())
return
}
saveMihomoEnabled(false)
ok(w, map[string]string{"message": "mihomo stopped"})
}
@@ -53,9 +57,24 @@ func HandleMihomoRestart(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// Restart keeps enabled=true (already set when it was first started).
ok(w, map[string]string{"message": "mihomo restarted"})
}
// saveMihomoEnabled persists mihomo.enabled to config.yaml so the binary
// auto-starts Mihomo on the next launch when enabled=true.
func saveMihomoEnabled(enabled bool) {
cfg, err := config.Load()
if err != nil {
log.Printf("Warning: load config to save mihomo enabled: %v", err)
return
}
cfg.Mihomo.Enabled = enabled
if err := config.Save(cfg); err != nil {
log.Printf("Warning: save mihomo enabled state: %v", err)
}
}
func HandleMihomoConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:

View File

@@ -8,7 +8,7 @@ import (
"net/url"
"strings"
"alpine-router/mihomo"
"nano-router/mihomo"
)
func getMihomoAPIBase() string {
@@ -150,7 +150,7 @@ func HandleMihomoWSProxy(w http.ResponseWriter, r *http.Request) {
upgradeReq += "Host: " + host + ":" + port + "\r\n"
upgradeReq += "Upgrade: websocket\r\n"
upgradeReq += "Connection: Upgrade\r\n"
upgradeReq += "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
upgradeReq += "Sec-WebSocket-Key: " + r.Header.Get("Sec-Websocket-Key") + "\r\n"
upgradeReq += "Sec-WebSocket-Version: 13\r\n"
if secret != "" {
upgradeReq += "Authorization: Bearer " + secret + "\r\n"

View File

@@ -4,8 +4,8 @@ import (
"encoding/json"
"net/http"
"alpine-router/config"
"alpine-router/nat"
"nano-router/config"
"nano-router/nat"
)
func HandleNATGet(w http.ResponseWriter, r *http.Request) {
@@ -46,16 +46,26 @@ func HandleNATSave(w http.ResponseWriter, r *http.Request) {
return
}
if err := nat.Save(&cfg); err != nil {
fail(w, http.StatusInternalServerError, "save: "+err.Error())
return
}
appCfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, "load config.yaml: "+err.Error())
return
}
for _, ifaceName := range cfg.Interfaces {
if appCfg.Interfaces != nil {
if ic, ok := appCfg.Interfaces[ifaceName]; ok && ic.Type == "wan" {
fail(w, http.StatusBadRequest, "WAN interface "+ifaceName+" cannot have NAT/Masquerade")
return
}
}
}
if err := nat.Save(&cfg); err != nil {
fail(w, http.StatusInternalServerError, "save: "+err.Error())
return
}
appCfg.NAT.Interfaces = cfg.Interfaces
if err := config.Save(appCfg); err != nil {
fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error())

172
handlers/overlap.go Normal file
View File

@@ -0,0 +1,172 @@
package handlers
import (
"nano-router/config"
"nano-router/dhcp"
"nano-router/network"
"net/http"
"strings"
)
func collectAllSubnets(excludeIface string) []network.SubnetOverlap {
var result []network.SubnetOverlap
appCfg, _ := config.Load()
if appCfg != nil {
for name, ic := range appCfg.Interfaces {
if name == excludeIface {
continue
}
addr, mask := resolveIfaceIP(name, ic.Mode, ic.Address, ic.Netmask)
if addr != "" && mask != "" {
result = append(result, network.SubnetOverlap{
Interface: name,
Label: ic.Label,
Subnet: addr + "/" + mask,
})
}
}
}
for name, pcfg := range network.GetAllPending() {
if name == excludeIface {
continue
}
addr, mask := resolveIfaceIP(name, pcfg.Mode, pcfg.Address, pcfg.Netmask)
if addr != "" && mask != "" {
result = append(result, network.SubnetOverlap{
Interface: name,
Label: pcfg.Label,
Subnet: addr + "/" + mask,
})
}
}
names, _ := network.GetInterfaces()
for _, name := range names {
if name == excludeIface {
continue
}
s, err := network.GetInterfaceStats(name)
if err != nil || s.IPv4 == "" || s.IPv4Mask == "" {
continue
}
var mode string
if appCfg != nil && appCfg.Interfaces != nil {
if ic, ok := appCfg.Interfaces[name]; ok {
mode = ic.Mode
}
}
if mode == "dhcp" {
result = append(result, network.SubnetOverlap{
Interface: name,
Subnet: s.IPv4 + "/" + s.IPv4Mask,
})
}
}
if appCfg != nil {
for _, pool := range appCfg.DHCP.Pools {
if pool.Interface == excludeIface {
continue
}
if pool.Subnet != "" && pool.Netmask != "" {
result = append(result, network.SubnetOverlap{
Interface: pool.Interface,
Subnet: pool.Subnet + "/" + pool.Netmask,
})
}
}
}
return result
}
func resolveIfaceIP(name, mode, addr, mask string) (string, string) {
if mode == "static" && addr != "" && mask != "" {
return addr, mask
}
if mode == "dhcp" {
s, err := network.GetInterfaceStats(name)
if err != nil {
return "", ""
}
if s.IPv4 != "" && s.IPv4Mask != "" {
return s.IPv4, s.IPv4Mask
}
}
return "", ""
}
func collectAllSubnetsForDHCP(excludeIface string) []network.SubnetOverlap {
result := collectAllSubnets(excludeIface)
cfg, _ := dhcp.Load()
if cfg != nil {
for _, pool := range cfg.Pools {
if pool.Interface == excludeIface {
continue
}
if pool.Subnet != "" && pool.Netmask != "" {
result = append(result, network.SubnetOverlap{
Interface: pool.Interface,
Subnet: pool.Subnet + "/" + pool.Netmask,
})
}
}
}
return result
}
func checkInterfaceOverlap(cfg *network.InterfaceConfig) (string, bool) {
if cfg.Mode != "static" || cfg.Address == "" || cfg.Netmask == "" {
return "", false
}
all := collectAllSubnets(cfg.Name)
overlaps := network.CheckOverlap(cfg.Address, cfg.Netmask, cfg.Name, all)
if len(overlaps) > 0 {
var names []string
for _, o := range overlaps {
label := o.Interface
if o.Label != "" {
label = o.Label + " (" + o.Interface + ")"
}
names = append(names, label)
}
return "IP-адрес пересекается с подсетью интерфейса " + strings.Join(names, ", "), true
}
return "", false
}
func checkDHCPPoolOverlap(subnet, mask, iface string) (string, bool) {
if subnet == "" || mask == "" {
return "", false
}
all := collectAllSubnetsForDHCP(iface)
overlaps := network.CheckOverlap(subnet, mask, iface, all)
if len(overlaps) > 0 {
var names []string
for _, o := range overlaps {
label := o.Interface
if o.Label != "" {
label = o.Label + " (" + o.Interface + ")"
}
names = append(names, label)
}
return "Подсеть пересекается с " + strings.Join(names, ", "), true
}
return "", false
}
func HandleSubnets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
exclude := r.URL.Query().Get("exclude")
result := collectAllSubnets(exclude)
ok(w, result)
}

67
handlers/policy_sync.go Normal file
View File

@@ -0,0 +1,67 @@
package handlers
import (
"sort"
"strings"
"time"
"nano-router/clients"
"nano-router/config"
)
// StartPolicySync starts a background goroutine that re-applies nftables rules
// whenever the ARP table changes for devices that have an explicit policy.
// This ensures that policy (VPN / disabled) follows a device even when its IP
// changes due to DHCP renewal or it connects on a different interface.
func StartPolicySync(interval time.Duration) {
go func() {
// Give the binary time to fully start before the first check.
time.Sleep(15 * time.Second)
var lastSig string
for {
sig := policyARPSignature()
if sig != lastSig {
lastSig = sig
applyBlockedFirewall()
}
time.Sleep(interval)
}
}()
}
// policyARPSignature returns a stable string that captures the current mapping
// of MAC→IPs only for devices that have an explicit (non-default) policy.
// If the string changes between ticks, the firewall needs to be re-applied.
func policyARPSignature() string {
cfg, err := config.Load()
if err != nil {
return ""
}
// Collect MACs with explicit policies.
policyMACs := make(map[string]string) // mac → policy
for _, kd := range cfg.KnownDevices {
if kd.MAC != "" && kd.Policy != "" {
policyMACs[kd.MAC] = kd.Policy
}
// Also treat legacy blocked=true as disabled policy.
if kd.MAC != "" && kd.Blocked && kd.Policy == "" {
policyMACs[kd.MAC] = "disabled"
}
}
if len(policyMACs) == 0 {
return ""
}
arpByMAC := clients.GetARPIPsByMAC()
var parts []string
for mac, policy := range policyMACs {
ips := arpByMAC[mac]
sort.Strings(ips)
parts = append(parts, policy+":"+mac+"="+strings.Join(ips, ","))
}
sort.Strings(parts)
return strings.Join(parts, "|")
}

View File

@@ -3,37 +3,77 @@ package handlers
import (
"log"
"alpine-router/config"
"alpine-router/firewall"
"alpine-router/nat"
"alpine-router/network"
"nano-router/clients"
"nano-router/config"
"nano-router/firewall"
"nano-router/nat"
"nano-router/network"
)
// resolveClientPolicy returns the effective routing policy for a device.
// Explicit per-device Policy takes priority; then legacy Blocked flag; then default.
func resolveClientPolicy(kd config.KnownDevice, defaultPolicy string) string {
if kd.Policy != "" {
return kd.Policy
}
if kd.Blocked {
return "disabled"
}
if defaultPolicy != "" {
return defaultPolicy
}
return "direct"
}
// applyAllRules rebuilds the complete nftables ruleset from the current config:
// NAT masquerade + user firewall rules + VLAN isolation + blocked clients.
// NAT masquerade + tproxy for VPN clients + disabled client drops +
// user firewall rules + VLAN isolation.
func applyAllRules(cfg *config.AppConfig) error {
if !nat.IsInstalled() {
return nil
}
// Collect blocked client IPs.
var blockedIPs []string
defaultPolicy := cfg.ClientPolicy.Default
if defaultPolicy == "" {
defaultPolicy = "direct"
}
// Classify each known device into disabled or vpn buckets.
// For devices connected on multiple interfaces (same MAC, different IPs)
// we also include all live ARP IPs so every interface gets the same policy.
arpByMAC := clients.GetARPIPsByMAC()
seenIP := make(map[string]bool)
var disabledIPs, vpnIPs []string
addIP := func(ip, policy string) {
if ip == "" || seenIP[ip] {
return
}
seenIP[ip] = true
switch policy {
case "disabled":
disabledIPs = append(disabledIPs, ip)
case "vpn":
vpnIPs = append(vpnIPs, ip)
}
}
for _, kd := range cfg.KnownDevices {
if kd.Blocked {
ip := kd.IP
if kd.StaticIP != "" {
ip = kd.StaticIP
}
if ip != "" {
blockedIPs = append(blockedIPs, ip)
}
policy := resolveClientPolicy(kd, defaultPolicy)
// Primary stored IP.
ip := kd.IP
if kd.StaticIP != "" {
ip = kd.StaticIP
}
addIP(ip, policy)
// All other IPs this MAC currently has in the ARP table.
for _, arpIP := range arpByMAC[kd.MAC] {
addIP(arpIP, policy)
}
}
// 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) {
@@ -49,7 +89,7 @@ func applyAllRules(cfg *config.AppConfig) error {
for _, name := range names {
if network.IsVLAN(name) {
addLAN(name)
addLAN(network.VLANParent(name)) // include parent (native VLAN) too
addLAN(network.VLANParent(name))
}
}
for name := range network.GetAllPending() {
@@ -80,12 +120,15 @@ func applyAllRules(cfg *config.AppConfig) error {
return firewall.ApplyAll(
firewall.NATConfig{Interfaces: cfg.NAT.Interfaces},
firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation},
blockedIPs,
lanIfaces,
firewall.ClientPolicies{
DisabledIPs: disabledIPs,
VPNIPs: vpnIPs,
},
)
}
// applyBlockedFirewall is the async helper called after client updates.
// applyBlockedFirewall is the async helper called after client or policy updates.
func applyBlockedFirewall() {
cfg, err := config.Load()
if err != nil {