Compare commits
2 Commits
6aa0349f5d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 91b4585175 | |||
| f50d79fab3 |
Submodule Meta-Docs updated: eedcf074cd...d31369ab45
@@ -1,4 +1,4 @@
|
|||||||
# Network Manager — веб-панель управления сетью для Alpine Linux
|
# NanoRouter — веб-панель управления сетью для Alpine Linux
|
||||||
|
|
||||||
Простая веб-панель для настройки сетевых интерфейсов на Alpine Linux.
|
Простая веб-панель для настройки сетевых интерфейсов на Alpine Linux.
|
||||||
Написана на Go (бэкенд) + чистый HTML/CSS/JS (фронтенд), без внешних зависимостей.
|
Написана на Go (бэкенд) + чистый HTML/CSS/JS (фронтенд), без внешних зависимостей.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
alpine-router/
|
NanoRouter/
|
||||||
├── main.go — точка входа, HTTP-роутинг
|
├── main.go — точка входа, HTTP-роутинг
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── handlers/
|
├── handlers/
|
||||||
@@ -36,7 +36,7 @@ alpine-router/
|
|||||||
## Быстрый запуск (разработка, Linux/macOS)
|
## Быстрый запуск (разработка, Linux/macOS)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd alpine-router
|
cd NanoRouter
|
||||||
go run .
|
go run .
|
||||||
# открыть http://localhost:8080
|
# открыть http://localhost:8080
|
||||||
```
|
```
|
||||||
@@ -60,7 +60,7 @@ apk add go git ifupdown
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# На самом роутере или кросс-компиляцией:
|
# На самом роутере или кросс-компиляцией:
|
||||||
cd alpine-router
|
cd NanoRouter
|
||||||
go build -o network-manager .
|
go build -o network-manager .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
BIN
alpine-router
BIN
alpine-router
Binary file not shown.
484
auth/auth.go
Normal file
484
auth/auth.go
Normal 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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"alpine-router/config"
|
"nano-router/config"
|
||||||
"alpine-router/traffic"
|
"nano-router/traffic"
|
||||||
)
|
)
|
||||||
|
|
||||||
const LeasesFile = "/var/lib/misc/dnsmasq.leases"
|
const LeasesFile = "/var/lib/misc/dnsmasq.leases"
|
||||||
@@ -28,6 +28,7 @@ type Client struct {
|
|||||||
Known bool `json:"known"`
|
Known bool `json:"known"`
|
||||||
Blocked bool `json:"blocked"`
|
Blocked bool `json:"blocked"`
|
||||||
StaticIP string `json:"static_ip"`
|
StaticIP string `json:"static_ip"`
|
||||||
|
Policy string `json:"policy"` // "disabled" | "direct" | "vpn" | "" (use default)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAll() ([]Client, error) {
|
func GetAll() ([]Client, error) {
|
||||||
@@ -78,30 +79,31 @@ func GetAll() ([]Client, error) {
|
|||||||
found := false
|
found := false
|
||||||
|
|
||||||
for ip, c := range byIP {
|
for ip, c := range byIP {
|
||||||
if kd.MAC != "" && c.MAC == kd.MAC {
|
matchedMAC := kd.MAC != "" && c.MAC == kd.MAC
|
||||||
c.Blocked = kd.Blocked
|
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
|
c.StaticIP = kd.StaticIP
|
||||||
if kd.Hostname != "" {
|
|
||||||
c.Hostname = kd.Hostname
|
|
||||||
}
|
|
||||||
if kd.StaticIP != "" {
|
if kd.StaticIP != "" {
|
||||||
c.IP = 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 != "" {
|
if !found && key != "" {
|
||||||
@@ -120,6 +122,7 @@ func GetAll() ([]Client, error) {
|
|||||||
Known: true,
|
Known: true,
|
||||||
Blocked: kd.Blocked,
|
Blocked: kd.Blocked,
|
||||||
StaticIP: kd.StaticIP,
|
StaticIP: kd.StaticIP,
|
||||||
|
Policy: kd.Policy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +153,22 @@ func GetAll() ([]Client, error) {
|
|||||||
|
|
||||||
go syncKnownDevices(byIP)
|
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))
|
result := make([]Client, 0, len(byIP))
|
||||||
for _, c := range byIP {
|
for _, c := range byIP {
|
||||||
|
if gatewayIPs[c.IP] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
result = append(result, *c)
|
result = append(result, *c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +194,7 @@ func syncKnownDevices(byIP map[string]*Client) {
|
|||||||
savedHostnames := make(map[string]string)
|
savedHostnames := make(map[string]string)
|
||||||
savedBlocked := make(map[string]bool)
|
savedBlocked := make(map[string]bool)
|
||||||
savedStaticIPs := make(map[string]string)
|
savedStaticIPs := make(map[string]string)
|
||||||
|
savedPolicies := make(map[string]string)
|
||||||
for _, kd := range cfg.KnownDevices {
|
for _, kd := range cfg.KnownDevices {
|
||||||
key := kd.MAC
|
key := kd.MAC
|
||||||
if key == "" {
|
if key == "" {
|
||||||
@@ -189,6 +207,9 @@ func syncKnownDevices(byIP map[string]*Client) {
|
|||||||
if kd.StaticIP != "" {
|
if kd.StaticIP != "" {
|
||||||
savedStaticIPs[key] = kd.StaticIP
|
savedStaticIPs[key] = kd.StaticIP
|
||||||
}
|
}
|
||||||
|
if kd.Policy != "" {
|
||||||
|
savedPolicies[key] = kd.Policy
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var seen []config.KnownDevice
|
var seen []config.KnownDevice
|
||||||
@@ -210,12 +231,32 @@ func syncKnownDevices(byIP map[string]*Client) {
|
|||||||
if sip, ok := savedStaticIPs[key]; ok {
|
if sip, ok := savedStaticIPs[key]; ok {
|
||||||
kd.StaticIP = sip
|
kd.StaticIP = sip
|
||||||
}
|
}
|
||||||
|
if pol, ok := savedPolicies[key]; ok {
|
||||||
|
kd.Policy = pol
|
||||||
|
}
|
||||||
seen = append(seen, kd)
|
seen = append(seen, kd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = config.UpdateKnownDevices(seen)
|
_ = 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) {
|
func parseDNSMasqLeases() (map[string]*Client, error) {
|
||||||
f, err := os.Open(LeasesFile)
|
f, err := os.Open(LeasesFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
type InterfaceConfig struct {
|
type InterfaceConfig struct {
|
||||||
Label string `yaml:"label,omitempty"`
|
Label string `yaml:"label,omitempty"`
|
||||||
|
Type string `yaml:"type,omitempty"`
|
||||||
Auto bool `yaml:"auto"`
|
Auto bool `yaml:"auto"`
|
||||||
Mode string `yaml:"mode"`
|
Mode string `yaml:"mode"`
|
||||||
Address string `yaml:"address,omitempty"`
|
Address string `yaml:"address,omitempty"`
|
||||||
@@ -47,6 +48,12 @@ type KnownDevice struct {
|
|||||||
Hostname string `yaml:"hostname"`
|
Hostname string `yaml:"hostname"`
|
||||||
Blocked bool `yaml:"blocked,omitempty"`
|
Blocked bool `yaml:"blocked,omitempty"`
|
||||||
StaticIP string `yaml:"static_ip,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 {
|
type MihomoConfig struct {
|
||||||
@@ -72,13 +79,33 @@ type FirewallConfig struct {
|
|||||||
VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"`
|
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 {
|
type AppConfig struct {
|
||||||
Interfaces map[string]*InterfaceConfig `yaml:"interfaces"`
|
Interfaces map[string]*InterfaceConfig `yaml:"interfaces"`
|
||||||
DHCP DHCPConfig `yaml:"dhcp"`
|
DHCP DHCPConfig `yaml:"dhcp"`
|
||||||
NAT NATConfig `yaml:"nat"`
|
NAT NATConfig `yaml:"nat"`
|
||||||
Firewall FirewallConfig `yaml:"firewall"`
|
Firewall FirewallConfig `yaml:"firewall"`
|
||||||
KnownDevices []KnownDevice `yaml:"known_devices"`
|
KnownDevices []KnownDevice `yaml:"known_devices"`
|
||||||
Mihomo MihomoConfig `yaml:"mihomo"`
|
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 (
|
var (
|
||||||
@@ -187,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) {
|
func EnsureDefaults(cfg *AppConfig) {
|
||||||
if cfg.Interfaces == nil {
|
if cfg.Interfaces == nil {
|
||||||
cfg.Interfaces = map[string]*InterfaceConfig{}
|
cfg.Interfaces = map[string]*InterfaceConfig{}
|
||||||
@@ -203,6 +243,12 @@ func EnsureDefaults(cfg *AppConfig) {
|
|||||||
if cfg.KnownDevices == nil {
|
if cfg.KnownDevices == nil {
|
||||||
cfg.KnownDevices = []KnownDevice{}
|
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 {
|
func UpdateKnownDevices(seen []KnownDevice) error {
|
||||||
@@ -238,6 +284,7 @@ func UpdateKnownDevices(seen []KnownDevice) error {
|
|||||||
if d.StaticIP != "" {
|
if d.StaticIP != "" {
|
||||||
existingDev.StaticIP = d.StaticIP
|
existingDev.StaticIP = d.StaticIP
|
||||||
}
|
}
|
||||||
|
// Policy is always preserved from existingDev; never overwritten by discovery
|
||||||
existing[key] = existingDev
|
existing[key] = existingDev
|
||||||
} else {
|
} else {
|
||||||
existing[key] = d
|
existing[key] = d
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ConfigFile = "/etc/dnsmasq.d/alpine-router-dhcp.conf"
|
ConfigFile = "/etc/dnsmasq.d/nano-router-dhcp.conf"
|
||||||
StateFile = "/var/lib/alpine-router/dhcp.json"
|
StateFile = "/var/lib/nano-router/dhcp.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pool describes a DHCP pool tied to one interface/subnet.
|
// Pool describes a DHCP pool tied to one interface/subnet.
|
||||||
@@ -76,7 +76,7 @@ func Save(cfg *Config) error {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
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)
|
return fmt.Errorf("mkdir state dir: %w", err)
|
||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
@@ -86,7 +86,7 @@ func Save(cfg *Config) error {
|
|||||||
return os.WriteFile(StateFile, data, 0644)
|
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).
|
// dnsmasq is used in DHCP-only mode (port=0 disables DNS resolver).
|
||||||
func WriteConfigs(cfg *Config) error {
|
func WriteConfigs(cfg *Config) error {
|
||||||
if err := os.MkdirAll("/etc/dnsmasq.d", 0755); err != nil {
|
if err := os.MkdirAll("/etc/dnsmasq.d", 0755); err != nil {
|
||||||
@@ -94,7 +94,7 @@ func WriteConfigs(cfg *Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
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("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n")
|
||||||
sb.WriteString("port=0\n") // disable DNS
|
sb.WriteString("port=0\n") // disable DNS
|
||||||
sb.WriteString("bind-interfaces\n") // only listen on specified interfaces
|
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
|
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("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n")
|
||||||
sb.WriteString("port=0\n")
|
sb.WriteString("port=0\n")
|
||||||
sb.WriteString("bind-interfaces\n")
|
sb.WriteString("bind-interfaces\n")
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tableName = "alpine-router"
|
const tableName = "nano-router"
|
||||||
|
|
||||||
|
// TproxyPort is the fixed Mihomo transparent-proxy port, always enabled.
|
||||||
|
const TproxyPort = 7893
|
||||||
|
|
||||||
|
// TproxyMark is the fwmark set on packets that should be routed to tproxy.
|
||||||
|
const TproxyMark = 1
|
||||||
|
|
||||||
|
// TproxyTable is the ip routing table used for fwmark-based local delivery.
|
||||||
|
const TproxyTable = 100
|
||||||
|
|
||||||
// Rule is a single stateless forward-filter rule.
|
// Rule is a single stateless forward-filter rule.
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
@@ -35,29 +44,76 @@ type NATConfig struct {
|
|||||||
Interfaces []string
|
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.
|
// IsInstalled reports whether the nft binary is available.
|
||||||
func IsInstalled() bool {
|
func IsInstalled() bool {
|
||||||
_, err := exec.LookPath("nft")
|
_, err := exec.LookPath("nft")
|
||||||
return err == nil
|
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:
|
// ApplyAll atomically regenerates the complete nftables ruleset:
|
||||||
|
// - Tproxy prerouting for cp.VPNIPs (mangle priority, TPROXY → Mihomo :TproxyPort)
|
||||||
// - NAT masquerade for natCfg.Interfaces
|
// - NAT masquerade for natCfg.Interfaces
|
||||||
// - Blocked client IP drops
|
// - Disabled client IP drops (cp.DisabledIPs)
|
||||||
// - User rules from fwCfg (in order, enabled only)
|
// - User rules from fwCfg (in order, enabled only)
|
||||||
// - LAN isolation (if fwCfg.VLANIsolation): blocks traffic between any two LAN interfaces
|
// - LAN isolation (if fwCfg.VLANIsolation): blocks traffic between any two LAN interfaces
|
||||||
// (native + tagged VLANs). User rules placed above have priority.
|
|
||||||
// - Default accept from LAN interfaces to WAN
|
// - Default accept from LAN interfaces to WAN
|
||||||
//
|
//
|
||||||
// lanIfaces is the union of NAT interfaces and all VLAN interfaces — every interface
|
// lanIfaces is the union of NAT interfaces and all VLAN interfaces.
|
||||||
// that serves a local subnet. Isolation prevents any two of them from talking directly.
|
func ApplyAll(natCfg NATConfig, fwCfg Config, lanIfaces []string, cp ClientPolicies) error {
|
||||||
func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) error {
|
|
||||||
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil {
|
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil {
|
||||||
return fmt.Errorf("enable ip_forward: %w", err)
|
return fmt.Errorf("enable ip_forward: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove both old and new table names to ensure clean state.
|
// Remove both old and new table names to ensure clean state.
|
||||||
exec.Command("nft", "delete", "table", "ip", "alpine-router-nat").Run()
|
exec.Command("nft", "delete", "table", "ip", "nano-router-nat").Run()
|
||||||
exec.Command("nft", "delete", "table", "ip", tableName).Run()
|
exec.Command("nft", "delete", "table", "ip", tableName).Run()
|
||||||
|
|
||||||
var activeRules []Rule
|
var activeRules []Rule
|
||||||
@@ -68,26 +124,48 @@ func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasNAT := len(natCfg.Interfaces) > 0
|
hasNAT := len(natCfg.Interfaces) > 0
|
||||||
hasBlocked := len(blockedIPs) > 0
|
hasDisabled := len(cp.DisabledIPs) > 0
|
||||||
|
hasVPN := len(cp.VPNIPs) > 0
|
||||||
hasVLANIsolation := fwCfg.VLANIsolation && len(lanIfaces) >= 2
|
hasVLANIsolation := fwCfg.VLANIsolation && len(lanIfaces) >= 2
|
||||||
|
|
||||||
if !hasNAT && !hasBlocked && !hasVLANIsolation && len(activeRules) == 0 {
|
if !hasNAT && !hasDisabled && !hasVPN && !hasVLANIsolation && len(activeRules) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "table ip %s {\n", tableName)
|
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 ────────────────────────────────────────────────────────
|
// ── Forward chain ────────────────────────────────────────────────────────
|
||||||
sb.WriteString(" chain forward {\n")
|
sb.WriteString(" chain forward {\n")
|
||||||
sb.WriteString(" type filter hook forward priority filter; policy drop;\n")
|
sb.WriteString(" type filter hook forward priority filter; policy drop;\n")
|
||||||
sb.WriteString(" ct state established,related accept\n")
|
sb.WriteString(" ct state established,related accept\n")
|
||||||
|
|
||||||
for _, ip := range blockedIPs {
|
// Drop traffic for disabled clients (both directions).
|
||||||
|
for _, ip := range cp.DisabledIPs {
|
||||||
fmt.Fprintf(&sb, " ip saddr %s drop\n", ip)
|
fmt.Fprintf(&sb, " ip saddr %s drop\n", ip)
|
||||||
fmt.Fprintf(&sb, " ip daddr %s drop\n", ip)
|
fmt.Fprintf(&sb, " ip daddr %s drop\n", ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User-defined forward rules (ordered, enabled only).
|
||||||
for _, rule := range activeRules {
|
for _, rule := range activeRules {
|
||||||
line := buildRuleLine(rule)
|
line := buildRuleLine(rule)
|
||||||
if line == "" {
|
if line == "" {
|
||||||
@@ -110,7 +188,7 @@ func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) er
|
|||||||
fmt.Fprintf(&sb, " iifname %s oifname %s drop\n", set, set)
|
fmt.Fprintf(&sb, " iifname %s oifname %s drop\n", set, set)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow from LAN/VLAN interfaces to WAN (non-VLAN, non-blocked traffic falls through above).
|
// Allow from LAN/VLAN interfaces outbound to WAN.
|
||||||
for _, iface := range natCfg.Interfaces {
|
for _, iface := range natCfg.Interfaces {
|
||||||
fmt.Fprintf(&sb, " iifname %q accept\n", iface)
|
fmt.Fprintf(&sb, " iifname %q accept\n", iface)
|
||||||
}
|
}
|
||||||
@@ -136,24 +214,23 @@ func ApplyAll(natCfg NATConfig, fwCfg Config, blockedIPs, lanIfaces []string) er
|
|||||||
return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err)
|
return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush connection tracking table so existing sessions are re-evaluated
|
// Set up ip rule/route for tproxy fwmark routing when VPN clients are active.
|
||||||
// against the new ruleset. Without this, traffic already tracked as
|
if hasVPN {
|
||||||
// "established/related" bypasses new drop rules until the session ends.
|
SetupTproxyRouting()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush connection tracking so existing sessions are re-evaluated.
|
||||||
flushConntrack()
|
flushConntrack()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// flushConntrack clears the kernel connection tracking table so that all traffic
|
// flushConntrack clears the kernel connection tracking table so that all traffic
|
||||||
// is re-evaluated against the current nftables ruleset. This is necessary when
|
// is re-evaluated against the current nftables ruleset.
|
||||||
// adding new drop/reject rules to prevent previously-established sessions from
|
|
||||||
// continuing to bypass the new rules via ct state established,related accept.
|
|
||||||
func flushConntrack() {
|
func flushConntrack() {
|
||||||
// Preferred: conntrack utility (part of conntrack-tools package).
|
|
||||||
if err := exec.Command("conntrack", "-F").Run(); err == nil {
|
if err := exec.Command("conntrack", "-F").Run(); err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Fallback: write to /proc (available when nf_conntrack module is loaded).
|
|
||||||
_ = os.WriteFile("/proc/sys/net/netfilter/nf_conntrack_flush", []byte("1"), 0644)
|
_ = os.WriteFile("/proc/sys/net/netfilter/nf_conntrack_flush", []byte("1"), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
109
handlers/api.go
109
handlers/api.go
@@ -5,8 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"alpine-router/config"
|
"nano-router/config"
|
||||||
"alpine-router/network"
|
"nano-router/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
type apiResp struct {
|
type apiResp struct {
|
||||||
@@ -47,10 +47,16 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
|
|||||||
*network.InterfaceStats
|
*network.InterfaceStats
|
||||||
Pending bool `json:"pending"`
|
Pending bool `json:"pending"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
|
NAT bool `json:"nat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, _ := config.Load()
|
appCfg, _ := config.Load()
|
||||||
|
|
||||||
|
var natIfaces []string
|
||||||
|
if appCfg != nil {
|
||||||
|
natIfaces = appCfg.NAT.Interfaces
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]iface, 0, len(names))
|
result := make([]iface, 0, len(names))
|
||||||
existingNames := map[string]bool{}
|
existingNames := map[string]bool{}
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
@@ -64,15 +70,30 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
hasPending := network.GetPendingConfig(name) != nil
|
hasPending := network.GetPendingConfig(name) != nil
|
||||||
label := ""
|
label := ""
|
||||||
|
ifaceType := ""
|
||||||
if appCfg != nil && appCfg.Interfaces != nil {
|
if appCfg != nil && appCfg.Interfaces != nil {
|
||||||
if ic, ok := appCfg.Interfaces[name]; ok {
|
if ic, ok := appCfg.Interfaces[name]; ok {
|
||||||
label = ic.Label
|
label = ic.Label
|
||||||
|
ifaceType = ic.Type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, iface{s, hasPending, label})
|
s.Type = ifaceType
|
||||||
|
if s.Type == "" && s.Gateway != "" {
|
||||||
|
s.Type = "wan"
|
||||||
|
}
|
||||||
|
if s.Type == "" && s.Mode != "loopback" {
|
||||||
|
s.Type = "lan"
|
||||||
|
}
|
||||||
|
isNAT := false
|
||||||
|
for _, ni := range natIfaces {
|
||||||
|
if ni == name {
|
||||||
|
isNAT = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, iface{s, hasPending, label, isNAT})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also include pending VLAN configs not yet present in the system.
|
|
||||||
for name, cfg := range network.GetAllPending() {
|
for name, cfg := range network.GetAllPending() {
|
||||||
if existingNames[name] || !network.IsVLAN(name) {
|
if existingNames[name] || !network.IsVLAN(name) {
|
||||||
continue
|
continue
|
||||||
@@ -81,6 +102,7 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: name,
|
Name: name,
|
||||||
State: "unknown",
|
State: "unknown",
|
||||||
Mode: cfg.Mode,
|
Mode: cfg.Mode,
|
||||||
|
Type: cfg.Type,
|
||||||
IPv6: []string{},
|
IPv6: []string{},
|
||||||
}
|
}
|
||||||
if cfg.Mode == "static" {
|
if cfg.Mode == "static" {
|
||||||
@@ -88,13 +110,26 @@ func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.IPv4Mask = cfg.Netmask
|
s.IPv4Mask = cfg.Netmask
|
||||||
s.Gateway = cfg.Gateway
|
s.Gateway = cfg.Gateway
|
||||||
}
|
}
|
||||||
|
if s.Type == "" && s.Gateway != "" {
|
||||||
|
s.Type = "wan"
|
||||||
|
}
|
||||||
|
if s.Type == "" {
|
||||||
|
s.Type = "lan"
|
||||||
|
}
|
||||||
label := cfg.Label
|
label := cfg.Label
|
||||||
if label == "" && appCfg != nil && appCfg.Interfaces != nil {
|
if label == "" && appCfg != nil && appCfg.Interfaces != nil {
|
||||||
if ic, ok := appCfg.Interfaces[name]; ok {
|
if ic, ok := appCfg.Interfaces[name]; ok {
|
||||||
label = ic.Label
|
label = ic.Label
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = append(result, iface{s, true, label})
|
isNAT := false
|
||||||
|
for _, ni := range natIfaces {
|
||||||
|
if ni == name {
|
||||||
|
isNAT = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, iface{s, true, label, isNAT})
|
||||||
}
|
}
|
||||||
|
|
||||||
ok(w, result)
|
ok(w, result)
|
||||||
@@ -131,9 +166,9 @@ func HandleInterfaceAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
var err error
|
var err error
|
||||||
switch action {
|
switch action {
|
||||||
case "up":
|
case "up":
|
||||||
err = network.IfUp(name)
|
err = network.LinkUp(name)
|
||||||
case "down":
|
case "down":
|
||||||
err = network.IfDown(name)
|
err = network.LinkDown(name)
|
||||||
case "restart":
|
case "restart":
|
||||||
err = network.IfRestart(name)
|
err = network.IfRestart(name)
|
||||||
case "delete":
|
case "delete":
|
||||||
@@ -166,16 +201,21 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
appCfg, _ := config.Load()
|
appCfg, _ := config.Load()
|
||||||
label := ""
|
label := ""
|
||||||
|
ifaceType := ""
|
||||||
if appCfg != nil && appCfg.Interfaces != nil {
|
if appCfg != nil && appCfg.Interfaces != nil {
|
||||||
if ic, ok2 := appCfg.Interfaces[name]; ok2 {
|
if ic, ok2 := appCfg.Interfaces[name]; ok2 {
|
||||||
label = ic.Label
|
label = ic.Label
|
||||||
|
ifaceType = ic.Type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg := network.GetPendingConfig(name); cfg != nil {
|
if cfg := network.GetPendingConfig(name); cfg != nil {
|
||||||
if cfg.Label != "" {
|
if cfg.Label != "" {
|
||||||
label = cfg.Label
|
label = cfg.Label
|
||||||
}
|
}
|
||||||
ok(w, map[string]interface{}{"config": cfg, "pending": true, "label": label})
|
if cfg.Type != "" {
|
||||||
|
ifaceType = cfg.Type
|
||||||
|
}
|
||||||
|
ok(w, map[string]interface{}{"config": cfg, "pending": true, "label": label, "type": ifaceType})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fileCfg, err := network.ParseConfig()
|
fileCfg, err := network.ParseConfig()
|
||||||
@@ -184,12 +224,17 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg, exists := fileCfg[name]; exists {
|
if cfg, exists := fileCfg[name]; exists {
|
||||||
ok(w, map[string]interface{}{"config": cfg, "pending": false, "label": label})
|
if ifaceType == "" {
|
||||||
|
ifaceType = cfg.Type
|
||||||
|
}
|
||||||
|
ok(w, map[string]interface{}{"config": cfg, "pending": false, "label": label, "type": ifaceType})
|
||||||
} else {
|
} else {
|
||||||
|
defaultType := "lan"
|
||||||
ok(w, map[string]interface{}{
|
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,
|
"pending": false,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"type": defaultType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +245,49 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg.Name = name
|
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 {
|
if cfg.Extra == nil {
|
||||||
cfg.Extra = map[string]string{}
|
cfg.Extra = map[string]string{}
|
||||||
}
|
}
|
||||||
@@ -215,6 +303,7 @@ func HandleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
appCfg.Interfaces[name] = &config.InterfaceConfig{
|
appCfg.Interfaces[name] = &config.InterfaceConfig{
|
||||||
Label: cfg.Label,
|
Label: cfg.Label,
|
||||||
|
Type: cfg.Type,
|
||||||
Auto: cfg.Auto,
|
Auto: cfg.Auto,
|
||||||
Mode: cfg.Mode,
|
Mode: cfg.Mode,
|
||||||
Address: cfg.Address,
|
Address: cfg.Address,
|
||||||
|
|||||||
211
handlers/auth.go
Normal file
211
handlers/auth.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"nano-router/auth"
|
||||||
|
"nano-router/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleAuthChallenge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nonce := auth.Global.CreateChallenge()
|
||||||
|
ok(w, map[string]string{"nonce": nonce})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
fail(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIP := auth.GetClientIP(r)
|
||||||
|
sessionID, loginOk := auth.Global.Login(req.Nonce, req.Response, clientIP)
|
||||||
|
if !loginOk {
|
||||||
|
fail(w, http.StatusUnauthorized, "invalid credentials or expired challenge")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.SetSessionCookie(w, sessionID)
|
||||||
|
username, _, isDefault := auth.Global.GetCredentials()
|
||||||
|
ok(w, map[string]interface{}{
|
||||||
|
"message": "authenticated",
|
||||||
|
"default_password": isDefault,
|
||||||
|
"username": username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := auth.GetSessionToken(r)
|
||||||
|
if token != "" {
|
||||||
|
auth.Global.DestroySession(token)
|
||||||
|
}
|
||||||
|
auth.ClearSessionCookie(w)
|
||||||
|
ok(w, map[string]string{"message": "logged out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, apiKey, isDefault := auth.Global.GetCredentials()
|
||||||
|
ok(w, map[string]interface{}{
|
||||||
|
"authenticated": true,
|
||||||
|
"username": username,
|
||||||
|
"default_password": isDefault,
|
||||||
|
"has_api_key": apiKey != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleAuthProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
username, apiKey, isDefault := auth.Global.GetCredentials()
|
||||||
|
ok(w, map[string]interface{}{
|
||||||
|
"username": username,
|
||||||
|
"default_password": isDefault,
|
||||||
|
"has_api_key": apiKey != "",
|
||||||
|
"api_key_prefix": apiKeyPrefix(apiKey),
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
NewPassword2 string `json:"new_password2"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
fail(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" && req.NewPassword == "" {
|
||||||
|
fail(w, http.StatusBadRequest, "nothing to update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameChanged := false
|
||||||
|
passwordChanged := false
|
||||||
|
|
||||||
|
if req.NewPassword != "" {
|
||||||
|
if req.OldPassword == "" {
|
||||||
|
fail(w, http.StatusBadRequest, "current password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldHash := auth.HashPassword(req.OldPassword)
|
||||||
|
if !auth.Global.VerifyPassword(oldHash) {
|
||||||
|
fail(w, http.StatusUnauthorized, "current password incorrect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NewPassword != req.NewPassword2 {
|
||||||
|
fail(w, http.StatusBadRequest, "passwords do not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.NewPassword) < 4 {
|
||||||
|
fail(w, http.StatusBadRequest, "password too short (min 4 chars)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash := auth.HashPassword(req.NewPassword)
|
||||||
|
clientIP := auth.GetClientIP(r)
|
||||||
|
newSessionID := auth.Global.UpdatePassword(newHash, clientIP)
|
||||||
|
auth.SetSessionCookie(w, newSessionID)
|
||||||
|
passwordChanged = true
|
||||||
|
|
||||||
|
cfg, _ := config.Load()
|
||||||
|
cfg.Auth.PasswordHash = newHash
|
||||||
|
if req.Username != "" {
|
||||||
|
cfg.Auth.Username = req.Username
|
||||||
|
auth.Global.UpdateUsername(req.Username)
|
||||||
|
usernameChanged = true
|
||||||
|
}
|
||||||
|
config.Save(cfg)
|
||||||
|
} else if req.Username != "" {
|
||||||
|
auth.Global.UpdateUsername(req.Username)
|
||||||
|
usernameChanged = true
|
||||||
|
cfg, _ := config.Load()
|
||||||
|
cfg.Auth.Username = req.Username
|
||||||
|
config.Save(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
username, apiKey, isDefault := auth.Global.GetCredentials()
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"username": username,
|
||||||
|
"default_password": isDefault,
|
||||||
|
"has_api_key": apiKey != "",
|
||||||
|
"api_key_prefix": apiKeyPrefix(apiKey),
|
||||||
|
}
|
||||||
|
if passwordChanged {
|
||||||
|
result["password_changed"] = true
|
||||||
|
}
|
||||||
|
if usernameChanged {
|
||||||
|
result["username_changed"] = true
|
||||||
|
}
|
||||||
|
ok(w, result)
|
||||||
|
|
||||||
|
default:
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleAuthAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
key := generateAPIKey()
|
||||||
|
auth.Global.SetAPIKey(key)
|
||||||
|
|
||||||
|
cfg, _ := config.Load()
|
||||||
|
cfg.Auth.APIKey = key
|
||||||
|
config.Save(cfg)
|
||||||
|
|
||||||
|
ok(w, map[string]interface{}{
|
||||||
|
"api_key": key,
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
auth.Global.SetAPIKey("")
|
||||||
|
|
||||||
|
cfg, _ := config.Load()
|
||||||
|
cfg.Auth.APIKey = ""
|
||||||
|
config.Save(cfg)
|
||||||
|
|
||||||
|
ok(w, map[string]string{"message": "api key revoked"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAPIKey() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return "ar_" + hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyPrefix(key string) string {
|
||||||
|
if len(key) > 8 {
|
||||||
|
return key[:8] + "..."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"alpine-router/clients"
|
"nano-router/clients"
|
||||||
"alpine-router/config"
|
"nano-router/config"
|
||||||
"alpine-router/dhcp"
|
"nano-router/dhcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleClients(w http.ResponseWriter, r *http.Request) {
|
func HandleClients(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -42,13 +42,14 @@ func HandleClientUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Blocked bool `json:"blocked"`
|
Blocked bool `json:"blocked"`
|
||||||
StaticIP string `json:"static_ip"`
|
StaticIP string `json:"static_ip"`
|
||||||
|
Policy string `json:"policy"` // "disabled" | "direct" | "vpn" | ""
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
|
||||||
return
|
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())
|
fail(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,88 @@ func HandleClientUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
ok(w, map[string]string{"message": "updated"})
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -67,9 +149,11 @@ func updateClient(mac, hostname string, blocked bool, staticIP string) error {
|
|||||||
found := false
|
found := false
|
||||||
for i := range cfg.KnownDevices {
|
for i := range cfg.KnownDevices {
|
||||||
if cfg.KnownDevices[i].MAC == mac {
|
if cfg.KnownDevices[i].MAC == mac {
|
||||||
cfg.KnownDevices[i].Blocked = blocked
|
|
||||||
cfg.KnownDevices[i].Hostname = hostname
|
cfg.KnownDevices[i].Hostname = hostname
|
||||||
cfg.KnownDevices[i].StaticIP = staticIP
|
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
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -79,15 +163,15 @@ func updateClient(mac, hostname string, blocked bool, staticIP string) error {
|
|||||||
cfg.KnownDevices = append(cfg.KnownDevices, config.KnownDevice{
|
cfg.KnownDevices = append(cfg.KnownDevices, config.KnownDevice{
|
||||||
MAC: mac,
|
MAC: mac,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Blocked: blocked,
|
|
||||||
StaticIP: staticIP,
|
StaticIP: staticIP,
|
||||||
|
Policy: policy,
|
||||||
|
Blocked: policy == "disabled",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return config.Save(cfg)
|
return config.Save(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func applyDHCPStaticBindings() {
|
func applyDHCPStaticBindings() {
|
||||||
if !dhcp.IsInstalled() {
|
if !dhcp.IsInstalled() {
|
||||||
return
|
return
|
||||||
|
|||||||
16
handlers/dashboard.go
Normal file
16
handlers/dashboard.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"nano-router/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := monitor.GetData()
|
||||||
|
ok(w, data)
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"alpine-router/config"
|
"nano-router/config"
|
||||||
"alpine-router/dhcp"
|
"nano-router/dhcp"
|
||||||
"alpine-router/network"
|
"nano-router/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleDHCPStatus(w http.ResponseWriter, r *http.Request) {
|
func HandleDHCPStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -90,6 +91,14 @@ func HandleDHCPConfigSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
if cfg.Pools == nil {
|
if cfg.Pools == nil {
|
||||||
cfg.Pools = []dhcp.Pool{}
|
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 {
|
if err := dhcp.Save(&cfg); err != nil {
|
||||||
fail(w, http.StatusInternalServerError, err.Error())
|
fail(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"alpine-router/config"
|
"nano-router/config"
|
||||||
"alpine-router/nat"
|
"nano-router/nat"
|
||||||
"alpine-router/network"
|
"nano-router/network"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleFirewall(w http.ResponseWriter, r *http.Request) {
|
func HandleFirewall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"alpine-router/mihomo"
|
"nano-router/config"
|
||||||
|
"nano-router/mihomo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleMihomoStatus(w http.ResponseWriter, r *http.Request) {
|
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())
|
fail(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
saveMihomoEnabled(true)
|
||||||
ok(w, map[string]string{"message": "mihomo started"})
|
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())
|
fail(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
saveMihomoEnabled(false)
|
||||||
ok(w, map[string]string{"message": "mihomo stopped"})
|
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())
|
fail(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Restart keeps enabled=true (already set when it was first started).
|
||||||
ok(w, map[string]string{"message": "mihomo restarted"})
|
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) {
|
func HandleMihomoConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"alpine-router/mihomo"
|
"nano-router/mihomo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getMihomoAPIBase() string {
|
func getMihomoAPIBase() string {
|
||||||
@@ -150,7 +150,7 @@ func HandleMihomoWSProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
upgradeReq += "Host: " + host + ":" + port + "\r\n"
|
upgradeReq += "Host: " + host + ":" + port + "\r\n"
|
||||||
upgradeReq += "Upgrade: websocket\r\n"
|
upgradeReq += "Upgrade: websocket\r\n"
|
||||||
upgradeReq += "Connection: Upgrade\r\n"
|
upgradeReq += "Connection: Upgrade\r\n"
|
||||||
upgradeReq += "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
upgradeReq += "Sec-WebSocket-Key: " + r.Header.Get("Sec-Websocket-Key") + "\r\n"
|
||||||
upgradeReq += "Sec-WebSocket-Version: 13\r\n"
|
upgradeReq += "Sec-WebSocket-Version: 13\r\n"
|
||||||
if secret != "" {
|
if secret != "" {
|
||||||
upgradeReq += "Authorization: Bearer " + secret + "\r\n"
|
upgradeReq += "Authorization: Bearer " + secret + "\r\n"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"alpine-router/config"
|
"nano-router/config"
|
||||||
"alpine-router/nat"
|
"nano-router/nat"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleNATGet(w http.ResponseWriter, r *http.Request) {
|
func HandleNATGet(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -46,16 +46,26 @@ func HandleNATSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := nat.Save(&cfg); err != nil {
|
|
||||||
fail(w, http.StatusInternalServerError, "save: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
appCfg, err := config.Load()
|
appCfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(w, http.StatusInternalServerError, "load config.yaml: "+err.Error())
|
fail(w, http.StatusInternalServerError, "load config.yaml: "+err.Error())
|
||||||
return
|
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
|
appCfg.NAT.Interfaces = cfg.Interfaces
|
||||||
if err := config.Save(appCfg); err != nil {
|
if err := config.Save(appCfg); err != nil {
|
||||||
fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error())
|
fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error())
|
||||||
|
|||||||
172
handlers/overlap.go
Normal file
172
handlers/overlap.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nano-router/config"
|
||||||
|
"nano-router/dhcp"
|
||||||
|
"nano-router/network"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collectAllSubnets(excludeIface string) []network.SubnetOverlap {
|
||||||
|
var result []network.SubnetOverlap
|
||||||
|
|
||||||
|
appCfg, _ := config.Load()
|
||||||
|
if appCfg != nil {
|
||||||
|
for name, ic := range appCfg.Interfaces {
|
||||||
|
if name == excludeIface {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, mask := resolveIfaceIP(name, ic.Mode, ic.Address, ic.Netmask)
|
||||||
|
if addr != "" && mask != "" {
|
||||||
|
result = append(result, network.SubnetOverlap{
|
||||||
|
Interface: name,
|
||||||
|
Label: ic.Label,
|
||||||
|
Subnet: addr + "/" + mask,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, pcfg := range network.GetAllPending() {
|
||||||
|
if name == excludeIface {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, mask := resolveIfaceIP(name, pcfg.Mode, pcfg.Address, pcfg.Netmask)
|
||||||
|
if addr != "" && mask != "" {
|
||||||
|
result = append(result, network.SubnetOverlap{
|
||||||
|
Interface: name,
|
||||||
|
Label: pcfg.Label,
|
||||||
|
Subnet: addr + "/" + mask,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names, _ := network.GetInterfaces()
|
||||||
|
for _, name := range names {
|
||||||
|
if name == excludeIface {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, err := network.GetInterfaceStats(name)
|
||||||
|
if err != nil || s.IPv4 == "" || s.IPv4Mask == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mode string
|
||||||
|
if appCfg != nil && appCfg.Interfaces != nil {
|
||||||
|
if ic, ok := appCfg.Interfaces[name]; ok {
|
||||||
|
mode = ic.Mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "dhcp" {
|
||||||
|
result = append(result, network.SubnetOverlap{
|
||||||
|
Interface: name,
|
||||||
|
Subnet: s.IPv4 + "/" + s.IPv4Mask,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if appCfg != nil {
|
||||||
|
for _, pool := range appCfg.DHCP.Pools {
|
||||||
|
if pool.Interface == excludeIface {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pool.Subnet != "" && pool.Netmask != "" {
|
||||||
|
result = append(result, network.SubnetOverlap{
|
||||||
|
Interface: pool.Interface,
|
||||||
|
Subnet: pool.Subnet + "/" + pool.Netmask,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveIfaceIP(name, mode, addr, mask string) (string, string) {
|
||||||
|
if mode == "static" && addr != "" && mask != "" {
|
||||||
|
return addr, mask
|
||||||
|
}
|
||||||
|
if mode == "dhcp" {
|
||||||
|
s, err := network.GetInterfaceStats(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if s.IPv4 != "" && s.IPv4Mask != "" {
|
||||||
|
return s.IPv4, s.IPv4Mask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectAllSubnetsForDHCP(excludeIface string) []network.SubnetOverlap {
|
||||||
|
result := collectAllSubnets(excludeIface)
|
||||||
|
|
||||||
|
cfg, _ := dhcp.Load()
|
||||||
|
if cfg != nil {
|
||||||
|
for _, pool := range cfg.Pools {
|
||||||
|
if pool.Interface == excludeIface {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pool.Subnet != "" && pool.Netmask != "" {
|
||||||
|
result = append(result, network.SubnetOverlap{
|
||||||
|
Interface: pool.Interface,
|
||||||
|
Subnet: pool.Subnet + "/" + pool.Netmask,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkInterfaceOverlap(cfg *network.InterfaceConfig) (string, bool) {
|
||||||
|
if cfg.Mode != "static" || cfg.Address == "" || cfg.Netmask == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
all := collectAllSubnets(cfg.Name)
|
||||||
|
overlaps := network.CheckOverlap(cfg.Address, cfg.Netmask, cfg.Name, all)
|
||||||
|
if len(overlaps) > 0 {
|
||||||
|
var names []string
|
||||||
|
for _, o := range overlaps {
|
||||||
|
label := o.Interface
|
||||||
|
if o.Label != "" {
|
||||||
|
label = o.Label + " (" + o.Interface + ")"
|
||||||
|
}
|
||||||
|
names = append(names, label)
|
||||||
|
}
|
||||||
|
return "IP-адрес пересекается с подсетью интерфейса " + strings.Join(names, ", "), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDHCPPoolOverlap(subnet, mask, iface string) (string, bool) {
|
||||||
|
if subnet == "" || mask == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
all := collectAllSubnetsForDHCP(iface)
|
||||||
|
overlaps := network.CheckOverlap(subnet, mask, iface, all)
|
||||||
|
if len(overlaps) > 0 {
|
||||||
|
var names []string
|
||||||
|
for _, o := range overlaps {
|
||||||
|
label := o.Interface
|
||||||
|
if o.Label != "" {
|
||||||
|
label = o.Label + " (" + o.Interface + ")"
|
||||||
|
}
|
||||||
|
names = append(names, label)
|
||||||
|
}
|
||||||
|
return "Подсеть пересекается с " + strings.Join(names, ", "), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSubnets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
fail(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exclude := r.URL.Query().Get("exclude")
|
||||||
|
result := collectAllSubnets(exclude)
|
||||||
|
ok(w, result)
|
||||||
|
}
|
||||||
67
handlers/policy_sync.go
Normal file
67
handlers/policy_sync.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nano-router/clients"
|
||||||
|
"nano-router/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartPolicySync starts a background goroutine that re-applies nftables rules
|
||||||
|
// whenever the ARP table changes for devices that have an explicit policy.
|
||||||
|
// This ensures that policy (VPN / disabled) follows a device even when its IP
|
||||||
|
// changes due to DHCP renewal or it connects on a different interface.
|
||||||
|
func StartPolicySync(interval time.Duration) {
|
||||||
|
go func() {
|
||||||
|
// Give the binary time to fully start before the first check.
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
var lastSig string
|
||||||
|
for {
|
||||||
|
sig := policyARPSignature()
|
||||||
|
if sig != lastSig {
|
||||||
|
lastSig = sig
|
||||||
|
applyBlockedFirewall()
|
||||||
|
}
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// policyARPSignature returns a stable string that captures the current mapping
|
||||||
|
// of MAC→IPs only for devices that have an explicit (non-default) policy.
|
||||||
|
// If the string changes between ticks, the firewall needs to be re-applied.
|
||||||
|
func policyARPSignature() string {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect MACs with explicit policies.
|
||||||
|
policyMACs := make(map[string]string) // mac → policy
|
||||||
|
for _, kd := range cfg.KnownDevices {
|
||||||
|
if kd.MAC != "" && kd.Policy != "" {
|
||||||
|
policyMACs[kd.MAC] = kd.Policy
|
||||||
|
}
|
||||||
|
// Also treat legacy blocked=true as disabled policy.
|
||||||
|
if kd.MAC != "" && kd.Blocked && kd.Policy == "" {
|
||||||
|
policyMACs[kd.MAC] = "disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(policyMACs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
arpByMAC := clients.GetARPIPsByMAC()
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
for mac, policy := range policyMACs {
|
||||||
|
ips := arpByMAC[mac]
|
||||||
|
sort.Strings(ips)
|
||||||
|
parts = append(parts, policy+":"+mac+"="+strings.Join(ips, ","))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(parts, "|")
|
||||||
|
}
|
||||||
@@ -3,37 +3,77 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"alpine-router/config"
|
"nano-router/clients"
|
||||||
"alpine-router/firewall"
|
"nano-router/config"
|
||||||
"alpine-router/nat"
|
"nano-router/firewall"
|
||||||
"alpine-router/network"
|
"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:
|
// applyAllRules rebuilds the complete nftables ruleset from the current config:
|
||||||
// NAT masquerade + user firewall rules + VLAN isolation + blocked clients.
|
// NAT masquerade + tproxy for VPN clients + disabled client drops +
|
||||||
|
// user firewall rules + VLAN isolation.
|
||||||
func applyAllRules(cfg *config.AppConfig) error {
|
func applyAllRules(cfg *config.AppConfig) error {
|
||||||
if !nat.IsInstalled() {
|
if !nat.IsInstalled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect blocked client IPs.
|
defaultPolicy := cfg.ClientPolicy.Default
|
||||||
var blockedIPs []string
|
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 {
|
for _, kd := range cfg.KnownDevices {
|
||||||
if kd.Blocked {
|
policy := resolveClientPolicy(kd, defaultPolicy)
|
||||||
ip := kd.IP
|
// Primary stored IP.
|
||||||
if kd.StaticIP != "" {
|
ip := kd.IP
|
||||||
ip = kd.StaticIP
|
if kd.StaticIP != "" {
|
||||||
}
|
ip = kd.StaticIP
|
||||||
if ip != "" {
|
}
|
||||||
blockedIPs = append(blockedIPs, ip)
|
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:
|
// Build the LAN interface set for isolation:
|
||||||
// all NAT interfaces + all VLAN interfaces (active + pending).
|
// all NAT interfaces + all VLAN interfaces (active + pending).
|
||||||
// This ensures native interfaces (eth0) and their VLANs (eth0.100) are all
|
|
||||||
// mutually isolated when VLANIsolation is enabled.
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var lanIfaces []string
|
var lanIfaces []string
|
||||||
addLAN := func(name string) {
|
addLAN := func(name string) {
|
||||||
@@ -49,7 +89,7 @@ func applyAllRules(cfg *config.AppConfig) error {
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if network.IsVLAN(name) {
|
if network.IsVLAN(name) {
|
||||||
addLAN(name)
|
addLAN(name)
|
||||||
addLAN(network.VLANParent(name)) // include parent (native VLAN) too
|
addLAN(network.VLANParent(name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for name := range network.GetAllPending() {
|
for name := range network.GetAllPending() {
|
||||||
@@ -80,12 +120,15 @@ func applyAllRules(cfg *config.AppConfig) error {
|
|||||||
return firewall.ApplyAll(
|
return firewall.ApplyAll(
|
||||||
firewall.NATConfig{Interfaces: cfg.NAT.Interfaces},
|
firewall.NATConfig{Interfaces: cfg.NAT.Interfaces},
|
||||||
firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation},
|
firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation},
|
||||||
blockedIPs,
|
|
||||||
lanIfaces,
|
lanIfaces,
|
||||||
|
firewall.ClientPolicies{
|
||||||
|
DisabledIPs: disabledIPs,
|
||||||
|
VPNIPs: vpnIPs,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyBlockedFirewall is the async helper called after client updates.
|
// applyBlockedFirewall is the async helper called after client or policy updates.
|
||||||
func applyBlockedFirewall() {
|
func applyBlockedFirewall() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
154
main.go
154
main.go
@@ -8,21 +8,31 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"alpine-router/config"
|
"nano-router/auth"
|
||||||
"alpine-router/dhcp"
|
"nano-router/clients"
|
||||||
"alpine-router/firewall"
|
"nano-router/config"
|
||||||
"alpine-router/handlers"
|
"nano-router/dhcp"
|
||||||
"alpine-router/mihomo"
|
"nano-router/firewall"
|
||||||
"alpine-router/nat"
|
"nano-router/handlers"
|
||||||
"alpine-router/network"
|
"nano-router/mihomo"
|
||||||
"alpine-router/traffic"
|
"nano-router/monitor"
|
||||||
|
"nano-router/nat"
|
||||||
|
"nano-router/network"
|
||||||
|
"nano-router/setup"
|
||||||
|
"nano-router/traffic"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed public
|
//go:embed public
|
||||||
var publicFS embed.FS
|
var publicFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "setup" {
|
||||||
|
setup.Run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load config.yaml: %v", err)
|
log.Fatalf("load config.yaml: %v", err)
|
||||||
@@ -36,6 +46,13 @@ func main() {
|
|||||||
log.Printf("Warning: ensure default mihomo config: %v", err)
|
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 {
|
if firstRun {
|
||||||
log.Printf("First run — importing current system state into %s", config.GetPath())
|
log.Printf("First run — importing current system state into %s", config.GetPath())
|
||||||
cfg = importSystemState()
|
cfg = importSystemState()
|
||||||
@@ -51,6 +68,12 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
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", handlers.HandleInterfaces)
|
||||||
mux.HandleFunc("/api/interfaces/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/interfaces/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
suffix := strings.TrimPrefix(r.URL.Path, "/api/interfaces/")
|
suffix := strings.TrimPrefix(r.URL.Path, "/api/interfaces/")
|
||||||
@@ -63,9 +86,12 @@ func main() {
|
|||||||
mux.HandleFunc("/api/config/", handlers.HandleConfig)
|
mux.HandleFunc("/api/config/", handlers.HandleConfig)
|
||||||
mux.HandleFunc("/api/apply", handlers.HandleApply)
|
mux.HandleFunc("/api/apply", handlers.HandleApply)
|
||||||
mux.HandleFunc("/api/pending", handlers.HandlePending)
|
mux.HandleFunc("/api/pending", handlers.HandlePending)
|
||||||
|
mux.HandleFunc("/api/subnets", handlers.HandleSubnets)
|
||||||
|
|
||||||
mux.HandleFunc("/api/clients", handlers.HandleClients)
|
mux.HandleFunc("/api/clients", handlers.HandleClients)
|
||||||
mux.HandleFunc("/api/clients/update/", handlers.HandleClientUpdate)
|
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/config.yaml", handlers.HandleConfigYAML)
|
||||||
|
|
||||||
@@ -107,22 +133,60 @@ func main() {
|
|||||||
mux.HandleFunc("/api/mihomo/api/", handlers.HandleMihomoAPIProxy)
|
mux.HandleFunc("/api/mihomo/api/", handlers.HandleMihomoAPIProxy)
|
||||||
mux.HandleFunc("/api/mihomo/ws/", handlers.HandleMihomoWSProxy)
|
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")
|
sub, err := fs.Sub(publicFS, "public")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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"
|
port := "8080"
|
||||||
if p := os.Getenv("PORT"); p != "" {
|
if p := os.Getenv("PORT"); p != "" {
|
||||||
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()
|
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("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 {
|
func importSystemState() *config.AppConfig {
|
||||||
@@ -220,17 +284,48 @@ func applyConfig(cfg *config.AppConfig) {
|
|||||||
log.Printf("Warning: save NAT state: %v", err)
|
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 {
|
for _, kd := range cfg.KnownDevices {
|
||||||
if kd.Blocked {
|
policy := kd.Policy
|
||||||
ip := kd.IP
|
if policy == "" {
|
||||||
if kd.StaticIP != "" {
|
if kd.Blocked {
|
||||||
ip = kd.StaticIP
|
policy = "disabled"
|
||||||
}
|
} else {
|
||||||
if ip != "" {
|
policy = defaultPolicy
|
||||||
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.
|
// Build LAN interface set: NAT interfaces + all VLAN interfaces + their parents.
|
||||||
@@ -267,15 +362,21 @@ func applyConfig(cfg *config.AppConfig) {
|
|||||||
err := firewall.ApplyAll(
|
err := firewall.ApplyAll(
|
||||||
firewall.NATConfig{Interfaces: cfg.NAT.Interfaces},
|
firewall.NATConfig{Interfaces: cfg.NAT.Interfaces},
|
||||||
firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation},
|
firewall.Config{Rules: fwRules, VLANIsolation: cfg.Firewall.VLANIsolation},
|
||||||
blockedIPs,
|
|
||||||
lanIfaces,
|
lanIfaces,
|
||||||
|
firewall.ClientPolicies{
|
||||||
|
DisabledIPs: disabledIPs,
|
||||||
|
VPNIPs: vpnIPs,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: apply firewall/NAT rules: %v", err)
|
log.Printf("Warning: apply firewall/NAT rules: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Firewall/NAT applied (%d NAT ifaces, %d fw rules, %d blocked, vlan_isolation=%v)",
|
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(blockedIPs), cfg.Firewall.VLANIsolation)
|
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 {
|
} else {
|
||||||
log.Printf("nftables not installed — NAT/firewall unavailable (install with: apk add nftables)")
|
log.Printf("nftables not installed — NAT/firewall unavailable (install with: apk add nftables)")
|
||||||
}
|
}
|
||||||
@@ -328,4 +429,13 @@ func applyConfig(cfg *config.AppConfig) {
|
|||||||
} else {
|
} else {
|
||||||
log.Printf("dnsmasq not installed — DHCP unavailable (install with: apk add dnsmasq)")
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,34 @@ func Status() map[string]interface{} {
|
|||||||
return status
|
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 {
|
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()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
832
monitor/monitor.go
Normal file
832
monitor/monitor.go
Normal 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
BIN
nano-router
Executable file
Binary file not shown.
@@ -6,34 +6,45 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IfDown brings an interface down via ifdown (or ip link set down as fallback).
|
// LinkDown sets admin state down without deconfiguring (ip link set <name> down).
|
||||||
func IfDown(name string) error {
|
func LinkDown(name string) error {
|
||||||
out, err := exec.Command("ifdown", "--force", name).CombinedOutput()
|
out, err := exec.Command("ip", "link", "set", name, "down").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// fallback: ip link set down
|
return fmt.Errorf("ip link set down %s: %s", name, strings.TrimSpace(string(out)))
|
||||||
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 nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IfUp brings an interface up via ifup (or ip link set up as fallback).
|
// LinkUp sets admin state up without re-running ifup (ip link set <name> up).
|
||||||
func IfUp(name string) error {
|
func LinkUp(name string) error {
|
||||||
out, err := exec.Command("ifup", name).CombinedOutput()
|
out, err := exec.Command("ip", "link", "set", name, "up").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out2, err2 := exec.Command("ip", "link", "set", name, "up").CombinedOutput()
|
return fmt.Errorf("ip link set up %s: %s", name, strings.TrimSpace(string(out)))
|
||||||
if err2 != nil {
|
}
|
||||||
return fmt.Errorf("ifup %s: %s\nip fallback: %s", name, strings.TrimSpace(string(out)), strings.TrimSpace(string(out2)))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IfRestart brings an interface down then up.
|
// IfRestart brings an interface down then up.
|
||||||
func IfRestart(name string) error {
|
func IfRestart(name string) error {
|
||||||
_ = IfDown(name) // ignore "already down" errors
|
_ = IfDown(name)
|
||||||
return IfUp(name)
|
return IfUp(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ const ConfigFile = "/etc/network/interfaces"
|
|||||||
// InterfaceConfig represents one stanza in /etc/network/interfaces.
|
// InterfaceConfig represents one stanza in /etc/network/interfaces.
|
||||||
type InterfaceConfig struct {
|
type InterfaceConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Label string `json:"label,omitempty"` // display name, stored in config.yaml only
|
Label string `json:"label,omitempty"`
|
||||||
Auto bool `json:"auto"`
|
Type string `json:"type,omitempty"` // wan or lan
|
||||||
|
Auto bool `json:"auto"`
|
||||||
Mode string `json:"mode"` // dhcp, static, loopback, manual
|
Mode string `json:"mode"` // dhcp, static, loopback, manual
|
||||||
Address string `json:"address,omitempty"` // static only
|
Address string `json:"address,omitempty"` // static only
|
||||||
Netmask string `json:"netmask,omitempty"`
|
Netmask string `json:"netmask,omitempty"`
|
||||||
Gateway string `json:"gateway,omitempty"`
|
Gateway string `json:"gateway,omitempty"`
|
||||||
DNS []string `json:"dns,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) ---
|
// --- Pending config store (in-memory, not yet written to disk) ---
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
type InterfaceStats struct {
|
type InterfaceStats struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
State string `json:"state"` // up, down, unknown
|
State string `json:"state"`
|
||||||
IPv4 string `json:"ipv4"`
|
IPv4 string `json:"ipv4"`
|
||||||
IPv4Mask string `json:"ipv4_mask"`
|
IPv4Mask string `json:"ipv4_mask"`
|
||||||
IPv6 []string `json:"ipv6"`
|
IPv6 []string `json:"ipv6"`
|
||||||
@@ -20,7 +20,8 @@ type InterfaceStats struct {
|
|||||||
TxBytes uint64 `json:"tx_bytes"`
|
TxBytes uint64 `json:"tx_bytes"`
|
||||||
RxPackets uint64 `json:"rx_packets"`
|
RxPackets uint64 `json:"rx_packets"`
|
||||||
TxPackets uint64 `json:"tx_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.
|
// GetInterfaces returns all network interface names from /sys/class/net.
|
||||||
@@ -40,7 +41,6 @@ func GetInterfaces() ([]string, error) {
|
|||||||
func GetInterfaceStats(name string) (*InterfaceStats, error) {
|
func GetInterfaceStats(name string) (*InterfaceStats, error) {
|
||||||
s := &InterfaceStats{Name: name, IPv6: []string{}}
|
s := &InterfaceStats{Name: name, IPv6: []string{}}
|
||||||
|
|
||||||
// Operational state
|
|
||||||
if raw, err := os.ReadFile("/sys/class/net/" + name + "/operstate"); err == nil {
|
if raw, err := os.ReadFile("/sys/class/net/" + name + "/operstate"); err == nil {
|
||||||
s.State = strings.TrimSpace(string(raw))
|
s.State = strings.TrimSpace(string(raw))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
87
network/overlap.go
Normal file
87
network/overlap.go
Normal 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)
|
||||||
|
}
|
||||||
407
public/app.js
407
public/app.js
@@ -1,17 +1,13 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ── State ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
interfaces: [], // latest data from /api/interfaces
|
interfaces: [],
|
||||||
pending: [], // interface names with pending config
|
pending: [],
|
||||||
configModal: null, // name of interface being configured (null = new VLAN)
|
configModal: null,
|
||||||
configModalParent: null, // parent interface when creating a new VLAN
|
configModalParent: null,
|
||||||
nat: null, // {installed, interfaces} from /api/nat
|
nat: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
const opts = {
|
const opts = {
|
||||||
method,
|
method,
|
||||||
@@ -30,8 +26,6 @@ const get = (path) => api('GET', path);
|
|||||||
const post = (path, body) => api('POST', path, body);
|
const post = (path, body) => api('POST', path, body);
|
||||||
const del = (path) => api('DELETE', path);
|
const del = (path) => api('DELETE', path);
|
||||||
|
|
||||||
// ── VLAN helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function isVLAN(name) {
|
function isVLAN(name) {
|
||||||
return /\.\d+$/.test(name);
|
return /\.\d+$/.test(name);
|
||||||
}
|
}
|
||||||
@@ -43,8 +37,6 @@ function vlanId(name) {
|
|||||||
return m ? parseInt(m[1]) : 0;
|
return m ? parseInt(m[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Format helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function fmtBytes(n) {
|
function fmtBytes(n) {
|
||||||
if (n === undefined || n === null) return '—';
|
if (n === undefined || n === null) return '—';
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
@@ -64,8 +56,6 @@ function modeLabel(m) {
|
|||||||
return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?');
|
return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SVG icons ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ICON = {
|
const ICON = {
|
||||||
pencil: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
@@ -83,13 +73,42 @@ const ICON = {
|
|||||||
</svg>`,
|
</svg>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────────
|
function ipClass(ip) {
|
||||||
|
if (!ip) return '';
|
||||||
|
const p = ip.split('.').map(Number);
|
||||||
|
if (p.length !== 4 || p.some(isNaN)) return '';
|
||||||
|
if (p[0] >= 1 && p[0] <= 126) return 'A';
|
||||||
|
if (p[0] === 127) return 'loopback';
|
||||||
|
if (p[0] >= 128 && p[0] <= 191) return 'B';
|
||||||
|
if (p[0] >= 192 && p[0] <= 223) return 'C';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessMask(ip) {
|
||||||
|
const c = ipClass(ip);
|
||||||
|
switch (c) {
|
||||||
|
case 'A': return '255.0.0.0';
|
||||||
|
case 'B': return '255.255.0.0';
|
||||||
|
case 'C': return '255.255.255.0';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskToCIDR(mask) {
|
||||||
|
if (!mask) return '';
|
||||||
|
if (/^\d+$/.test(mask)) return '/' + mask;
|
||||||
|
const parts = mask.split('.').map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some(isNaN)) return '/' + mask;
|
||||||
|
const bits = parts.map(p => (p >>> 0).toString(2).padStart(8, '0')).join('');
|
||||||
|
const cidr = bits.split('0')[0].length;
|
||||||
|
if (cidr > 0 && cidr <= 32) return '/' + cidr;
|
||||||
|
return '/' + mask;
|
||||||
|
}
|
||||||
|
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
const grid = document.getElementById('ifaceGrid');
|
const grid = document.getElementById('ifaceGrid');
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
// Group VLANs by parent
|
|
||||||
const vlansByParent = {};
|
const vlansByParent = {};
|
||||||
const physicals = [];
|
const physicals = [];
|
||||||
|
|
||||||
@@ -103,9 +122,38 @@ function renderAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'iface-table-wrap';
|
||||||
|
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.className = 'iface-table';
|
||||||
|
|
||||||
|
table.innerHTML = `
|
||||||
|
<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) {
|
for (const iface of physicals) {
|
||||||
|
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
|
||||||
|
if (isLo) continue;
|
||||||
const vlans = vlansByParent[iface.name] || [];
|
const vlans = vlansByParent[iface.name] || [];
|
||||||
grid.appendChild(buildCard(iface, vlans));
|
tbody.appendChild(buildPhysicalRow(iface, vlans));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('loading').classList.add('hidden');
|
document.getElementById('loading').classList.add('hidden');
|
||||||
@@ -114,131 +162,126 @@ function renderAll() {
|
|||||||
renderPendingBanner();
|
renderPendingBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCard(iface, vlans) {
|
function buildPhysicalRow(iface, vlans) {
|
||||||
const hasPending = state.pending.includes(iface.name);
|
const hasPending = state.pending.includes(iface.name);
|
||||||
const sc = stateClass(iface.state);
|
const sc = stateClass(iface.state);
|
||||||
const isLo = iface.name === 'lo' || iface.mode === 'loopback';
|
|
||||||
const isUp = iface.state === 'up';
|
const isUp = iface.state === 'up';
|
||||||
|
const isWAN = iface.type === 'wan';
|
||||||
const label = iface.label || '';
|
const label = iface.label || '';
|
||||||
|
const ipDisplay = iface.ipv4
|
||||||
|
? iface.ipv4 + (iface.ipv4_mask ? maskToCIDR(iface.ipv4_mask) : '')
|
||||||
|
: '<span class="none">—</span>';
|
||||||
|
const gwDisplay = iface.gateway
|
||||||
|
? iface.gateway
|
||||||
|
: '<span class="none">—</span>';
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const trafficDisplay = `
|
||||||
card.className = 'iface-card' + (hasPending ? ' has-pending' : '');
|
<div class="traffic-mini">
|
||||||
card.dataset.name = iface.name;
|
<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 =>
|
const nameCell = label
|
||||||
`<div class="info-row"><span class="info-label">IPv6</span><span class="info-val">${a}</span></div>`
|
? `<div class="iface-name-stack"><span class="iface-label-text">${label}</span><span class="iface-name-sub">${iface.name}</span></div>`
|
||||||
).join('');
|
: `<span class="iface-name-text">${iface.name}</span>`;
|
||||||
|
|
||||||
const nameBlock = label
|
const typeBadge = isWAN
|
||||||
? `<div class="card-name-stack">
|
? '<span class="type-badge type-wan">WAN</span>'
|
||||||
<span class="card-label-text">${label}</span>
|
: '<span class="type-badge type-lan">LAN</span>';
|
||||||
<span class="card-iface-sub">${iface.name}</span>
|
|
||||||
</div>`
|
|
||||||
: `<span class="card-iface-name">${iface.name}</span>`;
|
|
||||||
|
|
||||||
card.innerHTML = `
|
const tr = document.createElement('tr');
|
||||||
<div class="card-header">
|
tr.className = 'iface-row' + (hasPending ? ' has-pending' : '') + (isWAN ? ' row-wan' : '');
|
||||||
<div class="card-name">
|
tr.dataset.name = iface.name;
|
||||||
<span class="state-dot ${sc}"></span>
|
|
||||||
${nameBlock}
|
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>' : ''}
|
${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>
|
||||||
<span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span>
|
</td>
|
||||||
</div>
|
<td class="col-if-type">${typeBadge}</td>
|
||||||
|
<td class="col-if-ipv4 mono">${ipDisplay}</td>
|
||||||
<div class="card-info">
|
<td class="col-if-gw mono">${gwDisplay}</td>
|
||||||
<div class="info-row">
|
<td class="col-if-mode"><span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span></td>
|
||||||
<span class="info-label">IPv4</span>
|
<td class="col-if-traffic">${trafficDisplay}</td>
|
||||||
<span class="info-val">${iface.ipv4
|
<td class="col-if-actions">
|
||||||
? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '')
|
<div class="iface-actions">
|
||||||
: '<span class="none">—</span>'}</span>
|
<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>
|
||||||
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">—</span></div>`}
|
</td>
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Шлюз</span>
|
|
||||||
<span class="info-val">${iface.gateway || '<span class="none">—</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">
|
|
||||||
${!isLo ? `
|
|
||||||
<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>
|
|
||||||
|
|
||||||
${!isLo ? buildVLANSection(iface.name, vlans) : ''}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return card;
|
const frag = document.createDocumentFragment();
|
||||||
|
frag.appendChild(tr);
|
||||||
|
|
||||||
|
for (const v of vlans) {
|
||||||
|
frag.appendChild(buildVLANRow(v, iface.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return frag;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVLANSection(parentName, vlans) {
|
function buildVLANRow(v, parentName) {
|
||||||
const rows = vlans.map(v => {
|
const sc = stateClass(v.state);
|
||||||
const sc = stateClass(v.state);
|
const hasPending = state.pending.includes(v.name);
|
||||||
const hasPending = state.pending.includes(v.name);
|
const isUp = v.state === 'up';
|
||||||
const isUp = v.state === 'up';
|
const label = v.label || '';
|
||||||
const label = v.label || '';
|
const isWAN = v.type === 'wan';
|
||||||
const ip = v.ipv4
|
const ip = v.ipv4
|
||||||
? v.ipv4 + (v.ipv4_mask ? ' / ' + v.ipv4_mask : '')
|
? v.ipv4 + (v.ipv4_mask ? maskToCIDR(v.ipv4_mask) : '')
|
||||||
: '<span class="none">—</span>';
|
: '<span class="none">—</span>';
|
||||||
return `
|
const gwDisplay = v.gateway ? v.gateway : '<span class="none">—</span>';
|
||||||
<div class="vlan-row" data-name="${v.name}">
|
const typeBadge = isWAN
|
||||||
<div class="vlan-row-left">
|
? '<span class="type-badge type-wan">WAN</span>'
|
||||||
<span class="state-dot ${sc}" style="width:8px;height:8px"></span>
|
: '<span class="type-badge type-lan">LAN</span>';
|
||||||
${label
|
|
||||||
? `<div class="vlan-name-stack"><span class="vlan-label-text">${label}</span><span class="vlan-iface-name">${v.name}</span></div>`
|
|
||||||
: `<span class="vlan-iface-name">${v.name}</span>`}
|
|
||||||
<span class="vlan-id-tag">VLAN ${vlanId(v.name)}</span>
|
|
||||||
<span class="mode-badge ${v.mode || 'unknown'}">${modeLabel(v.mode)}</span>
|
|
||||||
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div class="vlan-row-info">${ip}</div>
|
|
||||||
<div class="vlan-row-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" 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>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const empty = vlans.length === 0
|
const nameCell = label
|
||||||
? `<div class="vlan-empty">Нет тегированных VLAN</div>`
|
? `<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>`;
|
||||||
|
|
||||||
return `
|
const tr = document.createElement('tr');
|
||||||
<div class="vlan-section">
|
tr.className = 'iface-row iface-row-vlan' + (hasPending ? ' has-pending' : '');
|
||||||
<div class="vlan-header">
|
tr.dataset.name = v.name;
|
||||||
<span class="vlan-title">VLAN</span>
|
tr.dataset.parent = parentName;
|
||||||
<button class="btn btn-ghost btn-xs" data-action="addvlan" data-iface="${parentName}">${ICON.plus} Добавить</button>
|
|
||||||
|
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>
|
</div>
|
||||||
<div class="vlan-list">
|
</td>
|
||||||
${rows}
|
<td class="col-if-type">${typeBadge}</td>
|
||||||
${empty}
|
<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>
|
</div>
|
||||||
</div>`;
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPendingBanner() {
|
function renderPendingBanner() {
|
||||||
@@ -252,8 +295,6 @@ function renderPendingBanner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
try {
|
try {
|
||||||
const [ifaces, pending] = await Promise.all([
|
const [ifaces, pending] = await Promise.all([
|
||||||
@@ -268,8 +309,6 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Interface actions ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function doAction(name, action) {
|
async function doAction(name, action) {
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
if (!confirm(`Удалить VLAN ${name}?`)) return;
|
if (!confirm(`Удалить VLAN ${name}?`)) return;
|
||||||
@@ -289,18 +328,15 @@ async function doAction(name, action) {
|
|||||||
await loadAll();
|
await loadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`${name} ${action}: ${e.message}`, 'error');
|
showToast(`${name} ${action}: ${e.message}`, 'error');
|
||||||
await loadAll(); // refresh to restore correct toggle state
|
await loadAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config modal ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function openConfig(name) {
|
async function openConfig(name) {
|
||||||
state.configModal = name;
|
state.configModal = name;
|
||||||
state.configModalParent = null;
|
state.configModalParent = null;
|
||||||
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
|
document.getElementById('modalTitle').textContent = `Настройка: ${name}`;
|
||||||
|
|
||||||
// Show/hide VLAN ID field
|
|
||||||
const vlanSection = document.getElementById('vlanIdSection');
|
const vlanSection = document.getElementById('vlanIdSection');
|
||||||
const vlanInput = document.getElementById('cfgVLANId');
|
const vlanInput = document.getElementById('cfgVLANId');
|
||||||
if (isVLAN(name)) {
|
if (isVLAN(name)) {
|
||||||
@@ -318,7 +354,9 @@ async function openConfig(name) {
|
|||||||
get('/api/nat').catch(() => null),
|
get('/api/nat').catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (natData) state.nat = natData;
|
if (natData) state.nat = natData;
|
||||||
fillForm(configData.config, configData.pending, name, configData.label || '');
|
let currentType = configData.type || 'lan';
|
||||||
|
if (isVLAN(name)) currentType = 'lan';
|
||||||
|
fillForm(configData.config, configData.pending, name, configData.label || '', currentType, isVLAN(name));
|
||||||
document.getElementById('modal').classList.remove('hidden');
|
document.getElementById('modal').classList.remove('hidden');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
|
showToast('Ошибка загрузки конфига: ' + e.message, 'error');
|
||||||
@@ -326,6 +364,11 @@ async function openConfig(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openNewVLAN(parentName) {
|
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.configModal = null;
|
||||||
state.configModalParent = parentName;
|
state.configModalParent = parentName;
|
||||||
document.getElementById('modalTitle').textContent = `Новый VLAN на ${parentName}`;
|
document.getElementById('modalTitle').textContent = `Новый VLAN на ${parentName}`;
|
||||||
@@ -341,11 +384,11 @@ async function openNewVLAN(parentName) {
|
|||||||
if (natData) state.nat = natData;
|
if (natData) state.nat = natData;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
fillForm({ auto: true, mode: 'static' }, false, '', '');
|
fillForm({ auto: true, mode: 'static' }, false, '', '', 'lan', true);
|
||||||
document.getElementById('modal').classList.remove('hidden');
|
document.getElementById('modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillForm(cfg, pending, name, label = '') {
|
function fillForm(cfg, pending, name, label = '', ifaceType = 'lan', forceLAN = false) {
|
||||||
document.getElementById('cfgLabel').value = label;
|
document.getElementById('cfgLabel').value = label;
|
||||||
document.getElementById('cfgAuto').checked = !!cfg.auto;
|
document.getElementById('cfgAuto').checked = !!cfg.auto;
|
||||||
document.getElementById('cfgAddress').value = cfg.address || '';
|
document.getElementById('cfgAddress').value = cfg.address || '';
|
||||||
@@ -353,22 +396,56 @@ function fillForm(cfg, pending, name, label = '') {
|
|||||||
document.getElementById('cfgGateway').value = cfg.gateway || '';
|
document.getElementById('cfgGateway').value = cfg.gateway || '';
|
||||||
document.getElementById('cfgDNS').value = (cfg.dns || []).join(' ');
|
document.getElementById('cfgDNS').value = (cfg.dns || []).join(' ');
|
||||||
|
|
||||||
|
setType(ifaceType, forceLAN);
|
||||||
|
|
||||||
const mode = cfg.mode === 'static' ? 'static' : 'dhcp';
|
const mode = cfg.mode === 'static' ? 'static' : 'dhcp';
|
||||||
setMode(mode);
|
setMode(mode);
|
||||||
|
|
||||||
if (pending && name) {
|
if (pending && name) {
|
||||||
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
|
document.getElementById('modalTitle').textContent = `Настройка: ${name} (несохранённые изменения)`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NAT section — show for all non-loopback interfaces
|
function setType(t, forceLAN = false) {
|
||||||
|
document.querySelectorAll('#typeSwitch .seg-btn').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.dataset.type === t);
|
||||||
|
});
|
||||||
|
const typeRow = document.getElementById('typeSwitch').closest('.form-row');
|
||||||
|
typeRow.classList.toggle('hidden', forceLAN);
|
||||||
|
updateTypeVisibility(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentType() {
|
||||||
|
return document.querySelector('#typeSwitch .seg-btn.active')?.dataset.type ?? 'lan';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTypeVisibility(type) {
|
||||||
|
const modeRow = document.getElementById('modeRow');
|
||||||
|
const gatewayRow = document.getElementById('gatewayRow');
|
||||||
|
const dnsRow = document.getElementById('dnsRow');
|
||||||
const natSection = document.getElementById('natSection');
|
const natSection = document.getElementById('natSection');
|
||||||
const natNotInstalled = document.getElementById('natNotInstalled');
|
|
||||||
const cfgNAT = document.getElementById('cfgNAT');
|
|
||||||
|
|
||||||
if (cfg.mode === 'loopback' || name === 'lo') {
|
if (type === 'lan') {
|
||||||
natSection.classList.add('hidden');
|
modeRow.classList.add('hidden');
|
||||||
} else {
|
setMode('static');
|
||||||
|
gatewayRow.classList.add('hidden');
|
||||||
|
dnsRow.classList.add('hidden');
|
||||||
natSection.classList.remove('hidden');
|
natSection.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
modeRow.classList.remove('hidden');
|
||||||
|
gatewayRow.classList.remove('hidden');
|
||||||
|
dnsRow.classList.remove('hidden');
|
||||||
|
natSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNATSection(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNATSection(type) {
|
||||||
|
if (type === 'lan') {
|
||||||
|
const natNotInstalled = document.getElementById('natNotInstalled');
|
||||||
|
const cfgNAT = document.getElementById('cfgNAT');
|
||||||
|
const name = state.configModal;
|
||||||
const natInstalled = state.nat?.installed !== false;
|
const natInstalled = state.nat?.installed !== false;
|
||||||
cfgNAT.disabled = !natInstalled;
|
cfgNAT.disabled = !natInstalled;
|
||||||
natNotInstalled.classList.toggle('hidden', natInstalled);
|
natNotInstalled.classList.toggle('hidden', natInstalled);
|
||||||
@@ -377,14 +454,14 @@ function fillForm(cfg, pending, name, label = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setMode(mode) {
|
function setMode(mode) {
|
||||||
document.querySelectorAll('.seg-btn').forEach(b => {
|
document.querySelectorAll('#modeSwitch .seg-btn').forEach(b => {
|
||||||
b.classList.toggle('active', b.dataset.mode === mode);
|
b.classList.toggle('active', b.dataset.mode === mode);
|
||||||
});
|
});
|
||||||
document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static');
|
document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static');
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentMode() {
|
function currentMode() {
|
||||||
return document.querySelector('.seg-btn.active')?.dataset.mode ?? 'dhcp';
|
return document.querySelector('#modeSwitch .seg-btn.active')?.dataset.mode ?? 'dhcp';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
@@ -398,7 +475,6 @@ async function saveConfig() {
|
|||||||
let name = state.configModal;
|
let name = state.configModal;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
// New VLAN — build name from parent + VLAN ID
|
|
||||||
const parent = state.configModalParent;
|
const parent = state.configModalParent;
|
||||||
const id = parseInt(document.getElementById('cfgVLANId').value);
|
const id = parseInt(document.getElementById('cfgVLANId').value);
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
@@ -408,23 +484,24 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
name = `${parent}.${id}`;
|
name = `${parent}.${id}`;
|
||||||
|
|
||||||
// Check for duplicate
|
|
||||||
if (state.interfaces.find(i => i.name === name)) {
|
if (state.interfaces.find(i => i.name === name)) {
|
||||||
showToast(`VLAN ${name} уже существует`, 'error');
|
showToast(`VLAN ${name} уже существует`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = currentMode();
|
const type = currentType();
|
||||||
|
const mode = type === 'lan' ? 'static' : currentMode();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
name,
|
name,
|
||||||
label: document.getElementById('cfgLabel').value.trim(),
|
label: document.getElementById('cfgLabel').value.trim(),
|
||||||
|
type,
|
||||||
auto: document.getElementById('cfgAuto').checked,
|
auto: document.getElementById('cfgAuto').checked,
|
||||||
mode,
|
mode,
|
||||||
address: document.getElementById('cfgAddress').value.trim(),
|
address: document.getElementById('cfgAddress').value.trim(),
|
||||||
netmask: document.getElementById('cfgNetmask').value.trim(),
|
netmask: document.getElementById('cfgNetmask').value.trim(),
|
||||||
gateway: document.getElementById('cfgGateway').value.trim(),
|
gateway: type === 'wan' ? document.getElementById('cfgGateway').value.trim() : '',
|
||||||
dns: document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean),
|
dns: type === 'wan' ? document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean) : [],
|
||||||
extra: {},
|
extra: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -433,10 +510,14 @@ async function saveConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'wan' && mode === 'static' && !cfg.netmask) {
|
||||||
|
showToast('Укажите маску сети', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await post(`/api/config/${name}`, cfg);
|
await post(`/api/config/${name}`, cfg);
|
||||||
|
|
||||||
// Save NAT setting if section is visible
|
|
||||||
const natSection = document.getElementById('natSection');
|
const natSection = document.getElementById('natSection');
|
||||||
if (!natSection.classList.contains('hidden') && state.nat?.installed !== false) {
|
if (!natSection.classList.contains('hidden') && state.nat?.installed !== false) {
|
||||||
const natEnabled = document.getElementById('cfgNAT').checked;
|
const natEnabled = document.getElementById('cfgNAT').checked;
|
||||||
@@ -456,8 +537,6 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Apply / discard ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function applyAll() {
|
async function applyAll() {
|
||||||
const btn = document.getElementById('applyBtn');
|
const btn = document.getElementById('applyBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -484,8 +563,6 @@ async function discardAll() {
|
|||||||
await loadAll();
|
await loadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
function showToast(msg, type = 'info') {
|
function showToast(msg, type = 'info') {
|
||||||
const t = document.getElementById('toast');
|
const t = document.getElementById('toast');
|
||||||
@@ -496,17 +573,12 @@ function showToast(msg, type = 'info') {
|
|||||||
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
|
||||||
document.getElementById('applyBtn').addEventListener('click', applyAll);
|
document.getElementById('applyBtn').addEventListener('click', applyAll);
|
||||||
document.getElementById('discardAllBtn').addEventListener('click', discardAll);
|
document.getElementById('discardAllBtn').addEventListener('click', discardAll);
|
||||||
|
|
||||||
// Card button clicks (delegated)
|
|
||||||
document.getElementById('ifaceGrid').addEventListener('click', e => {
|
document.getElementById('ifaceGrid').addEventListener('click', e => {
|
||||||
const btn = e.target.closest('[data-action]');
|
const btn = e.target.closest('[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
// Don't handle toggle inputs here (handled by 'change' below)
|
|
||||||
if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return;
|
if (btn.tagName === 'INPUT' && btn.type === 'checkbox') return;
|
||||||
const { action, iface } = btn.dataset;
|
const { action, iface } = btn.dataset;
|
||||||
if (!action || !iface) return;
|
if (!action || !iface) return;
|
||||||
@@ -519,14 +591,12 @@ document.getElementById('ifaceGrid').addEventListener('click', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle switch (on/off) — delegated change event
|
|
||||||
document.getElementById('ifaceGrid').addEventListener('change', e => {
|
document.getElementById('ifaceGrid').addEventListener('change', e => {
|
||||||
const input = e.target.closest('input[data-action="toggle"]');
|
const input = e.target.closest('input[data-action="toggle"]');
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
doAction(input.dataset.iface, input.checked ? 'up' : 'down');
|
doAction(input.dataset.iface, input.checked ? 'up' : 'down');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal close
|
|
||||||
document.getElementById('closeModal').addEventListener('click', closeModal);
|
document.getElementById('closeModal').addEventListener('click', closeModal);
|
||||||
document.getElementById('cancelConfigBtn').addEventListener('click', closeModal);
|
document.getElementById('cancelConfigBtn').addEventListener('click', closeModal);
|
||||||
document.getElementById('modalBackdrop').addEventListener('click', closeModal);
|
document.getElementById('modalBackdrop').addEventListener('click', closeModal);
|
||||||
@@ -534,24 +604,33 @@ document.addEventListener('keydown', e => {
|
|||||||
if (e.key === 'Escape') closeModal();
|
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 => {
|
document.getElementById('modeSwitch').addEventListener('click', e => {
|
||||||
const btn = e.target.closest('.seg-btn');
|
const btn = e.target.closest('.seg-btn');
|
||||||
if (btn) setMode(btn.dataset.mode);
|
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('saveConfigBtn').addEventListener('click', saveConfig);
|
||||||
document.getElementById('configForm').addEventListener('submit', e => {
|
document.getElementById('configForm').addEventListener('submit', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveConfig();
|
saveConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-refresh every 10 seconds
|
|
||||||
setInterval(loadAll, 10000);
|
setInterval(loadAll, 10000);
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await loadAll();
|
await loadAll();
|
||||||
})();
|
})();
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Клиенты — AlpineRouter</title>
|
<title>Клиенты — NanoRouter</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,21 +15,21 @@
|
|||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
<path d="M2 12l10 5 10-5"/>
|
<path d="M2 12l10 5 10-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>AlpineRouter</h1>
|
<h1>NanoRouter</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tab-nav">
|
<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">
|
<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="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
@@ -64,9 +64,41 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Прокси
|
Прокси
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<main class="clients-main">
|
<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-toolbar">
|
||||||
<div class="clients-summary" id="clientsSummary"></div>
|
<div class="clients-summary" id="clientsSummary"></div>
|
||||||
@@ -92,6 +124,7 @@
|
|||||||
<th>MAC-адрес</th>
|
<th>MAC-адрес</th>
|
||||||
<th>Интерфейс</th>
|
<th>Интерфейс</th>
|
||||||
<th>Тип</th>
|
<th>Тип</th>
|
||||||
|
<th>Маршрут</th>
|
||||||
<th class="col-tx">↑ Отправлено</th>
|
<th class="col-tx">↑ Отправлено</th>
|
||||||
<th class="col-rx">↓ Получено</th>
|
<th class="col-rx">↓ Получено</th>
|
||||||
<th>Активность</th>
|
<th>Активность</th>
|
||||||
@@ -143,15 +176,14 @@
|
|||||||
|
|
||||||
<hr class="form-divider">
|
<hr class="form-divider">
|
||||||
|
|
||||||
<div class="form-row" style="flex-direction:row; align-items:center; justify-content:space-between;">
|
<div class="form-row form-row--col">
|
||||||
<div>
|
<label>Выход в интернет</label>
|
||||||
<div style="font-weight:600;">Доступ в интернет</div>
|
<div class="segmented" id="modalPolicySelector">
|
||||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px;" id="modalBlockHint">Отключите, чтобы запретить устройству выход в интернет</div>
|
<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>
|
</div>
|
||||||
<label class="toggle-label" id="modalBlockToggle">
|
<span class="form-hint" id="modalPolicyHint">Пусто = использовать политику по умолчанию</span>
|
||||||
<input type="checkbox" id="modalBlocked">
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer" style="padding:18px 0 0;">
|
<div class="modal-footer" style="padding:18px 0 0;">
|
||||||
|
|||||||
@@ -3,6 +3,72 @@
|
|||||||
let allClients = [];
|
let allClients = [];
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let editingClient = null;
|
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() {
|
async function loadClients() {
|
||||||
try {
|
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() {
|
function render() {
|
||||||
const loading = document.getElementById('loading');
|
const loading = document.getElementById('loading');
|
||||||
const wrap = document.getElementById('clientsTableWrap');
|
const wrap = document.getElementById('clientsTableWrap');
|
||||||
@@ -84,15 +159,17 @@ function render() {
|
|||||||
|
|
||||||
loading.classList.add('hidden');
|
loading.classList.add('hidden');
|
||||||
|
|
||||||
const onlineCount = allClients.filter(c => isOnline(c)).length;
|
const onlineCount = allClients.filter(c => isOnline(c)).length;
|
||||||
const dhcpCount = allClients.filter(c => c.is_dhcp).length;
|
const dhcpCount = allClients.filter(c => c.is_dhcp).length;
|
||||||
const blockedCount = allClients.filter(c => c.blocked).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 =
|
summary.innerHTML =
|
||||||
`<span class="cl-stat"><span class="state-dot up"></span>${onlineCount} онлайн</span>` +
|
`<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">${allClients.length} всего</span>` +
|
||||||
`<span class="cl-stat cl-stat--muted">${dhcpCount} по DHCP</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));
|
const filtered = allClients.filter(c => matchesSearch(c, searchQuery));
|
||||||
|
|
||||||
@@ -123,9 +200,13 @@ function buildRow(c) {
|
|||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.className = 'client-row';
|
tr.className = 'client-row';
|
||||||
if (!online) tr.classList.add('row-offline');
|
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 activity = fmtLastActive(c);
|
||||||
|
const pl = policyLabel(effectivePolicy);
|
||||||
|
|
||||||
const typeCell = c.is_dhcp
|
const typeCell = c.is_dhcp
|
||||||
? '<span class="client-badge dhcp">DHCP</span>'
|
? '<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.static_ip)}</span> <span class="client-badge static-badge">фикс.</span>`
|
||||||
: `<span class="mono">${escHtml(c.ip)}</span>`;
|
: `<span class="mono">${escHtml(c.ip)}</span>`;
|
||||||
|
|
||||||
const blockedBadge = c.blocked
|
|
||||||
? ' <span class="client-badge blocked-badge">ЗАБЛОКИРОВАН</span>'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="col-status">
|
<td class="col-status">
|
||||||
<span class="state-dot ${online ? 'up' : 'down'}"
|
<span class="state-dot ${online ? 'up' : 'down'}"
|
||||||
title="${online ? 'онлайн' : 'офлайн'}"></span>
|
title="${online ? 'онлайн' : 'офлайн'}"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-host">${hostname}${blockedBadge}</td>
|
<td class="col-host">${hostname}</td>
|
||||||
<td class="col-ip">${ipDisplay}</td>
|
<td class="col-ip">${ipDisplay}</td>
|
||||||
<td class="col-mac"><span class="mono muted">${escHtml(c.mac || '—')}</span></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-iface">${escHtml(c.interface || '—')}</td>
|
||||||
<td class="col-type">${typeCell}</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-tx">${txHtml}</td>
|
||||||
<td class="col-rx">${rxHtml}</td>
|
<td class="col-rx">${rxHtml}</td>
|
||||||
<td class="col-activity">${actHtml}</td>
|
<td class="col-activity">${actHtml}</td>
|
||||||
@@ -174,6 +252,8 @@ function buildRow(c) {
|
|||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Modal ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openModal(c) {
|
function openModal(c) {
|
||||||
editingClient = { ...c };
|
editingClient = { ...c };
|
||||||
const modal = document.getElementById('clientModal');
|
const modal = document.getElementById('clientModal');
|
||||||
@@ -200,32 +280,36 @@ function openModal(c) {
|
|||||||
document.getElementById('modalMAC').textContent = c.mac || '—';
|
document.getElementById('modalMAC').textContent = c.mac || '—';
|
||||||
document.getElementById('modalIface').textContent = c.interface || '—';
|
document.getElementById('modalIface').textContent = c.interface || '—';
|
||||||
|
|
||||||
const blocked = document.getElementById('modalBlocked');
|
// Set policy selector — empty string means "use default"
|
||||||
blocked.checked = !c.blocked;
|
const effectivePolicy = c.policy || '';
|
||||||
updateBlockedToggle(c.blocked);
|
modalSelectedPolicy = effectivePolicy;
|
||||||
|
updateModalPolicySelector(effectivePolicy);
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
document.getElementById('modalHostname').focus();
|
document.getElementById('modalHostname').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBlockedToggle(isBlocked) {
|
function updateModalPolicySelector(val) {
|
||||||
const hint = document.getElementById('modalBlockHint');
|
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
|
||||||
const toggle = document.getElementById('modalBlockToggle');
|
btn.classList.toggle('active', btn.dataset.val === val);
|
||||||
const toggleContainer = document.getElementById('modalBlocked');
|
});
|
||||||
|
const hint = document.getElementById('modalPolicyHint');
|
||||||
if (isBlocked) {
|
const descriptions = {
|
||||||
hint.textContent = 'Доступ в интернет заблокирован';
|
disabled: 'Устройство заблокировано — нет доступа в интернет',
|
||||||
hint.style.color = 'var(--danger)';
|
direct: 'Трафик идёт напрямую через NAT, минуя VPN',
|
||||||
toggleContainer.checked = false;
|
vpn: 'Трафик перенаправляется через Mihomo (tproxy)',
|
||||||
toggle.classList.add('toggle-blocked');
|
'': 'Используется политика по умолчанию',
|
||||||
} else {
|
};
|
||||||
hint.textContent = 'Отключите, чтобы запретить устройству выход в интернет';
|
hint.textContent = descriptions[val] ?? '';
|
||||||
hint.style.color = '';
|
|
||||||
toggleContainer.checked = true;
|
|
||||||
toggle.classList.remove('toggle-blocked');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#modalPolicySelector .seg-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
modalSelectedPolicy = btn.dataset.val;
|
||||||
|
updateModalPolicySelector(btn.dataset.val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('clientModal').classList.add('hidden');
|
document.getElementById('clientModal').classList.add('hidden');
|
||||||
editingClient = null;
|
editingClient = null;
|
||||||
@@ -241,8 +325,8 @@ async function saveClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostname = document.getElementById('modalHostname').value.trim();
|
const hostname = document.getElementById('modalHostname').value.trim();
|
||||||
const isBlocked = !document.getElementById('modalBlocked').checked;
|
|
||||||
const staticIP = document.getElementById('modalStaticIP').value.trim();
|
const staticIP = document.getElementById('modalStaticIP').value.trim();
|
||||||
|
const policy = modalSelectedPolicy; // "disabled" | "direct" | "vpn" | ""
|
||||||
|
|
||||||
const btn = document.getElementById('modalSave');
|
const btn = document.getElementById('modalSave');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -251,7 +335,12 @@ async function saveClient() {
|
|||||||
const res = await fetch('/api/clients/update/' + encodeURIComponent(mac), {
|
const res = await fetch('/api/clients/update/' + encodeURIComponent(mac), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const json = await res.json();
|
||||||
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`);
|
||||||
@@ -265,6 +354,8 @@ async function saveClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ipToNum(ip) {
|
function ipToNum(ip) {
|
||||||
return (ip || '').split('.').reduce((acc, o) => acc * 256 + (+o || 0), 0);
|
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);
|
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('refreshBtn').addEventListener('click', loadClients);
|
// ── Event wiring ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
document.getElementById('clientsSearch').addEventListener('input', e => {
|
document.getElementById('clientsSearch').addEventListener('input', e => {
|
||||||
searchQuery = e.target.value.trim();
|
searchQuery = e.target.value.trim();
|
||||||
@@ -299,16 +391,11 @@ document.getElementById('clientForm').addEventListener('submit', e => {
|
|||||||
saveClient();
|
saveClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('modalBlocked').addEventListener('change', () => {
|
|
||||||
if (!editingClient) return;
|
|
||||||
const isBlocked = !document.getElementById('modalBlocked').checked;
|
|
||||||
updateBlockedToggle(isBlocked);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape') closeModal();
|
if (e.key === 'Escape') closeModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(loadClients, 10000);
|
setInterval(loadClients, 10000);
|
||||||
|
|
||||||
loadClients();
|
loadDefaultPolicy();
|
||||||
|
loadClients();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,21 +15,21 @@
|
|||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
<path d="M2 12l10 5 10-5"/>
|
<path d="M2 12l10 5 10-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>AlpineRouter</h1>
|
<h1>NanoRouter</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tab-nav">
|
<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">
|
<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="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
@@ -64,10 +64,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Прокси
|
Прокси
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<main class="dhcp-main">
|
<main class="dhcp-main">
|
||||||
|
|
||||||
<!-- Not-installed warning -->
|
<!-- Not-installed warning -->
|
||||||
<div id="notInstalledBanner" class="alert alert-error hidden">
|
<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">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||||
|
|||||||
@@ -296,7 +296,6 @@ function showToast(msg, type = 'info') {
|
|||||||
|
|
||||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
|
||||||
|
|
||||||
document.getElementById('enableToggle').addEventListener('change', e => {
|
document.getElementById('enableToggle').addEventListener('change', e => {
|
||||||
state.config.enabled = e.target.checked;
|
state.config.enabled = e.target.checked;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Файрвол — AlpineRouter</title>
|
<title>Файрвол — NanoRouter</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,21 +15,21 @@
|
|||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
<path d="M2 12l10 5 10-5"/>
|
<path d="M2 12l10 5 10-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>AlpineRouter</h1>
|
<h1>NanoRouter</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tab-nav">
|
<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">
|
<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="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
@@ -64,10 +64,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Прокси
|
Прокси
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<main class="fw-main">
|
<main class="fw-main">
|
||||||
|
|
||||||
<div id="notInstalledBanner" class="alert alert-error hidden">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
|
||||||
|
|||||||
@@ -279,7 +279,6 @@ function showToast(msg, type = 'info') {
|
|||||||
|
|
||||||
// ── Event wiring ──────────────────────────────────────────────────────────────
|
// ── Event wiring ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.getElementById('refreshBtn').addEventListener('click', loadAll);
|
|
||||||
document.getElementById('applyBtn').addEventListener('click', saveAndApply);
|
document.getElementById('applyBtn').addEventListener('click', saveAndApply);
|
||||||
document.getElementById('addRuleBtn').addEventListener('click', () => openModal(-1));
|
document.getElementById('addRuleBtn').addEventListener('click', () => openModal(-1));
|
||||||
|
|
||||||
|
|||||||
291
public/home.html
Normal file
291
public/home.html
Normal 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>⚠ Пароль по умолчанию!</strong> Аккаунт использует стандартный пароль <code>admin:admin</code>. <a href="/profile.html" style="color:var(--accent)">Задайте свой пароль →</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
785
public/home.js
Normal 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();
|
||||||
|
})();
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AlpineRouter</title>
|
<title>NanoRouter</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,17 +15,9 @@
|
|||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
<path d="M2 12l10 5 10-5"/>
|
<path d="M2 12l10 5 10-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>AlpineRouter</h1>
|
<h1>NanoRouter</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -41,7 +33,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="tab-nav">
|
<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">
|
<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="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
@@ -76,6 +75,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Прокси
|
Прокси
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -83,7 +89,7 @@
|
|||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<span>Загрузка...</span>
|
<span>Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="ifaceGrid" class="iface-grid hidden"></div>
|
<div id="ifaceGrid" class="hidden"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Config Modal -->
|
<!-- Config Modal -->
|
||||||
@@ -111,6 +117,14 @@
|
|||||||
<input type="text" id="cfgLabel" placeholder="Например: WAN, LAN, Гости…" style="font-family:inherit">
|
<input type="text" id="cfgLabel" placeholder="Например: WAN, LAN, Гости…" style="font-family:inherit">
|
||||||
</div>
|
</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">
|
<div class="form-row">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" id="cfgAuto">
|
<input type="checkbox" id="cfgAuto">
|
||||||
@@ -118,7 +132,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<!-- Mode: only shown for WAN -->
|
||||||
|
<div id="modeRow" class="form-row">
|
||||||
<label>Режим</label>
|
<label>Режим</label>
|
||||||
<div class="segmented" id="modeSwitch">
|
<div class="segmented" id="modeSwitch">
|
||||||
<button type="button" class="seg-btn active" data-mode="dhcp">DHCP</button>
|
<button type="button" class="seg-btn active" data-mode="dhcp">DHCP</button>
|
||||||
@@ -135,16 +150,17 @@
|
|||||||
<label for="cfgNetmask">Маска сети</label>
|
<label for="cfgNetmask">Маска сети</label>
|
||||||
<input type="text" id="cfgNetmask" placeholder="255.255.255.0">
|
<input type="text" id="cfgNetmask" placeholder="255.255.255.0">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div id="gatewayRow" class="form-row">
|
||||||
<label for="cfgGateway">Шлюз</label>
|
<label for="cfgGateway">Шлюз</label>
|
||||||
<input type="text" id="cfgGateway" placeholder="192.168.1.1">
|
<input type="text" id="cfgGateway" placeholder="192.168.1.1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div id="dnsRow" class="form-row">
|
||||||
<label for="cfgDNS">DNS (через пробел)</label>
|
<label for="cfgDNS">DNS (через пробел)</label>
|
||||||
<input type="text" id="cfgDNS" placeholder="8.8.8.8 8.8.4.4">
|
<input type="text" id="cfgDNS" placeholder="8.8.8.8 8.8.4.4">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NAT section: only shown for LAN -->
|
||||||
<div id="natSection" class="hidden">
|
<div id="natSection" class="hidden">
|
||||||
<div class="form-divider"></div>
|
<div class="form-divider"></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
189
public/login.html
Normal file
189
public/login.html
Normal 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
362
public/profile.html
Normal 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 <ваш_ключ>" \
|
||||||
|
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>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AlpineRouter — Прокси</title>
|
<title>NanoRouter — Прокси</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
<path d="M2 12l10 5 10-5"/>
|
<path d="M2 12l10 5 10-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>AlpineRouter</h1>
|
<h1>NanoRouter</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span id="statusBadge" class="svc-badge stopped">Остановлен</span>
|
<span id="statusBadge" class="svc-badge stopped">Остановлен</span>
|
||||||
@@ -23,7 +23,14 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tab-nav">
|
<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">
|
<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="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
@@ -58,6 +65,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Прокси
|
Прокси
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<main class="proxy-main">
|
<main class="proxy-main">
|
||||||
@@ -112,23 +126,23 @@
|
|||||||
<h3 class="dash-card-title">Трафик</h3>
|
<h3 class="dash-card-title">Трафик</h3>
|
||||||
<div class="dash-traffic-row">
|
<div class="dash-traffic-row">
|
||||||
<div class="dash-traffic-item">
|
<div class="dash-traffic-item">
|
||||||
<span class="dash-traffic-label">↑ Загрузка</span>
|
<span class="dash-traffic-label">↓ Загрузка</span>
|
||||||
<span class="dash-traffic-val" id="dashUp">0 B/s</span>
|
<span class="dash-traffic-val" id="dashDown">0 B/s</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-traffic-item">
|
<div class="dash-traffic-item">
|
||||||
<span class="dash-traffic-label">↓ Отдача</span>
|
<span class="dash-traffic-label">↑ Отдача</span>
|
||||||
<span class="dash-traffic-val" id="dashDown">0 B/s</span>
|
<span class="dash-traffic-val" id="dashUp">0 B/s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-traffic-row">
|
<div class="dash-traffic-row">
|
||||||
<div class="dash-traffic-item">
|
|
||||||
<span class="dash-traffic-label">↑ Всего</span>
|
|
||||||
<span class="dash-traffic-val dash-traffic-total" id="dashUpTotal">0 B</span>
|
|
||||||
</div>
|
|
||||||
<div class="dash-traffic-item">
|
<div class="dash-traffic-item">
|
||||||
<span class="dash-traffic-label">↓ Всего</span>
|
<span class="dash-traffic-label">↓ Всего</span>
|
||||||
<span class="dash-traffic-val dash-traffic-total" id="dashDownTotal">0 B</span>
|
<span class="dash-traffic-val dash-traffic-total" id="dashDownTotal">0 B</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dash-traffic-item">
|
||||||
|
<span class="dash-traffic-label">↑ Всего</span>
|
||||||
|
<span class="dash-traffic-val dash-traffic-total" id="dashUpTotal">0 B</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-mem-row">
|
<div class="dash-mem-row">
|
||||||
<span class="dash-traffic-label">Память</span>
|
<span class="dash-traffic-label">Память</span>
|
||||||
@@ -214,13 +228,26 @@
|
|||||||
|
|
||||||
<!-- Rules Tab -->
|
<!-- Rules Tab -->
|
||||||
<div id="tab-rules" class="ptab-content hidden">
|
<div id="tab-rules" class="ptab-content hidden">
|
||||||
<div class="section-header" style="margin-bottom:16px">
|
<div class="rules-toolbar">
|
||||||
<h2>Правила маршрутизации</h2>
|
<div class="rules-toolbar-left">
|
||||||
<div class="section-desc">Определите, какой трафик куда направляется. Правила применяются сверху вниз.</div>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15" style="color:var(--accent);flex-shrink:0">
|
||||||
</div>
|
<path d="M4 6h16M4 12h16M4 18h7"/>
|
||||||
<div class="form-row" style="margin-bottom:12px">
|
</svg>
|
||||||
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Добавить правило</button>
|
<div>
|
||||||
<button class="btn btn-ghost btn-sm" id="addBlockBtn" style="margin-left:8px">+ Блокировка домена</button>
|
<div class="rules-toolbar-title">Правила маршрутизации</div>
|
||||||
|
<div class="rules-toolbar-hint">Применяются сверху вниз</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rules-toolbar-right">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="addBlockBtn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><circle cx="12" cy="12" r="10"/><path d="M4.93 4.93l14.14 14.14"/></svg>
|
||||||
|
Блокировка домена
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="addRuleBtn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
Добавить правило
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="rulesList" class="rules-list"></div>
|
<div id="rulesList" class="rules-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,7 +603,7 @@ tls://1.1.1.1:853</textarea>
|
|||||||
<h2 id="proxyModalTitle">Добавить прокси</h2>
|
<h2 id="proxyModalTitle">Добавить прокси</h2>
|
||||||
<button class="btn-icon" id="closeProxyModal" title="Закрыть">✕</button>
|
<button class="btn-icon" id="closeProxyModal" title="Закрыть">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="proxyForm" autocomplete="off" style="max-height:65vh;overflow-y:auto;padding-right:4px">
|
<form id="proxyForm" autocomplete="off" style="max-height:52vh;overflow-y:auto;padding-right:4px">
|
||||||
|
|
||||||
<!-- Type -->
|
<!-- Type -->
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -1393,7 +1420,7 @@ tls://1.1.1.1:853</textarea>
|
|||||||
<h2 id="groupModalTitle">Добавить группу</h2>
|
<h2 id="groupModalTitle">Добавить группу</h2>
|
||||||
<button class="btn-icon" id="closeGroupModal" title="Закрыть">✕</button>
|
<button class="btn-icon" id="closeGroupModal" title="Закрыть">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="groupForm" autocomplete="off" style="max-height:65vh;overflow-y:auto;padding-right:4px">
|
<form id="groupForm" autocomplete="off" style="max-height:52vh;overflow-y:auto;padding-right:4px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="groupName">Имя группы</label>
|
<label for="groupName">Имя группы</label>
|
||||||
<input type="text" id="groupName" placeholder="proxy">
|
<input type="text" id="groupName" placeholder="proxy">
|
||||||
@@ -1576,7 +1603,7 @@ tls://1.1.1.1:853</textarea>
|
|||||||
<h2 id="ppModalTitle">Добавить провайдер прокси</h2>
|
<h2 id="ppModalTitle">Добавить провайдер прокси</h2>
|
||||||
<button class="btn-icon" id="closePPModal">✕</button>
|
<button class="btn-icon" id="closePPModal">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="ppForm" autocomplete="off" style="max-height:65vh;overflow-y:auto;padding-right:4px">
|
<form id="ppForm" autocomplete="off" style="max-height:52vh;overflow-y:auto;padding-right:4px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Имя</label>
|
<label>Имя</label>
|
||||||
<input type="text" id="ppName" placeholder="provider1">
|
<input type="text" id="ppName" placeholder="provider1">
|
||||||
|
|||||||
@@ -279,15 +279,19 @@ function renderRules() {
|
|||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'rule-item';
|
el.className = 'rule-item';
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
|
<span class="rule-num">${i + 1}</span>
|
||||||
<div class="rule-info">
|
<div class="rule-info">
|
||||||
<span class="rule-type">${esc(type)}</span>
|
<span class="rule-type">${esc(type)}</span>
|
||||||
<span class="rule-value">${esc(value || (type === 'MATCH' ? '*' : ''))}</span>
|
<span class="rule-value">${esc(value || (type === 'MATCH' ? '—' : ''))}</span>
|
||||||
<span class="rule-target">${esc(target)}</span>
|
|
||||||
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:4px">
|
<div class="rule-arrow">
|
||||||
<button class="btn btn-danger btn-sm" data-delete-rule="${i}">✕</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||||
</div>`;
|
</div>
|
||||||
|
<span class="rule-target">${esc(target)}</span>
|
||||||
|
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
|
||||||
|
<button class="btn-icon btn-icon-danger rule-del" data-delete-rule="${i}" title="Удалить">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
</button>`;
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1893,6 +1897,7 @@ document.getElementById('clearLogsBtn').addEventListener('click', () => {
|
|||||||
|
|
||||||
const DA = {
|
const DA = {
|
||||||
ws: null,
|
ws: null,
|
||||||
|
memWs: null,
|
||||||
trafficUp: 0,
|
trafficUp: 0,
|
||||||
trafficDown: 0,
|
trafficDown: 0,
|
||||||
totalUp: 0,
|
totalUp: 0,
|
||||||
@@ -1941,6 +1946,10 @@ function closeDashWS() {
|
|||||||
try { DA.ws.close(); } catch(e) {}
|
try { DA.ws.close(); } catch(e) {}
|
||||||
DA.ws = null;
|
DA.ws = null;
|
||||||
}
|
}
|
||||||
|
if (DA.memWs) {
|
||||||
|
try { DA.memWs.close(); } catch(e) {}
|
||||||
|
DA.memWs = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDashWS() {
|
function openDashWS() {
|
||||||
@@ -1960,6 +1969,19 @@ function openDashWS() {
|
|||||||
ws.onclose = () => { DA.ws = null; };
|
ws.onclose = () => { DA.ws = null; };
|
||||||
DA.ws = ws;
|
DA.ws = ws;
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
const memUrl = proto + '//' + location.host + '/api/mihomo/ws/memory';
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(memUrl);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
DA.memory = d.inuse || 0;
|
||||||
|
} catch(err) {}
|
||||||
|
};
|
||||||
|
ws.onerror = () => {};
|
||||||
|
ws.onclose = () => { DA.memWs = null; };
|
||||||
|
DA.memWs = ws;
|
||||||
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let dashTrafficInterval = null;
|
let dashTrafficInterval = null;
|
||||||
|
|||||||
1189
public/style.css
1189
public/style.css
File diff suppressed because it is too large
Load Diff
631
setup/setup.go
Normal file
631
setup/setup.go
Normal 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
45
setup/termios_linux.go
Normal 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)))
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
pollInterval = 20 * time.Second
|
pollInterval = 20 * time.Second
|
||||||
OnlineWindow = 5 * time.Minute
|
OnlineWindow = 5 * time.Minute
|
||||||
trackerTableName = "alpine-router-traffic"
|
trackerTableName = "nano-router-traffic"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IPStats struct {
|
type IPStats struct {
|
||||||
@@ -82,6 +82,7 @@ func EnsureIPTracked(ip string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
prev[ip] = [2]uint64{}
|
prev[ip] = [2]uint64{}
|
||||||
|
stats[ip] = &IPStats{}
|
||||||
addNFTRule(ip)
|
addNFTRule(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +91,10 @@ func setupNFTTable() error {
|
|||||||
|
|
||||||
script := fmt.Sprintf(`table ip %s {
|
script := fmt.Sprintf(`table ip %s {
|
||||||
chain tx {
|
chain tx {
|
||||||
type filter hook forward priority filter + 10; policy accept;
|
type filter hook prerouting priority raw; policy accept;
|
||||||
}
|
}
|
||||||
chain rx {
|
chain rx {
|
||||||
type filter hook forward priority filter + 20; policy accept;
|
type filter hook postrouting priority raw; policy accept;
|
||||||
}
|
}
|
||||||
}`, trackerTableName)
|
}`, trackerTableName)
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ func poll() {
|
|||||||
for ip := range prev {
|
for ip := range prev {
|
||||||
if _, exists := current[ip]; !exists {
|
if _, exists := current[ip]; !exists {
|
||||||
prev[ip] = [2]uint64{0, 0}
|
prev[ip] = [2]uint64{0, 0}
|
||||||
|
addNFTRule(ip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user