14.04.2026 Update
This commit is contained in:
109
handlers/api.go
109
handlers/api.go
@@ -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
211
handlers/auth.go
Normal 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 ""
|
||||
}
|
||||
@@ -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
16
handlers/dashboard.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
172
handlers/overlap.go
Normal 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
67
handlers/policy_sync.go
Normal 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, "|")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user