diff --git a/Meta-Docs b/Meta-Docs index eedcf07..d31369a 160000 --- a/Meta-Docs +++ b/Meta-Docs @@ -1 +1 @@ -Subproject commit eedcf074cd71fbe018a7902352dd25cce55f9e66 +Subproject commit d31369ab4517a5fcbb4b5d3ec81b3178bca502ca diff --git a/README.md b/README.md index cfaaf75..078e94c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Network Manager — веб-панель управления сетью для Alpine Linux +# NanoRouter — веб-панель управления сетью для Alpine Linux Простая веб-панель для настройки сетевых интерфейсов на Alpine Linux. Написана на Go (бэкенд) + чистый HTML/CSS/JS (фронтенд), без внешних зависимостей. @@ -16,7 +16,7 @@ ## Структура проекта ``` -alpine-router/ +NanoRouter/ ├── main.go — точка входа, HTTP-роутинг ├── go.mod ├── handlers/ @@ -36,7 +36,7 @@ alpine-router/ ## Быстрый запуск (разработка, Linux/macOS) ```bash -cd alpine-router +cd NanoRouter go run . # открыть http://localhost:8080 ``` @@ -60,7 +60,7 @@ apk add go git ifupdown ```sh # На самом роутере или кросс-компиляцией: -cd alpine-router +cd NanoRouter go build -o network-manager . ``` diff --git a/alpine-router b/alpine-router index 996fdb1..b9bf05f 100755 Binary files a/alpine-router and b/alpine-router differ diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..693fe48 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,484 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +const ( + sessionTTL = 8 * time.Hour + cookieName = "ar_session" + nonceExpiry = 2 * time.Minute + maxNonces = 256 + rotateInterval = 10 * time.Second + rotateGrace = 10 * time.Second + defaultUsername = "admin" + defaultPassword = "admin" + passwordHashInner = 10000 +) + +type Session struct { + CurrentToken string + PrevToken string + PrevRotatedAt time.Time + IP string + CreatedAt time.Time + LastSeen time.Time + LastRotate time.Time +} + +type Challenge struct { + Nonce string + CreatedAt time.Time +} + +type Store struct { + mu sync.Mutex + sessions map[string]*Session + nonces map[string]*Challenge + + muConfig sync.RWMutex + username string + password string + apiKey string +} + +var Global = &Store{ + sessions: make(map[string]*Session), + nonces: make(map[string]*Challenge), +} + +func (s *Store) Init(username, passwordHash, apiKey string) { + s.muConfig.Lock() + defer s.muConfig.Unlock() + if username != "" { + s.username = username + } else { + s.username = defaultUsername + } + if passwordHash != "" { + s.password = passwordHash + } else { + s.password = HashPassword(defaultPassword) + } + s.apiKey = apiKey +} + +func (s *Store) IsDefaultPassword() bool { + s.muConfig.RLock() + defer s.muConfig.RUnlock() + return s.password == HashPassword(defaultPassword) +} + +func (s *Store) GetCredentials() (username, apiKey string, isDefault bool) { + s.muConfig.RLock() + defer s.muConfig.RUnlock() + return s.username, s.apiKey, s.password == HashPassword(defaultPassword) +} + +func HashPassword(password string) string { + salt := "nano-router-salt-v1" + for i := 0; i < passwordHashInner; i++ { + data := salt + password + strconv.Itoa(i) + sum := sha256.Sum256([]byte(data)) + salt = hex.EncodeToString(sum[:16]) + } + final := sha256.Sum256([]byte(salt + password)) + return hex.EncodeToString(final[:]) +} + +func generateToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (s *Store) CreateChallenge() string { + nonce := generateToken() + s.mu.Lock() + defer s.mu.Unlock() + if len(s.nonces) > maxNonces { + now := time.Now() + for k, v := range s.nonces { + if now.Sub(v.CreatedAt) > nonceExpiry { + delete(s.nonces, k) + } + } + } + s.nonces[nonce] = &Challenge{Nonce: nonce, CreatedAt: time.Now()} + return nonce +} + +func (s *Store) ValidateChallenge(nonce string) bool { + s.mu.Lock() + defer s.mu.Unlock() + ch, ok := s.nonces[nonce] + if !ok { + return false + } + delete(s.nonces, nonce) + return time.Since(ch.CreatedAt) < nonceExpiry +} + +func ComputeResponse(nonce, passwordHash string) string { + h := sha256.New() + h.Write([]byte(nonce)) + h.Write([]byte(":")) + h.Write([]byte(passwordHash)) + return hex.EncodeToString(h.Sum(nil)) +} + +func (s *Store) Login(nonce, response, clientIP string) (sessionID string, ok bool) { + if !s.ValidateChallenge(nonce) { + return "", false + } + + s.muConfig.RLock() + defer s.muConfig.RUnlock() + + expected := ComputeResponse(nonce, s.password) + if subtle.ConstantTimeCompare([]byte(response), []byte(expected)) != 1 { + return "", false + } + + sessionID = generateToken() + session := &Session{ + CurrentToken: sessionID, + IP: clientIP, + CreatedAt: time.Now(), + LastSeen: time.Now(), + LastRotate: time.Now(), + } + + s.mu.Lock() + s.sessions[sessionID] = session + s.mu.Unlock() + + return sessionID, true +} + +func (s *Store) ValidateAndRotate(token, clientIP string) (newToken string, valid bool) { + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[token] + if !exists { + return "", false + } + + if session.IP != clientIP { + delete(s.sessions, session.CurrentToken) + if session.PrevToken != "" { + delete(s.sessions, session.PrevToken) + } + return "", false + } + + if time.Since(session.LastSeen) > sessionTTL { + delete(s.sessions, session.CurrentToken) + if session.PrevToken != "" { + delete(s.sessions, session.PrevToken) + } + return "", false + } + + isPrevToken := token == session.PrevToken + if isPrevToken { + if !session.PrevRotatedAt.IsZero() && time.Since(session.PrevRotatedAt) > rotateGrace { + delete(s.sessions, session.CurrentToken) + delete(s.sessions, session.PrevToken) + return "", false + } + } + + session.LastSeen = time.Now() + + if time.Since(session.LastRotate) < rotateInterval { + return "", true + } + + newTok := generateToken() + + if session.PrevToken != "" { + delete(s.sessions, session.PrevToken) + } + + session.PrevToken = session.CurrentToken + session.PrevRotatedAt = time.Now() + session.CurrentToken = newTok + session.LastRotate = time.Now() + + s.sessions[newTok] = session + + return newTok, true +} + +func (s *Store) DestroySession(token string) { + s.mu.Lock() + defer s.mu.Unlock() + session, exists := s.sessions[token] + if !exists { + return + } + if session.PrevToken != "" { + delete(s.sessions, session.PrevToken) + } + delete(s.sessions, session.CurrentToken) +} + +func (s *Store) DestroyAllSessions() { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions = make(map[string]*Session) +} + +func (s *Store) ChangeCredentials(newUsername, newPasswordHash string) { + s.muConfig.Lock() + defer s.muConfig.Unlock() + if newUsername != "" { + s.username = newUsername + } + if newPasswordHash != "" { + s.password = newPasswordHash + } + s.DestroyAllSessions() +} + +func (s *Store) VerifyPassword(passwordHash string) bool { + s.muConfig.RLock() + defer s.muConfig.RUnlock() + return subtle.ConstantTimeCompare([]byte(s.password), []byte(passwordHash)) == 1 +} + +func (s *Store) UpdateUsername(newUsername string) { + s.muConfig.Lock() + defer s.muConfig.Unlock() + if newUsername != "" { + s.username = newUsername + } +} + +func (s *Store) UpdatePassword(newPasswordHash string, clientIP string) (sessionID string) { + s.muConfig.Lock() + s.password = newPasswordHash + s.muConfig.Unlock() + + s.DestroyAllSessions() + + return s.createSessionAfterPasswordChange(clientIP) +} + +func (s *Store) createSessionAfterPasswordChange(clientIP string) string { + sessionID := generateToken() + session := &Session{ + CurrentToken: sessionID, + IP: clientIP, + CreatedAt: time.Now(), + LastSeen: time.Now(), + LastRotate: time.Now(), + } + s.mu.Lock() + s.sessions[sessionID] = session + s.mu.Unlock() + return sessionID +} + +func (s *Store) SetAPIKey(key string) { + s.muConfig.Lock() + defer s.muConfig.Unlock() + s.apiKey = key +} + +func (s *Store) ValidateAPIKey(key string) bool { + s.muConfig.RLock() + defer s.muConfig.RUnlock() + if s.apiKey == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(key), []byte(s.apiKey)) == 1 +} + +func (s *Store) Cleanup() { + now := time.Now() + s.mu.Lock() + seen := make(map[*Session]bool) + for _, session := range s.sessions { + if seen[session] { + continue + } + seen[session] = true + if now.Sub(session.LastSeen) > sessionTTL { + delete(s.sessions, session.CurrentToken) + if session.PrevToken != "" { + delete(s.sessions, session.PrevToken) + } + continue + } + if session.PrevToken != "" && !session.PrevRotatedAt.IsZero() && now.Sub(session.PrevRotatedAt) > rotateGrace { + delete(s.sessions, session.PrevToken) + session.PrevToken = "" + } + } + for k, v := range s.nonces { + if now.Sub(v.CreatedAt) > nonceExpiry { + delete(s.nonces, k) + } + } + s.mu.Unlock() +} + +func GetClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + return strings.TrimSpace(parts[0]) + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + ip := r.RemoteAddr + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + return ip +} + +func SetSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + MaxAge: int(sessionTTL.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func GetSessionToken(r *http.Request) string { + c, err := r.Cookie(cookieName) + if err != nil { + return "" + } + return c.Value +} + +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := GetSessionToken(r) + clientIP := GetClientIP(r) + + if token != "" { + if newToken, valid := Global.ValidateAndRotate(token, clientIP); valid { + if newToken != "" { + SetSessionCookie(w, newToken) + } + next.ServeHTTP(w, r) + return + } + } + + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + apiKey := strings.TrimPrefix(authHeader, "Bearer ") + if Global.ValidateAPIKey(apiKey) { + next.ServeHTTP(w, r) + return + } + } + + if isHTMLRequest(r) { + http.Redirect(w, r, "/login.html", http.StatusFound) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"success":false,"error":"unauthorized"}`) + }) +} + +func PublicAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login.html" || r.URL.Path == "/api/auth/challenge" || r.URL.Path == "/api/auth/login" { + next.ServeHTTP(w, r) + return + } + + if strings.HasPrefix(r.URL.Path, "/api/auth/") || r.URL.Path == "/style.css" { + next.ServeHTTP(w, r) + return + } + + token := GetSessionToken(r) + clientIP := GetClientIP(r) + + if token != "" { + if newToken, valid := Global.ValidateAndRotate(token, clientIP); valid { + if newToken != "" { + SetSessionCookie(w, newToken) + } + next.ServeHTTP(w, r) + return + } + } + + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + apiKey := strings.TrimPrefix(authHeader, "Bearer ") + if Global.ValidateAPIKey(apiKey) { + next.ServeHTTP(w, r) + return + } + } + + if isHTMLRequest(r) { + http.Redirect(w, r, "/login.html", http.StatusFound) + return + } + + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"success":false,"error":"unauthorized"}`) + return + } + + next.ServeHTTP(w, r) + }) +} + +func isHTMLRequest(r *http.Request) bool { + accept := r.Header.Get("Accept") + return strings.Contains(accept, "text/html") || + strings.HasSuffix(r.URL.Path, ".html") || + r.URL.Path == "/" || + (!strings.HasPrefix(r.URL.Path, "/api/") && !strings.Contains(accept, "application/json")) +} + +func StartCleanup(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + Global.Cleanup() + } + }() +} \ No newline at end of file diff --git a/clients/clients.go b/clients/clients.go index 2b5878b..ae70f58 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -8,8 +8,8 @@ import ( "strconv" "strings" - "alpine-router/config" - "alpine-router/traffic" + "nano-router/config" + "nano-router/traffic" ) const LeasesFile = "/var/lib/misc/dnsmasq.leases" @@ -28,6 +28,7 @@ type Client struct { Known bool `json:"known"` Blocked bool `json:"blocked"` StaticIP string `json:"static_ip"` + Policy string `json:"policy"` // "disabled" | "direct" | "vpn" | "" (use default) } func GetAll() ([]Client, error) { @@ -78,30 +79,31 @@ func GetAll() ([]Client, error) { found := false for ip, c := range byIP { - if kd.MAC != "" && c.MAC == kd.MAC { - c.Blocked = kd.Blocked + matchedMAC := kd.MAC != "" && c.MAC == kd.MAC + matchedIP := kd.IP != "" && ip == kd.IP && (kd.MAC == "" || c.MAC == kd.MAC) + if !matchedMAC && !matchedIP { + continue + } + + // Policy, blocked state, and hostname apply to every IP this MAC + // has on the network (device connected to multiple interfaces/VLANs). + c.Blocked = kd.Blocked + c.Policy = kd.Policy + if kd.Hostname != "" { + c.Hostname = kd.Hostname + } + + // Static IP binding (DHCP reservation) and IP override only apply + // to the canonical/primary entry for this device. + if matchedIP || ip == kd.StaticIP || (!found && matchedMAC) { c.StaticIP = kd.StaticIP - if kd.Hostname != "" { - c.Hostname = kd.Hostname - } if kd.StaticIP != "" { c.IP = kd.StaticIP } - found = true - break - } - if kd.IP != "" && ip == kd.IP && (kd.MAC == "" || c.MAC == kd.MAC) { - c.Blocked = kd.Blocked - c.StaticIP = kd.StaticIP - if kd.Hostname != "" { - c.Hostname = kd.Hostname - } - if kd.StaticIP != "" { - c.IP = kd.StaticIP - } - found = true - break } + + found = true + // No break — keep iterating so all IPs for this MAC are updated. } if !found && key != "" { @@ -120,6 +122,7 @@ func GetAll() ([]Client, error) { Known: true, Blocked: kd.Blocked, StaticIP: kd.StaticIP, + Policy: kd.Policy, } } @@ -150,8 +153,22 @@ func GetAll() ([]Client, error) { go syncKnownDevices(byIP) + // Exclude upstream gateways — they appear in the ARP table but are not + // LAN clients. Build the exclusion set from configured interface gateways. + gatewayIPs := make(map[string]bool) + if cfgErr == nil && cfg != nil { + for _, iface := range cfg.Interfaces { + if iface.Gateway != "" { + gatewayIPs[iface.Gateway] = true + } + } + } + result := make([]Client, 0, len(byIP)) for _, c := range byIP { + if gatewayIPs[c.IP] { + continue + } result = append(result, *c) } @@ -177,6 +194,7 @@ func syncKnownDevices(byIP map[string]*Client) { savedHostnames := make(map[string]string) savedBlocked := make(map[string]bool) savedStaticIPs := make(map[string]string) + savedPolicies := make(map[string]string) for _, kd := range cfg.KnownDevices { key := kd.MAC if key == "" { @@ -189,6 +207,9 @@ func syncKnownDevices(byIP map[string]*Client) { if kd.StaticIP != "" { savedStaticIPs[key] = kd.StaticIP } + if kd.Policy != "" { + savedPolicies[key] = kd.Policy + } } var seen []config.KnownDevice @@ -210,12 +231,32 @@ func syncKnownDevices(byIP map[string]*Client) { if sip, ok := savedStaticIPs[key]; ok { kd.StaticIP = sip } + if pol, ok := savedPolicies[key]; ok { + kd.Policy = pol + } seen = append(seen, kd) } } _ = config.UpdateKnownDevices(seen) } +// GetARPIPsByMAC returns a map of MAC address → all IPs currently seen in the +// ARP table for that MAC. Used by the firewall to apply per-device policies to +// every IP a device has (e.g. multi-interface or dual-stack devices). +func GetARPIPsByMAC() map[string][]string { + arp, err := parseARPTable() + if err != nil { + return nil + } + result := make(map[string][]string) + for ip, c := range arp { + if c.MAC != "" { + result[c.MAC] = append(result[c.MAC], ip) + } + } + return result +} + func parseDNSMasqLeases() (map[string]*Client, error) { f, err := os.Open(LeasesFile) if err != nil { diff --git a/config/config.go b/config/config.go index 9f0a9a9..b52f12e 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import ( type InterfaceConfig struct { Label string `yaml:"label,omitempty"` + Type string `yaml:"type,omitempty"` Auto bool `yaml:"auto"` Mode string `yaml:"mode"` Address string `yaml:"address,omitempty"` @@ -47,6 +48,12 @@ type KnownDevice struct { Hostname string `yaml:"hostname"` Blocked bool `yaml:"blocked,omitempty"` StaticIP string `yaml:"static_ip,omitempty"` + Policy string `yaml:"policy,omitempty"` // "disabled" | "direct" | "vpn" | "" (use default) +} + +// ClientPolicyConfig holds the default routing policy for newly discovered clients. +type ClientPolicyConfig struct { + Default string `yaml:"default"` // "disabled" | "direct" | "vpn" } type MihomoConfig struct { @@ -72,13 +79,32 @@ type FirewallConfig struct { VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"` } +type CheckEndpoint struct { + Name string `yaml:"name" json:"name"` + URL string `yaml:"url" json:"url"` +} + +type ConnectivityConfig struct { + Direct []CheckEndpoint `yaml:"direct" json:"direct"` + ViaProxy []CheckEndpoint `yaml:"via_proxy" json:"via_proxy"` +} + +type AuthConfig struct { + Username string `yaml:"username,omitempty"` + PasswordHash string `yaml:"password_hash,omitempty"` + APIKey string `yaml:"api_key,omitempty"` +} + type AppConfig struct { - 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"` + 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"` + ClientPolicy ClientPolicyConfig `yaml:"client_policy,omitempty"` + Connectivity ConnectivityConfig `yaml:"connectivity,omitempty" json:"connectivity,omitempty"` + Auth AuthConfig `yaml:"auth,omitempty"` } var ( @@ -187,6 +213,19 @@ func defaultConfig() *AppConfig { } } +func DefaultConnectivity() ConnectivityConfig { + return ConnectivityConfig{ + Direct: []CheckEndpoint{ + {Name: "Cloudflare", URL: "http://cp.cloudflare.com/generate_204"}, + {Name: "Google", URL: "http://connectivitycheck.gstatic.com/generate_204"}, + }, + ViaProxy: []CheckEndpoint{ + {Name: "Cloudflare", URL: "http://cp.cloudflare.com/generate_204"}, + {Name: "Google", URL: "http://connectivitycheck.gstatic.com/generate_204"}, + }, + } +} + func EnsureDefaults(cfg *AppConfig) { if cfg.Interfaces == nil { cfg.Interfaces = map[string]*InterfaceConfig{} @@ -203,6 +242,12 @@ func EnsureDefaults(cfg *AppConfig) { if cfg.KnownDevices == nil { cfg.KnownDevices = []KnownDevice{} } + if cfg.ClientPolicy.Default == "" { + cfg.ClientPolicy.Default = "direct" + } + if len(cfg.Connectivity.Direct) == 0 { + cfg.Connectivity = DefaultConnectivity() + } } func UpdateKnownDevices(seen []KnownDevice) error { @@ -238,6 +283,7 @@ func UpdateKnownDevices(seen []KnownDevice) error { if d.StaticIP != "" { existingDev.StaticIP = d.StaticIP } + // Policy is always preserved from existingDev; never overwritten by discovery existing[key] = existingDev } else { existing[key] = d diff --git a/dhcp/config.go b/dhcp/config.go index 3696180..45ee2e4 100644 --- a/dhcp/config.go +++ b/dhcp/config.go @@ -10,8 +10,8 @@ import ( ) const ( - ConfigFile = "/etc/dnsmasq.d/alpine-router-dhcp.conf" - StateFile = "/var/lib/alpine-router/dhcp.json" + ConfigFile = "/etc/dnsmasq.d/nano-router-dhcp.conf" + StateFile = "/var/lib/nano-router/dhcp.json" ) // Pool describes a DHCP pool tied to one interface/subnet. @@ -76,7 +76,7 @@ func Save(cfg *Config) error { mu.Lock() defer mu.Unlock() - if err := os.MkdirAll("/var/lib/alpine-router", 0755); err != nil { + if err := os.MkdirAll("/var/lib/nano-router", 0755); err != nil { return fmt.Errorf("mkdir state dir: %w", err) } data, err := json.MarshalIndent(cfg, "", " ") @@ -86,7 +86,7 @@ func Save(cfg *Config) error { return os.WriteFile(StateFile, data, 0644) } -// WriteConfigs generates /etc/dnsmasq.d/alpine-router-dhcp.conf. +// WriteConfigs generates /etc/dnsmasq.d/nano-router-dhcp.conf. // dnsmasq is used in DHCP-only mode (port=0 disables DNS resolver). func WriteConfigs(cfg *Config) error { if err := os.MkdirAll("/etc/dnsmasq.d", 0755); err != nil { @@ -94,7 +94,7 @@ func WriteConfigs(cfg *Config) error { } var sb strings.Builder - sb.WriteString("# Generated by alpine-router — do not edit manually\n") + sb.WriteString("# Generated by nano-router — do not edit manually\n") sb.WriteString("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n") sb.WriteString("port=0\n") // disable DNS sb.WriteString("bind-interfaces\n") // only listen on specified interfaces @@ -142,7 +142,7 @@ func WriteConfigsWithBindings(cfg *Config, bindings []StaticBinding) error { } var sb strings.Builder - sb.WriteString("# Generated by alpine-router — do not edit manually\n") + sb.WriteString("# Generated by nano-router — do not edit manually\n") sb.WriteString("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n") sb.WriteString("port=0\n") sb.WriteString("bind-interfaces\n") diff --git a/firewall/firewall.go b/firewall/firewall.go index 2801d24..769bb57 100644 --- a/firewall/firewall.go +++ b/firewall/firewall.go @@ -7,7 +7,16 @@ import ( "strings" ) -const tableName = "alpine-router" +const tableName = "nano-router" + +// TproxyPort is the fixed Mihomo transparent-proxy port, always enabled. +const TproxyPort = 7893 + +// TproxyMark is the fwmark set on packets that should be routed to tproxy. +const TproxyMark = 1 + +// TproxyTable is the ip routing table used for fwmark-based local delivery. +const TproxyTable = 100 // Rule is a single stateless forward-filter rule. type Rule struct { @@ -35,29 +44,76 @@ type NATConfig struct { Interfaces []string } +// ClientPolicies holds per-policy IP lists derived from known device policies. +type ClientPolicies struct { + DisabledIPs []string // drop all traffic for these IPs + VPNIPs []string // redirect TCP/UDP to tproxy for these IPs +} + // IsInstalled reports whether the nft binary is available. func IsInstalled() bool { _, err := exec.LookPath("nft") return err == nil } +// CleanupAll removes all kernel state managed by nano-router so that a fresh +// applyConfig() starts from a guaranteed clean slate: +// - All nftables tables created by nano-router (main + traffic) +// - The ip rule and route used for tproxy fwmark delivery +// +// Errors are silently ignored — most arise because entries don't exist yet. +func CleanupAll() { + // Remove nftables tables. The traffic table is managed by the traffic tracker. + exec.Command("nft", "delete", "table", "ip", tableName).Run() + exec.Command("nft", "delete", "table", "ip", "nano-router-nat").Run() + exec.Command("nft", "delete", "table", "ip", "nano-router-traffic").Run() + + // Remove all ip rules pointing to our tproxy routing table. + // Loop to handle duplicate entries that can accumulate across restarts. + mark := fmt.Sprintf("%d", TproxyMark) + table := fmt.Sprintf("%d", TproxyTable) + for { + err := exec.Command("ip", "rule", "del", "fwmark", mark, "table", table).Run() + if err != nil { + break + } + } + + // Remove the local default route in our tproxy routing table. + exec.Command("ip", "route", "del", + "local", "0.0.0.0/0", "dev", "lo", "table", table, + ).Run() +} + +// SetupTproxyRouting installs the ip rule and route needed for TPROXY to work. +// Packets marked with TproxyMark are routed to the loopback interface (local delivery), +// which allows Mihomo's tproxy socket to receive them. +// Call CleanupAll first to avoid duplicate entries. +func SetupTproxyRouting() { + mark := fmt.Sprintf("%d", TproxyMark) + table := fmt.Sprintf("%d", TproxyTable) + exec.Command("ip", "rule", "add", "fwmark", mark, "table", table).Run() + exec.Command("ip", "route", "add", + "local", "0.0.0.0/0", "dev", "lo", "table", table, + ).Run() +} + // ApplyAll atomically regenerates the complete nftables ruleset: +// - Tproxy prerouting for cp.VPNIPs (mangle priority, TPROXY → Mihomo :TproxyPort) // - NAT masquerade for natCfg.Interfaces -// - Blocked client IP drops +// - Disabled client IP drops (cp.DisabledIPs) // - 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 { +// lanIfaces is the union of NAT interfaces and all VLAN interfaces. +func ApplyAll(natCfg NATConfig, fwCfg Config, lanIfaces []string, cp ClientPolicies) 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", "nano-router-nat").Run() exec.Command("nft", "delete", "table", "ip", tableName).Run() var activeRules []Rule @@ -68,26 +124,48 @@ func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) er } hasNAT := len(natCfg.Interfaces) > 0 - hasBlocked := len(blockedIPs) > 0 + hasDisabled := len(cp.DisabledIPs) > 0 + hasVPN := len(cp.VPNIPs) > 0 hasVLANIsolation := fwCfg.VLANIsolation && len(lanIfaces) >= 2 - if !hasNAT && !hasBlocked && !hasVLANIsolation && len(activeRules) == 0 { + if !hasNAT && !hasDisabled && !hasVPN && !hasVLANIsolation && len(activeRules) == 0 { return nil } var sb strings.Builder fmt.Fprintf(&sb, "table ip %s {\n", tableName) + // ── Tproxy prerouting chain (mangle priority) ──────────────────────────── + // Intercepts TCP/UDP from VPN-policy clients and delivers them to Mihomo. + // Runs before the forward chain, so forwarding rules don't affect these packets. + if hasVPN { + sb.WriteString(" chain tproxy_pre {\n") + sb.WriteString(" type filter hook prerouting priority mangle; policy accept;\n") + // Never redirect traffic destined for the router itself (admin panel, + // SSH, DNS served locally, etc.). Without this, Mihomo intercepts + // connections to the router's own IPs and the admin UI becomes unreachable. + sb.WriteString(" fib daddr type local return\n") + for _, ip := range cp.VPNIPs { + fmt.Fprintf(&sb, + " ip saddr %s meta l4proto { tcp, udp } tproxy to :%d meta mark set %d\n", + ip, TproxyPort, TproxyMark, + ) + } + sb.WriteString(" }\n") + } + // ── 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 { + // Drop traffic for disabled clients (both directions). + for _, ip := range cp.DisabledIPs { fmt.Fprintf(&sb, " ip saddr %s drop\n", ip) fmt.Fprintf(&sb, " ip daddr %s drop\n", ip) } + // User-defined forward rules (ordered, enabled only). for _, rule := range activeRules { line := buildRuleLine(rule) if line == "" { @@ -110,7 +188,7 @@ func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) er 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). + // Allow from LAN/VLAN interfaces outbound to WAN. for _, iface := range natCfg.Interfaces { fmt.Fprintf(&sb, " iifname %q accept\n", iface) } @@ -136,24 +214,23 @@ func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) er 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. + // Set up ip rule/route for tproxy fwmark routing when VPN clients are active. + if hasVPN { + SetupTproxyRouting() + } + + // Flush connection tracking so existing sessions are re-evaluated. 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. +// is re-evaluated against the current nftables ruleset. 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) } diff --git a/go.mod b/go.mod index 2f89263..9ac9671 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module alpine-router +module nano-router go 1.21 diff --git a/handlers/api.go b/handlers/api.go index af21326..d14e031 100644 --- a/handlers/api.go +++ b/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, diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..46e6545 --- /dev/null +++ b/handlers/auth.go @@ -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 "" +} \ No newline at end of file diff --git a/handlers/clients.go b/handlers/clients.go index 46f160f..935f2ea 100644 --- a/handlers/clients.go +++ b/handlers/clients.go @@ -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 diff --git a/handlers/dashboard.go b/handlers/dashboard.go new file mode 100644 index 0000000..f7deb42 --- /dev/null +++ b/handlers/dashboard.go @@ -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) +} \ No newline at end of file diff --git a/handlers/dhcp.go b/handlers/dhcp.go index 53e1c7a..11c6f80 100644 --- a/handlers/dhcp.go +++ b/handlers/dhcp.go @@ -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 diff --git a/handlers/firewall.go b/handlers/firewall.go index cd16953..7ba6e81 100644 --- a/handlers/firewall.go +++ b/handlers/firewall.go @@ -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) { diff --git a/handlers/mihomo.go b/handlers/mihomo.go index cbee995..8b8ff0b 100644 --- a/handlers/mihomo.go +++ b/handlers/mihomo.go @@ -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: diff --git a/handlers/mihomo_proxy.go b/handlers/mihomo_proxy.go index 8a76bd8..f733c8d 100644 --- a/handlers/mihomo_proxy.go +++ b/handlers/mihomo_proxy.go @@ -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" diff --git a/handlers/nat.go b/handlers/nat.go index 69914ed..95bebbb 100644 --- a/handlers/nat.go +++ b/handlers/nat.go @@ -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()) diff --git a/handlers/overlap.go b/handlers/overlap.go new file mode 100644 index 0000000..5ad09d4 --- /dev/null +++ b/handlers/overlap.go @@ -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) +} \ No newline at end of file diff --git a/handlers/policy_sync.go b/handlers/policy_sync.go new file mode 100644 index 0000000..4549d4e --- /dev/null +++ b/handlers/policy_sync.go @@ -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, "|") +} diff --git a/handlers/rules.go b/handlers/rules.go index c33e8a2..478cc06 100644 --- a/handlers/rules.go +++ b/handlers/rules.go @@ -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 { diff --git a/main.go b/main.go index a64feee..6b28bc9 100644 --- a/main.go +++ b/main.go @@ -8,15 +8,19 @@ import ( "os" "path/filepath" "strings" + "time" - "alpine-router/config" - "alpine-router/dhcp" - "alpine-router/firewall" - "alpine-router/handlers" - "alpine-router/mihomo" - "alpine-router/nat" - "alpine-router/network" - "alpine-router/traffic" + "nano-router/auth" + "nano-router/clients" + "nano-router/config" + "nano-router/dhcp" + "nano-router/firewall" + "nano-router/handlers" + "nano-router/mihomo" + "nano-router/monitor" + "nano-router/nat" + "nano-router/network" + "nano-router/traffic" ) //go:embed public @@ -36,6 +40,13 @@ func main() { log.Printf("Warning: ensure default mihomo config: %v", err) } + // Always wipe stale kernel state (nftables, ip rules/routes) so that the + // config is the single source of truth even after crashes or partial updates. + if firewall.IsInstalled() { + firewall.CleanupAll() + log.Printf("Cleaned up previous nftables/routing state") + } + if firstRun { log.Printf("First run — importing current system state into %s", config.GetPath()) cfg = importSystemState() @@ -51,6 +62,12 @@ func main() { mux := http.NewServeMux() + // Public auth endpoints (no auth required) + mux.HandleFunc("/api/auth/challenge", handlers.HandleAuthChallenge) + mux.HandleFunc("/api/auth/login", handlers.HandleAuthLogin) + + mux.HandleFunc("/api/dashboard", handlers.HandleDashboard) + mux.HandleFunc("/api/interfaces", handlers.HandleInterfaces) mux.HandleFunc("/api/interfaces/", func(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/api/interfaces/") @@ -63,9 +80,12 @@ func main() { mux.HandleFunc("/api/config/", handlers.HandleConfig) mux.HandleFunc("/api/apply", handlers.HandleApply) mux.HandleFunc("/api/pending", handlers.HandlePending) + mux.HandleFunc("/api/subnets", handlers.HandleSubnets) mux.HandleFunc("/api/clients", handlers.HandleClients) mux.HandleFunc("/api/clients/update/", handlers.HandleClientUpdate) + mux.HandleFunc("/api/clients/policy", handlers.HandleClientPolicyDefault) + mux.HandleFunc("/api/clients/policy/apply-all", handlers.HandleClientPolicyApplyAll) mux.HandleFunc("/api/config.yaml", handlers.HandleConfigYAML) @@ -107,22 +127,45 @@ func main() { mux.HandleFunc("/api/mihomo/api/", handlers.HandleMihomoAPIProxy) mux.HandleFunc("/api/mihomo/ws/", handlers.HandleMihomoWSProxy) + // Auth-protected API endpoints + mux.HandleFunc("/api/auth/logout", handlers.HandleAuthLogout) + mux.HandleFunc("/api/auth/status", handlers.HandleAuthStatus) + mux.HandleFunc("/api/auth/profile", handlers.HandleAuthProfile) + mux.HandleFunc("/api/auth/api-key", handlers.HandleAuthAPIKey) + sub, err := fs.Sub(publicFS, "public") if err != nil { log.Fatal(err) } - mux.Handle("/", http.FileServer(http.FS(sub))) + fileHandler := http.FileServer(http.FS(sub)) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + http.Redirect(w, r, "/home.html", http.StatusFound) + return + } + fileHandler.ServeHTTP(w, r) + }) port := "8080" if p := os.Getenv("PORT"); p != "" { port = p } + // Initialize auth from config + auth.Global.Init(cfg.Auth.Username, cfg.Auth.PasswordHash, cfg.Auth.APIKey) + auth.StartCleanup(5 * time.Minute) + traffic.Start() + monitor.Start() + + // Re-apply nftables rules whenever a policy device changes its IP (DHCP renewal, + // new interface). Checks ARP every 30 seconds and re-applies only on change. + handlers.StartPolicySync(30 * time.Second) log.Printf("Config file: %s", config.GetPath()) log.Printf("Network Manager listening on http://0.0.0.0:%s", port) - log.Fatal(http.ListenAndServe(":"+port, mux)) + log.Fatal(http.ListenAndServe(":"+port, auth.PublicAuthMiddleware(mux))) } func importSystemState() *config.AppConfig { @@ -220,17 +263,48 @@ func applyConfig(cfg *config.AppConfig) { log.Printf("Warning: save NAT state: %v", err) } - var blockedIPs []string + defaultPolicy := cfg.ClientPolicy.Default + if defaultPolicy == "" { + defaultPolicy = "direct" + } + + // Classify known devices by effective routing policy. + // For devices with multiple IPs (same MAC, different interfaces) all ARP IPs + // are included so every interface gets the same policy in nftables. + 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 := kd.Policy + if policy == "" { + if kd.Blocked { + policy = "disabled" + } else { + policy = defaultPolicy } } + ip := kd.IP + if kd.StaticIP != "" { + ip = kd.StaticIP + } + addIP(ip, policy) + for _, arpIP := range arpByMAC[kd.MAC] { + addIP(arpIP, policy) + } } // Build LAN interface set: NAT interfaces + all VLAN interfaces + their parents. @@ -267,15 +341,21 @@ func applyConfig(cfg *config.AppConfig) { err := firewall.ApplyAll( firewall.NATConfig{Interfaces: cfg.NAT.Interfaces}, firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation}, - blockedIPs, lanIfaces, + firewall.ClientPolicies{ + DisabledIPs: disabledIPs, + VPNIPs: vpnIPs, + }, ) if err != nil { log.Printf("Warning: apply firewall/NAT rules: %v", err) } else { - 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) + log.Printf("Firewall/NAT applied (%d NAT ifaces, %d fw rules, %d disabled, %d vpn, vlan_isolation=%v)", + len(cfg.NAT.Interfaces), len(fwRules), len(disabledIPs), len(vpnIPs), cfg.Firewall.VLANIsolation) } + + // Ensure tproxy ip routing rules are in place for marked packets. + firewall.SetupTproxyRouting() } else { log.Printf("nftables not installed — NAT/firewall unavailable (install with: apk add nftables)") } @@ -328,4 +408,13 @@ func applyConfig(cfg *config.AppConfig) { } else { log.Printf("dnsmasq not installed — DHCP unavailable (install with: apk add dnsmasq)") } + + // Auto-start Mihomo if enabled in config. + if cfg.Mihomo.Enabled { + if err := mihomo.Start(); err != nil { + log.Printf("Warning: auto-start mihomo: %v", err) + } else { + log.Printf("Mihomo auto-started (enabled=true in config)") + } + } } \ No newline at end of file diff --git a/mihomo/mihomo.go b/mihomo/mihomo.go index d4414eb..1628b50 100644 --- a/mihomo/mihomo.go +++ b/mihomo/mihomo.go @@ -176,7 +176,34 @@ func Status() map[string]interface{} { return status } +// forcedTproxyPort is the fixed transparent-proxy port that is always injected +// into Mihomo's config. It matches firewall.TproxyPort (7893). +const forcedTproxyPort = 7893 + +// EnsureTproxyConfig patches the Mihomo config to always have tproxy-port set +// to the fixed value. This is called before every Start() so the admin panel +// cannot accidentally disable transparent proxy. +func EnsureTproxyConfig() error { + cfg, err := LoadConfig() + if err != nil { + return fmt.Errorf("load mihomo config: %w", err) + } + if v, ok := cfg["tproxy-port"]; ok { + if n, ok2 := v.(int); ok2 && n == forcedTproxyPort { + return nil // already correct + } + } + cfg["tproxy-port"] = forcedTproxyPort + return SaveConfig(cfg) +} + func Start() error { + // Force tproxy-port before acquiring the process lock so that the config + // is always consistent regardless of what the admin panel has saved. + if err := EnsureTproxyConfig(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: enforce tproxy config: %v\n", err) + } + mu.Lock() defer mu.Unlock() diff --git a/monitor/monitor.go b/monitor/monitor.go new file mode 100644 index 0000000..7435730 --- /dev/null +++ b/monitor/monitor.go @@ -0,0 +1,832 @@ +package monitor + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "nano-router/config" + "nano-router/mihomo" +) + +const ( + checkInterval = 30 * time.Second + speedInterval = 5 * time.Second + ipCheckInterval = 10 * time.Minute + minuteSlots = 60 +) + +type MinuteSlot struct { + Minute int64 `json:"minute"` + Status string `json:"status"` + Pings map[string]int `json:"pings,omitempty"` + PingCF int `json:"ping_cf,omitempty"` + PingGG int `json:"ping_gg,omitempty"` +} + +type ConnectivityState struct { + DirectUp bool `json:"direct_up"` + VPNUp bool `json:"vpn_up"` + DirectUpSince string `json:"direct_up_since,omitempty"` + VPNUpSince string `json:"vpn_up_since,omitempty"` + MinutesD []MinuteSlot `json:"minutes_direct"` + MinutesV []MinuteSlot `json:"minutes_vpn"` + EndpointNames []string `json:"endpoint_names"` +} + +type Sample struct { + Time time.Time `json:"time"` + RxPS float64 `json:"rx_bps"` + TxPS float64 `json:"tx_bps"` +} + +type IPInfo struct { + IP string `json:"ip"` + Country string `json:"country"` + CC string `json:"cc"` +} + +type SystemStats struct { + CPUPct float64 `json:"cpu_pct"` + MemPct float64 `json:"mem_pct"` + MemTotal uint64 `json:"mem_total"` + MemUsed uint64 `json:"mem_used"` + MemFree uint64 `json:"mem_free"` + Uptime int64 `json:"uptime"` +} + +type DashboardData struct { + System SystemStats `json:"system"` + Mihomo MihomoStatus `json:"mihomo"` + Connectivity ConnectivityState `json:"connectivity"` + GatewayIface string `json:"gateway_iface"` + TrafficAvg []Sample `json:"traffic_avg"` + TrafficReal []Sample `json:"traffic_real"` + IPDirect *IPInfo `json:"ip_direct"` + IPVPN *IPInfo `json:"ip_vpn"` +} + +type MihomoStatus struct { + Running bool `json:"running"` + PID int `json:"pid,omitempty"` +} + +var ( + mu sync.RWMutex + connState ConnectivityState + minuteSlotsD [minuteSlots]MinuteSlot + minuteSlotsV [minuteSlots]MinuteSlot + gatewayIface string + prevRx uint64 + prevTx uint64 + prevSpeedTime time.Time + curRxPS float64 + curTxPS float64 + avgSamples []Sample + realSamples []Sample + ipDirect *IPInfo + ipVPN *IPInfo + ipDirectT time.Time + ipVPNT time.Time + + directUpSince *time.Time + vpnUpSince *time.Time + wasDirectUp bool + wasVPNUp bool + + prevCPUTotal uint64 + prevCPUIdle uint64 + cpuInitialized bool + + stopCh chan struct{} +) + +func Start() { + stopCh = make(chan struct{}) + gatewayIface = detectGatewayIface() + for i := 0; i < minuteSlots; i++ { + minuteSlotsD[i] = MinuteSlot{Status: "no_data"} + minuteSlotsV[i] = MinuteSlot{Status: "no_data"} + } + go connectivityLoop() + go speedLoop() + go ipCheckLoop() +} + +func Stop() { + close(stopCh) +} + +func GetData() DashboardData { + mu.RLock() + defer mu.RUnlock() + + cfg, _ := config.Load() + var epNames []string + if cfg != nil && len(cfg.Connectivity.Direct) > 0 { + for _, ep := range cfg.Connectivity.Direct { + epNames = append(epNames, ep.Name) + } + } else { + epNames = []string{"Cloudflare", "Google"} + } + connState.EndpointNames = epNames + + d := DashboardData{ + System: readSystemStats(), + Mihomo: getMihomoRunning(), + Connectivity: connState, + GatewayIface: gatewayIface, + IPDirect: ipDirect, + IPVPN: ipVPN, + } + + d.Connectivity.MinutesD = buildMinuteSlots(minuteSlotsD[:]) + d.Connectivity.MinutesV = buildMinuteSlots(minuteSlotsV[:]) + + now := time.Now() + cutoff10m := now.Add(-10 * time.Minute) + cutoff1m := now.Add(-1 * time.Minute) + + filteredAvg := make([]Sample, 0, len(avgSamples)) + for _, s := range avgSamples { + if !s.Time.Before(cutoff10m) { + filteredAvg = append(filteredAvg, s) + } + } + d.TrafficAvg = filteredAvg + + filteredReal := make([]Sample, 0, len(realSamples)) + for _, s := range realSamples { + if !s.Time.Before(cutoff1m) { + filteredReal = append(filteredReal, s) + } + } + d.TrafficReal = filteredReal + + if d.IPDirect != nil && time.Since(ipDirectT) > ipCheckInterval+time.Minute { + d.IPDirect = nil + } + if d.IPVPN != nil && time.Since(ipVPNT) > ipCheckInterval+time.Minute { + d.IPVPN = nil + } + + return d +} + +func getMihomoRunning() MihomoStatus { + st := mihomo.Status() + r, _ := st["running"].(bool) + s := MihomoStatus{Running: r} + if r { + if pid, ok := st["pid"].(int); ok { + s.PID = pid + } + } + return s +} + +func readSystemStats() SystemStats { + s := SystemStats{} + readCPUStats(&s) + readMemStats(&s) + readUptime(&s) + return s +} + +func readCPUStats(s *SystemStats) { + f, err := os.Open("/proc/stat") + if err != nil { + return + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + return + } + line := scanner.Text() + if !strings.HasPrefix(line, "cpu ") { + return + } + fields := strings.Fields(line) + if len(fields) < 5 { + return + } + + var total, idle uint64 + for i := 1; i < len(fields) && i <= 8; i++ { + v, _ := strconv.ParseUint(fields[i], 10, 64) + total += v + if i == 4 { + idle = v + } + } + + if cpuInitialized && prevCPUTotal > 0 { + dTotal := total - prevCPUTotal + dIdle := idle - prevCPUIdle + if dTotal > 0 { + s.CPUPct = float64(dTotal-dIdle) / float64(dTotal) * 100.0 + } + } + prevCPUTotal = total + prevCPUIdle = idle + cpuInitialized = true +} + +func readMemStats(s *SystemStats) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return + } + defer f.Close() + + var memTotal, memFree, memAvailable, buffers, cached uint64 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + val, _ := strconv.ParseUint(parts[1], 10, 64) + switch { + case strings.HasPrefix(parts[0], "MemTotal:"): + memTotal = val + case strings.HasPrefix(parts[0], "MemFree:"): + memFree = val + case strings.HasPrefix(parts[0], "MemAvailable:"): + memAvailable = val + case strings.HasPrefix(parts[0], "Buffers:"): + buffers = val + case strings.HasPrefix(parts[0], "Cached:"): + cached = val + } + } + + s.MemTotal = memTotal * 1024 + s.MemFree = memFree * 1024 + used := memTotal - memAvailable + if memAvailable == 0 { + used = memTotal - memFree - buffers - cached + } + s.MemUsed = used * 1024 + if memTotal > 0 { + s.MemPct = float64(used) / float64(memTotal) * 100.0 + } +} + +func readUptime(s *SystemStats) { + f, err := os.Open("/proc/uptime") + if err != nil { + return + } + defer f.Close() + var upSec float64 + fmt.Fscanf(f, "%f", &upSec) + s.Uptime = int64(upSec) +} + +func detectGatewayIface() string { + out, err := exec.Command("ip", "route", "show", "default").Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + for i, f := range fields { + if f == "dev" && i+1 < len(fields) { + return fields[i+1] + } + } + } + return "" +} + +func readIfaceBytes(iface string) (rx, tx uint64) { + f, err := os.Open("/proc/net/dev") + if err != nil { + return 0, 0 + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + colon := strings.Index(line, ":") + if colon < 0 { + continue + } + name := strings.TrimSpace(line[:colon]) + if name != iface { + continue + } + fields := strings.Fields(line[colon+1:]) + if len(fields) >= 10 { + rx, _ = strconv.ParseUint(fields[0], 10, 64) + tx, _ = strconv.ParseUint(fields[8], 10, 64) + } + return + } + return 0, 0 +} + +func connectivityLoop() { + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + checkConnectivity() + + for { + select { + case <-ticker.C: + checkConnectivity() + case <-stopCh: + return + } + } +} + +func checkConnectivity() { + directResult := checkDirectConnectivity() + vpnResult := checkVPNConnectivity() + + now := time.Now() + minuteIdx := int(now.Unix()/60) % minuteSlots + + mu.Lock() + defer mu.Unlock() + + connState.DirectUp = directResult.Up + connState.VPNUp = vpnResult.Up + + if directResult.Up && !wasDirectUp { + directUpSince = &time.Time{} + *directUpSince = now + } else if !directResult.Up { + directUpSince = nil + } + wasDirectUp = directResult.Up + + mihomoRunning := mihomo.IsRunning() + if vpnResult.Up && !wasVPNUp { + vpnUpSince = &time.Time{} + *vpnUpSince = now + } else if !vpnResult.Up { + vpnUpSince = nil + } + wasVPNUp = vpnResult.Up + + if directUpSince != nil { + dur := now.Sub(*directUpSince) + connState.DirectUpSince = fmtDuration(dur) + } else { + connState.DirectUpSince = "" + } + if vpnUpSince != nil { + dur := now.Sub(*vpnUpSince) + connState.VPNUpSince = fmtDuration(dur) + } else { + connState.VPNUpSince = "" + } + + statusD := "up" + if !directResult.Up { + statusD = "down" + } + statusV := "up" + if !vpnResult.Up { + statusV = "down" + } + if !mihomoRunning { + statusV = "no_data" + } + + dSlot := MinuteSlot{Status: statusD, Pings: directResult.Pings} + if v, ok := directResult.Pings["Cloudflare"]; ok { + dSlot.PingCF = v + } + if v, ok := directResult.Pings["Google"]; ok { + dSlot.PingGG = v + } + minuteSlotsD[minuteIdx] = mergeSlot(minuteSlotsD[minuteIdx], dSlot) + + vSlot := MinuteSlot{Status: statusV, Pings: make(map[string]int)} + if vpnResult.Up { + vSlot.Pings = vpnResult.Pings + if v, ok := vpnResult.Pings["Cloudflare"]; ok { + vSlot.PingCF = v + } + if v, ok := vpnResult.Pings["Google"]; ok { + vSlot.PingGG = v + } + } + minuteSlotsV[minuteIdx] = mergeSlot(minuteSlotsV[minuteIdx], vSlot) +} + +func fmtDuration(d time.Duration) string { + s := int(d.Seconds()) + if s < 0 { + s = 0 + } + days := s / 86400 + s %= 86400 + hours := s / 3600 + s %= 3600 + mins := s / 60 + secs := s % 60 + + parts := []string{} + if days > 0 { + parts = append(parts, fmt.Sprintf("%dd", days)) + } + if hours > 0 || days > 0 { + parts = append(parts, fmt.Sprintf("%dh", hours)) + } + if mins > 0 || hours > 0 || days > 0 { + parts = append(parts, fmt.Sprintf("%dm", mins)) + } + parts = append(parts, fmt.Sprintf("%ds", secs)) + return strings.Join(parts, " ") +} + +func mergeSlot(existing, new MinuteSlot) MinuteSlot { + merged := MinuteSlot{ + Status: new.Status, + Pings: make(map[string]int), + } + if new.Status == "down" && existing.Status == "up" { + merged.Status = "down" + } else if new.Status == "up" && existing.Status == "down" { + merged.Status = "down" + } + for k, v := range existing.Pings { + merged.Pings[k] = v + } + for k, v := range new.Pings { + if v > 0 { + merged.Pings[k] = v + } + } + if v, ok := merged.Pings["Cloudflare"]; ok { + merged.PingCF = v + } + if v, ok := merged.Pings["Google"]; ok { + merged.PingGG = v + } + return merged +} + +type connResult struct { + Up bool + Pings map[string]int + PingCF int + PingGG int +} + +func checkDirectConnectivity() connResult { + result := connResult{Pings: make(map[string]int)} + client := &http.Client{Timeout: 8 * time.Second} + + cfg, _ := config.Load() + if cfg == nil || len(cfg.Connectivity.Direct) == 0 { + def := config.DefaultConnectivity() + cfg = &config.AppConfig{Connectivity: def} + } + endpoints := cfg.Connectivity.Direct + + anyUp := false + for _, ep := range endpoints { + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + req, err := http.NewRequestWithContext(ctx, "GET", ep.URL, nil) + if err != nil { + cancel() + continue + } + resp, err := client.Do(req) + elapsed := time.Since(start) + cancel() + if err != nil { + continue + } + resp.Body.Close() + ms := int(elapsed.Milliseconds()) + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + anyUp = true + result.Pings[ep.Name] = ms + } + } + result.Up = anyUp + return result +} + +func checkVPNConnectivity() connResult { + result := connResult{Pings: make(map[string]int)} + if !mihomo.IsRunning() { + return result + } + + mixedPort := getMihomoMixedPort() + if mixedPort == 0 { + mixedPort = 7890 + } + + proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort) + proxyURL, _ := url.Parse("http://" + proxyAddr) + + proxyClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + } + + cfg, _ := config.Load() + if cfg == nil || len(cfg.Connectivity.ViaProxy) == 0 { + def := config.DefaultConnectivity() + cfg = &config.AppConfig{Connectivity: def} + } + endpoints := cfg.Connectivity.ViaProxy + + anyUp := false + for _, ep := range endpoints { + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + req, err := http.NewRequestWithContext(ctx, "GET", ep.URL, nil) + if err != nil { + cancel() + continue + } + resp, err := proxyClient.Do(req) + elapsed := time.Since(start) + cancel() + if err != nil { + continue + } + resp.Body.Close() + ms := int(elapsed.Milliseconds()) + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + anyUp = true + result.Pings[ep.Name] = ms + } + } + result.Up = anyUp + return result +} + +func getMihomoMixedPort() int { + cfg, err := mihomo.LoadConfig() + if err != nil { + return 7890 + } + if mp, ok := cfg["mixed-port"]; ok { + switch v := mp.(type) { + case int: + return v + case float64: + return int(v) + } + } + return 7890 +} + +func speedLoop() { + iface := gatewayIface + if iface == "" { + iface = detectGatewayIface() + gatewayIface = iface + } + + if iface == "" { + return + } + + rx, tx := readIfaceBytes(iface) + prevRx = rx + prevTx = tx + prevSpeedTime = time.Now() + + ticker := time.NewTicker(speedInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + captureSpeedSample() + case <-stopCh: + return + } + } +} + +func captureSpeedSample() { + iface := gatewayIface + if iface == "" { + iface = detectGatewayIface() + if iface == "" { + return + } + mu.Lock() + gatewayIface = iface + mu.Unlock() + } + + rx, tx := readIfaceBytes(iface) + now := time.Now() + + mu.Lock() + defer mu.Unlock() + + dt := now.Sub(prevSpeedTime).Seconds() + if dt > 0 && prevSpeedTime.Before(now) { + curRxPS = float64(rx-prevRx) * 8.0 / dt + curTxPS = float64(tx-prevTx) * 8.0 / dt + } + + prevRx = rx + prevTx = tx + prevSpeedTime = now + + sample := Sample{Time: now, RxPS: curRxPS, TxPS: curTxPS} + realSamples = append(realSamples, sample) + + cutoff1m := now.Add(-1 * time.Minute) + filtered := make([]Sample, 0, len(realSamples)) + for _, s := range realSamples { + if !s.Time.Before(cutoff1m) { + filtered = append(filtered, s) + } + } + realSamples = filtered + + avgSamples = append(avgSamples, sample) + cutoff10m := now.Add(-10 * time.Minute) + filteredAvg := make([]Sample, 0, len(avgSamples)) + for _, s := range avgSamples { + if !s.Time.Before(cutoff10m) { + filteredAvg = append(filteredAvg, s) + } + } + avgSamples = filteredAvg +} + +func ipCheckLoop() { + checkIPs() + ticker := time.NewTicker(ipCheckInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + checkIPs() + case <-stopCh: + return + } + } +} + +func checkIPs() { + directInfo := fetchIPDirect() + if directInfo != nil { + mu.Lock() + ipDirect = directInfo + ipDirectT = time.Now() + mu.Unlock() + } + + if mihomo.IsRunning() { + vpnInfo := fetchIPVPN() + if vpnInfo != nil { + mu.Lock() + ipVPN = vpnInfo + ipVPNT = time.Now() + mu.Unlock() + } + } +} + +func fetchIPDirect() *IPInfo { + client := &http.Client{Timeout: 10 * time.Second} + return fetchIPFromServices(client) +} + +func fetchIPVPN() *IPInfo { + mixedPort := getMihomoMixedPort() + proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort) + proxyURL, _ := url.Parse("http://" + proxyAddr) + + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + } + return fetchIPFromServices(client) +} + +func fetchIPFromServices(client *http.Client) *IPInfo { + services := []struct { + url string + parse func(body []byte) *IPInfo + }{ + { + "https://ipinfo.io/json", + func(body []byte) *IPInfo { + var data struct { + IP string `json:"ip"` + Country string `json:"country"` + } + if err := json.Unmarshal(body, &data); err != nil || data.IP == "" { + return nil + } + return &IPInfo{IP: data.IP, Country: data.Country, CC: strings.ToLower(data.Country)} + }, + }, + { + "https://ipapi.co/json/", + func(body []byte) *IPInfo { + var data struct { + IP string `json:"ip"` + CountryCode string `json:"country_code"` + CountryName string `json:"country_name"` + } + if err := json.Unmarshal(body, &data); err != nil || data.IP == "" { + return nil + } + cc := strings.ToLower(data.CountryCode) + country := data.CountryName + if country == "" { + country = data.CountryCode + } + return &IPInfo{IP: data.IP, Country: country, CC: cc} + }, + }, + } + + for _, svc := range services { + resp, err := client.Get(svc.url) + if err != nil { + continue + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + if err != nil { + continue + } + info := svc.parse(body) + if info != nil { + return info + } + } + + return nil +} + +func buildMinuteSlots(slots []MinuteSlot) []MinuteSlot { + result := make([]MinuteSlot, minuteSlots) + now := time.Now() + currentMinute := now.Unix() / 60 + + for i := 0; i < minuteSlots; i++ { + minuteOffset := minuteSlots - 1 - i + minuteIdx := int((currentMinute - int64(minuteOffset)) % minuteSlots) + if minuteIdx < 0 { + minuteIdx += minuteSlots + } + result[i] = slots[minuteIdx] + result[i].Minute = currentMinute - int64(minuteOffset) + if result[i].Status == "" { + result[i].Status = "no_data" + } + } + + return result +} + +var countryFlagRe = regexp.MustCompile(`^[a-zA-Z]{2}$`) + +func FlagEmoji(cc string) string { + if !countryFlagRe.MatchString(cc) { + return "" + } + cc = strings.ToUpper(cc) + runes := []rune(cc) + if len(runes) != 2 { + return "" + } + base := rune(0x1F1E6) + return string(base+(runes[0]-'A')) + string(base+(runes[1]-'A')) +} \ No newline at end of file diff --git a/nano-router b/nano-router new file mode 100755 index 0000000..4a88b69 Binary files /dev/null and b/nano-router differ diff --git a/network/apply.go b/network/apply.go index 79a19d6..831a5c3 100644 --- a/network/apply.go +++ b/network/apply.go @@ -6,34 +6,45 @@ import ( "strings" ) -// IfDown brings an interface down via ifdown (or ip link set down as fallback). -func IfDown(name string) error { - out, err := exec.Command("ifdown", "--force", name).CombinedOutput() +// LinkDown sets admin state down without deconfiguring (ip link set down). +func LinkDown(name string) error { + out, err := exec.Command("ip", "link", "set", name, "down").CombinedOutput() if err != nil { - // fallback: ip link set down - out2, err2 := exec.Command("ip", "link", "set", name, "down").CombinedOutput() - if err2 != nil { - return fmt.Errorf("ifdown %s: %s\nip fallback: %s", name, strings.TrimSpace(string(out)), strings.TrimSpace(string(out2))) - } + return fmt.Errorf("ip link set down %s: %s", name, strings.TrimSpace(string(out))) } return nil } -// IfUp brings an interface up via ifup (or ip link set up as fallback). -func IfUp(name string) error { - out, err := exec.Command("ifup", name).CombinedOutput() +// LinkUp sets admin state up without re-running ifup (ip link set up). +func LinkUp(name string) error { + out, err := exec.Command("ip", "link", "set", name, "up").CombinedOutput() if err != nil { - out2, err2 := exec.Command("ip", "link", "set", name, "up").CombinedOutput() - if err2 != nil { - return fmt.Errorf("ifup %s: %s\nip fallback: %s", name, strings.TrimSpace(string(out)), strings.TrimSpace(string(out2))) - } + return fmt.Errorf("ip link set up %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + +// IfDown brings an interface down via ifdown --force. +func IfDown(name string) error { + out, err := exec.Command("ifdown", "--force", name).CombinedOutput() + if err != nil { + return fmt.Errorf("ifdown %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + +// IfUp brings an interface up via ifup --force. +func IfUp(name string) error { + out, err := exec.Command("ifup", "--force", name).CombinedOutput() + if err != nil { + return fmt.Errorf("ifup %s: %s", name, strings.TrimSpace(string(out))) } return nil } // IfRestart brings an interface down then up. func IfRestart(name string) error { - _ = IfDown(name) // ignore "already down" errors + _ = IfDown(name) return IfUp(name) } diff --git a/network/config.go b/network/config.go index b574e28..2ffa6c9 100644 --- a/network/config.go +++ b/network/config.go @@ -13,14 +13,15 @@ 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"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` // wan or lan + Auto bool `json:"auto"` Mode string `json:"mode"` // dhcp, static, loopback, manual Address string `json:"address,omitempty"` // static only Netmask string `json:"netmask,omitempty"` Gateway string `json:"gateway,omitempty"` DNS []string `json:"dns,omitempty"` - Extra map[string]string `json:"extra,omitempty"` // other raw options + Extra map[string]string `json:"extra,omitempty"` } // --- Pending config store (in-memory, not yet written to disk) --- diff --git a/network/interfaces.go b/network/interfaces.go index 3f04f60..554d5ac 100644 --- a/network/interfaces.go +++ b/network/interfaces.go @@ -11,7 +11,7 @@ import ( type InterfaceStats struct { Name string `json:"name"` - State string `json:"state"` // up, down, unknown + State string `json:"state"` IPv4 string `json:"ipv4"` IPv4Mask string `json:"ipv4_mask"` IPv6 []string `json:"ipv6"` @@ -20,7 +20,8 @@ type InterfaceStats struct { TxBytes uint64 `json:"tx_bytes"` RxPackets uint64 `json:"rx_packets"` TxPackets uint64 `json:"tx_packets"` - Mode string `json:"mode"` // dhcp, static, loopback, manual, unknown + Mode string `json:"mode"` + Type string `json:"type"` } // GetInterfaces returns all network interface names from /sys/class/net. @@ -40,7 +41,6 @@ func GetInterfaces() ([]string, error) { func GetInterfaceStats(name string) (*InterfaceStats, error) { s := &InterfaceStats{Name: name, IPv6: []string{}} - // Operational state if raw, err := os.ReadFile("/sys/class/net/" + name + "/operstate"); err == nil { s.State = strings.TrimSpace(string(raw)) } else { diff --git a/network/overlap.go b/network/overlap.go new file mode 100644 index 0000000..9e5e072 --- /dev/null +++ b/network/overlap.go @@ -0,0 +1,87 @@ +package network + +import ( + "fmt" + "net" +) + +type SubnetOverlap struct { + Interface string `json:"interface"` + Subnet string `json:"subnet"` + Label string `json:"label,omitempty"` +} + +func parseIPNet(ipStr, maskStr string) (*net.IPNet, error) { + ip := net.ParseIP(ipStr) + if ip == nil { + return nil, fmt.Errorf("invalid IP: %s", ipStr) + } + ip = ip.To4() + if ip == nil { + return nil, fmt.Errorf("invalid IPv4: %s", ipStr) + } + + mask := ipMaskFromString(maskStr) + if mask == nil { + return nil, fmt.Errorf("invalid netmask: %s", maskStr) + } + + return &net.IPNet{IP: ip.Mask(mask), Mask: mask}, nil +} + +func ipMaskFromString(maskStr string) net.IPMask { + m := net.ParseIP(maskStr) + if m != nil { + if m4 := m.To4(); m4 != nil { + return net.IPMask(m4) + } + } + return nil +} + +func subnetsOverlap(a, b *net.IPNet) bool { + return a.Contains(b.IP) || b.Contains(a.IP) +} + +func CheckOverlap(newIP, newMask, excludeIface string, existing []SubnetOverlap) []SubnetOverlap { + newNet, err := parseIPNet(newIP, newMask) + if err != nil { + return nil + } + + var result []SubnetOverlap + for _, s := range existing { + if s.Interface == excludeIface { + continue + } + parts, err := parseSubnetStr(s.Subnet) + if err != nil { + continue + } + existingNet, err := parseIPNet(parts[0], parts[1]) + if err != nil { + continue + } + if subnetsOverlap(newNet, existingNet) { + result = append(result, s) + } + } + return result +} + +func ParseSubnet(ipStr, maskStr string) (string, error) { + n, err := parseIPNet(ipStr, maskStr) + if err != nil { + return "", err + } + return n.String(), nil +} + +func parseSubnetStr(s string) ([2]string, error) { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == '/' { + return [2]string{s[:i], s[i+1:]}, nil + } + } + return [2]string{}, fmt.Errorf("invalid subnet format: %s", s) +} \ No newline at end of file diff --git a/public/app.js b/public/app.js index 5408864..e601443 100644 --- a/public/app.js +++ b/public/app.js @@ -1,17 +1,13 @@ 'use strict'; -// ── State ──────────────────────────────────────────────────────────────────── - const state = { - interfaces: [], // latest data from /api/interfaces - pending: [], // interface names with pending config - 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 + interfaces: [], + pending: [], + configModal: null, + configModalParent: null, + nat: null, }; -// ── API helpers ────────────────────────────────────────────────────────────── - async function api(method, path, body) { const opts = { method, @@ -30,8 +26,6 @@ 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); } @@ -43,8 +37,6 @@ function vlanId(name) { return m ? parseInt(m[1]) : 0; } -// ── Format helpers ─────────────────────────────────────────────────────────── - function fmtBytes(n) { if (n === undefined || n === null) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; @@ -64,8 +56,6 @@ function modeLabel(m) { return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?'); } -// ── SVG icons ──────────────────────────────────────────────────────────────── - const ICON = { pencil: ` @@ -83,13 +73,42 @@ const ICON = { `, }; -// ── Render ─────────────────────────────────────────────────────────────────── +function ipClass(ip) { + if (!ip) return ''; + const p = ip.split('.').map(Number); + if (p.length !== 4 || p.some(isNaN)) return ''; + if (p[0] >= 1 && p[0] <= 126) return 'A'; + if (p[0] === 127) return 'loopback'; + if (p[0] >= 128 && p[0] <= 191) return 'B'; + if (p[0] >= 192 && p[0] <= 223) return 'C'; + return ''; +} + +function guessMask(ip) { + const c = ipClass(ip); + switch (c) { + case 'A': return '255.0.0.0'; + case 'B': return '255.255.0.0'; + case 'C': return '255.255.255.0'; + default: return ''; + } +} + +function maskToCIDR(mask) { + if (!mask) return ''; + if (/^\d+$/.test(mask)) return '/' + mask; + const parts = mask.split('.').map(Number); + if (parts.length !== 4 || parts.some(isNaN)) return '/' + mask; + const bits = parts.map(p => (p >>> 0).toString(2).padStart(8, '0')).join(''); + const cidr = bits.split('0')[0].length; + if (cidr > 0 && cidr <= 32) return '/' + cidr; + return '/' + mask; +} function renderAll() { const grid = document.getElementById('ifaceGrid'); grid.innerHTML = ''; - // Group VLANs by parent const vlansByParent = {}; const physicals = []; @@ -103,9 +122,38 @@ function renderAll() { } } + const wrap = document.createElement('div'); + wrap.className = 'iface-table-wrap'; + + const table = document.createElement('table'); + table.className = 'iface-table'; + + table.innerHTML = ` + + + + Интерфейс + Тип + IPv4 + Шлюз + Режим + Трафик + Действия + + + + `; + + wrap.appendChild(table); + grid.appendChild(wrap); + + const tbody = document.getElementById('ifaceTableBody'); + for (const iface of physicals) { + const isLo = iface.name === 'lo' || iface.mode === 'loopback'; + if (isLo) continue; const vlans = vlansByParent[iface.name] || []; - grid.appendChild(buildCard(iface, vlans)); + tbody.appendChild(buildPhysicalRow(iface, vlans)); } document.getElementById('loading').classList.add('hidden'); @@ -114,131 +162,126 @@ function renderAll() { renderPendingBanner(); } -function buildCard(iface, vlans) { +function buildPhysicalRow(iface, vlans) { const hasPending = state.pending.includes(iface.name); const sc = stateClass(iface.state); - const isLo = iface.name === 'lo' || iface.mode === 'loopback'; const isUp = iface.state === 'up'; + const isWAN = iface.type === 'wan'; const label = iface.label || ''; + const ipDisplay = iface.ipv4 + ? iface.ipv4 + (iface.ipv4_mask ? maskToCIDR(iface.ipv4_mask) : '') + : ''; + const gwDisplay = iface.gateway + ? iface.gateway + : ''; - const card = document.createElement('div'); - card.className = 'iface-card' + (hasPending ? ' has-pending' : ''); - card.dataset.name = iface.name; + const trafficDisplay = ` +
+ ${fmtBytes(iface.rx_bytes)} + ${fmtBytes(iface.tx_bytes)} +
`; - const ipv6lines = (iface.ipv6 || []).map(a => - `
IPv6${a}
` - ).join(''); + const nameCell = label + ? `
${label}${iface.name}
` + : `${iface.name}`; - const nameBlock = label - ? `
- ${label} - ${iface.name} -
` - : `${iface.name}`; + const typeBadge = isWAN + ? 'WAN' + : 'LAN'; - card.innerHTML = ` -
-
- - ${nameBlock} + const tr = document.createElement('tr'); + tr.className = 'iface-row' + (hasPending ? ' has-pending' : '') + (isWAN ? ' row-wan' : ''); + tr.dataset.name = iface.name; + + tr.innerHTML = ` + + +
+ ${nameCell} ${hasPending ? 'несохранено' : ''} +
- ${modeLabel(iface.mode)} -
- -
-
- IPv4 - ${iface.ipv4 - ? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '') - : ''} + + ${typeBadge} + ${ipDisplay} + ${gwDisplay} + ${modeLabel(iface.mode)} + ${trafficDisplay} + +
+ + +
- ${ipv6lines || `
IPv6
`} -
- Шлюз - ${iface.gateway || ''} -
-
-
- ↓ RX - ${fmtBytes(iface.rx_bytes)} -
-
- ↑ TX - ${fmtBytes(iface.tx_bytes)} -
-
- Пакеты - ${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0} -
-
-
- -
- ${!isLo ? ` - - - ` : ''} - -
- - ${!isLo ? buildVLANSection(iface.name, vlans) : ''} + `; - return card; + const frag = document.createDocumentFragment(); + frag.appendChild(tr); + + for (const v of vlans) { + frag.appendChild(buildVLANRow(v, iface.name)); + } + + + return frag; } -function buildVLANSection(parentName, vlans) { - const rows = vlans.map(v => { - const sc = stateClass(v.state); - const hasPending = state.pending.includes(v.name); - const isUp = v.state === 'up'; - const label = v.label || ''; +function buildVLANRow(v, parentName) { + const sc = stateClass(v.state); + const hasPending = state.pending.includes(v.name); + const isUp = v.state === 'up'; + const label = v.label || ''; + const isWAN = v.type === 'wan'; const ip = v.ipv4 - ? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '') + ? v.ipv4 + (v.ipv4_mask ? maskToCIDR(v.ipv4_mask) : '') : ''; - return ` -
-
- - ${label - ? `
${label}${v.name}
` - : `${v.name}`} - VLAN ${vlanId(v.name)} - ${modeLabel(v.mode)} - ${hasPending ? 'несохранено' : ''} -
-
${ip}
-
- - - -
-
`; - }).join(''); + const gwDisplay = v.gateway ? v.gateway : ''; + const typeBadge = isWAN + ? 'WAN' + : 'LAN'; - const empty = vlans.length === 0 - ? `
Нет тегированных VLAN
` - : ''; + const nameCell = label + ? `
${label}${v.name}
` + : `${v.name}`; - return ` -
-
- VLAN - + const tr = document.createElement('tr'); + tr.className = 'iface-row iface-row-vlan' + (hasPending ? ' has-pending' : ''); + tr.dataset.name = v.name; + tr.dataset.parent = parentName; + + tr.innerHTML = ` + + +
+ + VLAN ${vlanId(v.name)} + ${nameCell} + ${hasPending ? 'несохранено' : ''}
-
- ${rows} - ${empty} + + ${typeBadge} + ${ip} + ${gwDisplay} + ${modeLabel(v.mode)} + + +
+ + +
-
`; + + `; + + return tr; } function renderPendingBanner() { @@ -252,8 +295,6 @@ function renderPendingBanner() { } } -// ── Data loading ───────────────────────────────────────────────────────────── - async function loadAll() { try { const [ifaces, pending] = await Promise.all([ @@ -268,8 +309,6 @@ async function loadAll() { } } -// ── Interface actions ───────────────────────────────────────────────────────── - async function doAction(name, action) { if (action === 'delete') { if (!confirm(`Удалить VLAN ${name}?`)) return; @@ -289,18 +328,15 @@ async function doAction(name, action) { await loadAll(); } catch (e) { showToast(`${name} ${action}: ${e.message}`, 'error'); - await loadAll(); // refresh to restore correct toggle state + await loadAll(); } } -// ── Config modal ────────────────────────────────────────────────────────────── - 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)) { @@ -318,7 +354,9 @@ async function openConfig(name) { get('/api/nat').catch(() => null), ]); if (natData) state.nat = natData; - fillForm(configData.config, configData.pending, name, configData.label || ''); + let currentType = configData.type || 'lan'; + if (isVLAN(name)) currentType = 'lan'; + fillForm(configData.config, configData.pending, name, configData.label || '', currentType, isVLAN(name)); document.getElementById('modal').classList.remove('hidden'); } catch (e) { showToast('Ошибка загрузки конфига: ' + e.message, 'error'); @@ -326,6 +364,11 @@ async function openConfig(name) { } async function openNewVLAN(parentName) { + const parentIface = state.interfaces.find(i => i.name === parentName); + if (parentIface && parentIface.type === 'wan') { + showToast('Нельзя добавить VLAN на WAN-интерфейс', 'error'); + return; + } state.configModal = null; state.configModalParent = parentName; document.getElementById('modalTitle').textContent = `Новый VLAN на ${parentName}`; @@ -341,11 +384,11 @@ async function openNewVLAN(parentName) { if (natData) state.nat = natData; } catch (_) {} - fillForm({ auto: true, mode: 'static' }, false, '', ''); + fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true); document.getElementById('modal').classList.remove('hidden'); } -function fillForm(cfg, pending, name, label = '') { +function fillForm(cfg, pending, name, label = '', ifaceType = 'lan', forceLAN = false) { document.getElementById('cfgLabel').value = label; document.getElementById('cfgAuto').checked = !!cfg.auto; document.getElementById('cfgAddress').value = cfg.address || ''; @@ -353,22 +396,56 @@ function fillForm(cfg, pending, name, label = '') { document.getElementById('cfgGateway').value = cfg.gateway || ''; document.getElementById('cfgDNS').value = (cfg.dns || []).join(' '); + setType(ifaceType, forceLAN); + const mode = cfg.mode === 'static' ? 'static' : 'dhcp'; setMode(mode); if (pending && name) { document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`; } +} - // NAT section — show for all non-loopback interfaces +function setType(t, forceLAN = false) { + document.querySelectorAll('#typeSwitch .seg-btn').forEach(b => { + b.classList.toggle('active', b.dataset.type === t); + }); + const typeRow = document.getElementById('typeSwitch').closest('.form-row'); + typeRow.classList.toggle('hidden', forceLAN); + updateTypeVisibility(t); +} + +function currentType() { + return document.querySelector('#typeSwitch .seg-btn.active')?.dataset.type ?? 'lan'; +} + +function updateTypeVisibility(type) { + const modeRow = document.getElementById('modeRow'); + const gatewayRow = document.getElementById('gatewayRow'); + const dnsRow = document.getElementById('dnsRow'); const natSection = document.getElementById('natSection'); - const natNotInstalled = document.getElementById('natNotInstalled'); - const cfgNAT = document.getElementById('cfgNAT'); - if (cfg.mode === 'loopback' || name === 'lo') { - natSection.classList.add('hidden'); - } else { + if (type === 'lan') { + modeRow.classList.add('hidden'); + setMode('static'); + gatewayRow.classList.add('hidden'); + dnsRow.classList.add('hidden'); natSection.classList.remove('hidden'); + } else { + modeRow.classList.remove('hidden'); + gatewayRow.classList.remove('hidden'); + dnsRow.classList.remove('hidden'); + natSection.classList.add('hidden'); + } + + updateNATSection(type); +} + +function updateNATSection(type) { + if (type === 'lan') { + const natNotInstalled = document.getElementById('natNotInstalled'); + const cfgNAT = document.getElementById('cfgNAT'); + const name = state.configModal; const natInstalled = state.nat?.installed !== false; cfgNAT.disabled = !natInstalled; natNotInstalled.classList.toggle('hidden', natInstalled); @@ -377,14 +454,14 @@ function fillForm(cfg, pending, name, label = '') { } function setMode(mode) { - document.querySelectorAll('.seg-btn').forEach(b => { + document.querySelectorAll('#modeSwitch .seg-btn').forEach(b => { b.classList.toggle('active', b.dataset.mode === mode); }); document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static'); } function currentMode() { - return document.querySelector('.seg-btn.active')?.dataset.mode ?? 'dhcp'; + return document.querySelector('#modeSwitch .seg-btn.active')?.dataset.mode ?? 'dhcp'; } function closeModal() { @@ -398,7 +475,6 @@ async function saveConfig() { 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; @@ -408,23 +484,24 @@ async function saveConfig() { } name = `${parent}.${id}`; - // Check for duplicate if (state.interfaces.find(i => i.name === name)) { showToast(`VLAN ${name} уже существует`, 'error'); return; } } - const mode = currentMode(); + const type = currentType(); + const mode = type === 'lan' ? 'static' : currentMode(); const cfg = { name, label: document.getElementById('cfgLabel').value.trim(), + type, auto: document.getElementById('cfgAuto').checked, mode, address: document.getElementById('cfgAddress').value.trim(), netmask: document.getElementById('cfgNetmask').value.trim(), - gateway: document.getElementById('cfgGateway').value.trim(), - dns: document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean), + gateway: type === 'wan' ? document.getElementById('cfgGateway').value.trim() : '', + dns: type === 'wan' ? document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean) : [], extra: {}, }; @@ -433,10 +510,14 @@ async function saveConfig() { return; } + if (type === 'wan' && mode === 'static' && !cfg.netmask) { + showToast('Укажите маску сети', 'error'); + return; + } + try { await post(`/api/config/${name}`, cfg); - // Save NAT setting if section is visible const natSection = document.getElementById('natSection'); if (!natSection.classList.contains('hidden') && state.nat?.installed !== false) { const natEnabled = document.getElementById('cfgNAT').checked; @@ -456,8 +537,6 @@ async function saveConfig() { } } -// ── Apply / discard ─────────────────────────────────────────────────────────── - async function applyAll() { const btn = document.getElementById('applyBtn'); btn.disabled = true; @@ -484,8 +563,6 @@ async function discardAll() { await loadAll(); } -// ── Toast ───────────────────────────────────────────────────────────────────── - let toastTimer; function showToast(msg, type = 'info') { const t = document.getElementById('toast'); @@ -496,17 +573,12 @@ function showToast(msg, type = 'info') { toastTimer = setTimeout(() => t.classList.add('hidden'), 3500); } -// ── Event wiring ────────────────────────────────────────────────────────────── - -document.getElementById('refreshBtn').addEventListener('click', loadAll); document.getElementById('applyBtn').addEventListener('click', applyAll); document.getElementById('discardAllBtn').addEventListener('click', discardAll); -// Card button clicks (delegated) document.getElementById('ifaceGrid').addEventListener('click', e => { const btn = e.target.closest('[data-action]'); if (!btn) return; - // Don't handle toggle inputs here (handled by 'change' below) if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return; const { action, iface } = btn.dataset; if (!action || !iface) return; @@ -519,14 +591,12 @@ document.getElementById('ifaceGrid').addEventListener('click', e => { } }); -// Toggle switch (on/off) — delegated change event document.getElementById('ifaceGrid').addEventListener('change', e => { const input = e.target.closest('input[data-action="toggle"]'); if (!input) return; doAction(input.dataset.iface, input.checked ? 'up' : 'down'); }); -// Modal close document.getElementById('closeModal').addEventListener('click', closeModal); document.getElementById('cancelConfigBtn').addEventListener('click', closeModal); document.getElementById('modalBackdrop').addEventListener('click', closeModal); @@ -534,24 +604,33 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); -// Mode switcher +document.getElementById('typeSwitch').addEventListener('click', e => { + const btn = e.target.closest('.seg-btn'); + if (btn) setType(btn.dataset.type); +}); + document.getElementById('modeSwitch').addEventListener('click', e => { const btn = e.target.closest('.seg-btn'); if (btn) setMode(btn.dataset.mode); }); -// Save config +document.getElementById('cfgNetmask').addEventListener('focus', () => { + const maskInput = document.getElementById('cfgNetmask'); + if (maskInput.value) return; + const addr = document.getElementById('cfgAddress').value.trim(); + if (!addr) return; + const m = guessMask(addr); + if (m) maskInput.value = m; +}); + document.getElementById('saveConfigBtn').addEventListener('click', saveConfig); document.getElementById('configForm').addEventListener('submit', e => { e.preventDefault(); saveConfig(); }); -// Auto-refresh every 10 seconds setInterval(loadAll, 10000); -// ── Init ────────────────────────────────────────────────────────────────────── - (async () => { await loadAll(); -})(); +})(); \ No newline at end of file diff --git a/public/clients.html b/public/clients.html index fb677ab..55bf360 100644 --- a/public/clients.html +++ b/public/clients.html @@ -3,7 +3,7 @@ - Клиенты — AlpineRouter + Клиенты — NanoRouter @@ -15,21 +15,21 @@ -

AlpineRouter

+

NanoRouter

-
+ +
+
+ +
+ + + +
+
+
+
+ +
+ + + +
+
+
@@ -92,6 +124,7 @@ MAC-адрес Интерфейс Тип + Маршрут ↑ Отправлено ↓ Получено Активность @@ -143,15 +176,14 @@
-
-
-
Доступ в интернет
-
Отключите, чтобы запретить устройству выход в интернет
+
+ +
+ + +
- + Пусто = использовать политику по умолчанию
-
-
-
-
- -
@@ -41,7 +33,14 @@
@@ -83,7 +89,7 @@
Загрузка...
- +
@@ -111,6 +117,14 @@
+
+ +
+ + +
+
+
-
+ +
@@ -135,16 +150,17 @@
-
+
-
+
+