Compare commits

..

4 Commits

Author SHA1 Message Date
91b4585175 15.04.2026 Update 2026-04-15 12:25:39 +03:00
f50d79fab3 14.04.2026 Update 2026-04-15 11:38:26 +03:00
MoonDev
6aa0349f5d Mihomo improved 2026-04-13 18:56:13 +03:00
MoonDev
8c35022483 Firewall added & some fixes 2026-04-13 12:40:49 +03:00
50 changed files with 11489 additions and 1188 deletions

View File

@@ -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 .
```

View File

@@ -1,8 +1,9 @@
1. Все настройки обязательно сохранять в config.yaml и восстанавливать оттуда при первом запуске бинарника.
1. Все настройки всех подсистем обязательно сохранять в config.yaml и восстанавливать оттуда при первом запуске бинарника, затрия то, что осталось в конфигах управляемых сервисов.
2. Функциональные разделы админки писать отдельными html страницами и добавлять в главное меню.
3. Документировать весь новый функционал в docs/
Установить пакеты:
Зависимости alpine:
dnsmasq
nftables
conntrack-tools

Binary file not shown.

484
auth/auth.go Normal file
View File

@@ -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()
}
}()
}

View File

@@ -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 {

View File

@@ -10,6 +10,8 @@ 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"`
@@ -46,18 +48,64 @@ 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 {
Enabled bool `yaml:"enabled"`
}
type FirewallRule struct {
ID string `yaml:"id" json:"id"`
Enabled bool `yaml:"enabled" json:"enabled"`
Action string `yaml:"action" json:"action"`
Protocol string `yaml:"protocol" json:"protocol"`
SrcAddr string `yaml:"src_addr" json:"src_addr"`
SrcPort string `yaml:"src_port" json:"src_port"`
DstAddr string `yaml:"dst_addr" json:"dst_addr"`
DstPort string `yaml:"dst_port" json:"dst_port"`
InIface string `yaml:"in_iface" json:"in_iface"`
OutIface string `yaml:"out_iface" json:"out_iface"`
Comment string `yaml:"comment" json:"comment"`
}
type FirewallConfig struct {
Rules []FirewallRule `yaml:"rules" json:"rules"`
VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"`
}
type 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"`
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"`
ListenAddresses []string `yaml:"listen_addresses,omitempty" json:"listen_addresses,omitempty"`
}
var (
@@ -166,6 +214,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{}
@@ -176,9 +237,18 @@ func EnsureDefaults(cfg *AppConfig) {
if cfg.NAT.Interfaces == nil {
cfg.NAT.Interfaces = []string{}
}
if cfg.Firewall.Rules == nil {
cfg.Firewall.Rules = []FirewallRule{}
}
if cfg.KnownDevices == nil {
cfg.KnownDevices = []KnownDevice{}
}
if cfg.ClientPolicy.Default == "" {
cfg.ClientPolicy.Default = "direct"
}
if len(cfg.Connectivity.Direct) == 0 {
cfg.Connectivity = DefaultConnectivity()
}
}
func UpdateKnownDevices(seen []KnownDevice) error {
@@ -214,6 +284,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

View File

@@ -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")

278
firewall/firewall.go Normal file
View File

@@ -0,0 +1,278 @@
package firewall
import (
"fmt"
"os"
"os/exec"
"strings"
)
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 {
ID string `yaml:"id" json:"id"`
Enabled bool `yaml:"enabled" json:"enabled"`
Action string `yaml:"action" json:"action"` // accept | drop | reject
Protocol string `yaml:"protocol" json:"protocol"` // tcp | udp | icmp | all
SrcAddr string `yaml:"src_addr" json:"src_addr"` // CIDR or IP, empty = any
SrcPort string `yaml:"src_port" json:"src_port"` // "80" | "80-443", empty = any
DstAddr string `yaml:"dst_addr" json:"dst_addr"`
DstPort string `yaml:"dst_port" json:"dst_port"`
InIface string `yaml:"in_iface" json:"in_iface"` // input interface, empty = any
OutIface string `yaml:"out_iface" json:"out_iface"` // output interface, empty = any
Comment string `yaml:"comment" json:"comment"`
}
// Config is the top-level firewall config stored in config.yaml.
type Config struct {
Rules []Rule `yaml:"rules" json:"rules"`
VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"`
}
// NATConfig holds NAT masquerade settings (passed to avoid a direct nat import).
type NATConfig struct {
Interfaces []string
}
// 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
// - 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
// - Default accept from LAN interfaces to WAN
//
// 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", "nano-router-nat").Run()
exec.Command("nft", "delete", "table", "ip", tableName).Run()
var activeRules []Rule
for _, r := range fwCfg.Rules {
if r.Enabled {
activeRules = append(activeRules, r)
}
}
hasNAT := len(natCfg.Interfaces) > 0
hasDisabled := len(cp.DisabledIPs) > 0
hasVPN := len(cp.VPNIPs) > 0
hasVLANIsolation := fwCfg.VLANIsolation && len(lanIfaces) >= 2
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")
// 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 == "" {
continue
}
if rule.Comment != "" {
fmt.Fprintf(&sb, " # %s\n", rule.Comment)
}
fmt.Fprintf(&sb, " %s\n", line)
}
// LAN isolation — drop traffic between any two local (LAN) interfaces.
// Placed AFTER user rules so explicit allow rules above still take effect.
if hasVLANIsolation {
quoted := make([]string, len(lanIfaces))
for i, v := range lanIfaces {
quoted[i] = fmt.Sprintf("%q", v)
}
set := "{ " + strings.Join(quoted, ", ") + " }"
fmt.Fprintf(&sb, " iifname %s oifname %s drop\n", set, set)
}
// Allow from LAN/VLAN interfaces outbound to WAN.
for _, iface := range natCfg.Interfaces {
fmt.Fprintf(&sb, " iifname %q accept\n", iface)
}
sb.WriteString(" }\n")
// ── Postrouting (masquerade) ─────────────────────────────────────────────
if hasNAT {
sb.WriteString(" chain postrouting {\n")
sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n")
for _, iface := range natCfg.Interfaces {
fmt.Fprintf(&sb, " iifname %q masquerade\n", iface)
}
sb.WriteString(" }\n")
}
sb.WriteString("}\n")
cmd := exec.Command("nft", "-f", "-")
cmd.Stdin = strings.NewReader(sb.String())
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err)
}
// 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.
func flushConntrack() {
if err := exec.Command("conntrack", "-F").Run(); err == nil {
return
}
_ = os.WriteFile("/proc/sys/net/netfilter/nf_conntrack_flush", []byte("1"), 0644)
}
// buildRuleLine converts a Rule to a single nftables match+action string.
// Returns "" if the rule has no valid action.
func buildRuleLine(r Rule) string {
if r.Action == "" {
return ""
}
var parts []string
if r.InIface != "" {
parts = append(parts, fmt.Sprintf("iifname %q", r.InIface))
}
if r.OutIface != "" {
parts = append(parts, fmt.Sprintf("oifname %q", r.OutIface))
}
if r.SrcAddr != "" {
parts = append(parts, "ip saddr "+r.SrcAddr)
}
if r.DstAddr != "" {
parts = append(parts, "ip daddr "+r.DstAddr)
}
proto := strings.ToLower(r.Protocol)
switch proto {
case "tcp", "udp":
if r.SrcPort != "" || r.DstPort != "" {
if r.SrcPort != "" {
parts = append(parts, proto+" sport "+r.SrcPort)
}
if r.DstPort != "" {
parts = append(parts, proto+" dport "+r.DstPort)
}
} else {
parts = append(parts, "ip protocol "+proto)
}
case "icmp":
parts = append(parts, "ip protocol icmp")
}
parts = append(parts, r.Action)
return strings.Join(parts, " ")
}

2
go.mod
View File

@@ -1,4 +1,4 @@
module alpine-router
module nano-router
go 1.21

View File

@@ -5,8 +5,8 @@ import (
"net/http"
"strings"
"alpine-router/config"
"alpine-router/network"
"nano-router/config"
"nano-router/network"
)
type apiResp struct {
@@ -45,11 +45,22 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
type iface struct {
*network.InterfaceStats
Pending bool `json:"pending"`
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 {
existingNames[name] = true
s, err := network.GetInterfaceStats(name)
if err != nil {
continue
@@ -57,8 +68,68 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
if cfg, ok := fileCfg[name]; ok {
s.Mode = cfg.Mode
}
_, hasPending := network.GetPendingConfig(name), network.GetPendingConfig(name) != nil
result = append(result, iface{s, hasPending})
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
}
}
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})
}
for name, cfg := range network.GetAllPending() {
if existingNames[name] || !network.IsVLAN(name) {
continue
}
s := &network.InterfaceStats{
Name: name,
State: "unknown",
Mode: cfg.Mode,
Type: cfg.Type,
IPv6: []string{},
}
if cfg.Mode == "static" {
s.IPv4 = cfg.Address
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
}
}
isNAT := false
for _, ni := range natIfaces {
if ni == name {
isNAT = true
break
}
}
result = append(result, iface{s, true, label, isNAT})
}
ok(w, result)
@@ -95,11 +166,18 @@ 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":
if !network.IsVLAN(name) {
fail(w, http.StatusBadRequest, "delete is only supported for VLAN interfaces")
return
}
network.ClearPendingConfig(name)
err = network.DeleteVLAN(name)
default:
fail(w, http.StatusBadRequest, "unknown action: "+action)
return
@@ -121,8 +199,23 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
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 {
ok(w, map[string]interface{}{"config": cfg, "pending": true})
if cfg.Label != "" {
label = cfg.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()
@@ -131,11 +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})
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,
})
}
@@ -146,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{}
}
@@ -160,6 +302,8 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
appCfg.Interfaces = map[string]*config.InterfaceConfig{}
}
appCfg.Interfaces[name] = &config.InterfaceConfig{
Label: cfg.Label,
Type: cfg.Type,
Auto: cfg.Auto,
Mode: cfg.Mode,
Address: cfg.Address,

211
handlers/auth.go Normal file
View File

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

View File

@@ -6,10 +6,9 @@ import (
"net/http"
"strings"
"alpine-router/clients"
"alpine-router/config"
"alpine-router/dhcp"
"alpine-router/nat"
"nano-router/clients"
"nano-router/config"
"nano-router/dhcp"
)
func HandleClients(w http.ResponseWriter, r *http.Request) {
@@ -43,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
}
@@ -59,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
@@ -68,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
}
@@ -80,46 +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 applyBlockedFirewall() {
if !nat.IsInstalled() {
return
}
cfg, err := config.Load()
if err != nil {
log.Printf("Warning: load config for blocked firewall: %v", err)
return
}
var blockedIPs []string
for _, kd := range cfg.KnownDevices {
if kd.Blocked {
ip := kd.IP
if kd.StaticIP != "" {
ip = kd.StaticIP
}
if ip != "" {
blockedIPs = append(blockedIPs, ip)
}
}
}
natCfg := &nat.Config{Interfaces: cfg.NAT.Interfaces}
if err := nat.ApplyRulesWithBlocked(natCfg, blockedIPs); err != nil {
log.Printf("Warning: apply blocked firewall rules: %v", err)
} else {
log.Printf("Applied firewall rules (%d blocked clients)", len(blockedIPs))
}
}
func applyDHCPStaticBindings() {
if !dhcp.IsInstalled() {
return

16
handlers/dashboard.go Normal file
View File

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

View File

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

100
handlers/firewall.go Normal file
View File

@@ -0,0 +1,100 @@
package handlers
import (
"encoding/json"
"fmt"
"math/rand"
"net/http"
"nano-router/config"
"nano-router/nat"
"nano-router/network"
)
func HandleFirewall(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleFirewallGet(w, r)
case http.MethodPost:
handleFirewallSave(w, r)
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleFirewallGet(w http.ResponseWriter, r *http.Request) {
cfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// Collect interface names for the UI dropdowns.
names, _ := network.GetInterfaces()
ok(w, map[string]interface{}{
"installed": nat.IsInstalled(),
"rules": cfg.Firewall.Rules,
"vlan_isolation": cfg.Firewall.VLANIsolation,
"interfaces": names,
})
}
func handleFirewallSave(w http.ResponseWriter, r *http.Request) {
var body struct {
Rules []config.FirewallRule `json:"rules"`
VLANIsolation bool `json:"vlan_isolation"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
// Assign IDs to new rules that lack one.
for i := range body.Rules {
if body.Rules[i].ID == "" {
body.Rules[i].ID = fmt.Sprintf("%x", rand.Int63())
}
}
cfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, "load config: "+err.Error())
return
}
cfg.Firewall.Rules = body.Rules
cfg.Firewall.VLANIsolation = body.VLANIsolation
if err := config.Save(cfg); err != nil {
fail(w, http.StatusInternalServerError, "save config: "+err.Error())
return
}
ok(w, map[string]string{"message": "saved"})
}
func HandleFirewallApply(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !nat.IsInstalled() {
fail(w, http.StatusServiceUnavailable, "nftables (nft) не установлен — выполните: apk add nftables")
return
}
cfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, "load config: "+err.Error())
return
}
if err := applyAllRules(cfg); err != nil {
fail(w, http.StatusInternalServerError, "apply: "+err.Error())
return
}
ok(w, map[string]string{"message": "firewall applied"})
}

View File

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

217
handlers/mihomo_proxy.go Normal file
View File

@@ -0,0 +1,217 @@
package handlers
import (
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"nano-router/mihomo"
)
func getMihomoAPIBase() string {
cfg, err := mihomo.LoadConfig()
if err != nil {
return "http://127.0.0.1:9090"
}
ec, _ := cfg["external-controller"].(string)
if ec == "" {
ec = "0.0.0.0:9090"
}
if strings.HasPrefix(ec, "0.0.0.0:") || strings.HasPrefix(ec, ":") {
ec = "127.0.0.1" + strings.TrimPrefix(ec, "0.0.0.0")
}
if !strings.HasPrefix(ec, "http") {
ec = "http://" + ec
}
return ec
}
func getMihomoSecret() string {
cfg, err := mihomo.LoadConfig()
if err != nil {
return ""
}
s, _ := cfg["secret"].(string)
return s
}
func getMihomoHostPort() (string, string) {
u, err := url.Parse(getMihomoAPIBase())
if err != nil {
return "127.0.0.1", "9090"
}
host := u.Hostname()
if host == "0.0.0.0" || host == "" {
host = "127.0.0.1"
}
port := u.Port()
if port == "" {
if u.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
return host, port
}
func HandleMihomoAPIProxy(w http.ResponseWriter, r *http.Request) {
base := getMihomoAPIBase()
secret := getMihomoSecret()
suffix := strings.TrimPrefix(r.URL.Path, "/api/mihomo/api/")
target, err := url.Parse(base + "/" + suffix)
if err != nil {
fail(w, http.StatusInternalServerError, "parse mihomo url: "+err.Error())
return
}
q := r.URL.Query()
if r.Method == http.MethodGet {
q.Set("nonce", "1")
}
target.RawQuery = q.Encode()
proxyReq, err := http.NewRequest(r.Method, target.String(), r.Body)
if err != nil {
fail(w, http.StatusInternalServerError, "create proxy request: "+err.Error())
return
}
for k, vv := range r.Header {
if strings.EqualFold(k, "Authorization") {
continue
}
for _, v := range vv {
proxyReq.Header.Add(k, v)
}
}
if secret != "" {
proxyReq.Header.Set("Authorization", "Bearer "+secret)
}
client := &http.Client{}
resp, err := client.Do(proxyReq)
if err != nil {
fail(w, http.StatusBadGateway, "mihomo api unreachable: "+err.Error())
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
if strings.EqualFold(k, "Content-Length") {
continue
}
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, err = io.Copy(w, resp.Body)
if err != nil {
log.Printf("proxy copy error: %v", err)
}
}
func HandleMihomoWSProxy(w http.ResponseWriter, r *http.Request) {
secret := getMihomoSecret()
host, port := getMihomoHostPort()
suffix := strings.TrimPrefix(r.URL.Path, "/api/mihomo/ws/")
path := "/" + suffix
if r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
}
hj, ok := w.(http.Hijacker)
if !ok {
fail(w, http.StatusInternalServerError, "websocket hijack not supported")
return
}
clientConn, _, err := hj.Hijack()
if err != nil {
fail(w, http.StatusInternalServerError, "hijack failed: "+err.Error())
return
}
remoteConn, err := net.Dial("tcp", net.JoinHostPort(host, port))
if err != nil {
fail(w, http.StatusBadGateway, "mihomo ws connect failed: "+err.Error())
clientConn.Close()
return
}
upgradeReq := "GET " + path + " HTTP/1.1\r\n"
upgradeReq += "Host: " + host + ":" + port + "\r\n"
upgradeReq += "Upgrade: websocket\r\n"
upgradeReq += "Connection: Upgrade\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"
}
for k, vv := range r.Header {
if strings.EqualFold(k, "Upgrade") || strings.EqualFold(k, "Connection") ||
strings.EqualFold(k, "Sec-Websocket-Key") || strings.EqualFold(k, "Sec-Websocket-Version") ||
strings.EqualFold(k, "Sec-Websocket-Extensions") || strings.EqualFold(k, "Sec-Websocket-Protocol") ||
strings.EqualFold(k, "Authorization") {
continue
}
for _, v := range vv {
upgradeReq += k + ": " + v + "\r\n"
}
}
for k, v := range r.Header {
if strings.EqualFold(k, "Sec-WebSocket-Protocol") {
upgradeReq += "Sec-WebSocket-Protocol: " + strings.Join(v, ", ") + "\r\n"
}
if strings.EqualFold(k, "Sec-WebSocket-Extensions") {
upgradeReq += "Sec-WebSocket-Extensions: " + strings.Join(v, ", ") + "\r\n"
}
}
upgradeReq += "\r\n"
_, err = remoteConn.Write([]byte(upgradeReq))
if err != nil {
clientConn.Close()
remoteConn.Close()
return
}
buf := make([]byte, 4096)
n, err := remoteConn.Read(buf)
if err != nil {
clientConn.Close()
remoteConn.Close()
return
}
respStr := string(buf[:n])
if !strings.Contains(respStr, "101") {
clientConn.Write(buf[:n])
clientConn.Close()
remoteConn.Close()
return
}
headerEnd := strings.Index(respStr, "\r\n\r\n")
if headerEnd >= 0 {
clientConn.Write(buf[:n])
} else {
clientConn.Write(buf[:n])
}
go func() {
io.Copy(remoteConn, clientConn)
remoteConn.Close()
}()
go func() {
io.Copy(clientConn, remoteConn)
clientConn.Close()
}()
}

View File

@@ -4,8 +4,8 @@ import (
"encoding/json"
"net/http"
"alpine-router/config"
"alpine-router/nat"
"nano-router/config"
"nano-router/nat"
)
func HandleNATGet(w http.ResponseWriter, r *http.Request) {
@@ -46,23 +46,33 @@ 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())
return
}
if err := nat.ApplyRules(&cfg); err != nil {
if err := applyAllRules(appCfg); err != nil {
fail(w, http.StatusInternalServerError, "apply: "+err.Error())
return
}

172
handlers/overlap.go Normal file
View File

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

67
handlers/policy_sync.go Normal file
View File

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

141
handlers/rules.go Normal file
View File

@@ -0,0 +1,141 @@
package handlers
import (
"log"
"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 + tproxy for VPN clients + disabled client drops +
// user firewall rules + VLAN isolation.
func applyAllRules(cfg *config.AppConfig) error {
if !nat.IsInstalled() {
return nil
}
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 {
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).
seen := map[string]bool{}
var lanIfaces []string
addLAN := func(name string) {
if name != "" && !seen[name] {
lanIfaces = append(lanIfaces, name)
seen[name] = true
}
}
for _, name := range cfg.NAT.Interfaces {
addLAN(name)
}
names, _ := network.GetInterfaces()
for _, name := range names {
if network.IsVLAN(name) {
addLAN(name)
addLAN(network.VLANParent(name))
}
}
for name := range network.GetAllPending() {
if network.IsVLAN(name) {
addLAN(name)
addLAN(network.VLANParent(name))
}
}
// Convert config.FirewallRule → firewall.Rule.
fwRules := make([]firewall.Rule, len(cfg.Firewall.Rules))
for i, r := range cfg.Firewall.Rules {
fwRules[i] = firewall.Rule{
ID: r.ID,
Enabled: r.Enabled,
Action: r.Action,
Protocol: r.Protocol,
SrcAddr: r.SrcAddr,
SrcPort: r.SrcPort,
DstAddr: r.DstAddr,
DstPort: r.DstPort,
InIface: r.InIface,
OutIface: r.OutIface,
Comment: r.Comment,
}
}
return firewall.ApplyAll(
firewall.NATConfig{Interfaces: cfg.NAT.Interfaces},
firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation},
lanIfaces,
firewall.ClientPolicies{
DisabledIPs: disabledIPs,
VPNIPs: vpnIPs,
},
)
}
// applyBlockedFirewall is the async helper called after client or policy updates.
func applyBlockedFirewall() {
cfg, err := config.Load()
if err != nil {
log.Printf("Warning: load config for firewall: %v", err)
return
}
if err := applyAllRules(cfg); err != nil {
log.Printf("Warning: apply firewall rules: %v", err)
}
}

198
main.go
View File

@@ -8,20 +8,31 @@ import (
"os"
"path/filepath"
"strings"
"time"
"alpine-router/config"
"alpine-router/dhcp"
"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/setup"
"nano-router/traffic"
)
//go:embed public
var publicFS embed.FS
func main() {
if len(os.Args) > 1 && os.Args[1] == "setup" {
setup.Run()
return
}
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config.yaml: %v", err)
@@ -35,6 +46,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()
@@ -50,6 +68,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/")
@@ -62,12 +86,18 @@ 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)
mux.HandleFunc("/api/firewall", handlers.HandleFirewall)
mux.HandleFunc("/api/firewall/apply", handlers.HandleFirewallApply)
mux.HandleFunc("/api/nat", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
@@ -100,23 +130,63 @@ func main() {
mux.HandleFunc("/api/mihomo/config.yaml", handlers.HandleMihomoConfigYAML)
mux.HandleFunc("/api/mihomo/logs", handlers.HandleMihomoLogs)
mux.HandleFunc("/api/mihomo/upload-core", handlers.HandleMihomoUploadCore)
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))
handler := auth.PublicAuthMiddleware(mux)
if len(cfg.ListenAddresses) > 0 {
servers := make([]*http.Server, len(cfg.ListenAddresses))
errCh := make(chan error, len(cfg.ListenAddresses))
for i, addr := range cfg.ListenAddresses {
bind := addr + ":" + port
srv := &http.Server{Addr: bind, Handler: handler}
servers[i] = srv
log.Printf("Network Manager listening on http://%s", bind)
go func(s *http.Server) { errCh <- s.ListenAndServe() }(srv)
}
log.Fatal(<-errCh)
} else {
log.Printf("Network Manager listening on http://0.0.0.0:%s", port)
log.Fatal(http.ListenAndServe(":"+port, handler))
}
}
func importSystemState() *config.AppConfig {
@@ -214,26 +284,101 @@ 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
policy := kd.Policy
if policy == "" {
if kd.Blocked {
policy = "disabled"
} else {
policy = defaultPolicy
}
if ip != "" {
blockedIPs = append(blockedIPs, ip)
}
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.
seenLAN := map[string]bool{}
var lanIfaces []string
addLAN := func(name string) {
if name != "" && !seenLAN[name] {
lanIfaces = append(lanIfaces, name)
seenLAN[name] = true
}
}
for _, name := range cfg.NAT.Interfaces {
addLAN(name)
}
if names, err := network.GetInterfaces(); err == nil {
for _, name := range names {
if network.IsVLAN(name) {
addLAN(name)
addLAN(network.VLANParent(name))
}
}
}
if err := nat.ApplyRulesWithBlocked(natCfg, blockedIPs); err != nil {
log.Printf("Warning: apply NAT: %v", err)
} else {
log.Printf("NAT rules applied (%d interfaces, %d blocked clients)", len(cfg.NAT.Interfaces), len(blockedIPs))
// Convert config firewall rules.
fwRules := make([]firewall.Rule, len(cfg.Firewall.Rules))
for i, r := range cfg.Firewall.Rules {
fwRules[i] = firewall.Rule{
ID: r.ID, Enabled: r.Enabled, Action: r.Action, Protocol: r.Protocol,
SrcAddr: r.SrcAddr, SrcPort: r.SrcPort, DstAddr: r.DstAddr, DstPort: r.DstPort,
InIface: r.InIface, OutIface: r.OutIface, Comment: r.Comment,
}
}
err := firewall.ApplyAll(
firewall.NATConfig{Interfaces: cfg.NAT.Interfaces},
firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation},
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 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 unavailable (install with: apk add nftables)")
log.Printf("nftables not installed — NAT/firewall unavailable (install with: apk add nftables)")
}
if dhcp.IsInstalled() {
@@ -284,4 +429,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)")
}
}
}

View File

@@ -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()

832
monitor/monitor.go Normal file
View File

@@ -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'))
}

BIN
nano-router Executable file

Binary file not shown.

View File

@@ -6,19 +6,13 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
)
const tableName = "alpine-router-nat"
// Config holds NAT masquerade settings per interface.
type Config struct {
// Interfaces is the list of LAN interface names for which masquerade is enabled.
// Traffic arriving on these interfaces will be NATted to the outgoing WAN interface.
Interfaces []string `json:"interfaces"`
}
// configPath returns the path to nat.json next to the running binary.
func configPath() string {
exe, err := os.Executable()
if err != nil {
@@ -34,7 +28,6 @@ func IsInstalled() bool {
}
// Load reads the NAT config from disk.
// Returns an empty config if the file does not exist yet.
func Load() (*Config, error) {
data, err := os.ReadFile(configPath())
if err != nil {
@@ -53,7 +46,7 @@ func Load() (*Config, error) {
return &cfg, nil
}
// Save writes the NAT config to the configs/ directory next to the binary.
// Save writes the NAT config to the configs/ directory.
func Save(cfg *Config) error {
p := configPath()
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
@@ -65,59 +58,3 @@ func Save(cfg *Config) error {
}
return os.WriteFile(p, data, 0644)
}
// ApplyRules flushes the existing alpine-router NAT table and recreates it
// from the provided config. Called on every daemon startup and on config save.
//
// nftables is used instead of iptables because it applies all rules atomically
// in a single kernel call, which is faster and avoids partial-state issues.
func ApplyRules(cfg *Config) error {
return ApplyRulesWithBlocked(cfg, nil)
}
// ApplyRulesWithBlocked is like ApplyRules but also installs drop rules for
// the given list of blocked client IPs.
func ApplyRulesWithBlocked(cfg *Config, blockedIPs []string) error {
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil {
return fmt.Errorf("enable ip_forward: %w", err)
}
exec.Command("nft", "delete", "table", "ip", tableName).Run()
if len(cfg.Interfaces) == 0 && len(blockedIPs) == 0 {
return nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "table ip %s {\n", tableName)
sb.WriteString(" chain forward {\n")
sb.WriteString(" type filter hook forward priority filter; policy drop;\n")
sb.WriteString(" ct state established,related accept\n")
for _, ip := range blockedIPs {
fmt.Fprintf(&sb, " ip saddr %s drop\n", ip)
fmt.Fprintf(&sb, " ip daddr %s drop\n", ip)
}
for _, iface := range cfg.Interfaces {
fmt.Fprintf(&sb, " iifname \"%s\" accept\n", iface)
}
sb.WriteString(" }\n")
sb.WriteString(" chain postrouting {\n")
sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n")
for _, iface := range cfg.Interfaces {
fmt.Fprintf(&sb, " iifname \"%s\" masquerade\n", iface)
}
sb.WriteString(" }\n")
sb.WriteString("}\n")
cmd := exec.Command("nft", "-f", "-")
cmd.Stdin = strings.NewReader(sb.String())
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}

View File

@@ -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 <name> 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 <name> 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)
}
@@ -72,6 +83,13 @@ func ApplyPending() map[string]error {
ClearPendingConfig(name)
continue
}
// For VLAN interfaces ensure the kernel interface exists before ifup.
if IsVLAN(name) {
if err := EnsureVLANExists(name); err != nil {
errs[name] = err
continue
}
}
_ = IfDown(name)
if cfg := configs[name]; cfg != nil && cfg.Auto {
if err := IfUp(name); err != nil {

View File

@@ -13,13 +13,15 @@ const ConfigFile = "/etc/network/interfaces"
// InterfaceConfig represents one stanza in /etc/network/interfaces.
type InterfaceConfig struct {
Name string `json:"name"`
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) ---
@@ -154,12 +156,18 @@ func WriteConfig(configs map[string]*InterfaceConfig) error {
}
defer f.Close()
// loopback first
// loopback first, then physical interfaces, then VLANs (sorted within each group)
if lo, ok := configs["lo"]; ok {
writeStanza(f, lo)
}
for name, cfg := range configs {
if name == "lo" {
if name == "lo" || IsVLAN(name) {
continue
}
writeStanza(f, cfg)
}
for name, cfg := range configs {
if !IsVLAN(name) {
continue
}
writeStanza(f, cfg)
@@ -188,6 +196,12 @@ func writeStanza(f *os.File, c *InterfaceConfig) {
fmt.Fprintf(f, "\tdns-nameservers %s\n", strings.Join(c.DNS, " "))
}
}
// VLAN interfaces need vlan-raw-device unless already in Extra
if IsVLAN(c.Name) {
if _, ok := c.Extra["vlan-raw-device"]; !ok {
fmt.Fprintf(f, "\tvlan-raw-device %s\n", VLANParent(c.Name))
}
}
for k, v := range c.Extra {
fmt.Fprintf(f, "\t%s %s\n", k, v)
}

View File

@@ -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 {

87
network/overlap.go Normal file
View File

@@ -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)
}

80
network/vlan.go Normal file
View File

@@ -0,0 +1,80 @@
package network
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
// IsVLAN reports whether name is a VLAN interface (e.g. "eth0.100").
func IsVLAN(name string) bool {
idx := strings.LastIndex(name, ".")
if idx <= 0 {
return false
}
suffix := name[idx+1:]
if suffix == "" {
return false
}
_, err := strconv.Atoi(suffix)
return err == nil
}
// VLANParent returns the parent interface (e.g. "eth0.100" → "eth0").
func VLANParent(name string) string {
idx := strings.LastIndex(name, ".")
if idx < 0 {
return ""
}
return name[:idx]
}
// VLANId returns the VLAN ID (e.g. "eth0.100" → 100).
func VLANId(name string) int {
idx := strings.LastIndex(name, ".")
if idx < 0 {
return 0
}
id, _ := strconv.Atoi(name[idx+1:])
return id
}
// EnsureVLANExists creates the VLAN interface in the kernel if it doesn't exist.
func EnsureVLANExists(name string) error {
if _, err := os.Stat("/sys/class/net/" + name); err == nil {
return nil
}
parent := VLANParent(name)
id := VLANId(name)
if parent == "" || id == 0 {
return fmt.Errorf("invalid VLAN name: %s", name)
}
out, err := exec.Command("ip", "link", "add", "link", parent,
"name", name, "type", "vlan", "id", strconv.Itoa(id)).CombinedOutput()
if err != nil {
return fmt.Errorf("ip link add %s: %s", name, strings.TrimSpace(string(out)))
}
return nil
}
// DeleteVLAN brings down and removes a VLAN interface and its /etc/network/interfaces stanza.
func DeleteVLAN(name string) error {
_ = IfDown(name)
if _, err := os.Stat("/sys/class/net/" + name); err == nil {
out, err2 := exec.Command("ip", "link", "delete", name).CombinedOutput()
if err2 != nil {
return fmt.Errorf("ip link delete %s: %s", name, strings.TrimSpace(string(out)))
}
}
configs, err := ParseConfig()
if err != nil {
return err
}
if _, ok := configs[name]; ok {
delete(configs, name)
return WriteConfig(configs)
}
return nil
}

View File

@@ -1,16 +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
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,
@@ -29,7 +26,16 @@ const get = (path) => api('GET', path);
const post = (path, body) => api('POST', path, body);
const del = (path) => api('DELETE', path);
// ── Format helpers ───────────────────────────────────────────────────────────
function isVLAN(name) {
return /\.\d+$/.test(name);
}
function vlanParent(name) {
return name.replace(/\.\d+$/, '');
}
function vlanId(name) {
const m = name.match(/\.(\d+)$/);
return m ? parseInt(m[1]) : 0;
}
function fmtBytes(n) {
if (n === undefined || n === null) return '—';
@@ -50,15 +56,105 @@ function modeLabel(m) {
return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?');
}
// ── Render ───────────────────────────────────────────────────────────────────
const ICON = {
pencil: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>`,
restart: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>`,
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
</svg>`,
plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
<path d="M12 5v14M5 12h14"/>
</svg>`,
};
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 = '';
state.interfaces.forEach(iface => {
grid.appendChild(buildCard(iface));
});
const vlansByParent = {};
const physicals = [];
for (const iface of state.interfaces) {
if (isVLAN(iface.name)) {
const p = vlanParent(iface.name);
if (!vlansByParent[p]) vlansByParent[p] = [];
vlansByParent[p].push(iface);
} else {
physicals.push(iface);
}
}
const wrap = document.createElement('div');
wrap.className = 'iface-table-wrap';
const table = document.createElement('table');
table.className = 'iface-table';
table.innerHTML = `
<thead>
<tr>
<th class="col-if-state"></th>
<th class="col-if-name">Интерфейс</th>
<th class="col-if-type">Тип</th>
<th class="col-if-ipv4">IPv4</th>
<th class="col-if-gw">Шлюз</th>
<th class="col-if-mode">Режим</th>
<th class="col-if-traffic">Трафик</th>
<th class="col-if-actions">Действия</th>
</tr>
</thead>
<tbody id="ifaceTableBody"></tbody>
`;
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] || [];
tbody.appendChild(buildPhysicalRow(iface, vlans));
}
document.getElementById('loading').classList.add('hidden');
grid.classList.remove('hidden');
@@ -66,72 +162,126 @@ function renderAll() {
renderPendingBanner();
}
function buildCard(iface) {
function buildPhysicalRow(iface, vlans) {
const hasPending = state.pending.includes(iface.name);
const sc = stateClass(iface.state);
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) : '')
: '<span class="none">&mdash;</span>';
const gwDisplay = iface.gateway
? iface.gateway
: '<span class="none">&mdash;</span>';
const card = document.createElement('div');
card.className = 'iface-card' + (hasPending ? ' has-pending' : '');
card.dataset.name = iface.name;
const trafficDisplay = `
<div class="traffic-mini">
<span class="traffic-mini-item"><span class="traffic-mini-label">↓</span> ${fmtBytes(iface.rx_bytes)}</span>
<span class="traffic-mini-item"><span class="traffic-mini-label">↑</span> ${fmtBytes(iface.tx_bytes)}</span>
</div>`;
const ipv6lines = (iface.ipv6 || []).map(a =>
`<div class="info-row"><span class="info-label">IPv6</span><span class="info-val">${a}</span></div>`
).join('');
const nameCell = label
? `<div class="iface-name-stack"><span class="iface-label-text">${label}</span><span class="iface-name-sub">${iface.name}</span></div>`
: `<span class="iface-name-text">${iface.name}</span>`;
card.innerHTML = `
<div class="card-header">
<div class="card-name">
<span class="state-dot ${sc}"></span>
<span>${iface.name}</span>
const typeBadge = isWAN
? '<span class="type-badge type-wan">WAN</span>'
: '<span class="type-badge type-lan">LAN</span>';
const tr = document.createElement('tr');
tr.className = 'iface-row' + (hasPending ? ' has-pending' : '') + (isWAN ? ' row-wan' : '');
tr.dataset.name = iface.name;
tr.innerHTML = `
<td class="col-if-state"><span class="state-dot ${sc}"></span></td>
<td class="col-if-name">
<div class="iface-name-block">
${nameCell}
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
<button class="btn btn-ghost btn-xs${isWAN ? ' hidden' : ''}" data-action="addvlan" data-iface="${iface.name}" style="margin-left:8px">${ICON.plus} VLAN</button>
</div>
<div style="display:flex;gap:6px;align-items:center">
<span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span>
</td>
<td class="col-if-type">${typeBadge}</td>
<td class="col-if-ipv4 mono">${ipDisplay}</td>
<td class="col-if-gw mono">${gwDisplay}</td>
<td class="col-if-mode"><span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span></td>
<td class="col-if-traffic">${trafficDisplay}</td>
<td class="col-if-actions">
<div class="iface-actions">
<label class="toggle-label iface-power-toggle" title="${isUp ? 'Выключить интерфейс' : 'Включить интерфейс'}">
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${iface.name}">
<span class="toggle-slider"></span>
<span class="iface-toggle-label">${isUp ? 'Вкл' : 'Выкл'}</span>
</label>
<button class="btn-icon" data-action="restart" data-iface="${iface.name}" title="Перезапустить">${ICON.restart}</button>
<button class="btn-icon btn-icon-accent" data-action="config" data-iface="${iface.name}" title="Настроить" style="margin-left:auto">${ICON.pencil}</button>
</div>
</div>
<div class="card-info">
<div class="info-row">
<span class="info-label">Статус</span>
<span class="info-val">${iface.state || 'unknown'}</span>
</div>
<div class="info-row">
<span class="info-label">IPv4</span>
<span class="info-val">${iface.ipv4
? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '')
: '<span class="none">&mdash;</span>'}</span>
</div>
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">&mdash;</span></div>`}
<div class="info-row">
<span class="info-label">Шлюз</span>
<span class="info-val">${iface.gateway || '<span class="none">&mdash;</span>'}</span>
</div>
<div class="traffic-row">
<div class="traffic-item">
<span class="traffic-label">RX</span>
<span class="traffic-val">${fmtBytes(iface.rx_bytes)}</span>
</div>
<div class="traffic-item">
<span class="traffic-label">TX</span>
<span class="traffic-val">${fmtBytes(iface.tx_bytes)}</span>
</div>
<div class="traffic-item">
<span class="traffic-label">Пакеты</span>
<span class="traffic-val">${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0}</span>
</div>
</div>
</div>
<div class="card-actions">
<button class="btn btn-success btn-sm" data-action="up" data-iface="${iface.name}">ON</button>
<button class="btn btn-danger btn-sm" data-action="down" data-iface="${iface.name}">OFF</button>
<button class="btn btn-ghost btn-sm" data-action="restart" data-iface="${iface.name}">RESTART</button>
<button class="btn btn-primary btn-sm" data-action="config" data-iface="${iface.name}" style="margin-left:auto">CONFIG</button>
</div>
</td>
`;
return card;
const frag = document.createDocumentFragment();
frag.appendChild(tr);
for (const v of vlans) {
frag.appendChild(buildVLANRow(v, iface.name));
}
return frag;
}
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 ? maskToCIDR(v.ipv4_mask) : '')
: '<span class="none">&mdash;</span>';
const gwDisplay = v.gateway ? v.gateway : '<span class="none">&mdash;</span>';
const typeBadge = isWAN
? '<span class="type-badge type-wan">WAN</span>'
: '<span class="type-badge type-lan">LAN</span>';
const nameCell = label
? `<div class="iface-name-stack"><span class="iface-label-text">${label}</span><span class="iface-name-sub">${v.name}</span></div>`
: `<span class="iface-name-text">${v.name}</span>`;
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 = `
<td class="col-if-state"><span class="state-dot ${sc}" style="width:8px;height:8px"></span></td>
<td class="col-if-name">
<div class="vlan-nested-name">
<span class="vlan-tree-line"></span>
<span class="vlan-id-tag">VLAN ${vlanId(v.name)}</span>
${nameCell}
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
</div>
</td>
<td class="col-if-type">${typeBadge}</td>
<td class="col-if-ipv4 mono">${ip}</td>
<td class="col-if-gw mono">${gwDisplay}</td>
<td class="col-if-mode"><span class="mode-badge ${v.mode || 'unknown'}">${modeLabel(v.mode)}</span></td>
<td class="col-if-traffic"></td>
<td class="col-if-actions">
<div class="iface-actions">
<label class="toggle-label" title="${isUp ? 'Выключить' : 'Включить'}">
<input type="checkbox" ${isUp ? 'checked' : ''} data-action="toggle" data-iface="${v.name}">
<span class="toggle-slider toggle-sm"></span>
</label>
<button class="btn-icon btn-icon-accent" data-action="config" data-iface="${v.name}" title="Настроить">${ICON.pencil}</button>
<button class="btn-icon btn-icon-danger" data-action="delete" data-iface="${v.name}" title="Удалить VLAN">${ICON.trash}</button>
</div>
</td>
`;
return tr;
}
function renderPendingBanner() {
@@ -145,8 +295,6 @@ function renderPendingBanner() {
}
}
// ── Data loading ─────────────────────────────────────────────────────────────
async function loadAll() {
try {
const [ifaces, pending] = await Promise.all([
@@ -161,11 +309,18 @@ async function loadAll() {
}
}
// ── Interface actions ─────────────────────────────────────────────────────────
async function doAction(name, action) {
const btn = document.querySelector(`[data-action="${action}"][data-iface="${name}"]`);
if (btn) { btn.disabled = true; btn.textContent = '...'; }
if (action === 'delete') {
if (!confirm(`Удалить VLAN ${name}?`)) return;
try {
await post(`/api/interfaces/${name}/delete`);
showToast(`${name}: удалён`, 'success');
await loadAll();
} catch (e) {
showToast(`${name} delete: ${e.message}`, 'error');
}
return;
}
try {
await post(`/api/interfaces/${name}/${action}`);
@@ -173,55 +328,124 @@ async function doAction(name, action) {
await loadAll();
} catch (e) {
showToast(`${name} ${action}: ${e.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
await loadAll();
}
}
// ── Config modal ──────────────────────────────────────────────────────────────
async function openConfig(name) {
state.configModal = name;
state.configModalParent = null;
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
const vlanSection = document.getElementById('vlanIdSection');
const vlanInput = document.getElementById('cfgVLANId');
if (isVLAN(name)) {
vlanSection.classList.remove('hidden');
vlanInput.value = vlanId(name);
vlanInput.readOnly = true;
} else {
vlanSection.classList.add('hidden');
vlanInput.readOnly = false;
}
try {
const [{ config, pending }, natData] = await Promise.all([
const [configData, natData] = await Promise.all([
get(`/api/config/${name}`),
get('/api/nat').catch(() => null),
]);
if (natData) state.nat = natData;
fillForm(config, pending, name);
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');
}
}
function fillForm(cfg, pending, 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}`;
const vlanSection = document.getElementById('vlanIdSection');
const vlanInput = document.getElementById('cfgVLANId');
vlanSection.classList.remove('hidden');
vlanInput.readOnly = false;
vlanInput.value = '';
try {
const natData = await get('/api/nat').catch(() => null);
if (natData) state.nat = natData;
} catch (_) {}
fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true);
document.getElementById('modal').classList.remove('hidden');
}
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 || '';
document.getElementById('cfgNetmask').value = cfg.netmask || '';
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);
// Mark pending visually
const title = document.getElementById('modalTitle');
if (pending) {
title.textContent = `Настройка: ${state.configModal} (несохранённые изменения)`;
if (pending && name) {
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
}
}
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');
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');
}
// NAT section — show for all non-loopback interfaces
const natSection = document.getElementById('natSection');
const natNotInstalled = document.getElementById('natNotInstalled');
const cfgNAT = document.getElementById('cfgNAT');
updateNATSection(type);
}
if (cfg.mode === 'loopback' || name === 'lo') {
natSection.classList.add('hidden');
} else {
natSection.classList.remove('hidden');
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);
@@ -230,48 +454,70 @@ function fillForm(cfg, pending, name) {
}
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() {
document.getElementById('modal').classList.add('hidden');
document.getElementById('configForm').reset();
state.configModal = null;
state.configModalParent = null;
}
async function saveConfig() {
const name = state.configModal;
if (!name) return;
let name = state.configModal;
const mode = currentMode();
if (!name) {
const parent = state.configModalParent;
const id = parseInt(document.getElementById('cfgVLANId').value);
if (!parent) return;
if (!id || id < 1 || id > 4094) {
showToast('Укажите корректный VLAN ID (14094)', 'error');
return;
}
name = `${parent}.${id}`;
if (state.interfaces.find(i => i.name === name)) {
showToast(`VLAN ${name} уже существует`, 'error');
return;
}
}
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: {},
};
// Basic validation for static
if (mode === 'static' && !cfg.address) {
showToast('Укажите IP-адрес', 'error');
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;
@@ -291,8 +537,6 @@ async function saveConfig() {
}
}
// ── Apply / discard ───────────────────────────────────────────────────────────
async function applyAll() {
const btn = document.getElementById('applyBtn');
btn.disabled = true;
@@ -315,13 +559,10 @@ async function discardAll() {
}
state.pending = [];
renderPendingBanner();
renderAll();
showToast('Изменения отменены', 'info');
await loadAll();
}
// ── Toast ─────────────────────────────────────────────────────────────────────
let toastTimer;
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
@@ -332,26 +573,30 @@ 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 action buttons (delegated)
document.getElementById('ifaceGrid').addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return;
const { action, iface } = btn.dataset;
if (!action || !iface) return;
if (action === 'config') {
openConfig(iface);
} else if (action === 'addvlan') {
openNewVLAN(iface);
} else {
doAction(iface, action);
}
});
// Modal close
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');
});
document.getElementById('closeModal').addEventListener('click', closeModal);
document.getElementById('cancelConfigBtn').addEventListener('click', closeModal);
document.getElementById('modalBackdrop').addEventListener('click', closeModal);
@@ -359,30 +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 () => {
// Try to get hostname
try {
const res = await fetch('/api/interfaces');
// hostname from Location header or just skip
} catch (_) {}
await loadAll();
})();

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Клиенты — AlpineRouter</title>
<title>Клиенты — NanoRouter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -15,21 +15,21 @@
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>AlpineRouter</h1>
<h1>NanoRouter</h1>
</div>
<div class="header-right">
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
Обновить
</button>
</div>
</header>
<nav class="tab-nav">
<a href="/" class="tab-link">
<a href="/home.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная
</a>
<a href="/ifaces.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
@@ -50,15 +50,55 @@
</svg>
Клиенты
</a>
<a href="/proxy.html" class="tab-link">
<a href="/firewall.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Файрвол
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
Прокси
</a>
<a href="/profile.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</a>
</nav>
<main class="clients-main">
<!-- Policy management panel -->
<div class="policy-panel">
<div class="policy-section">
<div class="policy-section-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Политика для новых устройств
</div>
<div class="segmented" id="defaultPolicySelector">
<button type="button" class="seg-btn" data-val="disabled">Отключён</button>
<button type="button" class="seg-btn" data-val="direct">Напрямую</button>
<button type="button" class="seg-btn active" data-val="vpn">Через VPN</button>
</div>
</div>
<div class="policy-divider"></div>
<div class="policy-section">
<div class="policy-section-label">Применить ко всем</div>
<div class="policy-apply-all">
<button type="button" class="btn btn-ghost btn-sm policy-all-btn" data-val="disabled">Отключить всех</button>
<button type="button" class="btn btn-ghost btn-sm policy-all-btn" data-val="direct">Напрямую</button>
<button type="button" class="btn btn-primary btn-sm policy-all-btn" data-val="vpn">Через VPN</button>
</div>
</div>
</div>
<div class="clients-toolbar">
<div class="clients-summary" id="clientsSummary"></div>
@@ -84,6 +124,7 @@
<th>MAC-адрес</th>
<th>Интерфейс</th>
<th>Тип</th>
<th>Маршрут</th>
<th class="col-tx">↑ Отправлено</th>
<th class="col-rx">↓ Получено</th>
<th>Активность</th>
@@ -135,15 +176,14 @@
<hr class="form-divider">
<div class="form-row" style="flex-direction:row; align-items:center; justify-content:space-between;">
<div>
<div style="font-weight:600;">Доступ в интернет</div>
<div style="font-size:.8rem;color:var(--muted);margin-top:2px;" id="modalBlockHint">Отключите, чтобы запретить устройству выход в интернет</div>
<div class="form-row form-row--col">
<label>Выход в интернет</label>
<div class="segmented" id="modalPolicySelector">
<button type="button" class="seg-btn" data-val="disabled">Отключён</button>
<button type="button" class="seg-btn" data-val="direct">Напрямую</button>
<button type="button" class="seg-btn" data-val="vpn">Через VPN</button>
</div>
<label class="toggle-label" id="modalBlockToggle">
<input type="checkbox" id="modalBlocked">
<span class="toggle-slider"></span>
</label>
<span class="form-hint" id="modalPolicyHint">Пусто = использовать политику по умолчанию</span>
</div>
<div class="modal-footer" style="padding:18px 0 0;">

View File

@@ -3,6 +3,72 @@
let allClients = [];
let searchQuery = '';
let editingClient = null;
let modalSelectedPolicy = ''; // "" means "use default"
// ── Default policy ─────────────────────────────────────────────────────────
async function loadDefaultPolicy() {
try {
const res = await fetch('/api/clients/policy');
const json = await res.json();
if (!res.ok || !json.success) return;
setDefaultPolicyUI(json.data.default || 'direct');
} catch (_) {}
}
function setDefaultPolicyUI(val) {
document.querySelectorAll('#defaultPolicySelector .seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === val);
});
}
async function saveDefaultPolicy(val) {
try {
const res = await fetch('/api/clients/policy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ default: val }),
});
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
setDefaultPolicyUI(val);
showToast('Политика по умолчанию сохранена', 'success');
loadClients();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.querySelectorAll('#defaultPolicySelector .seg-btn').forEach(btn => {
btn.addEventListener('click', () => saveDefaultPolicy(btn.dataset.val));
});
// ── Apply-all ──────────────────────────────────────────────────────────────
async function applyPolicyToAll(val) {
const labels = { disabled: 'Отключён', direct: 'Напрямую', vpn: 'Через VPN' };
if (!confirm(`Применить политику «${labels[val]}» ко всем устройствам?`)) return;
try {
const res = await fetch('/api/clients/policy/apply-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ policy: val }),
});
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
showToast(`Политика применена к ${json.data.updated} устройствам`, 'success');
loadClients();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.querySelectorAll('.policy-all-btn').forEach(btn => {
btn.addEventListener('click', () => applyPolicyToAll(btn.dataset.val));
});
// ── Clients list ───────────────────────────────────────────────────────────
async function loadClients() {
try {
@@ -75,6 +141,15 @@ function matchesSearch(c, q) {
);
}
function policyLabel(policy) {
switch (policy) {
case 'disabled': return { text: 'Отключён', cls: 'policy-badge policy-disabled' };
case 'vpn': return { text: 'VPN', cls: 'policy-badge policy-vpn' };
case 'direct': return { text: 'Напрямую', cls: 'policy-badge policy-direct' };
default: return { text: 'по умолч.', cls: 'policy-badge policy-default' };
}
}
function render() {
const loading = document.getElementById('loading');
const wrap = document.getElementById('clientsTableWrap');
@@ -84,15 +159,17 @@ function render() {
loading.classList.add('hidden');
const onlineCount = allClients.filter(c => isOnline(c)).length;
const dhcpCount = allClients.filter(c => c.is_dhcp).length;
const blockedCount = allClients.filter(c => c.blocked).length;
const onlineCount = allClients.filter(c => isOnline(c)).length;
const dhcpCount = allClients.filter(c => c.is_dhcp).length;
const disabledCount = allClients.filter(c => c.policy === 'disabled' || (c.policy === '' && c.blocked)).length;
const vpnCount = allClients.filter(c => c.policy === 'vpn').length;
summary.innerHTML =
`<span class="cl-stat"><span class="state-dot up"></span>${onlineCount} онлайн</span>` +
`<span class="cl-stat cl-stat--muted">${allClients.length} всего</span>` +
`<span class="cl-stat cl-stat--muted">${dhcpCount} по DHCP</span>` +
(blockedCount > 0 ? `<span class="cl-stat cl-stat--blocked">${blockedCount} заблокировано</span>` : '');
(vpnCount > 0 ? `<span class="cl-stat cl-stat--vpn">${vpnCount} через VPN</span>` : '') +
(disabledCount > 0 ? `<span class="cl-stat cl-stat--blocked">${disabledCount} отключено</span>` : '');
const filtered = allClients.filter(c => matchesSearch(c, searchQuery));
@@ -123,9 +200,13 @@ function buildRow(c) {
const tr = document.createElement('tr');
tr.className = 'client-row';
if (!online) tr.classList.add('row-offline');
if (c.blocked) tr.classList.add('row-blocked');
const effectivePolicy = c.policy || (c.blocked ? 'disabled' : '');
if (effectivePolicy === 'disabled') tr.classList.add('row-blocked');
if (effectivePolicy === 'vpn') tr.classList.add('row-vpn');
const activity = fmtLastActive(c);
const pl = policyLabel(effectivePolicy);
const typeCell = c.is_dhcp
? '<span class="client-badge dhcp">DHCP</span>'
@@ -151,20 +232,17 @@ function buildRow(c) {
? `<span class="mono">${escHtml(c.static_ip)}</span> <span class="client-badge static-badge">фикс.</span>`
: `<span class="mono">${escHtml(c.ip)}</span>`;
const blockedBadge = c.blocked
? ' <span class="client-badge blocked-badge">ЗАБЛОКИРОВАН</span>'
: '';
tr.innerHTML = `
<td class="col-status">
<span class="state-dot ${online ? 'up' : 'down'}"
title="${online ? 'онлайн' : 'офлайн'}"></span>
</td>
<td class="col-host">${hostname}${blockedBadge}</td>
<td class="col-host">${hostname}</td>
<td class="col-ip">${ipDisplay}</td>
<td class="col-mac"><span class="mono muted">${escHtml(c.mac || '—')}</span></td>
<td class="col-iface">${escHtml(c.interface || '—')}</td>
<td class="col-type">${typeCell}</td>
<td class="col-policy"><span class="${pl.cls}">${pl.text}</span></td>
<td class="col-tx">${txHtml}</td>
<td class="col-rx">${rxHtml}</td>
<td class="col-activity">${actHtml}</td>
@@ -174,6 +252,8 @@ function buildRow(c) {
return tr;
}
// ── Modal ──────────────────────────────────────────────────────────────────
function openModal(c) {
editingClient = { ...c };
const modal = document.getElementById('clientModal');
@@ -200,32 +280,36 @@ function openModal(c) {
document.getElementById('modalMAC').textContent = c.mac || '—';
document.getElementById('modalIface').textContent = c.interface || '—';
const blocked = document.getElementById('modalBlocked');
blocked.checked = !c.blocked;
updateBlockedToggle(c.blocked);
// Set policy selector — empty string means "use default"
const effectivePolicy = c.policy || '';
modalSelectedPolicy = effectivePolicy;
updateModalPolicySelector(effectivePolicy);
modal.classList.remove('hidden');
document.getElementById('modalHostname').focus();
}
function updateBlockedToggle(isBlocked) {
const hint = document.getElementById('modalBlockHint');
const toggle = document.getElementById('modalBlockToggle');
const toggleContainer = document.getElementById('modalBlocked');
if (isBlocked) {
hint.textContent = 'Доступ в интернет заблокирован';
hint.style.color = 'var(--danger)';
toggleContainer.checked = false;
toggle.classList.add('toggle-blocked');
} else {
hint.textContent = 'Отключите, чтобы запретить устройству выход в интернет';
hint.style.color = '';
toggleContainer.checked = true;
toggle.classList.remove('toggle-blocked');
}
function updateModalPolicySelector(val) {
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === val);
});
const hint = document.getElementById('modalPolicyHint');
const descriptions = {
disabled: 'Устройство заблокировано — нет доступа в интернет',
direct: 'Трафик идёт напрямую через NAT, минуя VPN',
vpn: 'Трафик перенаправляется через Mihomo (tproxy)',
'': 'Используется политика по умолчанию',
};
hint.textContent = descriptions[val] ?? '';
}
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
btn.addEventListener('click', () => {
modalSelectedPolicy = btn.dataset.val;
updateModalPolicySelector(btn.dataset.val);
});
});
function closeModal() {
document.getElementById('clientModal').classList.add('hidden');
editingClient = null;
@@ -241,8 +325,8 @@ async function saveClient() {
}
const hostname = document.getElementById('modalHostname').value.trim();
const isBlocked = !document.getElementById('modalBlocked').checked;
const staticIP = document.getElementById('modalStaticIP').value.trim();
const policy = modalSelectedPolicy; // "disabled" | "direct" | "vpn" | ""
const btn = document.getElementById('modalSave');
btn.disabled = true;
@@ -251,7 +335,12 @@ async function saveClient() {
const res = await fetch('/api/clients/update/' + encodeURIComponent(mac), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, blocked: isBlocked, static_ip: staticIP })
body: JSON.stringify({
hostname,
blocked: policy === 'disabled',
static_ip: staticIP,
policy,
}),
});
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
@@ -265,6 +354,8 @@ async function saveClient() {
}
}
// ── Utilities ──────────────────────────────────────────────────────────────
function ipToNum(ip) {
return (ip || '').split('.').reduce((acc, o) => acc * 256 + (+o || 0), 0);
}
@@ -284,7 +375,8 @@ function showToast(msg, type = 'info') {
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
}
document.getElementById('refreshBtn').addEventListener('click', loadClients);
// ── Event wiring ───────────────────────────────────────────────────────────
document.getElementById('clientsSearch').addEventListener('input', e => {
searchQuery = e.target.value.trim();
@@ -299,16 +391,11 @@ document.getElementById('clientForm').addEventListener('submit', e => {
saveClient();
});
document.getElementById('modalBlocked').addEventListener('change', () => {
if (!editingClient) return;
const isBlocked = !document.getElementById('modalBlocked').checked;
updateBlockedToggle(isBlocked);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
setInterval(loadClients, 10000);
loadDefaultPolicy();
loadClients();

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DHCP сервер — AlpineRouter</title>
<title>DHCP сервер — NanoRouter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -15,21 +15,21 @@
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>AlpineRouter</h1>
<h1>NanoRouter</h1>
</div>
<div class="header-right">
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
Обновить
</button>
</div>
</header>
<nav class="tab-nav">
<a href="/" class="tab-link">
<a href="/home.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная
</a>
<a href="/ifaces.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
@@ -50,16 +50,30 @@
</svg>
Клиенты
</a>
<a href="/proxy.html" class="tab-link">
<a href="/firewall.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Файрвол
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
Прокси
</a>
<a href="/profile.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</a>
</nav>
<main class="dhcp-main">
<!-- Not-installed warning -->
<div id="notInstalledBanner" class="alert alert-error hidden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">

View File

@@ -296,7 +296,6 @@ function showToast(msg, type = 'info') {
// ── Event wiring ──────────────────────────────────────────────────────────────
document.getElementById('refreshBtn').addEventListener('click', loadAll);
document.getElementById('enableToggle').addEventListener('change', e => {
state.config.enabled = e.target.checked;

240
public/firewall.html Normal file
View File

@@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Файрвол — NanoRouter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>NanoRouter</h1>
</div>
<div class="header-right">
</div>
</header>
<nav class="tab-nav">
<a href="/home.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная
</a>
<a href="/ifaces.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/firewall.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Файрвол
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
Прокси
</a>
<a href="/profile.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</a>
</nav>
<main class="fw-main">
<div id="notInstalledBanner" class="alert alert-error hidden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
</svg>
<div>
<strong>nftables не установлен.</strong>
Для работы файрвола выполните на роутере:
<code>apk add nftables</code>
</div>
</div>
<!-- Top controls bar -->
<div class="fw-toolbar">
<div class="fw-toolbar-left">
<label class="toggle-label" id="vlanIsolationLabel" title="Запрещает трафик между VLAN-интерфейсами по умолчанию. Явные правила разрешения выше имеют приоритет.">
<input type="checkbox" id="vlanIsolation">
<span class="toggle-slider"></span>
<span>Изоляция VLAN</span>
</label>
<span class="fw-hint">— теговые VLAN не видят друг друга; NAT — только выход в интернет</span>
</div>
<div class="fw-toolbar-right">
<button class="btn btn-ghost btn-sm" id="addRuleBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14M5 12h14"/></svg>
Добавить правило
</button>
<button class="btn btn-primary" id="applyBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M5 12l5 5L20 7"/></svg>
Сохранить и применить
</button>
</div>
</div>
<!-- Rules table -->
<div class="fw-card">
<div class="fw-table-wrap">
<table class="fw-table" id="rulesTable">
<thead>
<tr>
<th class="col-drag"></th>
<th class="col-num">#</th>
<th class="col-en">Вкл</th>
<th class="col-action">Действие</th>
<th class="col-proto">Протокол</th>
<th class="col-iface">Вх. интерфейс</th>
<th class="col-iface">Вых. интерфейс</th>
<th class="col-addr">Источник</th>
<th class="col-addr">Назначение</th>
<th class="col-comment">Комментарий</th>
<th class="col-btns"></th>
</tr>
</thead>
<tbody id="rulesTbody">
<tr id="emptyRow">
<td colspan="11" class="fw-empty">
Правил нет. Нажмите «Добавить правило» для создания.
</td>
</tr>
</tbody>
</table>
</div>
<div class="fw-policy-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
</svg>
Политика по умолчанию: <strong>DROP</strong> — весь трафик запрещён, если не разрешён явно выше или через NAT.
Трафик established/related всегда разрешается.
</div>
</div>
</main>
<!-- Rule Edit Modal -->
<div id="ruleModal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" id="ruleModalBackdrop"></div>
<div class="modal-box modal-wide">
<div class="modal-header">
<h2 id="ruleModalTitle">Добавить правило</h2>
<button class="btn-icon" id="closeRuleModal"></button>
</div>
<form id="ruleForm" autocomplete="off">
<div class="form-row">
<label for="rComment">Комментарий</label>
<input type="text" id="rComment" placeholder="Описание правила (необязательно)">
</div>
<div class="form-row">
<label>Действие</label>
<div class="segmented" id="actionSwitch">
<button type="button" class="seg-btn active" data-val="accept">Разрешить</button>
<button type="button" class="seg-btn" data-val="drop">Запретить</button>
<button type="button" class="seg-btn" data-val="reject">Отклонить</button>
</div>
</div>
<div class="form-row">
<label>Протокол</label>
<div class="segmented" id="protoSwitch">
<button type="button" class="seg-btn active" data-val="all">Любой</button>
<button type="button" class="seg-btn" data-val="tcp">TCP</button>
<button type="button" class="seg-btn" data-val="udp">UDP</button>
<button type="button" class="seg-btn" data-val="icmp">ICMP</button>
</div>
</div>
<div class="form-grid-2">
<div class="form-row">
<label for="rInIface">Входящий интерфейс</label>
<input type="text" id="rInIface" placeholder="eth0, eth0.100 … (пусто = любой)" list="ifaceList">
</div>
<div class="form-row">
<label for="rOutIface">Исходящий интерфейс</label>
<input type="text" id="rOutIface" placeholder="eth1 … (пусто = любой)" list="ifaceList">
</div>
</div>
<div class="form-grid-2">
<div class="form-row">
<label for="rSrcAddr">Источник (адрес/подсеть)</label>
<input type="text" id="rSrcAddr" placeholder="192.168.1.0/24 или 10.0.0.5">
</div>
<div class="form-row">
<label for="rDstAddr">Назначение (адрес/подсеть)</label>
<input type="text" id="rDstAddr" placeholder="10.0.0.0/24 или 8.8.8.8">
</div>
</div>
<div class="form-grid-2" id="portFields">
<div class="form-row">
<label for="rSrcPort">Порт источника</label>
<input type="text" id="rSrcPort" placeholder="80 или 1000-2000">
</div>
<div class="form-row">
<label for="rDstPort">Порт назначения</label>
<input type="text" id="rDstPort" placeholder="443 или 8080-8090">
</div>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="rEnabled" checked>
<span>Правило активно</span>
</label>
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancelRuleBtn">Отмена</button>
<button class="btn btn-primary" id="saveRuleBtn">Сохранить</button>
</div>
</div>
</div>
<datalist id="ifaceList"></datalist>
<div id="toast" class="toast hidden"></div>
<script src="firewall.js"></script>
</body>
</html>

329
public/firewall.js Normal file
View File

@@ -0,0 +1,329 @@
'use strict';
// ── State ────────────────────────────────────────────────────────────────────
const state = {
rules: [], // current rule list (order matters)
interfaces: [], // available interface names for autocomplete
editIdx: -1, // index in state.rules being edited (-1 = new)
};
// ── API helpers ──────────────────────────────────────────────────────────────
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
return json.data;
}
const get = p => api('GET', p);
const post = (p, b) => api('POST', p, b);
// ── Load ─────────────────────────────────────────────────────────────────────
async function loadAll() {
try {
const data = await get('/api/firewall');
state.rules = data.rules || [];
state.interfaces = data.interfaces || [];
document.getElementById('vlanIsolation').checked = !!data.vlan_isolation;
const notInstalled = document.getElementById('notInstalledBanner');
if (!data.installed) {
notInstalled.classList.remove('hidden');
} else {
notInstalled.classList.add('hidden');
}
// Populate datalist for interface autocomplete.
const dl = document.getElementById('ifaceList');
dl.innerHTML = state.interfaces.map(n => `<option value="${n}">`).join('');
renderRules();
} catch (e) {
showToast('Ошибка загрузки: ' + e.message, 'error');
}
}
// ── Render ───────────────────────────────────────────────────────────────────
const ACTION_LABELS = { accept: 'Разрешить', drop: 'Запретить', reject: 'Отклонить' };
const ACTION_CLASS = { accept: 'fw-accept', drop: 'fw-drop', reject: 'fw-reject' };
const PROTO_LABELS = { all: 'Любой', tcp: 'TCP', udp: 'UDP', icmp: 'ICMP' };
function addrPort(addr, port) {
if (!addr && !port) return '<span class="none">любой</span>';
const a = addr || '<span class="none">*</span>';
const p = port ? `:<b>${port}</b>` : '';
return a + p;
}
function renderRules() {
const tbody = document.getElementById('rulesTbody');
const emptyRow = document.getElementById('emptyRow');
// Remove all rows except the empty placeholder.
[...tbody.querySelectorAll('.fw-row')].forEach(r => r.remove());
if (state.rules.length === 0) {
emptyRow.classList.remove('hidden');
return;
}
emptyRow.classList.add('hidden');
state.rules.forEach((rule, idx) => {
const tr = document.createElement('tr');
tr.className = 'fw-row' + (rule.enabled ? '' : ' fw-row-disabled');
tr.draggable = true;
tr.dataset.ruleId = rule.id || String(idx);
const inIface = rule.in_iface || '<span class="none">—</span>';
const outIface = rule.out_iface || '<span class="none">—</span>';
tr.innerHTML = `
<td class="col-drag"><span class="drag-handle" title="Перетащить">⠿</span></td>
<td class="col-num">${idx + 1}</td>
<td class="col-en">
<label class="mini-toggle" title="${rule.enabled ? 'Отключить' : 'Включить'}">
<input type="checkbox" data-idx="${idx}" class="rule-toggle" ${rule.enabled ? 'checked' : ''}>
<span class="mini-slider"></span>
</label>
</td>
<td class="col-action"><span class="action-badge ${ACTION_CLASS[rule.action] || 'fw-drop'}">${ACTION_LABELS[rule.action] || rule.action}</span></td>
<td class="col-proto">${PROTO_LABELS[rule.protocol] || rule.protocol || 'Любой'}</td>
<td class="col-iface">${inIface}</td>
<td class="col-iface">${outIface}</td>
<td class="col-addr">${addrPort(rule.src_addr, rule.src_port)}</td>
<td class="col-addr">${addrPort(rule.dst_addr, rule.dst_port)}</td>
<td class="col-comment">${rule.comment ? `<span class="fw-comment">${esc(rule.comment)}</span>` : '<span class="none">—</span>'}</td>
<td class="col-btns">
<button class="btn btn-ghost btn-xs rule-edit" data-idx="${idx}" title="Редактировать">✎</button>
<button class="btn btn-danger btn-xs rule-del" data-idx="${idx}" title="Удалить">✕</button>
</td>
`;
addDragHandlers(tr);
tbody.appendChild(tr);
});
}
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Drag-to-reorder ───────────────────────────────────────────────────────────
let dragSrc = null;
function addDragHandlers(row) {
row.addEventListener('dragstart', e => {
dragSrc = row;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => row.classList.add('dragging'), 0);
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
document.querySelectorAll('.fw-row').forEach(r => r.classList.remove('drag-over'));
commitDragOrder();
dragSrc = null;
});
row.addEventListener('dragover', e => {
e.preventDefault();
if (!dragSrc || dragSrc === row) return;
document.querySelectorAll('.fw-row').forEach(r => r.classList.remove('drag-over'));
row.classList.add('drag-over');
});
row.addEventListener('drop', e => {
e.preventDefault();
if (!dragSrc || dragSrc === row) return;
const tbody = row.parentNode;
const rows = [...tbody.querySelectorAll('.fw-row')];
const si = rows.indexOf(dragSrc);
const ti = rows.indexOf(row);
if (si < ti) {
tbody.insertBefore(dragSrc, row.nextSibling);
} else {
tbody.insertBefore(dragSrc, row);
}
});
}
function commitDragOrder() {
const rows = [...document.querySelectorAll('.fw-row')];
const byId = {};
state.rules.forEach(r => { byId[r.id] = r; });
const newRules = [];
rows.forEach((row, i) => {
const id = row.dataset.ruleId;
const r = byId[id] || state.rules[parseInt(id)];
if (r) newRules.push(r);
});
state.rules = newRules;
renderRules();
}
// ── Modal ─────────────────────────────────────────────────────────────────────
function openModal(idx) {
state.editIdx = idx;
const isNew = idx === -1;
document.getElementById('ruleModalTitle').textContent = isNew ? 'Добавить правило' : 'Редактировать правило';
const rule = isNew ? { enabled: true, action: 'accept', protocol: 'all' } : state.rules[idx];
document.getElementById('rEnabled').checked = rule.enabled !== false;
document.getElementById('rComment').value = rule.comment || '';
document.getElementById('rSrcAddr').value = rule.src_addr || '';
document.getElementById('rSrcPort').value = rule.src_port || '';
document.getElementById('rDstAddr').value = rule.dst_addr || '';
document.getElementById('rDstPort').value = rule.dst_port || '';
document.getElementById('rInIface').value = rule.in_iface || '';
document.getElementById('rOutIface').value = rule.out_iface || '';
setSegmented('actionSwitch', rule.action || 'accept');
setSegmented('protoSwitch', rule.protocol || 'all');
updatePortFields(rule.protocol || 'all');
document.getElementById('ruleModal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('ruleModal').classList.add('hidden');
document.getElementById('ruleForm').reset();
}
function setSegmented(id, val) {
document.querySelectorAll(`#${id} .seg-btn`).forEach(b => {
b.classList.toggle('active', b.dataset.val === val);
});
}
function getSegmented(id) {
return document.querySelector(`#${id} .seg-btn.active`)?.dataset.val ?? '';
}
function updatePortFields(proto) {
const show = proto === 'tcp' || proto === 'udp';
document.getElementById('portFields').classList.toggle('hidden', !show);
}
function saveRule() {
const rule = {
id: state.editIdx === -1 ? genId() : (state.rules[state.editIdx]?.id || genId()),
enabled: document.getElementById('rEnabled').checked,
action: getSegmented('actionSwitch'),
protocol: getSegmented('protoSwitch'),
src_addr: document.getElementById('rSrcAddr').value.trim(),
src_port: document.getElementById('rSrcPort').value.trim(),
dst_addr: document.getElementById('rDstAddr').value.trim(),
dst_port: document.getElementById('rDstPort').value.trim(),
in_iface: document.getElementById('rInIface').value.trim(),
out_iface: document.getElementById('rOutIface').value.trim(),
comment: document.getElementById('rComment').value.trim(),
};
if (!rule.action) { showToast('Выберите действие', 'error'); return; }
if (state.editIdx === -1) {
state.rules.push(rule);
} else {
state.rules[state.editIdx] = rule;
}
closeModal();
renderRules();
}
function genId() {
return Math.random().toString(36).slice(2, 10);
}
// ── Save & Apply ──────────────────────────────────────────────────────────────
async function saveAndApply() {
const btn = document.getElementById('applyBtn');
btn.disabled = true;
btn.textContent = 'Применяю...';
try {
await post('/api/firewall', {
rules: state.rules,
vlan_isolation: document.getElementById('vlanIsolation').checked,
});
await post('/api/firewall/apply');
showToast('Правила файрвола применены', 'success');
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Сохранить и применить';
}
}
// ── Toast ─────────────────────────────────────────────────────────────────────
let toastTimer;
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `toast ${type}`;
t.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
}
// ── Event wiring ──────────────────────────────────────────────────────────────
document.getElementById('applyBtn').addEventListener('click', saveAndApply);
document.getElementById('addRuleBtn').addEventListener('click', () => openModal(-1));
// Table events (delegated)
document.getElementById('rulesTbody').addEventListener('click', e => {
const editBtn = e.target.closest('.rule-edit');
const delBtn = e.target.closest('.rule-del');
if (editBtn) { openModal(parseInt(editBtn.dataset.idx)); return; }
if (delBtn) {
const idx = parseInt(delBtn.dataset.idx);
if (confirm(`Удалить правило ${idx + 1}?`)) {
state.rules.splice(idx, 1);
renderRules();
}
return;
}
});
document.getElementById('rulesTbody').addEventListener('change', e => {
const toggle = e.target.closest('.rule-toggle');
if (toggle) {
const idx = parseInt(toggle.dataset.idx);
state.rules[idx].enabled = toggle.checked;
renderRules();
}
});
// Modal
document.getElementById('closeRuleModal').addEventListener('click', closeModal);
document.getElementById('cancelRuleBtn').addEventListener('click', closeModal);
document.getElementById('ruleModalBackdrop').addEventListener('click', closeModal);
document.getElementById('saveRuleBtn').addEventListener('click', saveRule);
document.getElementById('ruleForm').addEventListener('submit', e => { e.preventDefault(); saveRule(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// Segmented switchers
document.getElementById('actionSwitch').addEventListener('click', e => {
const b = e.target.closest('.seg-btn');
if (b) setSegmented('actionSwitch', b.dataset.val);
});
document.getElementById('protoSwitch').addEventListener('click', e => {
const b = e.target.closest('.seg-btn');
if (b) { setSegmented('protoSwitch', b.dataset.val); updatePortFields(b.dataset.val); }
});
// ── Init ──────────────────────────────────────────────────────────────────────
loadAll();

291
public/home.html Normal file
View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NanoRouter — Главная</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>NanoRouter</h1>
</div>
<div class="header-right">
</div>
</header>
<nav class="tab-nav">
<a href="/home.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная
</a>
<a href="/ifaces.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/firewall.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Файрвол
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
Прокси
</a>
<a href="/profile.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</a>
</nav>
<main class="home-main">
<div id="defaultPwWarning" class="alert alert-error hidden" style="margin-bottom:16px">
<div>
<strong>&#9888; Пароль по умолчанию!</strong> Аккаунт использует стандартный пароль <code>admin:admin</code>. <a href="/profile.html" style="color:var(--accent)">Задайте свой пароль &rarr;</a>
</div>
</div>
<div id="loading" class="loading">
<div class="spinner"></div>
<span>Загрузка...</span>
</div>
<!-- Status Banner -->
<div id="statusBanner" class="status-banner hidden">
<div id="statusIcon" class="status-icon"></div>
<div class="status-text">
<div id="statusTitle" class="status-title"></div>
<div id="statusSubtitle" class="status-subtitle"></div>
</div>
</div>
<!-- Top Row: System + Mihomo -->
<div class="home-grid-2">
<!-- System Resources -->
<div class="dash-card">
<h3 class="dash-card-title">Система</h3>
<div class="gauge-row">
<div class="gauge-item">
<div class="gauge-svg-wrap">
<svg viewBox="0 0 100 100" class="gauge-svg">
<circle cx="50" cy="50" r="42" class="gauge-bg"/>
<circle cx="50" cy="50" r="42" class="gauge-fg" id="cpuArc" transform="rotate(-90 50 50)"/>
</svg>
<div class="gauge-val" id="cpuVal"></div>
</div>
<div class="gauge-label">CPU</div>
</div>
<div class="gauge-item">
<div class="gauge-svg-wrap">
<svg viewBox="0 0 100 100" class="gauge-svg">
<circle cx="50" cy="50" r="42" class="gauge-bg"/>
<circle cx="50" cy="50" r="42" class="gauge-fg gauge-mem" id="memArc" transform="rotate(-90 50 50)"/>
</svg>
<div class="gauge-val" id="memVal"></div>
</div>
<div class="gauge-label">ОЗУ</div>
</div>
</div>
<div class="sys-info" id="sysInfo"></div>
</div>
<!-- Mihomo Status -->
<div class="dash-card">
<h3 class="dash-card-title">Mihomo</h3>
<div id="mihomoStatus" class="mihomo-block">
<div class="mihomo-info">
<span id="mihomoBadge" class="svc-badge stopped">Остановлен</span>
<span id="mihomoPid" class="mihomo-pid"></span>
</div>
<div class="mihomo-actions">
<label class="toggle-label" title="Запустить / Остановить">
<input type="checkbox" id="mihomoToggle">
<span class="toggle-slider"></span>
</label>
<button class="btn-icon" id="mihomoRestartBtn" disabled title="Перезапустить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Connectivity -->
<div class="dash-card">
<div class="dash-card-header-row">
<h3 class="dash-card-title">Подключение</h3>
<button class="btn-icon" id="connSettingsBtn" title="Настройки проверки подключения">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
<div class="conn-row">
<div class="conn-item">
<div class="conn-label">Интернет (напрямую)</div>
<div class="conn-timeline" id="directTimeline"></div>
<div class="conn-current" id="directStatus"></div>
</div>
<div class="conn-item">
<div class="conn-label">VPN (через Mihomo)</div>
<div class="conn-timeline" id="vpnTimeline"></div>
<div class="conn-current" id="vpnStatus"></div>
</div>
</div>
</div>
<!-- Traffic Speed -->
<div class="dash-card dash-wide">
<div class="dash-card-header-row">
<h3 class="dash-card-title">Загрузка сети</h3>
<div class="dash-mode-switch" id="speedModeSwitch">
<button class="seg-btn" data-mode="avg">10 мин</button>
<button class="seg-btn active" data-mode="real">Realtime</button>
</div>
</div>
<div class="speed-current-row">
<div class="speed-item">
<span class="speed-arrow"></span>
<span class="speed-value" id="rxSpeed">0 bps</span>
</div>
<div class="speed-item">
<span class="speed-arrow"></span>
<span class="speed-value" id="txSpeed">0 bps</span>
</div>
<div class="speed-iface" id="gwIface"></div>
</div>
<div class="speed-chart-wrap">
<canvas id="speedChart" width="800" height="200"></canvas>
</div>
</div>
<!-- IP Info -->
<div class="home-grid-2">
<div class="dash-card">
<h3 class="dash-card-title">IP без VPN</h3>
<div id="ipDirect" class="ip-block">
<div class="ip-address"></div>
<div class="ip-country"></div>
</div>
</div>
<div class="dash-card">
<h3 class="dash-card-title">IP с VPN</h3>
<div id="ipVPN" class="ip-block">
<div class="ip-address"></div>
<div class="ip-country"></div>
</div>
</div>
</div>
</main>
<!-- Connectivity Settings Modal -->
<div id="connModal" class="modal hidden">
<div class="modal-backdrop" id="connModalBackdrop"></div>
<div class="modal-box">
<div class="modal-header">
<h2>Настройки проверки подключения</h2>
<button class="btn-icon" id="closeConnModal" title="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form id="connSettingsForm">
<div class="conn-settings-section">
<div class="conn-settings-label">Прямое подключение</div>
<div class="conn-endpoint-row">
<div class="form-row">
<label>Название 1</label>
<input type="text" id="direct1Name" placeholder="Cloudflare">
</div>
<div class="form-row">
<label>URL 1</label>
<input type="text" id="direct1Url" placeholder="http://cp.cloudflare.com/generate_204">
</div>
</div>
<div class="conn-endpoint-row">
<div class="form-row">
<label>Название 2</label>
<input type="text" id="direct2Name" placeholder="Google">
</div>
<div class="form-row">
<label>URL 2</label>
<input type="text" id="direct2Url" placeholder="http://connectivitycheck.gstatic.com/generate_204">
</div>
</div>
</div>
<hr class="form-divider">
<div class="conn-settings-section">
<div class="conn-settings-label">Через прокси (VPN)</div>
<div class="conn-endpoint-row">
<div class="form-row">
<label>Название 1</label>
<input type="text" id="proxy1Name" placeholder="Cloudflare">
</div>
<div class="form-row">
<label>URL 1</label>
<input type="text" id="proxy1Url" placeholder="http://cp.cloudflare.com/generate_204">
</div>
</div>
<div class="conn-endpoint-row">
<div class="form-row">
<label>Название 2</label>
<input type="text" id="proxy2Name" placeholder="Google">
</div>
<div class="form-row">
<label>URL 2</label>
<input type="text" id="proxy2Url" placeholder="http://connectivitycheck.gstatic.com/generate_204">
</div>
</div>
</div>
<div class="conn-settings-hint">Соединение считается успешным при HTTP-коде 2xx или 3xx.</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" id="cancelConnSettings">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="home.js"></script>
</body>
</html>

785
public/home.js Normal file
View File

@@ -0,0 +1,785 @@
'use strict';
const $ = id => document.getElementById(id);
let dashData = null;
let speedMode = 'real';
let chartSamples = [];
let chartHoverIdx = null;
function fmtSpeed(bps) {
if (bps === 0) return '0 bps';
const units = ['bps', 'Kbps', 'Mbps', 'Gbps'];
let v = bps;
let i = 0;
while (v >= 1000 && i < units.length - 1) { v /= 1000; i++; }
if (i === 0) return Math.round(v) + ' bps';
return v.toFixed(1) + ' ' + units[i];
}
function fmtBytes(n) {
if (n === undefined || n === null) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = Number(n);
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function fmtTime(d) {
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function countryFlag(cc) {
if (!cc || cc.length !== 2) return '';
const base = 0x1F1E6;
return String.fromCodePoint(base + cc.toUpperCase().charCodeAt(0) - 65) +
String.fromCodePoint(base + cc.toUpperCase().charCodeAt(1) - 65);
}
function setArc(id, pct, colorVar) {
const el = $(id);
if (!el) return;
const r = 42;
const c = 2 * Math.PI * r;
const offset = c - (pct / 100) * c;
el.style.strokeDasharray = c;
el.style.strokeDashoffset = offset;
if (colorVar) el.style.stroke = colorVar;
}
async function api(method, path, body) {
const opts = { method, headers: body ? { 'Content-Type': 'application/json' } : {} };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || 'HTTP ' + res.status);
return json.data;
}
const get = path => api('GET', path);
const post = (path, body) => api('POST', path, body);
async function loadData() {
try {
dashData = await get('/api/dashboard');
render();
} catch (e) {
console.error('dashboard load error', e);
}
$('loading').classList.add('hidden');
}
function render() {
if (!dashData) return;
const d = dashData;
// System
const cpuPct = d.system.cpu_pct || 0;
const memPct = d.system.mem_pct || 0;
setArc('cpuArc', cpuPct, cpuPct > 80 ? 'var(--danger)' : 'var(--accent)');
setArc('memArc', memPct, memPct > 90 ? 'var(--danger)' : 'var(--success)');
$('cpuVal').textContent = Math.round(cpuPct) + '%';
$('memVal').textContent = Math.round(memPct) + '%';
const sysLines = [];
if (d.system.mem_total) {
sysLines.push(fmtBytes(d.system.mem_used) + ' / ' + fmtBytes(d.system.mem_total));
}
if (d.system.uptime) {
const u = d.system.uptime;
const days = Math.floor(u / 86400);
const hrs = Math.floor((u % 86400) / 3600);
const mins = Math.floor((u % 3600) / 60);
sysLines.push('Uptime: ' + (days ? days + 'д ' : '') + hrs + 'ч ' + mins + 'м');
}
$('sysInfo').innerHTML = sysLines.map(l => '<div class="sys-line">' + l + '</div>').join('');
// Mihomo
const mihomoRunning = d.mihomo.running;
const badge = $('mihomoBadge');
const toggle = $('mihomoToggle');
const restartBtn = $('mihomoRestartBtn');
const pidEl = $('mihomoPid');
if (mihomoRunning) {
badge.className = 'svc-badge running';
badge.textContent = 'Запущен';
pidEl.textContent = 'PID ' + (d.mihomo.pid || '?');
toggle.checked = true;
restartBtn.disabled = false;
} else {
badge.className = 'svc-badge stopped';
badge.textContent = 'Остановлен';
pidEl.textContent = '';
toggle.checked = false;
restartBtn.disabled = true;
}
// Connectivity Status Banner
const conn = d.connectivity;
const banner = $('statusBanner');
const icon = $('statusIcon');
const title = $('statusTitle');
const subtitle = $('statusSubtitle');
banner.classList.remove('hidden', 'status-online', 'status-warning', 'status-offline');
if (conn.direct_up) {
banner.classList.add('status-online');
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>';
if (!conn.vpn_up && mihomoRunning) {
banner.classList.remove('status-online');
banner.classList.add('status-warning');
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
title.textContent = 'Интернет есть, но VPN недоступен';
subtitle.textContent = 'Прямое подключение работает, но VPN-туннель не отвечает';
} else {
title.textContent = 'Мы онлайн';
subtitle.textContent = conn.vpn_up && mihomoRunning ? 'Интернет и VPN работают' : 'Подключение к интернету активно';
}
} else {
banner.classList.add('status-offline');
icon.innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
title.textContent = 'Нет интернет-соединения';
subtitle.textContent = 'Проверьте подключение к сети';
}
// Connectivity timelines
renderTimeline('directTimeline', conn.minutes_direct, conn.direct_up);
renderTimeline('vpnTimeline', conn.minutes_vpn, conn.vpn_up);
// Current connectivity status markers
$('directStatus').innerHTML = conn.direct_up
? '<span class="conn-up">● Доступен ' + (conn.direct_up_since ? conn.direct_up_since : '') + '</span>'
: '<span class="conn-down">● Недоступен</span>';
$('vpnStatus').innerHTML = mihomoRunning
? (conn.vpn_up ? '<span class="conn-up">● Доступен ' + (conn.vpn_up_since ? conn.vpn_up_since : '') + '</span>' : '<span class="conn-down">● Недоступен</span>')
: '<span class="conn-na">— Не запущен</span>';
// Traffic speed
const samples = speedMode === 'real' ? (d.traffic_real || []) : (d.traffic_avg || []);
$('gwIface').textContent = d.gateway_iface ? ('Шлюз: ' + d.gateway_iface) : '';
const lastSample = samples.length > 0 ? samples[samples.length - 1] : null;
$('rxSpeed').textContent = lastSample ? fmtSpeed(lastSample.rx_bps) : '0 bps';
$('txSpeed').textContent = lastSample ? fmtSpeed(lastSample.tx_bps) : '0 bps';
drawChart(samples);
// IP Info
renderIP('ipDirect', d.ip_direct);
renderIP('ipVPN', d.ip_vpn, !mihomoRunning);
}
function renderTimeline(containerId, minutes) {
const container = $(containerId);
if (!container) return;
const n = minutes.length;
let html = '<div class="tl-bars">';
for (let i = 0; i < n; i++) {
const m = minutes[i];
let cls = 'tl-bar';
if (m.status === 'up') cls += ' tl-up';
else if (m.status === 'down') cls += ' tl-down';
else cls += ' tl-na';
const minuteTime = new Date(m.minute * 60000);
const label = minuteTime.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const pingsData = m.pings || {};
const pingsJson = encodeURIComponent(JSON.stringify(pingsData));
const dataPingCF = m.ping_cf || '';
const dataPingGG = m.ping_gg || '';
html += '<div class="' + cls + '" style="width:calc(100%/' + n + ')" data-time="' + label + '" data-status="' + m.status + '" data-pings="' + pingsJson + '" data-ping-cf="' + dataPingCF + '" data-ping-gg="' + dataPingGG + '"></div>';
}
html += '</div>';
html += '<div class="tl-labels"><span>1ч назад</span><span>45м</span><span>30м</span><span>15м</span><span>Сейчас</span></div>';
container.innerHTML = html;
}
function drawChart(samples) {
chartSamples = samples || [];
const canvas = $('speedChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
_drawChartContent(ctx, rect.width, rect.height, chartSamples, chartHoverIdx);
}
// Chart inner padding — must match CHART_PAD in event handlers below
const CHART_PAD = { top: 18, right: 20, bottom: 34, left: 72 };
function _drawChartContent(ctx, w, h, samples, hoverIdx) {
const pad = CHART_PAD;
const cw = w - pad.left - pad.right;
const ch = h - pad.top - pad.bottom;
ctx.clearRect(0, 0, w, h);
if (!samples || samples.length < 2) {
ctx.fillStyle = 'rgba(122, 162, 204, 0.4)';
ctx.font = '13px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Нет данных', w / 2, h / 2);
return;
}
let maxVal = 1000;
for (const s of samples) {
if (s.rx_bps > maxVal) maxVal = s.rx_bps;
if (s.tx_bps > maxVal) maxVal = s.tx_bps;
}
// Round up to a clean value
const rawMax = maxVal * 1.2;
const mag = Math.pow(10, Math.floor(Math.log10(rawMax)));
maxVal = Math.ceil(rawMax / mag) * mag;
const timeStart = new Date(samples[0].time).getTime();
const timeEnd = new Date(samples[samples.length - 1].time).getTime();
const timeRange = timeEnd - timeStart || 1;
function xOf(s) {
return pad.left + ((new Date(s.time).getTime() - timeStart) / timeRange) * cw;
}
function yOf(bps) {
return pad.top + ch * (1 - bps / maxVal);
}
// ── Y-axis grid + labels ──
const gridLines = 5;
ctx.font = '10px JetBrains Mono, monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= gridLines; i++) {
const y = pad.top + (ch / gridLines) * i;
const val = maxVal * (1 - i / gridLines);
ctx.strokeStyle = i === gridLines ? 'rgba(0,200,255,0.18)' : 'rgba(0,200,255,0.06)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(w - pad.right, y);
ctx.stroke();
ctx.fillStyle = 'rgba(122,162,204,0.9)';
ctx.fillText(fmtSpeed(val), pad.left - 8, y);
}
// ── X-axis time labels ──
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.font = '10px JetBrains Mono, monospace';
ctx.fillStyle = 'rgba(122,162,204,0.6)';
const minGap = 70;
const pxPerSmp = samples.length > 1 ? cw / (samples.length - 1) : cw;
const step = Math.max(1, Math.ceil(minGap / pxPerSmp));
const shownLbl = new Set();
for (let i = 0; i < samples.length; i += step) {
const x = xOf(samples[i]);
ctx.fillText(fmtTime(new Date(samples[i].time)), x, pad.top + ch + 6);
shownLbl.add(i);
ctx.strokeStyle = 'rgba(0,200,255,0.14)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, pad.top + ch); ctx.lineTo(x, pad.top + ch + 4); ctx.stroke();
}
// Always show last label if not too close to previous
const lastIdx = samples.length - 1;
if (!shownLbl.has(lastIdx)) {
const lastX = xOf(samples[lastIdx]);
const prevI = Math.floor(lastIdx / step) * step;
const prevX = xOf(samples[Math.min(prevI, lastIdx)]);
if (lastX - prevX > 44) {
ctx.fillText(fmtTime(new Date(samples[lastIdx].time)), lastX, pad.top + ch + 6);
ctx.strokeStyle = 'rgba(0,200,255,0.14)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lastX, pad.top + ch); ctx.lineTo(lastX, pad.top + ch + 4); ctx.stroke();
}
}
const baseY = pad.top + ch;
// ── Cardinal-spline path helper (control points clamped to chart bounds) ──
function smoothPath(pts) {
if (pts.length < 2) return;
ctx.moveTo(pts[0].x, pts[0].y);
const t = 0.2;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, pts.length - 1)];
const clampY = v => Math.max(pad.top, Math.min(baseY, v));
ctx.bezierCurveTo(
p1.x + (p2.x - p0.x) * t, clampY(p1.y + (p2.y - p0.y) * t),
p2.x - (p3.x - p1.x) * t, clampY(p2.y - (p3.y - p1.y) * t),
p2.x, p2.y
);
}
}
const rxPts = samples.map(s => ({ x: xOf(s), y: yOf(s.rx_bps) }));
const txPts = samples.map(s => ({ x: xOf(s), y: yOf(s.tx_bps) }));
// ── Clip fills + lines to plot area so bezier curves can't overflow ──
ctx.save();
ctx.beginPath();
ctx.rect(pad.left, pad.top, cw, ch);
ctx.clip();
// ── RX fill ──
const rxGrad = ctx.createLinearGradient(0, pad.top, 0, baseY);
rxGrad.addColorStop(0, 'rgba(0,212,255,0.22)');
rxGrad.addColorStop(1, 'rgba(0,212,255,0.01)');
ctx.beginPath();
smoothPath(rxPts);
ctx.lineTo(rxPts[rxPts.length - 1].x, baseY);
ctx.lineTo(rxPts[0].x, baseY);
ctx.closePath();
ctx.fillStyle = rxGrad;
ctx.fill();
// ── RX line ──
ctx.beginPath();
smoothPath(rxPts);
ctx.strokeStyle = 'rgba(0,212,255,0.95)';
ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.stroke();
// ── TX fill ──
const txGrad = ctx.createLinearGradient(0, pad.top, 0, baseY);
txGrad.addColorStop(0, 'rgba(0,255,136,0.16)');
txGrad.addColorStop(1, 'rgba(0,255,136,0.01)');
ctx.beginPath();
smoothPath(txPts);
ctx.lineTo(txPts[txPts.length - 1].x, baseY);
ctx.lineTo(txPts[0].x, baseY);
ctx.closePath();
ctx.fillStyle = txGrad;
ctx.fill();
// ── TX line ──
ctx.beginPath();
smoothPath(txPts);
ctx.strokeStyle = 'rgba(0,255,136,0.9)';
ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.stroke();
ctx.restore(); // end clip
// ── Legend (top-right, clear of Y-axis labels) ──
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '11px Inter, sans-serif';
const ly = pad.top + 11;
const lx = w - pad.right - 104;
ctx.fillStyle = 'rgba(0,212,255,0.9)'; ctx.fillRect(lx, ly - 1, 14, 2);
ctx.fillStyle = 'rgba(122,162,204,0.85)'; ctx.fillText('↓ RX', lx + 18, ly);
ctx.fillStyle = 'rgba(0,255,136,0.9)'; ctx.fillRect(lx + 58, ly - 1, 14, 2);
ctx.fillStyle = 'rgba(122,162,204,0.85)'; ctx.fillText('↑ TX', lx + 76, ly);
// ── Hover: crosshair + glowing dots ──
if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < samples.length) {
const s = samples[hoverIdx];
const hx = xOf(s);
const ry = yOf(s.rx_bps);
const ty = yOf(s.tx_bps);
// Vertical dashed crosshair
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(hx, pad.top); ctx.lineTo(hx, baseY); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// Horizontal guide lines to Y-axis
ctx.save();
ctx.lineWidth = 1;
ctx.setLineDash([2, 5]);
ctx.strokeStyle = 'rgba(0,212,255,0.2)';
ctx.beginPath(); ctx.moveTo(pad.left, ry); ctx.lineTo(hx, ry); ctx.stroke();
ctx.strokeStyle = 'rgba(0,255,136,0.18)';
ctx.beginPath(); ctx.moveTo(pad.left, ty); ctx.lineTo(hx, ty); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// RX dot — outer glow
ctx.beginPath(); ctx.arc(hx, ry, 8, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,212,255,0.15)'; ctx.fill();
// RX dot — core
ctx.beginPath(); ctx.arc(hx, ry, 4.5, 0, Math.PI * 2);
ctx.fillStyle = '#00d4ff'; ctx.fill();
ctx.strokeStyle = 'rgba(10,14,26,0.9)'; ctx.lineWidth = 1.5; ctx.stroke();
// TX dot — outer glow
ctx.beginPath(); ctx.arc(hx, ty, 8, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,255,136,0.12)'; ctx.fill();
// TX dot — core
ctx.beginPath(); ctx.arc(hx, ty, 4.5, 0, Math.PI * 2);
ctx.fillStyle = '#00ff88'; ctx.fill();
ctx.strokeStyle = 'rgba(10,14,26,0.9)'; ctx.lineWidth = 1.5; ctx.stroke();
}
}
function renderIP(containerId, info, disabled) {
const el = $(containerId);
if (!el) return;
if (disabled) {
el.innerHTML = '<div class="ip-address ip-na">Mihomo не запущен</div><div class="ip-country"></div>';
return;
}
if (!info) {
el.innerHTML = '<div class="ip-address ip-loading">Определение...</div><div class="ip-country"></div>';
return;
}
const flag = info.cc ? countryFlag(info.cc) + ' ' : '';
el.innerHTML = '<div class="ip-address">' + info.ip + '</div>' +
'<div class="ip-country">' + flag + (info.country || '') + '</div>';
}
// Mihomo controls
$('mihomoToggle').addEventListener('change', async (e) => {
const action = e.target.checked ? 'start' : 'stop';
try {
await post('/api/mihomo/' + action, null);
showToast('Mihomo ' + (action === 'start' ? 'запущен' : 'остановлен'), 'success');
await loadData();
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
await loadData();
}
});
$('mihomoRestartBtn').addEventListener('click', async () => {
try {
await post('/api/mihomo/restart', null);
showToast('Mihomo перезапущен', 'success');
await loadData();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
// Speed mode switch
$('speedModeSwitch').addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (!btn) return;
speedMode = btn.dataset.mode;
$('speedModeSwitch').querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (dashData) render();
});
// Connectivity settings modal
function openConnSettings() {
const conn = dashData ? dashData.connectivity : null;
const defaultDirect = [
{ name: 'Cloudflare', url: 'http://cp.cloudflare.com/generate_204' },
{ name: 'Google', url: 'http://connectivitycheck.gstatic.com/generate_204' },
];
const direct = (conn && conn.endpoint_names) ? [] : defaultDirect;
if (dashData && dashData.connectivity) {
fetch('/api/config.yaml', { method: 'GET' })
.then(r => r.json())
.then(j => {
const c = j.data && j.data.connectivity ? j.data.connectivity : {};
const d = c.direct || defaultDirect;
const v = c.via_proxy || defaultDirect;
$('direct1Name').value = (d[0] && d[0].name) || '';
$('direct1Url').value = (d[0] && d[0].url) || '';
$('direct2Name').value = (d[1] && d[1].name) || '';
$('direct2Url').value = (d[1] && d[1].url) || '';
$('proxy1Name').value = (v[0] && v[0].name) || '';
$('proxy1Url').value = (v[0] && v[0].url) || '';
$('proxy2Name').value = (v[1] && v[1].name) || '';
$('proxy2Url').value = (v[1] && v[1].url) || '';
})
.catch(() => {
$('direct1Name').value = defaultDirect[0].name;
$('direct1Url').value = defaultDirect[0].url;
$('direct2Name').value = defaultDirect[1].name;
$('direct2Url').value = defaultDirect[1].url;
$('proxy1Name').value = defaultDirect[0].name;
$('proxy1Url').value = defaultDirect[0].url;
$('proxy2Name').value = defaultDirect[1].name;
$('proxy2Url').value = defaultDirect[1].url;
});
} else {
$('direct1Name').value = defaultDirect[0].name;
$('direct1Url').value = defaultDirect[0].url;
$('direct2Name').value = defaultDirect[1].name;
$('direct2Url').value = defaultDirect[1].url;
$('proxy1Name').value = defaultDirect[0].name;
$('proxy1Url').value = defaultDirect[0].url;
$('proxy2Name').value = defaultDirect[1].name;
$('proxy2Url').value = defaultDirect[1].url;
}
$('connModal').classList.remove('hidden');
}
function closeConnSettings() {
$('connModal').classList.add('hidden');
}
async function saveConnSettings(e) {
if (e) e.preventDefault();
const endpoints = {
direct: [
{ name: $('direct1Name').value.trim(), url: $('direct1Url').value.trim() },
{ name: $('direct2Name').value.trim(), url: $('direct2Url').value.trim() },
].filter(ep => ep.name && ep.url),
via_proxy: [
{ name: $('proxy1Name').value.trim(), url: $('proxy1Url').value.trim() },
{ name: $('proxy2Name').value.trim(), url: $('proxy2Url').value.trim() },
].filter(ep => ep.name && ep.url),
};
if (endpoints.direct.length === 0) {
showToast('Укажите хотя бы одну точку для прямого подключения', 'error');
return;
}
if (endpoints.via_proxy.length === 0) {
showToast('Укажите хотя бы одну точку для проверки через прокси', 'error');
return;
}
try {
const cfgRes = await get('/api/config.yaml');
cfgRes.connectivity = endpoints;
const resp = await fetch('/api/config.yaml', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfgRes),
});
const j = await resp.json();
if (!j.success) throw new Error(j.error || 'save failed');
showToast('Настройки подключения сохранены', 'success');
closeConnSettings();
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
}
$('connSettingsBtn').addEventListener('click', openConnSettings);
$('closeConnModal').addEventListener('click', closeConnSettings);
$('cancelConnSettings').addEventListener('click', closeConnSettings);
$('connModalBackdrop').addEventListener('click', closeConnSettings);
$('connSettingsForm').addEventListener('submit', saveConnSettings);
let toastTimer;
function showToast(msg, type) {
const t = $('toast');
t.textContent = msg;
t.className = 'toast ' + type;
t.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
}
// Resize chart
window.addEventListener('resize', () => {
if (dashData) {
const samples = speedMode === 'real' ? dashData.traffic_real : dashData.traffic_avg;
drawChart(samples || []);
}
});
// Auto-refresh
setInterval(loadData, 500);
// ── Custom timeline tooltip ──
const tlTooltip = document.createElement('div');
tlTooltip.className = 'tl-tooltip';
tlTooltip.style.left = '-9999px';
tlTooltip.style.top = '-9999px';
document.body.appendChild(tlTooltip);
// ── Chart hover tooltip ──
const chartTooltip = document.createElement('div');
chartTooltip.className = 'chart-tooltip';
document.body.appendChild(chartTooltip);
let tlTooltipTimer = null;
let tlCursorX = 0;
let tlCursorY = 0;
document.addEventListener('mousemove', (e) => {
const zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
tlCursorX = e.clientX / zoom;
tlCursorY = e.clientY / zoom;
const bar = e.target.closest('.tl-bar');
if (!bar) {
tlTooltip.classList.remove('visible');
return;
}
if (tlTooltip.classList.contains('visible')) positionTooltip();
});
document.addEventListener('mouseover', (e) => {
const bar = e.target.closest('.tl-bar');
if (!bar) return;
const time = bar.dataset.time;
const status = bar.dataset.status;
let statusText, dotClass;
if (status === 'up') { statusText = 'Доступен'; dotClass = 'up'; }
else if (status === 'down') { statusText = 'Недоступен'; dotClass = 'down'; }
else { statusText = 'Нет данных'; dotClass = 'na'; }
let pingHtml = '';
if (status === 'up') {
const parts = [];
const pingsData = bar.dataset.pings;
if (pingsData) {
try {
const pings = JSON.parse(decodeURIComponent(pingsData));
for (const [name, ms] of Object.entries(pings)) {
if (ms > 0) parts.push(name + ': ' + ms + ' мс');
}
} catch (_) {
const pingCF = bar.dataset.pingCf;
const pingGG = bar.dataset.pingGg;
if (pingCF) parts.push('CF: ' + pingCF + ' мс');
if (pingGG) parts.push('Google: ' + pingGG + ' мс');
}
} else {
const pingCF = bar.dataset.pingCf;
const pingGG = bar.dataset.pingGg;
if (pingCF) parts.push('CF: ' + pingCF + ' мс');
if (pingGG) parts.push('Google: ' + pingGG + ' мс');
}
if (parts.length) pingHtml = '<span class="tl-tooltip-ping">' + parts.join(', ') + '</span>';
}
tlTooltip.innerHTML =
'<div class="tl-tooltip-time">' + time + '</div>' +
'<div class="tl-tooltip-row"><span class="tl-tooltip-dot ' + dotClass + '"></span>' + statusText + pingHtml + '</div>';
void tlTooltip.offsetWidth;
positionTooltip();
tlTooltip.classList.add('visible');
clearTimeout(tlTooltipTimer);
});
document.addEventListener('mouseout', (e) => {
const bar = e.target.closest('.tl-bar');
if (!bar) return;
const related = e.relatedTarget;
if (related && related.closest && related.closest('.tl-bar')) return;
tlTooltipTimer = setTimeout(() => tlTooltip.classList.remove('visible'), 80);
});
function positionTooltip() {
const tw = tlTooltip.offsetWidth;
const th = tlTooltip.offsetHeight;
let left = tlCursorX - tw / 2;
let top = tlCursorY - th - 12;
if (top < 4) top = tlCursorY + 16;
if (left < 4) left = 4;
if (left + tw > window.innerWidth - 4) left = window.innerWidth - tw - 4;
tlTooltip.style.left = left + 'px';
tlTooltip.style.top = top + 'px';
}
// ── Chart hover / tooltip logic ──
function hideChartTooltip() {
chartTooltip.classList.remove('visible');
if (chartHoverIdx !== null) {
chartHoverIdx = null;
redrawChartHover();
}
}
function redrawChartHover() {
const canvas = $('speedChart');
if (!canvas || !chartSamples || chartSamples.length < 2) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
_drawChartContent(ctx, rect.width, rect.height, chartSamples, chartHoverIdx);
}
$('speedChart').addEventListener('mousemove', (e) => {
if (!chartSamples || chartSamples.length < 2) { hideChartTooltip(); return; }
const canvas = $('speedChart');
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left; // offset within canvas in clientX pixels
const pad = CHART_PAD;
const cw = rect.width - pad.left - pad.right;
if (mx < pad.left - 6 || mx > rect.width - pad.right + 6) {
hideChartTooltip(); return;
}
const timeStart = new Date(chartSamples[0].time).getTime();
const timeEnd = new Date(chartSamples[chartSamples.length - 1].time).getTime();
const timeRange = timeEnd - timeStart || 1;
let closestIdx = 0, closestDist = Infinity;
for (let i = 0; i < chartSamples.length; i++) {
const sx = pad.left + ((new Date(chartSamples[i].time).getTime() - timeStart) / timeRange) * cw;
const d = Math.abs(mx - sx);
if (d < closestDist) { closestDist = d; closestIdx = i; }
}
if (closestIdx !== chartHoverIdx) {
chartHoverIdx = closestIdx;
redrawChartHover();
}
// Position tooltip above the hovered data point
const s = chartSamples[closestIdx];
const sx = pad.left + ((new Date(s.time).getTime() - timeStart) / timeRange) * cw;
const zoom = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
chartTooltip.innerHTML =
'<div class="chart-tt-time">' + fmtTime(new Date(s.time)) + '</div>' +
'<div class="chart-tt-row"><span class="chart-tt-dot rx"></span>' +
'<span class="chart-tt-label">↓ RX</span>' +
'<span class="chart-tt-val">' + fmtSpeed(s.rx_bps) + '</span></div>' +
'<div class="chart-tt-row"><span class="chart-tt-dot tx"></span>' +
'<span class="chart-tt-label">↑ TX</span>' +
'<span class="chart-tt-val">' + fmtSpeed(s.tx_bps) + '</span></div>';
const ttW = chartTooltip.offsetWidth;
const ttH = chartTooltip.offsetHeight;
const anchorX = (rect.left + sx) / zoom;
const anchorY = rect.top / zoom;
let left = anchorX - ttW / 2;
let top = anchorY - ttH - 12;
if (top < 4) top = (rect.bottom / zoom) + 8;
if (left < 4) left = 4;
if (left + ttW > window.innerWidth / zoom - 4) left = window.innerWidth / zoom - ttW - 4;
chartTooltip.style.left = left + 'px';
chartTooltip.style.top = top + 'px';
chartTooltip.classList.add('visible');
});
$('speedChart').addEventListener('mouseleave', hideChartTooltip);
(async () => {
try {
const status = await get('/api/auth/status');
if (status.default_password) {
$('defaultPwWarning').classList.remove('hidden');
}
} catch (_) {}
await loadData();
})();

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AlpineRouter</title>
<title>NanoRouter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -15,17 +15,9 @@
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>AlpineRouter</h1>
<h1>NanoRouter</h1>
</div>
<div class="header-right">
<span id="hostname" class="hostname"></span>
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
Обновить
</button>
</div>
</header>
@@ -41,7 +33,14 @@
</div>
<nav class="tab-nav">
<a href="/" class="tab-link active">
<a href="/home.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная
</a>
<a href="/ifaces.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
@@ -62,12 +61,27 @@
</svg>
Клиенты
</a>
<a href="/proxy.html" class="tab-link">
<a href="/firewall.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Файрвол
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
Прокси
</a>
<a href="/profile.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</a>
</nav>
<main>
@@ -75,7 +89,7 @@
<div class="spinner"></div>
<span>Загрузка...</span>
</div>
<div id="ifaceGrid" class="iface-grid hidden"></div>
<div id="ifaceGrid" class="hidden"></div>
</main>
<!-- Config Modal -->
@@ -88,6 +102,29 @@
</div>
<form id="configForm" autocomplete="off">
<!-- VLAN ID — shown only for VLAN interfaces -->
<div id="vlanIdSection" class="hidden">
<div class="form-row">
<label for="cfgVLANId">VLAN ID <span class="form-hint-inline">(14094)</span></label>
<input type="number" id="cfgVLANId" min="1" max="4094" placeholder="100">
</div>
<div class="form-divider"></div>
</div>
<div class="form-row">
<label for="cfgLabel">Название (метка)</label>
<input type="text" id="cfgLabel" placeholder="Например: WAN, LAN, Гости…" style="font-family:inherit">
</div>
<div class="form-row">
<label for="cfgType">Тип интерфейса</label>
<div class="segmented" id="typeSwitch">
<button type="button" class="seg-btn" data-type="wan">WAN</button>
<button type="button" class="seg-btn active" data-type="lan">LAN</button>
</div>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="cfgAuto">
@@ -95,7 +132,8 @@
</label>
</div>
<div class="form-row">
<!-- Mode: only shown for WAN -->
<div id="modeRow" class="form-row">
<label>Режим</label>
<div class="segmented" id="modeSwitch">
<button type="button" class="seg-btn active" data-mode="dhcp">DHCP</button>
@@ -112,16 +150,17 @@
<label for="cfgNetmask">Маска сети</label>
<input type="text" id="cfgNetmask" placeholder="255.255.255.0">
</div>
<div class="form-row">
<div id="gatewayRow" class="form-row">
<label for="cfgGateway">Шлюз</label>
<input type="text" id="cfgGateway" placeholder="192.168.1.1">
</div>
<div class="form-row">
<div id="dnsRow" class="form-row">
<label for="cfgDNS">DNS (через пробел)</label>
<input type="text" id="cfgDNS" placeholder="8.8.8.8 8.8.4.4">
</div>
</div>
<!-- NAT section: only shown for LAN -->
<div id="natSection" class="hidden">
<div class="form-divider"></div>
<div class="form-row">

189
public/login.html Normal file
View File

@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NanoRouter — Вход</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="login-body">
<div class="login-wrapper">
<div class="login-card">
<div class="login-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="48" height="48">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<h1 class="login-title">NanoRouter</h1>
<p class="login-subtitle">Войдите в панель управления</p>
<form id="loginForm" class="login-form" autocomplete="off">
<div class="form-row">
<label for="username">Логин</label>
<input type="text" id="username" name="username" placeholder="admin" required autocomplete="username">
</div>
<div class="form-row">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
</div>
<input type="hidden" id="nonce" name="nonce">
<input type="hidden" id="response" name="response">
<button type="submit" class="btn btn-primary login-btn" id="submitBtn">Войти</button>
</form>
<div id="loginError" class="login-error hidden"></div>
</div>
</div>
<script>
(function() {
async function sha256hex(str) {
const enc = new TextEncoder();
const data = enc.encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function hashPassword(password) {
let salt = 'nano-router-salt-v1';
for (let i = 0; i < 10000; i++) {
const data = salt + password + i.toString();
const hash = await sha256hex(data);
salt = hash.substring(0, 32);
}
const finalHash = await sha256hex(salt + password);
return finalHash;
}
async function computeResponse(nonce, passwordHash) {
const str = nonce + ':' + passwordHash;
return await sha256hex(str);
}
async function login(e) {
e.preventDefault();
const errEl = document.getElementById('loginError');
const btn = document.getElementById('submitBtn');
errEl.classList.add('hidden');
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
if (!username || !password) {
errEl.textContent = 'Введите логин и пароль';
errEl.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.textContent = 'Вход...';
try {
const chRes = await fetch('/api/auth/challenge');
const chJson = await chRes.json();
if (!chJson.success) {
throw new Error(chJson.error || 'Failed to get challenge');
}
const nonce = chJson.data.nonce;
const passwordHash = await hashPassword(password);
const response = await computeResponse(nonce, passwordHash);
const loginRes = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nonce, response })
});
const loginJson = await loginRes.json();
if (!loginJson.success) {
throw new Error(loginJson.error || 'Authentication failed');
}
const redirect = new URLSearchParams(window.location.search).get('redirect') || '/home.html';
window.location.href = redirect;
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Войти';
}
}
document.getElementById('loginForm').addEventListener('submit', login);
})();
</script>
<style>
.login-body {
background: var(--bg);
background-image:
radial-gradient(ellipse at 20% 0%, rgba(0, 212, 255, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, rgba(0, 255, 136, 0.04) 0%, transparent 50%);
}
.login-wrapper {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 380px;
background: var(--surface);
border: 1px solid var(--border-hi);
border-radius: var(--radius);
padding: 40px 32px;
backdrop-filter: blur(20px);
box-shadow: var(--shadow), 0 0 40px rgba(0, 212, 255, 0.06);
text-align: center;
}
.login-logo { margin-bottom: 16px; }
.login-logo svg { stroke: var(--accent); filter: drop-shadow(0 0 12px var(--accent-glow)); }
.login-title {
font-size: 1.4rem;
font-weight: 700;
color: var(--text);
text-shadow: 0 0 20px rgba(0, 212, 255, 0.2);
margin-bottom: 6px;
}
.login-subtitle {
font-size: .85rem;
color: var(--muted);
margin-bottom: 28px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
text-align: left;
}
.login-form .form-row label {
font-size: .76rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .06em;
}
.login-btn {
width: 100%;
padding: 12px;
font-size: 1rem;
margin-top: 8px;
}
.login-error {
margin-top: 16px;
padding: 10px 14px;
border-radius: var(--radius-sm);
background: rgba(255, 51, 102, 0.1);
border: 1px solid rgba(255, 51, 102, 0.3);
color: var(--danger);
font-size: .85rem;
text-align: center;
}
</style>
</body>
</html>

362
public/profile.html Normal file
View File

@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NanoRouter — Профиль</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>NanoRouter</h1>
</div>
<div class="header-right">
</div>
</header>
<nav class="tab-nav">
<a href="/home.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная
</a>
<a href="/ifaces.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/firewall.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Файрвол
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
Прокси
</a>
<a href="/profile.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</a>
</nav>
<main class="profile-main">
<div id="defaultWarning" class="alert alert-error hidden" style="margin-bottom:20px">
<div>
<strong>Внимание!</strong> Используется пароль по умолчанию (admin:admin). Задайте собственный пароль в профиле для безопасности.
</div>
</div>
<div class="profile-grid">
<!-- Credentials -->
<div class="dash-card">
<h3 class="dash-card-title">Учётные данные</h3>
<form id="credForm" class="profile-form">
<div class="form-row">
<label>Логин</label>
<input type="text" id="profUsername" placeholder="admin" autocomplete="off">
</div>
<div class="form-row">
<label>Текущий пароль</label>
<input type="password" id="profOldPassword" placeholder="Текущий пароль" autocomplete="off">
</div>
<div class="form-row">
<label>Новый пароль</label>
<input type="password" id="profNewPassword" placeholder="Новый пароль" autocomplete="new-password">
</div>
<div class="form-row">
<label>Подтверждение пароля</label>
<input type="password" id="profNewPassword2" placeholder="Повторите новый пароль" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
</div>
<!-- API Key -->
<div class="dash-card">
<h3 class="dash-card-title">API ключ</h3>
<div id="apiKeySection">
<div id="noApiKey" class="profile-no-key hidden">
<p class="profile-hint">API ключ не создан. Сгенерируйте его для доступа к API без авторизации через браузер.</p>
<button id="genApiKeyBtn" class="btn btn-primary">Сгенерировать API ключ</button>
</div>
<div id="hasApiKey" class="hidden">
<div class="api-key-display">
<div class="api-key-row">
<span class="api-key-label">Ключ:</span>
<code id="apiKeyValue" class="api-key-val"></code>
<button class="btn-icon" id="copyApiKey" title="Копировать">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<div class="api-key-row">
<span class="api-key-label">Префикс:</span>
<code id="apiKeyPrefix" class="api-key-val"></code>
</div>
</div>
<div class="profile-section-gap"></div>
<button id="revokeApiKeyBtn" class="btn btn-danger btn-sm">Отозвать ключ</button>
<div class="profile-section-gap"></div>
<div class="profile-api-info">
<h4>Использование API ключа</h4>
<p>Передайте ключ в заголовке <code>Authorization</code> в формате <code>Bearer</code>:</p>
<pre class="profile-code">curl -H "Authorization: Bearer &lt;ваш_ключ&gt;" \
http://router:8080/api/interfaces</pre>
<p>Все endpoints API доступны с этим ключом. Ключ одноразовый — после отзыва нужно сгенерировать новый.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Logout -->
<div class="profile-logout">
<button id="logoutBtn" class="btn btn-ghost">Выйти из системы</button>
</div>
</main>
<div id="toast" class="toast hidden"></div>
<script>
(function() {
const $ = id => document.getElementById(id);
let toastTimer;
function showToast(msg, type) {
const t = $('toast');
t.textContent = msg;
t.className = 'toast ' + type;
t.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
}
async function api(method, path, body) {
const opts = { method, headers: body ? { 'Content-Type': 'application/json' } : {} };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || 'HTTP ' + res.status);
return json.data;
}
async function sha256hex(str) {
const enc = new TextEncoder();
const data = enc.encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function hashPassword(password) {
let salt = 'nano-router-salt-v1';
for (let i = 0; i < 10000; i++) {
const data = salt + password + i.toString();
const hash = await sha256hex(data);
salt = hash.substring(0, 32);
}
const finalHash = await sha256hex(salt + password);
return finalHash;
}
async function loadProfile() {
try {
const data = await api('GET', '/api/auth/profile');
$('profUsername').value = data.username || 'admin';
if (data.default_password) {
$('defaultWarning').classList.remove('hidden');
} else {
$('defaultWarning').classList.add('hidden');
}
if (data.has_api_key) {
$('noApiKey').classList.add('hidden');
$('hasApiKey').classList.remove('hidden');
$('apiKeyPrefix').textContent = data.api_key_prefix || '';
} else {
$('noApiKey').classList.remove('hidden');
$('hasApiKey').classList.add('hidden');
}
} catch (e) {
showToast('Ошибка загрузки профиля: ' + e.message, 'error');
}
}
$('credForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = $('profUsername').value.trim();
const oldPassword = $('profOldPassword').value;
const newPassword = $('profNewPassword').value;
const newPassword2 = $('profNewPassword2').value;
if (newPassword && !oldPassword) {
showToast('Введите текущий пароль', 'error');
return;
}
if (newPassword && newPassword !== newPassword2) {
showToast('Пароли не совпадают', 'error');
return;
}
try {
const body = { username };
if (newPassword) {
body.old_password = oldPassword;
body.new_password = newPassword;
body.new_password2 = newPassword2;
}
const data = await api('POST', '/api/auth/profile', body);
showToast('Настройки сохранены', 'success');
$('profOldPassword').value = '';
$('profNewPassword').value = '';
$('profNewPassword2').value = '';
if (data.default_password) {
$('defaultWarning').classList.remove('hidden');
} else {
$('defaultWarning').classList.add('hidden');
}
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
});
$('genApiKeyBtn').addEventListener('click', async () => {
try {
const data = await api('POST', '/api/auth/api-key');
$('apiKeyValue').textContent = data.api_key;
$('apiKeyPrefix').textContent = data.api_key.substring(0, 8) + '...';
$('noApiKey').classList.add('hidden');
$('hasApiKey').classList.remove('hidden');
showToast('API ключ создан', 'success');
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
});
$('copyApiKey').addEventListener('click', () => {
const key = $('apiKeyValue').textContent;
if (key) {
navigator.clipboard.writeText(key).then(() => {
showToast('Ключ скопирован', 'success');
});
}
});
$('revokeApiKeyBtn').addEventListener('click', async () => {
if (!confirm('Отозвать API ключ? Это действие необратимо.')) return;
try {
await api('DELETE', '/api/auth/api-key');
$('noApiKey').classList.remove('hidden');
$('hasApiKey').classList.add('hidden');
showToast('API ключ отозван', 'success');
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
});
$('logoutBtn').addEventListener('click', async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (_) {}
window.location.href = '/login.html';
});
loadProfile();
})();
</script>
<style>
.profile-main { padding: 28px; max-width: var(--max-w); margin: 0 auto; }
.profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.profile-grid { grid-template-columns: 1fr; }
}
.profile-form { display: flex; flex-direction: column; gap: 14px; padding: 0; }
.profile-no-key { text-align: center; padding: 20px 0; }
.profile-hint { color: var(--muted); font-size: .85rem; margin-bottom: 16px; line-height: 1.5; }
.profile-logout { margin-top: 24px; text-align: center; }
.profile-api-info {
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 18px;
font-size: .85rem;
line-height: 1.6;
color: var(--text-dim);
}
.profile-api-info h4 { color: var(--accent); margin: 0 0 8px; font-size: .9rem; }
.profile-api-info code {
font-family: "JetBrains Mono", monospace;
background: rgba(0,0,0,.3);
padding: 2px 6px;
border-radius: 4px;
font-size: .82rem;
}
.profile-code {
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-family: "JetBrains Mono", monospace;
font-size: .82rem;
overflow-x: auto;
margin: 10px 0;
color: var(--text);
}
.api-key-display { display: flex; flex-direction: column; gap: 8px; }
.api-key-row { display: flex; align-items: center; gap: 10px; }
.api-key-label { font-size: .8rem; color: var(--muted); min-width: 60px; }
.api-key-val {
font-family: "JetBrains Mono", monospace;
font-size: .85rem;
color: var(--accent);
background: rgba(0,212,255,0.06);
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
word-break: break-all;
user-select: all;
flex: 1;
}
.profile-section-gap { height: 16px; }
</style>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

631
setup/setup.go Normal file
View File

@@ -0,0 +1,631 @@
package setup
import (
"bufio"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"nano-router/auth"
"nano-router/config"
"nano-router/network"
)
const serviceName = "network-manager"
const servicePath = "/etc/init.d/" + serviceName
const binInstallPath = "/usr/local/bin/network-manager"
func Run() {
reader := bufio.NewReader(os.Stdin)
printBanner()
fmt.Println()
fmt.Println(" This wizard will guide you through initial router configuration.")
fmt.Println(" Press Enter to accept the default value shown in brackets.")
fmt.Println()
ifaces, err := network.GetInterfaces()
if err != nil {
fmt.Fprintf(os.Stderr, " [ERROR] Cannot list network interfaces: %v\n", err)
os.Exit(1)
}
physicalIfaces := filterPhysicalIfaces(ifaces)
if len(physicalIfaces) < 2 {
fmt.Fprintf(os.Stderr, " [ERROR] At least 2 physical interfaces are required, found %d.\n", len(physicalIfaces))
os.Exit(1)
}
fmt.Println(" ── Step 1: WAN Interface ──────────────────────────────────────────")
fmt.Println()
wanIface := selectInterface(reader, physicalIfaces, "Select WAN interface")
fmt.Println()
fmt.Printf(" Configuring WAN interface: %s\n", wanIface)
fmt.Println()
wanMode := selectWANMode(reader)
fmt.Println()
var wanAddress, wanNetmask, wanGateway string
if wanMode == "static" {
wanStats, _ := network.GetInterfaceStats(wanIface)
defAddr := ""
if wanStats != nil && wanStats.IPv4 != "" {
defAddr = wanStats.IPv4
}
defMask := "255.255.255.0"
if wanStats != nil && wanStats.IPv4Mask != "" {
defMask = wanStats.IPv4Mask
}
defGW := ""
if wanStats != nil && wanStats.Gateway != "" {
defGW = wanStats.Gateway
}
wanAddress = readIP(reader, " WAN IP address", defAddr)
wanNetmask = readNetmask(reader, " WAN netmask", defMask)
wanGateway = readIPOrDefault(reader, " WAN gateway", defGW)
}
fmt.Println()
fmt.Println(" ── Step 2: LAN Interface ──────────────────────────────────────────")
fmt.Println()
remainingIfaces := removeItem(physicalIfaces, wanIface)
lanIface := selectInterface(reader, remainingIfaces, "Select LAN interface")
fmt.Println()
lanStats, _ := network.GetInterfaceStats(lanIface)
defLanAddr := "192.168.1.1"
defLanMask := "255.255.255.0"
if lanStats != nil && lanStats.IPv4 != "" {
defLanAddr = lanStats.IPv4
}
if lanStats != nil && lanStats.IPv4Mask != "" {
defLanMask = lanStats.IPv4Mask
}
fmt.Printf(" Configuring LAN interface: %s\n", lanIface)
fmt.Println()
lanAddress := readIP(reader, " LAN IP address", defLanAddr)
lanNetmask := readNetmask(reader, " LAN netmask", defLanMask)
lanSubnet := computeSubnet(lanAddress, lanNetmask)
fmt.Println()
enableDHCP := readYesNo(reader, " Enable DHCP server on LAN?", true)
fmt.Println()
var dhcpRangeStart, dhcpRangeEnd string
var dhcpLeaseTime int
enableDHCPPool := false
if enableDHCP {
defStart := incrementIP(lanAddress, 100)
defEnd := incrementIP(lanAddress, 200)
if lanStats != nil && lanStats.IPv4 != "" {
defStart = incrementIP(lanStats.IPv4, 100)
defEnd = incrementIP(lanStats.IPv4, 200)
}
fmt.Println(" ── DHCP Pool Configuration ─────────────────────────────────────────")
fmt.Println()
dhcpRangeStart = readIP(reader, " DHCP range start", defStart)
dhcpRangeEnd = readIP(reader, " DHCP range end", defEnd)
dhcpLeaseTime = readInt(reader, " Lease time (seconds)", 86400)
enableDHCPPool = true
fmt.Println()
}
fmt.Println(" ── Step 3: Administrator Account ──────────────────────────────────")
fmt.Println()
adminUsername := readNonEmpty(reader, " Administrator login", "admin")
adminPassword := readPassword(reader, " Administrator password")
fmt.Println()
fmt.Println()
fmt.Println(" ── Generating Configuration ────────────────────────────────────────")
fmt.Println()
cfg := buildConfig(wanIface, wanMode, wanAddress, wanNetmask, wanGateway,
lanIface, lanAddress, lanNetmask, lanSubnet,
enableDHCP, enableDHCPPool, dhcpRangeStart, dhcpRangeEnd, dhcpLeaseTime,
adminUsername, adminPassword)
if err := config.Save(cfg); err != nil {
fmt.Fprintf(os.Stderr, " [ERROR] Cannot save config: %v\n", err)
os.Exit(1)
}
fmt.Printf(" Configuration saved to %s\n", config.GetPath())
fmt.Println()
fmt.Println(" ── Installing Service ──────────────────────────────────────────────")
fmt.Println()
if err := installService(); err != nil {
fmt.Fprintf(os.Stderr, " [ERROR] Cannot install service: %v\n", err)
os.Exit(1)
}
fmt.Println()
fmt.Println(" ── Setup Complete ──────────────────────────────────────────────────")
fmt.Println()
fmt.Printf(" Admin panel: http://%s:8080\n", lanAddress)
fmt.Printf(" Login: %s\n", adminUsername)
fmt.Printf(" Password: %s\n", adminPassword)
fmt.Println()
fmt.Println(" The service has been installed and enabled for autostart.")
fmt.Println(" Run 'rc-service network-manager start' or reboot to activate.")
fmt.Println()
}
func printBanner() {
fmt.Println()
fmt.Println(" ┌──────────────────────────────────────────────────────────────────┐")
fmt.Println(" │ Alpine Router -- Initial Setup Wizard │")
fmt.Println(" └──────────────────────────────────────────────────────────────────┘")
}
func selectInterface(reader *bufio.Reader, ifaces []string, prompt string) string {
fmt.Printf(" %s:\n", prompt)
fmt.Println()
for i, name := range ifaces {
stats, _ := network.GetInterfaceStats(name)
extra := ""
if stats != nil {
parts := []string{}
if stats.IPv4 != "" {
parts = append(parts, stats.IPv4)
}
if stats.State != "" {
parts = append(parts, "["+stats.State+"]")
}
if len(parts) > 0 {
extra = " " + strings.Join(parts, " ")
}
}
fmt.Printf(" %2d) %s%s\n", i+1, name, extra)
}
fmt.Println()
for {
fmt.Print(" Enter number: ")
input := strings.TrimSpace(readLine(reader))
num, err := strconv.Atoi(input)
if err != nil || num < 1 || num > len(ifaces) {
fmt.Printf(" [!] Please enter a number between 1 and %d\n", len(ifaces))
continue
}
selected := ifaces[num-1]
fmt.Printf(" Selected: %s\n", selected)
return selected
}
}
func selectWANMode(reader *bufio.Reader) string {
fmt.Println(" WAN addressing mode:")
fmt.Println()
fmt.Println(" 1) DHCP ( automatic )")
fmt.Println(" 2) Static ( manual IP configuration )")
fmt.Println()
for {
fmt.Print(" Select mode (1-2) [1]: ")
input := strings.TrimSpace(readLine(reader))
if input == "" || input == "1" {
fmt.Println(" Selected: DHCP")
return "dhcp"
}
if input == "2" {
fmt.Println(" Selected: Static")
return "static"
}
fmt.Println(" [!] Enter 1 or 2")
}
}
func readIP(reader *bufio.Reader, prompt, def string) string {
for {
promptStr := fmt.Sprintf(" %s", prompt)
if def != "" {
promptStr += fmt.Sprintf(" [%s]", def)
}
promptStr += ": "
fmt.Print(promptStr)
input := strings.TrimSpace(readLine(reader))
if input == "" && def != "" {
input = def
}
if net.ParseIP(input) == nil {
fmt.Println(" [!] Invalid IP address, try again")
continue
}
return input
}
}
func readIPOrDefault(reader *bufio.Reader, prompt, def string) string {
for {
promptStr := fmt.Sprintf(" %s", prompt)
if def != "" {
promptStr += fmt.Sprintf(" [%s]", def)
}
promptStr += " (or leave empty to skip): "
fmt.Print(promptStr)
input := strings.TrimSpace(readLine(reader))
if input == "" {
if def != "" {
return def
}
return ""
}
if net.ParseIP(input) == nil {
fmt.Println(" [!] Invalid IP address, try again")
continue
}
return input
}
}
func readNetmask(reader *bufio.Reader, prompt, def string) string {
for {
promptStr := fmt.Sprintf(" %s", prompt)
if def != "" {
promptStr += fmt.Sprintf(" [%s]", def)
}
promptStr += ": "
fmt.Print(promptStr)
input := strings.TrimSpace(readLine(reader))
if input == "" && def != "" {
input = def
}
if isValidNetmask(input) {
return input
}
fmt.Println(" [!] Invalid netmask, try again (e.g. 255.255.255.0)")
}
}
func readYesNo(reader *bufio.Reader, prompt string, def bool) bool {
defStr := "Y/n"
if !def {
defStr = "y/N"
}
for {
fmt.Printf(" %s [%s]: ", prompt, defStr)
input := strings.TrimSpace(strings.ToLower(readLine(reader)))
if input == "" {
return def
}
switch input {
case "y", "yes":
return true
case "n", "no":
return false
default:
fmt.Println(" [!] Enter y or n")
}
}
}
func readNonEmpty(reader *bufio.Reader, prompt, def string) string {
for {
promptStr := fmt.Sprintf(" %s", prompt)
if def != "" {
promptStr += fmt.Sprintf(" [%s]", def)
}
promptStr += ": "
fmt.Print(promptStr)
input := strings.TrimSpace(readLine(reader))
if input == "" && def != "" {
return def
}
if input != "" {
return input
}
fmt.Println(" [!] Value cannot be empty")
}
}
func readPassword(reader *bufio.Reader, prompt string) string {
fd := int(os.Stdin.Fd())
oldState, canMute := termiosGetState(fd)
for {
fmt.Printf(" %s: ", prompt)
if canMute {
termiosDisableEcho(fd)
}
pw1 := readLineRaw()
if canMute {
termiosRestore(fd, oldState)
fmt.Println()
}
if len(pw1) < 4 {
fmt.Println(" [!] Password must be at least 4 characters")
continue
}
fmt.Print(" Confirm password: ")
if canMute {
termiosDisableEcho(fd)
}
pw2 := readLineRaw()
if canMute {
termiosRestore(fd, oldState)
fmt.Println()
}
if pw1 != pw2 {
fmt.Println(" [!] Passwords do not match, try again")
continue
}
return pw1
}
}
func readLineRaw() string {
var line []byte
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
if buf[0] == '\n' || buf[0] == '\r' {
break
}
line = append(line, buf[0])
}
if err != nil {
break
}
}
return string(line)
}
func readInt(reader *bufio.Reader, prompt string, def int) int {
for {
fmt.Printf(" %s [%d]: ", prompt, def)
input := strings.TrimSpace(readLine(reader))
if input == "" {
return def
}
num, err := strconv.Atoi(input)
if err != nil || num <= 0 {
fmt.Println(" [!] Enter a positive integer")
continue
}
return num
}
}
func readLine(reader *bufio.Reader) string {
line, _ := reader.ReadString('\n')
return strings.TrimRight(line, "\r\n")
}
func filterPhysicalIfaces(ifaces []string) []string {
skip := map[string]bool{"lo": true}
var result []string
for _, name := range ifaces {
if skip[name] {
continue
}
if network.IsVLAN(name) {
continue
}
result = append(result, name)
}
sort.Strings(result)
return result
}
func removeItem(xs []string, item string) []string {
var result []string
for _, x := range xs {
if x != item {
result = append(result, x)
}
}
return result
}
func isValidNetmask(mask string) bool {
m := net.ParseIP(mask)
if m == nil {
return false
}
m4 := m.To4()
if m4 == nil {
return false
}
var n uint32
for _, b := range m4 {
n = (n << 8) | uint32(b)
}
if n == 0 {
return false
}
complement := ^n
return complement&((complement)+1) == 0
}
func computeSubnet(ipStr, maskStr string) string {
ip := net.ParseIP(ipStr)
mask := net.ParseIP(maskStr)
if ip == nil || mask == nil {
return ipStr
}
ip4 := ip.To4()
mask4 := mask.To4()
if ip4 == nil || mask4 == nil {
return ipStr
}
ipMask := net.IPMask(mask4)
subnet := net.IPNet{
IP: ip4.Mask(ipMask),
Mask: ipMask,
}
return subnet.String()
}
func incrementIP(ipStr string, offset int) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ipStr
}
ip4 := ip.To4()
if ip4 == nil {
return ipStr
}
val := uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3])
val += uint32(offset)
return fmt.Sprintf("%d.%d.%d.%d",
(val>>24)&0xFF, (val>>16)&0xFF, (val>>8)&0xFF, val&0xFF)
}
func buildConfig(
wanIface, wanMode, wanAddress, wanNetmask, wanGateway,
lanIface, lanAddress, lanNetmask, lanSubnet string,
enableDHCP, enableDHCPPool bool,
dhcpRangeStart, dhcpRangeEnd string,
dhcpLeaseTime int,
adminUsername, adminPassword string,
) *config.AppConfig {
cfg := &config.AppConfig{
Interfaces: map[string]*config.InterfaceConfig{
wanIface: {
Type: "wan",
Auto: true,
Mode: wanMode,
Address: wanAddress,
Netmask: wanNetmask,
Gateway: wanGateway,
},
lanIface: {
Type: "lan",
Auto: true,
Mode: "static",
Address: lanAddress,
Netmask: lanNetmask,
},
},
NAT: config.NATConfig{
Interfaces: []string{lanIface},
},
Firewall: config.FirewallConfig{
VLANIsolation: true,
Rules: []config.FirewallRule{},
},
DHCP: config.DHCPConfig{
Enabled: enableDHCP,
Pools: []config.DHCPPool{},
},
KnownDevices: []config.KnownDevice{},
Mihomo: config.MihomoConfig{Enabled: false},
ClientPolicy: config.ClientPolicyConfig{Default: "direct"},
Auth: config.AuthConfig{
Username: adminUsername,
PasswordHash: auth.HashPassword(adminPassword),
},
ListenAddresses: []string{lanAddress},
}
if enableDHCP && enableDHCPPool {
parts := strings.Split(lanSubnet, "/")
subnetPart := lanSubnet
if len(parts) == 2 {
subnetPart = parts[0] + "/" + parts[1]
}
dnsServers := []string{lanAddress}
if wanMode == "static" && wanGateway != "" {
dnsServers = []string{lanAddress, wanGateway}
}
cfg.DHCP.Pools = append(cfg.DHCP.Pools, config.DHCPPool{
Interface: lanIface,
Enabled: true,
Subnet: subnetPart,
Netmask: lanNetmask,
RangeStart: dhcpRangeStart,
RangeEnd: dhcpRangeEnd,
Router: lanAddress,
DNS: dnsServers,
LeaseTime: dhcpLeaseTime,
})
}
return cfg
}
func installService() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("detect executable path: %w", err)
}
resolved, err := filepath.EvalSymlinks(exe)
if err != nil {
resolved = exe
}
if _, err := os.Stat(servicePath); err == nil {
fmt.Println(" Removing existing service...")
_ = exec.Command("rc-service", serviceName, "stop").Run()
_ = exec.Command("rc-update", "delete", serviceName).Run()
_ = os.Remove(servicePath)
}
fmt.Printf(" Installing binary to %s...\n", binInstallPath)
if err := os.MkdirAll(filepath.Dir(binInstallPath), 0755); err != nil {
return fmt.Errorf("create bin directory: %w", err)
}
if out, err := exec.Command("cp", resolved, binInstallPath).CombinedOutput(); err != nil {
return fmt.Errorf("copy binary: %s: %w", strings.TrimSpace(string(out)), err)
}
if err := os.Chmod(binInstallPath, 0755); err != nil {
return fmt.Errorf("chmod binary: %w", err)
}
fmt.Println(" Binary installed and made executable")
serviceContent := `#!/sbin/openrc-run
description="Network Manager Web Panel"
command="` + binInstallPath + `"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/network-manager.log"
error_log="/var/log/network-manager.log"
depend() {
need net
after firewall
}
start_pre() {
checkpath --directory /var/log
}
`
if err := os.WriteFile(servicePath, []byte(serviceContent), 0755); err != nil {
return fmt.Errorf("write service file: %w", err)
}
fmt.Printf(" Service file created: %s\n", servicePath)
if out, err := exec.Command("rc-update", "add", serviceName, "default").CombinedOutput(); err != nil {
return fmt.Errorf("enable service: %s: %w", strings.TrimSpace(string(out)), err)
}
fmt.Println(" Service enabled for autostart (default runlevel)")
return nil
}

45
setup/termios_linux.go Normal file
View File

@@ -0,0 +1,45 @@
package setup
import (
"syscall"
"unsafe"
)
type termios struct {
Iflag uint32
Oflag uint32
Cflag uint32
Lflag uint32
Cc [20]byte
Ispeed uint32
Ospeed uint32
}
const (
tcgetattr = 0x5401
tcsetattr = 0x5402
TCSAFLUSH = 2
ECHO = 0x8
ICANON = 0x100
)
func termiosGetState(fd int) (*termios, bool) {
var state termios
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcgetattr), uintptr(unsafe.Pointer(&state))); err != 0 {
return nil, false
}
return &state, true
}
func termiosDisableEcho(fd int) {
var state termios
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcgetattr), uintptr(unsafe.Pointer(&state))); err != 0 {
return
}
state.Lflag &^= ECHO | ICANON
syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcsetattr), uintptr(unsafe.Pointer(&state)))
}
func termiosRestore(fd int, state *termios) {
syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tcsetattr), uintptr(unsafe.Pointer(state)))
}

View File

@@ -14,7 +14,7 @@ import (
const (
pollInterval = 20 * time.Second
OnlineWindow = 5 * time.Minute
trackerTableName = "alpine-router-traffic"
trackerTableName = "nano-router-traffic"
)
type IPStats struct {
@@ -82,6 +82,7 @@ func EnsureIPTracked(ip string) {
return
}
prev[ip] = [2]uint64{}
stats[ip] = &IPStats{}
addNFTRule(ip)
}
@@ -90,10 +91,10 @@ func setupNFTTable() error {
script := fmt.Sprintf(`table ip %s {
chain tx {
type filter hook forward priority filter + 10; policy accept;
type filter hook prerouting priority raw; policy accept;
}
chain rx {
type filter hook forward priority filter + 20; policy accept;
type filter hook postrouting priority raw; policy accept;
}
}`, trackerTableName)
@@ -154,6 +155,7 @@ func poll() {
for ip := range prev {
if _, exists := current[ip]; !exists {
prev[ip] = [2]uint64{0, 0}
addNFTRule(ip)
}
}
}

1
zashboard Submodule

Submodule zashboard added at 132970daa3