commit 7eaa9750b04e6098833928cc7f5ce96e0bea608e Author: Vyacheslav K Date: Mon Apr 13 09:46:02 2026 +0300 first commit diff --git a/Meta-Docs b/Meta-Docs new file mode 160000 index 0000000..d31369a --- /dev/null +++ b/Meta-Docs @@ -0,0 +1 @@ +Subproject commit d31369ab4517a5fcbb4b5d3ec81b3178bca502ca diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfaaf75 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# Network Manager — веб-панель управления сетью для Alpine Linux + +Простая веб-панель для настройки сетевых интерфейсов на Alpine Linux. +Написана на Go (бэкенд) + чистый HTML/CSS/JS (фронтенд), без внешних зависимостей. + +## Возможности + +- Вкл/выкл/рестарт интерфейса (`ifup` / `ifdown`) +- Статистика трафика (rx/tx байт и пакетов) из `/proc/net/dev` +- Текущий IP (v4 и v6), маска сети, шлюз +- Режим DHCP или статический IP +- Редактирование всех параметров (IP, маска, шлюз, DNS) +- **Настройки применяются только после нажатия «Применить»** +- Автообновление данных каждые 10 секунд + +## Структура проекта + +``` +alpine-router/ +├── main.go — точка входа, HTTP-роутинг +├── go.mod +├── handlers/ +│ └── api.go — REST-обработчики +├── network/ +│ ├── interfaces.go — чтение состояния интерфейсов +│ ├── config.go — парсинг/запись /etc/network/interfaces +│ └── apply.go — применение конфига (ifup/ifdown) +├── public/ +│ ├── index.html +│ ├── style.css +│ └── app.js +└── alpine-init/ + └── network-manager — OpenRC-скрипт +``` + +## Быстрый запуск (разработка, Linux/macOS) + +```bash +cd alpine-router +go run . +# открыть http://localhost:8080 +``` + +> На не-Alpine системе панель запустится, но ifup/ifdown не сработают — +> зато статистика и отображение интерфейсов будут работать. + +--- + +## Деплой на Alpine Linux + +### 1. Установить зависимости + +```sh +apk add go git ifupdown +``` + +> Если `ifupdown` уже включён в базовой системе — пропустить. + +### 2. Собрать бинарник + +```sh +# На самом роутере или кросс-компиляцией: +cd alpine-router +go build -o network-manager . +``` + +Кросс-компиляция с x86_64 → ARM (например, для Raspberry Pi): + +```sh +GOOS=linux GOARCH=arm64 go build -o network-manager . +``` + +### 3. Установить файлы + +```sh +# Бинарник +install -m 755 network-manager /usr/local/bin/network-manager + +# Фронтенд (панель ищет папку ./public относительно cwd) +mkdir -p /usr/local/share/network-manager +cp -r public/ /usr/local/share/network-manager/public +``` + +Если хотите запускать из `/usr/local/share/network-manager`: + +```sh +cd /usr/local/share/network-manager && network-manager +``` + +Либо укажите путь к public через переменную окружения: + +```sh +# Добавьте в OpenRC-скрипт: +directory="/usr/local/share/network-manager" +``` + +### 4. Настроить автозапуск (OpenRC) + +```sh +cp alpine-init/network-manager /etc/init.d/network-manager +chmod +x /etc/init.d/network-manager + +rc-service network-manager start +rc-update add network-manager default +``` + +### 5. Открыть панель + +``` +http://:8080 +``` + +Порт можно изменить переменной окружения: + +```sh +PORT=8888 network-manager +``` + +--- + +## API + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/api/interfaces` | Список всех интерфейсов со статистикой | +| GET | `/api/interfaces/{name}` | Статистика одного интерфейса | +| POST | `/api/interfaces/{name}/up` | Поднять интерфейс | +| POST | `/api/interfaces/{name}/down` | Опустить интерфейс | +| POST | `/api/interfaces/{name}/restart` | Рестарт интерфейса | +| GET | `/api/config/{name}` | Получить конфиг (pending или из файла) | +| POST | `/api/config/{name}` | Сохранить конфиг как pending | +| DELETE | `/api/config/{name}` | Удалить pending конфиг | +| GET | `/api/pending` | Список интерфейсов с pending изменениями | +| POST | `/api/apply` | Записать pending конфиги и перезапустить интерфейсы | + +--- + +## Формат `/etc/network/interfaces` + +Панель читает и пишет стандартный Debian/Alpine формат: + +``` +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp + +auto eth1 +iface eth1 inet static + address 192.168.1.1 + netmask 255.255.255.0 + gateway 192.168.0.1 + dns-nameservers 8.8.8.8 1.1.1.1 +``` + +Перед каждой записью создаётся резервная копия `/etc/network/interfaces.bak`. + +--- + +## Требования + +- Alpine Linux (или любой Linux с `/proc/net/dev` и `/sys/class/net`) +- Go 1.21+ +- `ifupdown` или `busybox` с поддержкой `ifup`/`ifdown` diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..391c1e4 --- /dev/null +++ b/RULES.md @@ -0,0 +1,8 @@ +1. Все настройки обязательно сохранять в config.yaml и восстанавливать оттуда при первом запуске бинарника. +2. Функциональные разделы админки писать отдельными html страницами и добавлять в главное меню. + + + +Установить пакеты: +dnsmasq +nftables \ No newline at end of file diff --git a/alpine-init/network-manager b/alpine-init/network-manager new file mode 100644 index 0000000..aac7eef --- /dev/null +++ b/alpine-init/network-manager @@ -0,0 +1,17 @@ +#!/sbin/openrc-run + +description="Network Manager Web Panel" +command="/usr/local/bin/network-manager" +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 +} diff --git a/alpine-router b/alpine-router new file mode 100755 index 0000000..a7d1e6c Binary files /dev/null and b/alpine-router differ diff --git a/clients/clients.go b/clients/clients.go new file mode 100644 index 0000000..2b5878b --- /dev/null +++ b/clients/clients.go @@ -0,0 +1,306 @@ +package clients + +import ( + "bufio" + "fmt" + "os" + "sort" + "strconv" + "strings" + + "alpine-router/config" + "alpine-router/traffic" +) + +const LeasesFile = "/var/lib/misc/dnsmasq.leases" + +type Client struct { + IP string `json:"ip"` + MAC string `json:"mac"` + Hostname string `json:"hostname"` + Interface string `json:"interface"` + LeaseExpires int64 `json:"lease_expires"` + IsDHCP bool `json:"is_dhcp"` + Online bool `json:"online"` + TxBytes uint64 `json:"tx_bytes"` + RxBytes uint64 `json:"rx_bytes"` + LastActive int64 `json:"last_active"` + Known bool `json:"known"` + Blocked bool `json:"blocked"` + StaticIP string `json:"static_ip"` +} + +func GetAll() ([]Client, error) { + leases, err := parseDNSMasqLeases() + if err != nil { + leases = map[string]*Client{} + } + + arpEntries, err := parseARPTable() + if err != nil { + return nil, fmt.Errorf("arp: %w", err) + } + + byIP := make(map[string]*Client, len(arpEntries)) + + for ip, c := range arpEntries { + byIP[ip] = c + } + + for ip, lease := range leases { + if c, exists := byIP[ip]; exists { + c.IsDHCP = true + c.LeaseExpires = lease.LeaseExpires + if lease.Hostname != "" { + c.Hostname = lease.Hostname + } + if c.MAC == "" { + c.MAC = lease.MAC + } + } else { + byIP[ip] = lease + } + } + + blockedByMAC := make(map[string]bool) + + cfg, cfgErr := config.Load() + if cfgErr == nil && cfg != nil { + knownByMAC := make(map[string]bool) + for _, c := range byIP { + if c.MAC != "" { + knownByMAC[c.MAC] = true + } + } + + for _, kd := range cfg.KnownDevices { + key := kd.IP + found := false + + for ip, c := range byIP { + if 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 + } + 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 + } + } + + if !found && key != "" { + knownByMACKey := kd.MAC + if knownByMACKey != "" && knownByMAC[knownByMACKey] { + continue + } + displayIP := kd.IP + if kd.StaticIP != "" { + displayIP = kd.StaticIP + } + byIP[key] = &Client{ + IP: displayIP, + MAC: kd.MAC, + Hostname: kd.Hostname, + Known: true, + Blocked: kd.Blocked, + StaticIP: kd.StaticIP, + } + } + + if kd.Blocked && kd.MAC != "" { + blockedByMAC[kd.MAC] = true + } + } + } + + trafficAvailable := traffic.Available() + for ip, c := range byIP { + if c.Known && c.IP == "" { + continue + } + if trafficAvailable { + traffic.EnsureIPTracked(ip) + } + ts := traffic.Get(ip) + c.TxBytes = ts.TxBytes + c.RxBytes = ts.RxBytes + if !ts.LastActive.IsZero() { + c.LastActive = ts.LastActive.Unix() + } + if trafficAvailable { + c.Online = traffic.IsOnline(ip) + } + } + + go syncKnownDevices(byIP) + + result := make([]Client, 0, len(byIP)) + for _, c := range byIP { + result = append(result, *c) + } + + sort.Slice(result, func(i, j int) bool { + if result[i].Online != result[j].Online { + return result[i].Online + } + if result[i].Known != result[j].Known { + return result[i].Known + } + return ipLess(result[i].IP, result[j].IP) + }) + + return result, nil +} + +func syncKnownDevices(byIP map[string]*Client) { + cfg, err := config.Load() + if err != nil { + return + } + + savedHostnames := make(map[string]string) + savedBlocked := make(map[string]bool) + savedStaticIPs := make(map[string]string) + for _, kd := range cfg.KnownDevices { + key := kd.MAC + if key == "" { + key = kd.IP + } + savedHostnames[key] = kd.Hostname + if kd.Blocked { + savedBlocked[key] = true + } + if kd.StaticIP != "" { + savedStaticIPs[key] = kd.StaticIP + } + } + + var seen []config.KnownDevice + for _, c := range byIP { + if c.MAC != "" && c.IP != "" { + key := c.MAC + hostname := c.Hostname + if saved, ok := savedHostnames[key]; ok { + hostname = saved + } + kd := config.KnownDevice{ + IP: c.IP, + MAC: c.MAC, + Hostname: hostname, + } + if savedBlocked[key] { + kd.Blocked = true + } + if sip, ok := savedStaticIPs[key]; ok { + kd.StaticIP = sip + } + seen = append(seen, kd) + } + } + _ = config.UpdateKnownDevices(seen) +} + +func parseDNSMasqLeases() (map[string]*Client, error) { + f, err := os.Open(LeasesFile) + if err != nil { + if os.IsNotExist(err) { + return map[string]*Client{}, nil + } + return nil, err + } + defer f.Close() + + out := map[string]*Client{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 4 { + continue + } + exp, _ := strconv.ParseInt(fields[0], 10, 64) + mac := fields[1] + ip := fields[2] + hostname := fields[3] + if hostname == "*" { + hostname = "" + } + out[ip] = &Client{ + IP: ip, + MAC: mac, + Hostname: hostname, + LeaseExpires: exp, + IsDHCP: true, + } + } + return out, scanner.Err() +} + +func parseARPTable() (map[string]*Client, error) { + f, err := os.Open("/proc/net/arp") + if err != nil { + return nil, err + } + defer f.Close() + + out := map[string]*Client{} + scanner := bufio.NewScanner(f) + scanner.Scan() + + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 6 { + continue + } + ip := fields[0] + flags := fields[2] + mac := fields[3] + iface := fields[5] + + if mac == "00:00:00:00:00:00" || iface == "lo" { + continue + } + + arpOnline := flags == "0x2" || flags == "0x6" + + out[ip] = &Client{ + IP: ip, + MAC: mac, + Interface: iface, + Online: arpOnline, + } + } + return out, scanner.Err() +} + +func ipLess(a, b string) bool { + return ipToUint32(a) < ipToUint32(b) +} + +func ipToUint32(ip string) uint32 { + parts := strings.SplitN(ip, ".", 4) + if len(parts) != 4 { + return 0 + } + var v uint32 + for _, p := range parts { + n, _ := strconv.ParseUint(p, 10, 8) + v = v<<8 | uint32(n) + } + return v +} \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0219740 --- /dev/null +++ b/config/config.go @@ -0,0 +1,229 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "gopkg.in/yaml.v3" +) + +type InterfaceConfig struct { + Auto bool `yaml:"auto"` + Mode string `yaml:"mode"` + Address string `yaml:"address,omitempty"` + Netmask string `yaml:"netmask,omitempty"` + Gateway string `yaml:"gateway,omitempty"` + DNS []string `yaml:"dns,omitempty"` + Extra map[string]string `yaml:"extra,omitempty"` +} + +type DHCPPool struct { + Interface string `yaml:"interface"` + Enabled bool `yaml:"enabled"` + Subnet string `yaml:"subnet"` + Netmask string `yaml:"netmask"` + RangeStart string `yaml:"range_start"` + RangeEnd string `yaml:"range_end"` + Router string `yaml:"router"` + DNS []string `yaml:"dns"` + LeaseTime int `yaml:"lease_time"` +} + +type DHCPConfig struct { + Enabled bool `yaml:"enabled"` + Pools []DHCPPool `yaml:"pools"` +} + +type NATConfig struct { + Interfaces []string `yaml:"interfaces"` +} + +type KnownDevice struct { + IP string `yaml:"ip"` + MAC string `yaml:"mac"` + Hostname string `yaml:"hostname"` + Blocked bool `yaml:"blocked,omitempty"` + StaticIP string `yaml:"static_ip,omitempty"` +} + +type MihomoConfig struct { + Enabled bool `yaml:"enabled"` +} + +type AppConfig struct { + Interfaces map[string]*InterfaceConfig `yaml:"interfaces"` + DHCP DHCPConfig `yaml:"dhcp"` + NAT NATConfig `yaml:"nat"` + KnownDevices []KnownDevice `yaml:"known_devices"` + Mihomo MihomoConfig `yaml:"mihomo"` +} + +var ( + mu sync.Mutex + filePath string +) + +func init() { + dir := executableDir() + filePath = filepath.Join(dir, "config.yaml") +} + +func executableDir() string { + exe, err := os.Executable() + if err != nil { + return "." + } + // Resolve symlinks — /proc/self/exe on Linux points to real path + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return filepath.Dir(exe) +} + +func SetPath(p string) { + mu.Lock() + defer mu.Unlock() + filePath = p +} + +func GetPath() string { + mu.Lock() + defer mu.Unlock() + return filePath +} + +func Load() (*AppConfig, error) { + mu.Lock() + defer mu.Unlock() + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return defaultConfig(), nil + } + return nil, fmt.Errorf("read config: %w", err) + } + + var cfg AppConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + EnsureDefaults(&cfg) + return &cfg, nil +} + +func Save(cfg *AppConfig) error { + mu.Lock() + defer mu.Unlock() + + EnsureDefaults(cfg) + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("mkdir config dir: %w", err) + } + + tmp := filePath + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return fmt.Errorf("write config tmp: %w", err) + } + + if err := os.Rename(tmp, filePath); err != nil { + return fmt.Errorf("rename config: %w", err) + } + + return nil +} + +func LoadInto(dst interface{}) error { + mu.Lock() + defer mu.Unlock() + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read config: %w", err) + } + return yaml.Unmarshal(data, dst) +} + +func defaultConfig() *AppConfig { + return &AppConfig{ + Interfaces: map[string]*InterfaceConfig{}, + DHCP: DHCPConfig{Pools: []DHCPPool{}}, + NAT: NATConfig{Interfaces: []string{}}, + KnownDevices: []KnownDevice{}, + Mihomo: MihomoConfig{Enabled: false}, + } +} + +func EnsureDefaults(cfg *AppConfig) { + if cfg.Interfaces == nil { + cfg.Interfaces = map[string]*InterfaceConfig{} + } + if cfg.DHCP.Pools == nil { + cfg.DHCP.Pools = []DHCPPool{} + } + if cfg.NAT.Interfaces == nil { + cfg.NAT.Interfaces = []string{} + } + if cfg.KnownDevices == nil { + cfg.KnownDevices = []KnownDevice{} + } +} + +func UpdateKnownDevices(seen []KnownDevice) error { + cfg, err := Load() + if err != nil { + return err + } + + existing := make(map[string]KnownDevice) + for _, d := range cfg.KnownDevices { + key := d.MAC + if key == "" { + key = d.IP + } + existing[key] = d + } + + for _, d := range seen { + key := d.MAC + if key == "" { + key = d.IP + } + if existingDev, ok := existing[key]; ok { + if d.Hostname != "" { + existingDev.Hostname = d.Hostname + } + if d.IP != "" { + existingDev.IP = d.IP + } + if d.MAC != "" { + existingDev.MAC = d.MAC + } + if d.StaticIP != "" { + existingDev.StaticIP = d.StaticIP + } + existing[key] = existingDev + } else { + existing[key] = d + } + } + + cfg.KnownDevices = make([]KnownDevice, 0, len(existing)) + for _, d := range existing { + cfg.KnownDevices = append(cfg.KnownDevices, d) + } + + return Save(cfg) +} \ No newline at end of file diff --git a/dhcp/config.go b/dhcp/config.go new file mode 100644 index 0000000..3696180 --- /dev/null +++ b/dhcp/config.go @@ -0,0 +1,222 @@ +package dhcp + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "sync" +) + +const ( + ConfigFile = "/etc/dnsmasq.d/alpine-router-dhcp.conf" + StateFile = "/var/lib/alpine-router/dhcp.json" +) + +// Pool describes a DHCP pool tied to one interface/subnet. +type Pool struct { + Interface string `json:"interface"` + Enabled bool `json:"enabled"` + Subnet string `json:"subnet"` // e.g. "172.16.54.0" + Netmask string `json:"netmask"` // e.g. "255.255.255.0" + RangeStart string `json:"range_start"` // e.g. "172.16.54.100" + RangeEnd string `json:"range_end"` // e.g. "172.16.54.200" + Router string `json:"router"` // option routers (advertised gateway) + DNS []string `json:"dns"` // option domain-name-servers + LeaseTime int `json:"lease_time"` // seconds, 0 → 86400 +} + +// Config is the top-level DHCP configuration persisted on disk. +type Config struct { + Enabled bool `json:"enabled"` + Pools []Pool `json:"pools"` +} + +// StaticBinding represents a DHCP host reservation (MAC → fixed IP). +type StaticBinding struct { + MAC string `json:"mac"` + Host string `json:"host"` + IP string `json:"ip"` +} + +var mu sync.Mutex + +// IsInstalled reports whether dnsmasq binary is available. +func IsInstalled() bool { + _, err := exec.LookPath("dnsmasq") + return err == nil +} + +// Load reads the config from the state file. +// Returns an empty config if the file does not exist yet. +func Load() (*Config, error) { + mu.Lock() + defer mu.Unlock() + + data, err := os.ReadFile(StateFile) + if err != nil { + if os.IsNotExist(err) { + return &Config{Pools: []Pool{}}, nil + } + return nil, fmt.Errorf("read state: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse state: %w", err) + } + if cfg.Pools == nil { + cfg.Pools = []Pool{} + } + return &cfg, nil +} + +// Save writes the config to the state file (JSON, not dnsmasq.conf). +func Save(cfg *Config) error { + mu.Lock() + defer mu.Unlock() + + if err := os.MkdirAll("/var/lib/alpine-router", 0755); err != nil { + return fmt.Errorf("mkdir state dir: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(StateFile, data, 0644) +} + +// WriteConfigs generates /etc/dnsmasq.d/alpine-router-dhcp.conf. +// dnsmasq is used in DHCP-only mode (port=0 disables DNS resolver). +func WriteConfigs(cfg *Config) error { + if err := os.MkdirAll("/etc/dnsmasq.d", 0755); err != nil { + return fmt.Errorf("mkdir /etc/dnsmasq.d: %w", err) + } + + var sb strings.Builder + sb.WriteString("# Generated by alpine-router — do not edit manually\n") + sb.WriteString("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n") + sb.WriteString("port=0\n") // disable DNS + sb.WriteString("bind-interfaces\n") // only listen on specified interfaces + sb.WriteString("\n") + + for _, pool := range cfg.Pools { + if !pool.Enabled { + continue + } + + leaseTime := pool.LeaseTime + if leaseTime <= 0 { + leaseTime = 86400 + } + + tag := pool.Interface // use interface name as tag for option scoping + + fmt.Fprintf(&sb, "# Pool: %s\n", pool.Interface) + fmt.Fprintf(&sb, "interface=%s\n", pool.Interface) + + if pool.RangeStart != "" && pool.RangeEnd != "" { + fmt.Fprintf(&sb, "dhcp-range=set:%s,%s,%s,%s,%ds\n", + tag, pool.RangeStart, pool.RangeEnd, pool.Netmask, leaseTime) + } + + if pool.Router != "" { + fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:router,%s\n", tag, pool.Router) + } + + if len(pool.DNS) > 0 { + fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:dns-server,%s\n", + tag, strings.Join(pool.DNS, ",")) + } + + sb.WriteString("\n") + } + + return os.WriteFile(ConfigFile, []byte(sb.String()), 0644) +} + +// WriteConfigsWithBindings generates dnsmasq config including static host reservations. +func WriteConfigsWithBindings(cfg *Config, bindings []StaticBinding) error { + if err := os.MkdirAll("/etc/dnsmasq.d", 0755); err != nil { + return fmt.Errorf("mkdir /etc/dnsmasq.d: %w", err) + } + + var sb strings.Builder + sb.WriteString("# Generated by alpine-router — do not edit manually\n") + sb.WriteString("# dnsmasq running in DHCP-only mode (DNS disabled)\n\n") + sb.WriteString("port=0\n") + sb.WriteString("bind-interfaces\n") + sb.WriteString("\n") + + for _, pool := range cfg.Pools { + if !pool.Enabled { + continue + } + + leaseTime := pool.LeaseTime + if leaseTime <= 0 { + leaseTime = 86400 + } + + tag := pool.Interface + + fmt.Fprintf(&sb, "# Pool: %s\n", pool.Interface) + fmt.Fprintf(&sb, "interface=%s\n", pool.Interface) + + if pool.RangeStart != "" && pool.RangeEnd != "" { + fmt.Fprintf(&sb, "dhcp-range=set:%s,%s,%s,%s,%ds\n", + tag, pool.RangeStart, pool.RangeEnd, pool.Netmask, leaseTime) + } + + if pool.Router != "" { + fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:router,%s\n", tag, pool.Router) + } + + if len(pool.DNS) > 0 { + fmt.Fprintf(&sb, "dhcp-option=tag:%s,option:dns-server,%s\n", + tag, strings.Join(pool.DNS, ",")) + } + + sb.WriteString("\n") + } + + if len(bindings) > 0 { + sb.WriteString("# Static host reservations\n") + for _, b := range bindings { + if b.MAC == "" || b.IP == "" { + continue + } + if b.Host != "" { + fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s,infinite\n", b.MAC, b.IP, b.Host) + } else { + fmt.Fprintf(&sb, "dhcp-host=%s,%s,infinite\n", b.MAC, b.IP) + } + } + sb.WriteString("\n") + } + + return os.WriteFile(ConfigFile, []byte(sb.String()), 0644) +} + +// ServiceStatus returns true if dnsmasq is running. +func ServiceStatus() bool { + return exec.Command("rc-service", "dnsmasq", "status").Run() == nil +} + +// ServiceRestart restarts the dnsmasq service. +func ServiceRestart() error { + out, err := exec.Command("rc-service", "dnsmasq", "restart").CombinedOutput() + if err != nil { + return fmt.Errorf("restart dnsmasq: %s", strings.TrimSpace(string(out))) + } + return nil +} + +// ServiceStop stops the dnsmasq service. +func ServiceStop() error { + out, err := exec.Command("rc-service", "dnsmasq", "stop").CombinedOutput() + if err != nil { + return fmt.Errorf("stop dnsmasq: %s", strings.TrimSpace(string(out))) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2f89263 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module alpine-router + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/api.go b/handlers/api.go new file mode 100644 index 0000000..3552f27 --- /dev/null +++ b/handlers/api.go @@ -0,0 +1,244 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "alpine-router/config" + "alpine-router/network" +) + +type apiResp struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func ok(w http.ResponseWriter, data interface{}) { + writeJSON(w, http.StatusOK, apiResp{Success: true, Data: data}) +} + +func fail(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, apiResp{Error: msg}) +} + +func HandleInterfaces(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + names, err := network.GetInterfaces() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + fileCfg, _ := network.ParseConfig() + + type iface struct { + *network.InterfaceStats + Pending bool `json:"pending"` + } + + result := make([]iface, 0, len(names)) + for _, name := range names { + s, err := network.GetInterfaceStats(name) + if err != nil { + continue + } + if cfg, ok := fileCfg[name]; ok { + s.Mode = cfg.Mode + } + _, hasPending := network.GetPendingConfig(name), network.GetPendingConfig(name) != nil + result = append(result, iface{s, hasPending}) + } + + ok(w, result) +} + +func HandleInterfaceSingle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + name := strings.TrimPrefix(r.URL.Path, "/api/interfaces/") + s, err := network.GetInterfaceStats(name) + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + ok(w, s) +} + +func HandleInterfaceAction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + suffix := strings.TrimPrefix(r.URL.Path, "/api/interfaces/") + parts := strings.SplitN(suffix, "/", 2) + if len(parts) != 2 { + fail(w, http.StatusBadRequest, "invalid path") + return + } + name, action := parts[0], parts[1] + + var err error + switch action { + case "up": + err = network.IfUp(name) + case "down": + err = network.IfDown(name) + case "restart": + err = network.IfRestart(name) + default: + fail(w, http.StatusBadRequest, "unknown action: "+action) + return + } + + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + ok(w, map[string]string{"message": action + " ok"}) +} + +func HandleConfig(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/api/config/") + if name == "" { + fail(w, http.StatusBadRequest, "interface name required") + return + } + + switch r.Method { + case http.MethodGet: + if cfg := network.GetPendingConfig(name); cfg != nil { + ok(w, map[string]interface{}{"config": cfg, "pending": true}) + return + } + fileCfg, err := network.ParseConfig() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + if cfg, exists := fileCfg[name]; exists { + ok(w, map[string]interface{}{"config": cfg, "pending": false}) + } else { + ok(w, map[string]interface{}{ + "config": &network.InterfaceConfig{Name: name, Auto: true, Mode: "dhcp", Extra: map[string]string{}}, + "pending": false, + }) + } + + case http.MethodPost: + var cfg network.InterfaceConfig + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + cfg.Name = name + if cfg.Extra == nil { + cfg.Extra = map[string]string{} + } + network.SetPendingConfig(&cfg) + + appCfg, err := config.Load() + if err != nil { + fail(w, http.StatusInternalServerError, "load config: "+err.Error()) + return + } + if appCfg.Interfaces == nil { + appCfg.Interfaces = map[string]*config.InterfaceConfig{} + } + appCfg.Interfaces[name] = &config.InterfaceConfig{ + Auto: cfg.Auto, + Mode: cfg.Mode, + Address: cfg.Address, + Netmask: cfg.Netmask, + Gateway: cfg.Gateway, + DNS: cfg.DNS, + Extra: cfg.Extra, + } + if err := config.Save(appCfg); err != nil { + fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error()) + return + } + + ok(w, map[string]string{"message": "saved as pending"}) + + case http.MethodDelete: + network.ClearPendingConfig(name) + ok(w, map[string]string{"message": "pending cleared"}) + + default: + fail(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func HandlePending(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + p := network.GetAllPending() + names := make([]string, 0, len(p)) + for n := range p { + names = append(names, n) + } + ok(w, names) +} + +func HandleApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + errs := network.ApplyPending() + if len(errs) > 0 { + msgs := map[string]string{} + for k, e := range errs { + msgs[k] = e.Error() + } + writeJSON(w, http.StatusInternalServerError, apiResp{Error: "partial failure", Data: msgs}) + return + } + ok(w, map[string]string{"message": "applied"}) +} + +func HandleConfigYAML(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + data, err := config.Load() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + ok(w, data) + + case http.MethodPut: + var newCfg config.AppConfig + if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + config.EnsureDefaults(&newCfg) + if err := config.Save(&newCfg); err != nil { + fail(w, http.StatusInternalServerError, "save: "+err.Error()) + return + } + ok(w, map[string]string{"message": "config updated"}) + + default: + fail(w, http.StatusMethodNotAllowed, "method not allowed") + } +} \ No newline at end of file diff --git a/handlers/clients.go b/handlers/clients.go new file mode 100644 index 0000000..c4848dd --- /dev/null +++ b/handlers/clients.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "alpine-router/clients" + "alpine-router/config" + "alpine-router/dhcp" + "alpine-router/nat" +) + +func HandleClients(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + list, err := clients.GetAll() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + ok(w, list) +} + +func HandleClientUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + mac := strings.TrimPrefix(r.URL.Path, "/api/clients/update/") + if mac == "" { + fail(w, http.StatusBadRequest, "mac address required") + return + } + + var req struct { + Hostname string `json:"hostname"` + Blocked bool `json:"blocked"` + StaticIP string `json:"static_ip"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + + if err := updateClient(mac, req.Hostname, req.Blocked, req.StaticIP); err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + go applyBlockedFirewall() + go applyDHCPStaticBindings() + ok(w, map[string]string{"message": "updated"}) +} + +func updateClient(mac, hostname string, blocked bool, staticIP string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + found := false + for i := range cfg.KnownDevices { + if cfg.KnownDevices[i].MAC == mac { + cfg.KnownDevices[i].Blocked = blocked + cfg.KnownDevices[i].Hostname = hostname + cfg.KnownDevices[i].StaticIP = staticIP + found = true + break + } + } + + if !found { + cfg.KnownDevices = append(cfg.KnownDevices, config.KnownDevice{ + MAC: mac, + Hostname: hostname, + Blocked: blocked, + StaticIP: staticIP, + }) + } + + return config.Save(cfg) +} + +func applyBlockedFirewall() { + if !nat.IsInstalled() { + return + } + + cfg, err := config.Load() + if err != nil { + log.Printf("Warning: load config for blocked firewall: %v", err) + return + } + + var blockedIPs []string + for _, kd := range cfg.KnownDevices { + if kd.Blocked { + ip := kd.IP + if kd.StaticIP != "" { + ip = kd.StaticIP + } + if ip != "" { + blockedIPs = append(blockedIPs, ip) + } + } + } + + natCfg := &nat.Config{Interfaces: cfg.NAT.Interfaces} + if err := nat.ApplyRulesWithBlocked(natCfg, blockedIPs); err != nil { + log.Printf("Warning: apply blocked firewall rules: %v", err) + } else { + log.Printf("Applied firewall rules (%d blocked clients)", len(blockedIPs)) + } +} + +func applyDHCPStaticBindings() { + if !dhcp.IsInstalled() { + return + } + + cfg, err := config.Load() + if err != nil { + log.Printf("Warning: load config for DHCP static bindings: %v", err) + return + } + + var bindings []dhcp.StaticBinding + for _, kd := range cfg.KnownDevices { + if kd.StaticIP != "" && kd.MAC != "" { + bindings = append(bindings, dhcp.StaticBinding{ + MAC: kd.MAC, + Host: kd.Hostname, + IP: kd.StaticIP, + }) + } + } + + dhcpCfg := &dhcp.Config{ + Enabled: cfg.DHCP.Enabled, + Pools: make([]dhcp.Pool, len(cfg.DHCP.Pools)), + } + for i, p := range cfg.DHCP.Pools { + dhcpCfg.Pools[i] = dhcp.Pool{ + Interface: p.Interface, + Enabled: p.Enabled, + Subnet: p.Subnet, + Netmask: p.Netmask, + RangeStart: p.RangeStart, + RangeEnd: p.RangeEnd, + Router: p.Router, + DNS: p.DNS, + LeaseTime: p.LeaseTime, + } + } + + if err := dhcp.WriteConfigsWithBindings(dhcpCfg, bindings); err != nil { + log.Printf("Warning: write dnsmasq config with static bindings: %v", err) + return + } + + if dhcpCfg.Enabled { + if err := dhcp.ServiceRestart(); err != nil { + log.Printf("Warning: restart dnsmasq after static binding update: %v", err) + } else { + log.Printf("dnsmasq restarted with %d static bindings", len(bindings)) + } + } +} diff --git a/handlers/dhcp.go b/handlers/dhcp.go new file mode 100644 index 0000000..53e1c7a --- /dev/null +++ b/handlers/dhcp.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "alpine-router/config" + "alpine-router/dhcp" + "alpine-router/network" +) + +func HandleDHCPStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + installed := dhcp.IsInstalled() + running := false + if installed { + running = dhcp.ServiceStatus() + } + + ok(w, map[string]interface{}{ + "installed": installed, + "running": running, + }) +} + +type ifaceInfo struct { + Name string `json:"name"` + IPv4 string `json:"ipv4"` + Netmask string `json:"ipv4_mask"` + HasGW bool `json:"has_gateway"` +} + +func HandleDHCPConfigGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + cfg, err := dhcp.Load() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + names, _ := network.GetInterfaces() + fileCfg, _ := network.ParseConfig() + + ifaces := []ifaceInfo{} + for _, name := range names { + if name == "lo" { + continue + } + s, err := network.GetInterfaceStats(name) + if err != nil { + continue + } + hasGW := s.Gateway != "" + if ncfg, exists := fileCfg[name]; exists && ncfg.Gateway != "" { + hasGW = true + } + ifaces = append(ifaces, ifaceInfo{ + Name: name, + IPv4: s.IPv4, + Netmask: s.IPv4Mask, + HasGW: hasGW, + }) + } + + ok(w, map[string]interface{}{ + "config": cfg, + "interfaces": ifaces, + }) +} + +func HandleDHCPConfigSave(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var cfg dhcp.Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + if cfg.Pools == nil { + cfg.Pools = []dhcp.Pool{} + } + if err := dhcp.Save(&cfg); err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + appCfg, err := config.Load() + if err != nil { + fail(w, http.StatusInternalServerError, "load config.yaml: "+err.Error()) + return + } + appCfg.DHCP.Enabled = cfg.Enabled + appCfg.DHCP.Pools = make([]config.DHCPPool, len(cfg.Pools)) + for i, p := range cfg.Pools { + appCfg.DHCP.Pools[i] = config.DHCPPool{ + Interface: p.Interface, + Enabled: p.Enabled, + Subnet: p.Subnet, + Netmask: p.Netmask, + RangeStart: p.RangeStart, + RangeEnd: p.RangeEnd, + Router: p.Router, + DNS: p.DNS, + LeaseTime: p.LeaseTime, + } + } + if err := config.Save(appCfg); err != nil { + fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error()) + return + } + + ok(w, map[string]string{"message": "saved"}) +} + +func HandleDHCPApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + if !dhcp.IsInstalled() { + fail(w, http.StatusBadRequest, "dnsmasq не установлен — выполните: apk add dnsmasq") + return + } + + cfg, err := dhcp.Load() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := dhcp.WriteConfigs(cfg); err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + if cfg.Enabled { + if err := dhcp.ServiceRestart(); err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + } else { + _ = dhcp.ServiceStop() + } + + ok(w, map[string]string{"message": "applied"}) +} \ No newline at end of file diff --git a/handlers/mihomo.go b/handlers/mihomo.go new file mode 100644 index 0000000..cbee995 --- /dev/null +++ b/handlers/mihomo.go @@ -0,0 +1,200 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "alpine-router/mihomo" +) + +func HandleMihomoStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + ok(w, mihomo.Status()) +} + +func HandleMihomoStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if err := mihomo.Start(); err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + ok(w, map[string]string{"message": "mihomo started"}) +} + +func HandleMihomoStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if err := mihomo.Stop(); err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + ok(w, map[string]string{"message": "mihomo stopped"}) +} + +func HandleMihomoRestart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if err := mihomo.Restart(); err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + ok(w, map[string]string{"message": "mihomo restarted"}) +} + +func HandleMihomoConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cfg, err := mihomo.LoadConfig() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + ok(w, cfg) + + case http.MethodPut: + var cfg map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + if err := mihomo.SaveConfig(cfg); err != nil { + fail(w, http.StatusInternalServerError, "save config: "+err.Error()) + return + } + ok(w, map[string]string{"message": "config saved"}) + + default: + fail(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func HandleMihomoLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + ok(w, mihomo.Logs()) +} + +func HandleMihomoConfigYAML(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + data, err := os.ReadFile(mihomo.ConfigPath()) + if err != nil { + if os.IsNotExist(err) { + w.WriteHeader(http.StatusNotFound) + return + } + fail(w, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set("Content-Type", "text/yaml; charset=utf-8") + w.Write(data) + + case http.MethodPut: + data, err := io.ReadAll(r.Body) + if err != nil { + fail(w, http.StatusBadRequest, "read body: "+err.Error()) + return + } + if err := os.MkdirAll(mihomo.DataDir(), 0755); err != nil { + fail(w, http.StatusInternalServerError, "mkdir: "+err.Error()) + return + } + tmp := mihomo.ConfigPath() + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + fail(w, http.StatusInternalServerError, "write: "+err.Error()) + return + } + if err := os.Rename(tmp, mihomo.ConfigPath()); err != nil { + fail(w, http.StatusInternalServerError, "rename: "+err.Error()) + return + } + ok(w, map[string]string{"message": "config.yaml updated"}) + + default: + fail(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func HandleMihomoUploadCore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + if err := r.ParseMultipartForm(64 << 20); err != nil { + fail(w, http.StatusBadRequest, "parse form: "+err.Error()) + return + } + + file, header, err := r.FormFile("core") + if err != nil { + fail(w, http.StatusBadRequest, "file required: "+err.Error()) + return + } + defer file.Close() + + name := header.Filename + for _, arch := range []string{"amd64", "arm64", "armv7"} { + if strings.Contains(name, arch) { + dstPath := filepath.Join(mihomo.CoresDir(), fmt.Sprintf("mihomo-linux-%s", arch)) + if err := os.MkdirAll(mihomo.CoresDir(), 0755); err != nil { + fail(w, http.StatusInternalServerError, "mkdir: "+err.Error()) + return + } + dst, err := os.Create(dstPath) + if err != nil { + fail(w, http.StatusInternalServerError, "create: "+err.Error()) + return + } + defer dst.Close() + if _, err := io.Copy(dst, file); err != nil { + fail(w, http.StatusInternalServerError, "write: "+err.Error()) + return + } + if err := os.Chmod(dstPath, 0755); err != nil { + fail(w, http.StatusInternalServerError, "chmod: "+err.Error()) + return + } + ok(w, map[string]string{"message": "core uploaded", "arch": arch}) + return + } + } + + dstPath := filepath.Join(mihomo.CoresDir(), "mihomo-linux-amd64") + if err := os.MkdirAll(mihomo.CoresDir(), 0755); err != nil { + fail(w, http.StatusInternalServerError, "mkdir: "+err.Error()) + return + } + dst, err := os.Create(dstPath) + if err != nil { + fail(w, http.StatusInternalServerError, "create: "+err.Error()) + return + } + defer dst.Close() + if _, err := io.Copy(dst, file); err != nil { + fail(w, http.StatusInternalServerError, "write: "+err.Error()) + return + } + if err := os.Chmod(dstPath, 0755); err != nil { + fail(w, http.StatusInternalServerError, "chmod: "+err.Error()) + return + } + ok(w, map[string]string{"message": "core uploaded", "path": dstPath}) +} \ No newline at end of file diff --git a/handlers/nat.go b/handlers/nat.go new file mode 100644 index 0000000..cd49162 --- /dev/null +++ b/handlers/nat.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "alpine-router/config" + "alpine-router/nat" +) + +func HandleNATGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + cfg, err := nat.Load() + if err != nil { + fail(w, http.StatusInternalServerError, err.Error()) + return + } + + ok(w, map[string]interface{}{ + "installed": nat.IsInstalled(), + "interfaces": cfg.Interfaces, + }) +} + +func HandleNATSave(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var cfg nat.Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + fail(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + if cfg.Interfaces == nil { + cfg.Interfaces = []string{} + } + + if !nat.IsInstalled() { + fail(w, http.StatusServiceUnavailable, "nftables (nft) не установлен — выполните: apk add nftables") + return + } + + if err := nat.Save(&cfg); err != nil { + fail(w, http.StatusInternalServerError, "save: "+err.Error()) + return + } + + appCfg, err := config.Load() + if err != nil { + fail(w, http.StatusInternalServerError, "load config.yaml: "+err.Error()) + return + } + appCfg.NAT.Interfaces = cfg.Interfaces + if err := config.Save(appCfg); err != nil { + fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error()) + return + } + + if err := nat.ApplyRules(&cfg); err != nil { + fail(w, http.StatusInternalServerError, "apply: "+err.Error()) + return + } + + ok(w, map[string]string{"message": "nat applied"}) +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..4111f85 --- /dev/null +++ b/main.go @@ -0,0 +1,287 @@ +package main + +import ( + "embed" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "alpine-router/config" + "alpine-router/dhcp" + "alpine-router/handlers" + "alpine-router/mihomo" + "alpine-router/nat" + "alpine-router/network" + "alpine-router/traffic" +) + +//go:embed public +var publicFS embed.FS + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config.yaml: %v", err) + } + + firstRun := len(cfg.Interfaces) == 0 && len(cfg.NAT.Interfaces) == 0 && len(cfg.DHCP.Pools) == 0 + + mihomo.SetConfigDir(filepath.Join(filepath.Dir(config.GetPath()), "mihomo")) + + if err := mihomo.EnsureDefaultConfig(); err != nil { + log.Printf("Warning: ensure default mihomo config: %v", err) + } + + if firstRun { + log.Printf("First run — importing current system state into %s", config.GetPath()) + cfg = importSystemState() + if err := config.Save(cfg); err != nil { + log.Printf("Warning: save initial config.yaml: %v", err) + } else { + log.Printf("Saved initial config.yaml with %d interfaces, %d NAT, %d DHCP pools", + len(cfg.Interfaces), len(cfg.NAT.Interfaces), len(cfg.DHCP.Pools)) + } + } else { + applyConfig(cfg) + } + + mux := http.NewServeMux() + + mux.HandleFunc("/api/interfaces", handlers.HandleInterfaces) + mux.HandleFunc("/api/interfaces/", func(w http.ResponseWriter, r *http.Request) { + suffix := strings.TrimPrefix(r.URL.Path, "/api/interfaces/") + if strings.Contains(suffix, "/") { + handlers.HandleInterfaceAction(w, r) + } else { + handlers.HandleInterfaceSingle(w, r) + } + }) + mux.HandleFunc("/api/config/", handlers.HandleConfig) + mux.HandleFunc("/api/apply", handlers.HandleApply) + mux.HandleFunc("/api/pending", handlers.HandlePending) + + mux.HandleFunc("/api/clients", handlers.HandleClients) + mux.HandleFunc("/api/clients/update/", handlers.HandleClientUpdate) + + mux.HandleFunc("/api/config.yaml", handlers.HandleConfigYAML) + + mux.HandleFunc("/api/nat", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + handlers.HandleNATGet(w, r) + case "POST": + handlers.HandleNATSave(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + mux.HandleFunc("/api/dhcp/status", handlers.HandleDHCPStatus) + mux.HandleFunc("/api/dhcp/config", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + handlers.HandleDHCPConfigGet(w, r) + case "POST": + handlers.HandleDHCPConfigSave(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/dhcp/apply", handlers.HandleDHCPApply) + + mux.HandleFunc("/api/mihomo/status", handlers.HandleMihomoStatus) + mux.HandleFunc("/api/mihomo/start", handlers.HandleMihomoStart) + mux.HandleFunc("/api/mihomo/stop", handlers.HandleMihomoStop) + mux.HandleFunc("/api/mihomo/restart", handlers.HandleMihomoRestart) + mux.HandleFunc("/api/mihomo/config", handlers.HandleMihomoConfig) + mux.HandleFunc("/api/mihomo/config.yaml", handlers.HandleMihomoConfigYAML) + mux.HandleFunc("/api/mihomo/logs", handlers.HandleMihomoLogs) + mux.HandleFunc("/api/mihomo/upload-core", handlers.HandleMihomoUploadCore) + + sub, err := fs.Sub(publicFS, "public") + if err != nil { + log.Fatal(err) + } + mux.Handle("/", http.FileServer(http.FS(sub))) + + port := "8080" + if p := os.Getenv("PORT"); p != "" { + port = p + } + + traffic.Start() + + 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)) +} + +func importSystemState() *config.AppConfig { + cfg := &config.AppConfig{ + Interfaces: map[string]*config.InterfaceConfig{}, + NAT: config.NATConfig{Interfaces: []string{}}, + DHCP: config.DHCPConfig{Pools: []config.DHCPPool{}}, + KnownDevices: []config.KnownDevice{}, + } + + ifaceConfigs, err := network.ParseConfig() + if err != nil { + log.Printf("Warning: parse /etc/network/interfaces: %v", err) + } else { + for name, ic := range ifaceConfigs { + cfg.Interfaces[name] = &config.InterfaceConfig{ + Auto: ic.Auto, + Mode: ic.Mode, + Address: ic.Address, + Netmask: ic.Netmask, + Gateway: ic.Gateway, + DNS: ic.DNS, + Extra: ic.Extra, + } + } + log.Printf("Imported %d interfaces from /etc/network/interfaces", len(cfg.Interfaces)) + } + + if nat.IsInstalled() { + natCfg, err := nat.Load() + if err != nil { + log.Printf("Warning: load NAT state: %v", err) + } else { + cfg.NAT.Interfaces = natCfg.Interfaces + log.Printf("Imported %d NAT interfaces", len(cfg.NAT.Interfaces)) + } + } + + if dhcp.IsInstalled() { + dhcpCfg, err := dhcp.Load() + if err != nil { + log.Printf("Warning: load DHCP state: %v", err) + } else { + cfg.DHCP.Enabled = dhcpCfg.Enabled + for _, p := range dhcpCfg.Pools { + cfg.DHCP.Pools = append(cfg.DHCP.Pools, config.DHCPPool{ + Interface: p.Interface, + Enabled: p.Enabled, + Subnet: p.Subnet, + Netmask: p.Netmask, + RangeStart: p.RangeStart, + RangeEnd: p.RangeEnd, + Router: p.Router, + DNS: p.DNS, + LeaseTime: p.LeaseTime, + }) + } + log.Printf("Imported DHCP config (enabled=%v, %d pools)", cfg.DHCP.Enabled, len(cfg.DHCP.Pools)) + } + } + + return cfg +} + +func applyConfig(cfg *config.AppConfig) { + if len(cfg.Interfaces) > 0 { + curConfigs, err := network.ParseConfig() + if err != nil { + log.Printf("Warning: parse current interfaces: %v", err) + curConfigs = map[string]*network.InterfaceConfig{} + } + for name, iface := range cfg.Interfaces { + ic := &network.InterfaceConfig{ + Name: name, + Auto: iface.Auto, + Mode: iface.Mode, + Address: iface.Address, + Netmask: iface.Netmask, + Gateway: iface.Gateway, + DNS: iface.DNS, + Extra: iface.Extra, + } + curConfigs[name] = ic + } + if err := network.WriteConfig(curConfigs); err != nil { + log.Printf("Warning: write interfaces: %v", err) + } else { + log.Printf("Applied %d interface configs", len(cfg.Interfaces)) + } + } + + if nat.IsInstalled() { + natCfg := &nat.Config{Interfaces: cfg.NAT.Interfaces} + if err := nat.Save(natCfg); err != nil { + log.Printf("Warning: save NAT state: %v", err) + } + + var blockedIPs []string + for _, kd := range cfg.KnownDevices { + if kd.Blocked { + ip := kd.IP + if kd.StaticIP != "" { + ip = kd.StaticIP + } + if ip != "" { + blockedIPs = append(blockedIPs, ip) + } + } + } + + if err := nat.ApplyRulesWithBlocked(natCfg, blockedIPs); err != nil { + log.Printf("Warning: apply NAT: %v", err) + } else { + log.Printf("NAT rules applied (%d interfaces, %d blocked clients)", len(cfg.NAT.Interfaces), len(blockedIPs)) + } + } else { + log.Printf("nftables not installed — NAT unavailable (install with: apk add nftables)") + } + + if dhcp.IsInstalled() { + dhcpCfg := &dhcp.Config{ + Enabled: cfg.DHCP.Enabled, + Pools: make([]dhcp.Pool, len(cfg.DHCP.Pools)), + } + for i, p := range cfg.DHCP.Pools { + dhcpCfg.Pools[i] = dhcp.Pool{ + Interface: p.Interface, + Enabled: p.Enabled, + Subnet: p.Subnet, + Netmask: p.Netmask, + RangeStart: p.RangeStart, + RangeEnd: p.RangeEnd, + Router: p.Router, + DNS: p.DNS, + LeaseTime: p.LeaseTime, + } + } + + var dhcpBindings []dhcp.StaticBinding + for _, kd := range cfg.KnownDevices { + if kd.StaticIP != "" && kd.MAC != "" { + dhcpBindings = append(dhcpBindings, dhcp.StaticBinding{ + MAC: kd.MAC, + Host: kd.Hostname, + IP: kd.StaticIP, + }) + } + } + + if err := dhcp.Save(dhcpCfg); err != nil { + log.Printf("Warning: save DHCP state: %v", err) + } + if err := dhcp.WriteConfigsWithBindings(dhcpCfg, dhcpBindings); err != nil { + log.Printf("Warning: write dnsmasq config: %v", err) + } else { + log.Printf("DHCP config written (%d pools, %d static bindings)", len(dhcpCfg.Pools), len(dhcpBindings)) + } + if dhcpCfg.Enabled { + if err := dhcp.ServiceRestart(); err != nil { + log.Printf("Warning: start dnsmasq: %v", err) + } else { + log.Printf("dnsmasq started") + } + } + } else { + log.Printf("dnsmasq not installed — DHCP unavailable (install with: apk add dnsmasq)") + } +} \ No newline at end of file diff --git a/mihomo-linux-amd64-v1.19.23 b/mihomo-linux-amd64-v1.19.23 new file mode 100644 index 0000000..f6d85f4 Binary files /dev/null and b/mihomo-linux-amd64-v1.19.23 differ diff --git a/mihomo/default.yaml b/mihomo/default.yaml new file mode 100644 index 0000000..cbdcd93 --- /dev/null +++ b/mihomo/default.yaml @@ -0,0 +1,35 @@ +mixed-port: 7890 +allow-lan: true +bind-address: "*" +mode: rule +log-level: info +ipv6: true +external-controller: 0.0.0.0:9090 +tcp-concurrent: true +find-process-mode: "off" +dns: + enable: true + ipv6: true + listen: 0.0.0.0:53 + enhanced-mode: redir-host + fake-ip-range: 198.18.0.1/16 + fake-ip-filter: + - "*.lan" + - "*.local" + - "+.market.xiaomi.com" + default-nameserver: + - 223.5.5.5 + - 119.29.29.29 + nameserver: + - https://doh.pub/dns-query + - https://dns.alidns.com/dns-query + fallback: + - tls://8.8.8.8:853 + - tls://1.1.1.1:853 +proxies: [] +proxy-groups: [] +rules: + - MATCH,DIRECT +profile: + store-selected: true + store-fake-ip: true \ No newline at end of file diff --git a/mihomo/mihomo.go b/mihomo/mihomo.go new file mode 100644 index 0000000..d4414eb --- /dev/null +++ b/mihomo/mihomo.go @@ -0,0 +1,322 @@ +package mihomo + +import ( + "embed" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +var ( + mu sync.Mutex + process *os.Process + running bool + configDir string + + logMu sync.Mutex + logRing [500]string + logLen int + logPos int +) + +//go:embed default.yaml +var defaultConfigFS embed.FS + +func init() { + configDir = defaultConfigDir() +} + +func defaultConfigDir() string { + exe, err := os.Executable() + if err != nil { + return "/lib/mihomo" + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + base := filepath.Dir(exe) + if base == "/usr/bin" || base == "/usr/sbin" || base == "/sbin" || base == "/bin" { + return "/lib/mihomo" + } + return filepath.Join(base, "mihomo") +} + +func SetConfigDir(dir string) { + mu.Lock() + defer mu.Unlock() + configDir = dir +} + +func ConfigDir() string { + return configDir +} + +func DataDir() string { + return filepath.Join(ConfigDir(), "data") +} + +func CoresDir() string { + return filepath.Join(ConfigDir(), "cores") +} + +func ConfigPath() string { + return filepath.Join(DataDir(), "config.yaml") +} + +func CorePath() string { + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "amd64" + case "arm64": + arch = "arm64" + case "arm": + arch = "armv7" + default: + arch = "amd64" + } + return filepath.Join(CoresDir(), fmt.Sprintf("mihomo-linux-%s", arch)) +} + +func EnsureDefaultConfig() error { + cfgPath := ConfigPath() + if _, err := os.Stat(cfgPath); err == nil { + return nil + } + if err := os.MkdirAll(DataDir(), 0755); err != nil { + return fmt.Errorf("mkdir mihomo data dir: %w", err) + } + data, err := defaultConfigFS.ReadFile("default.yaml") + if err != nil { + return fmt.Errorf("read default config: %w", err) + } + tmp := cfgPath + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return fmt.Errorf("write default config: %w", err) + } + if err := os.Rename(tmp, cfgPath); err != nil { + return fmt.Errorf("rename default config: %w", err) + } + return nil +} + +func appendLog(line string) { + logMu.Lock() + defer logMu.Unlock() + logRing[logPos%len(logRing)] = line + logPos++ + if logLen < len(logRing) { + logLen++ + } +} + +func Logs() []string { + logMu.Lock() + defer logMu.Unlock() + result := make([]string, 0, logLen) + start := logPos - logLen + for i := start; i < logPos; i++ { + result = append(result, logRing[i%len(logRing)]) + } + return result +} + +type lineWriter struct{} + +func (lineWriter) Write(p []byte) (int, error) { + start := 0 + for i, b := range p { + if b == '\n' { + line := string(p[start:i]) + if line != "" { + appendLog(line) + } + start = i + 1 + } + } + if start < len(p) { + line := string(p[start:]) + if line != "" { + appendLog(line) + } + } + return len(p), nil +} + +func Status() map[string]interface{} { + mu.Lock() + defer mu.Unlock() + corePath := CorePath() + _, err := os.Stat(corePath) + coreExists := err == nil + + _, cfgErr := os.Stat(ConfigPath()) + cfgExists := cfgErr == nil + + status := map[string]interface{}{ + "running": running, + "core_exists": coreExists, + "core_path": corePath, + "config_dir": DataDir(), + "config_file": ConfigPath(), + "config_exists": cfgExists, + } + + if running && process != nil { + status["pid"] = process.Pid + } + + return status +} + +func Start() error { + mu.Lock() + defer mu.Unlock() + + if running { + return fmt.Errorf("mihomo is already running") + } + + corePath := CorePath() + if _, err := os.Stat(corePath); err != nil { + return fmt.Errorf("mihomo core not found at %s: %w", corePath, err) + } + + cfgPath := ConfigPath() + if _, err := os.Stat(cfgPath); err != nil { + return fmt.Errorf("mihomo config not found at %s: %w", cfgPath, err) + } + + cmd := exec.Command(corePath, "-d", DataDir()) + lw := lineWriter{} + w := io.MultiWriter(os.Stdout, lw) + cmd.Stdout = w + cmd.Stderr = io.MultiWriter(os.Stderr, lw) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start mihomo: %w", err) + } + + process = cmd.Process + running = true + + go func() { + err := cmd.Wait() + mu.Lock() + running = false + process = nil + mu.Unlock() + if err != nil { + fmt.Fprintf(os.Stderr, "mihomo process exited: %v\n", err) + } + }() + + time.Sleep(500 * time.Millisecond) + if !running { + return fmt.Errorf("mihomo exited immediately") + } + + return nil +} + +func Stop() error { + mu.Lock() + defer mu.Unlock() + + if !running || process == nil { + running = false + process = nil + return nil + } + + if err := process.Signal(os.Interrupt); err != nil { + _ = process.Kill() + } + running = false + process = nil + return nil +} + +func Restart() error { + if err := Stop(); err != nil { + return err + } + time.Sleep(500 * time.Millisecond) + return Start() +} + +func IsRunning() bool { + mu.Lock() + defer mu.Unlock() + return running +} + +func InstallCore(srcPath string) error { + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "amd64" + case "arm64": + arch = "arm64" + case "arm": + arch = "armv7" + default: + arch = "amd64" + } + dstPath := filepath.Join(CoresDir(), fmt.Sprintf("mihomo-linux-%s", arch)) + + if err := os.MkdirAll(CoresDir(), 0755); err != nil { + return fmt.Errorf("mkdir cores: %w", err) + } + + data, err := os.ReadFile(srcPath) + if err != nil { + return fmt.Errorf("read core source: %w", err) + } + + if err := os.WriteFile(dstPath, data, 0755); err != nil { + return fmt.Errorf("write core: %w", err) + } + + return nil +} + +func LoadConfig() (map[string]interface{}, error) { + data, err := os.ReadFile(ConfigPath()) + if err != nil { + if os.IsNotExist(err) { + return map[string]interface{}{}, nil + } + return nil, fmt.Errorf("read mihomo config: %w", err) + } + var cfg map[string]interface{} + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse mihomo config: %w", err) + } + return cfg, nil +} + +func SaveConfig(cfg map[string]interface{}) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal mihomo config: %w", err) + } + if err := os.MkdirAll(DataDir(), 0755); err != nil { + return fmt.Errorf("mkdir mihomo data dir: %w", err) + } + tmp := ConfigPath() + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return fmt.Errorf("write mihomo config: %w", err) + } + if err := os.Rename(tmp, ConfigPath()); err != nil { + return fmt.Errorf("rename mihomo config: %w", err) + } + return nil +} \ No newline at end of file diff --git a/nat/nat.go b/nat/nat.go new file mode 100644 index 0000000..20bc690 --- /dev/null +++ b/nat/nat.go @@ -0,0 +1,123 @@ +package nat + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const tableName = "alpine-router-nat" + +// Config holds NAT masquerade settings per interface. +type Config struct { + // Interfaces is the list of LAN interface names for which masquerade is enabled. + // Traffic arriving on these interfaces will be NATted to the outgoing WAN interface. + Interfaces []string `json:"interfaces"` +} + +// configPath returns the path to nat.json next to the running binary. +func configPath() string { + exe, err := os.Executable() + if err != nil { + return filepath.Join("configs", "nat.json") + } + return filepath.Join(filepath.Dir(exe), "configs", "nat.json") +} + +// IsInstalled reports whether the nft (nftables) binary is available. +func IsInstalled() bool { + _, err := exec.LookPath("nft") + return err == nil +} + +// Load reads the NAT config from disk. +// Returns an empty config if the file does not exist yet. +func Load() (*Config, error) { + data, err := os.ReadFile(configPath()) + if err != nil { + if os.IsNotExist(err) { + return &Config{Interfaces: []string{}}, nil + } + return nil, fmt.Errorf("read nat config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse nat config: %w", err) + } + if cfg.Interfaces == nil { + cfg.Interfaces = []string{} + } + return &cfg, nil +} + +// Save writes the NAT config to the configs/ directory next to the binary. +func Save(cfg *Config) error { + p := configPath() + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + return fmt.Errorf("mkdir configs: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0644) +} + +// ApplyRules flushes the existing alpine-router NAT table and recreates it +// from the provided config. Called on every daemon startup and on config save. +// +// nftables is used instead of iptables because it applies all rules atomically +// in a single kernel call, which is faster and avoids partial-state issues. +func ApplyRules(cfg *Config) error { + return ApplyRulesWithBlocked(cfg, nil) +} + +// ApplyRulesWithBlocked is like ApplyRules but also installs drop rules for +// the given list of blocked client IPs. +func ApplyRulesWithBlocked(cfg *Config, blockedIPs []string) error { + if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil { + return fmt.Errorf("enable ip_forward: %w", err) + } + + exec.Command("nft", "delete", "table", "ip", tableName).Run() + + if len(cfg.Interfaces) == 0 && len(blockedIPs) == 0 { + return nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "table ip %s {\n", tableName) + + sb.WriteString(" chain forward {\n") + sb.WriteString(" type filter hook forward priority filter; policy drop;\n") + sb.WriteString(" ct state established,related accept\n") + + for _, ip := range blockedIPs { + fmt.Fprintf(&sb, " ip saddr %s drop\n", ip) + fmt.Fprintf(&sb, " ip daddr %s drop\n", ip) + } + + for _, iface := range cfg.Interfaces { + fmt.Fprintf(&sb, " iifname \"%s\" accept\n", iface) + } + sb.WriteString(" }\n") + + sb.WriteString(" chain postrouting {\n") + sb.WriteString(" type nat hook postrouting priority srcnat; policy accept;\n") + for _, iface := range cfg.Interfaces { + fmt.Fprintf(&sb, " iifname \"%s\" masquerade\n", iface) + } + sb.WriteString(" }\n") + sb.WriteString("}\n") + + cmd := exec.Command("nft", "-f", "-") + cmd.Stdin = strings.NewReader(sb.String()) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("nft apply: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} diff --git a/network/apply.go b/network/apply.go new file mode 100644 index 0000000..7845b1d --- /dev/null +++ b/network/apply.go @@ -0,0 +1,86 @@ +package network + +import ( + "fmt" + "os/exec" + "strings" +) + +// IfDown brings an interface down via ifdown (or ip link set down as fallback). +func IfDown(name string) error { + out, err := exec.Command("ifdown", "--force", name).CombinedOutput() + if err != nil { + // fallback: ip link set down + out2, err2 := exec.Command("ip", "link", "set", name, "down").CombinedOutput() + if err2 != nil { + return fmt.Errorf("ifdown %s: %s\nip fallback: %s", name, strings.TrimSpace(string(out)), strings.TrimSpace(string(out2))) + } + } + return nil +} + +// IfUp brings an interface up via ifup (or ip link set up as fallback). +func IfUp(name string) error { + out, err := exec.Command("ifup", name).CombinedOutput() + if err != nil { + out2, err2 := exec.Command("ip", "link", "set", name, "up").CombinedOutput() + if err2 != nil { + return fmt.Errorf("ifup %s: %s\nip fallback: %s", name, strings.TrimSpace(string(out)), strings.TrimSpace(string(out2))) + } + } + return nil +} + +// IfRestart brings an interface down then up. +func IfRestart(name string) error { + _ = IfDown(name) // ignore "already down" errors + return IfUp(name) +} + +// ApplyPending merges pending configs into /etc/network/interfaces, +// writes the file, and restarts affected interfaces. +// Returns a per-interface error map (nil key = write error). +func ApplyPending() map[string]error { + errs := map[string]error{} + + pending := GetAllPending() + if len(pending) == 0 { + return errs + } + + // Read current file config + configs, err := ParseConfig() + if err != nil { + errs["__parse__"] = err + return errs + } + + // Merge + for name, cfg := range pending { + configs[name] = cfg + } + + // Write file + if err := WriteConfig(configs); err != nil { + errs["__write__"] = err + return errs + } + + // Restart each changed interface + for name := range pending { + if name == "lo" { + ClearPendingConfig(name) + continue + } + _ = IfDown(name) + if cfg := configs[name]; cfg != nil && cfg.Auto { + if err := IfUp(name); err != nil { + errs[name] = err + continue + } + } + ClearPendingConfig(name) + } + + return errs +} diff --git a/network/config.go b/network/config.go new file mode 100644 index 0000000..59fabe6 --- /dev/null +++ b/network/config.go @@ -0,0 +1,195 @@ +package network + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" +) + +const ConfigFile = "/etc/network/interfaces" + +// InterfaceConfig represents one stanza in /etc/network/interfaces. +type InterfaceConfig struct { + Name string `json:"name"` + Auto bool `json:"auto"` + Mode string `json:"mode"` // dhcp, static, loopback, manual + Address string `json:"address,omitempty"` // static only + Netmask string `json:"netmask,omitempty"` + Gateway string `json:"gateway,omitempty"` + DNS []string `json:"dns,omitempty"` + Extra map[string]string `json:"extra,omitempty"` // other raw options +} + +// --- Pending config store (in-memory, not yet written to disk) --- + +var ( + pendingMu sync.Mutex + pending = map[string]*InterfaceConfig{} +) + +func GetPendingConfig(name string) *InterfaceConfig { + pendingMu.Lock() + defer pendingMu.Unlock() + return pending[name] +} + +func SetPendingConfig(cfg *InterfaceConfig) { + pendingMu.Lock() + defer pendingMu.Unlock() + pending[cfg.Name] = cfg +} + +func ClearPendingConfig(name string) { + pendingMu.Lock() + defer pendingMu.Unlock() + delete(pending, name) +} + +func ClearAllPending() { + pendingMu.Lock() + defer pendingMu.Unlock() + pending = map[string]*InterfaceConfig{} +} + +func GetAllPending() map[string]*InterfaceConfig { + pendingMu.Lock() + defer pendingMu.Unlock() + out := make(map[string]*InterfaceConfig, len(pending)) + for k, v := range pending { + cp := *v + out[k] = &cp + } + return out +} + +// --- Parse /etc/network/interfaces --- + +func ParseConfig() (map[string]*InterfaceConfig, error) { + f, err := os.Open(ConfigFile) + if err != nil { + if os.IsNotExist(err) { + return map[string]*InterfaceConfig{}, nil + } + return nil, fmt.Errorf("open %s: %w", ConfigFile, err) + } + defer f.Close() + + configs := map[string]*InterfaceConfig{} + autoSet := map[string]bool{} + var cur *InterfaceConfig + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + // Strip inline comments + if idx := strings.Index(line, "#"); idx >= 0 { + line = line[:idx] + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := strings.Fields(line) + switch fields[0] { + case "auto": + for _, n := range fields[1:] { + autoSet[n] = true + } + + case "iface": + // iface + if len(fields) < 4 { + continue + } + cur = &InterfaceConfig{ + Name: fields[1], + Mode: fields[3], + Extra: map[string]string{}, + } + configs[fields[1]] = cur + + default: + if cur == nil || len(fields) < 2 { + continue + } + val := strings.Join(fields[1:], " ") + switch fields[0] { + case "address": + cur.Address = val + case "netmask": + cur.Netmask = val + case "gateway": + cur.Gateway = val + case "dns-nameservers": + cur.DNS = fields[1:] + default: + cur.Extra[fields[0]] = val + } + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + for name, cfg := range configs { + cfg.Auto = autoSet[name] + } + return configs, nil +} + +// --- Write /etc/network/interfaces --- + +func WriteConfig(configs map[string]*InterfaceConfig) error { + // Backup + if data, err := os.ReadFile(ConfigFile); err == nil { + _ = os.WriteFile(ConfigFile+".bak", data, 0644) + } + + f, err := os.Create(ConfigFile) + if err != nil { + return fmt.Errorf("create %s: %w", ConfigFile, err) + } + defer f.Close() + + // loopback first + if lo, ok := configs["lo"]; ok { + writeStanza(f, lo) + } + for name, cfg := range configs { + if name == "lo" { + continue + } + writeStanza(f, cfg) + } + return nil +} + +func writeStanza(f *os.File, c *InterfaceConfig) { + if c.Auto { + fmt.Fprintf(f, "auto %s\n", c.Name) + } + family := "inet" + fmt.Fprintf(f, "iface %s %s %s\n", c.Name, family, c.Mode) + + if c.Mode == "static" { + if c.Address != "" { + fmt.Fprintf(f, "\taddress %s\n", c.Address) + } + if c.Netmask != "" { + fmt.Fprintf(f, "\tnetmask %s\n", c.Netmask) + } + if c.Gateway != "" { + fmt.Fprintf(f, "\tgateway %s\n", c.Gateway) + } + if len(c.DNS) > 0 { + fmt.Fprintf(f, "\tdns-nameservers %s\n", strings.Join(c.DNS, " ")) + } + } + for k, v := range c.Extra { + fmt.Fprintf(f, "\t%s %s\n", k, v) + } + fmt.Fprintln(f) +} diff --git a/network/interfaces.go b/network/interfaces.go new file mode 100644 index 0000000..3f04f60 --- /dev/null +++ b/network/interfaces.go @@ -0,0 +1,143 @@ +package network + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +type InterfaceStats struct { + Name string `json:"name"` + State string `json:"state"` // up, down, unknown + IPv4 string `json:"ipv4"` + IPv4Mask string `json:"ipv4_mask"` + IPv6 []string `json:"ipv6"` + Gateway string `json:"gateway"` + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + RxPackets uint64 `json:"rx_packets"` + TxPackets uint64 `json:"tx_packets"` + Mode string `json:"mode"` // dhcp, static, loopback, manual, unknown +} + +// GetInterfaces returns all network interface names from /sys/class/net. +func GetInterfaces() ([]string, error) { + entries, err := os.ReadDir("/sys/class/net") + if err != nil { + return nil, fmt.Errorf("read /sys/class/net: %w", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names, nil +} + +// GetInterfaceStats returns current runtime state of an interface. +func GetInterfaceStats(name string) (*InterfaceStats, error) { + s := &InterfaceStats{Name: name, IPv6: []string{}} + + // Operational state + if raw, err := os.ReadFile("/sys/class/net/" + name + "/operstate"); err == nil { + s.State = strings.TrimSpace(string(raw)) + } else { + s.State = "unknown" + } + + // IP addresses + if out, err := exec.Command("ip", "addr", "show", "dev", name).Output(); err == nil { + parseIPAddr(string(out), s) + } + + // Default gateway for this interface + if out, err := exec.Command("ip", "route", "show", "dev", name).Output(); err == nil { + parseRoute(string(out), s) + } + + // Traffic stats from /proc/net/dev + _ = parseNetDev(name, s) + + return s, nil +} + +func parseIPAddr(output string, s *InterfaceStats) { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "inet "): + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + cidr := parts[1] + if slash := strings.Index(cidr, "/"); slash >= 0 { + s.IPv4 = cidr[:slash] + if prefix, err := strconv.Atoi(cidr[slash+1:]); err == nil { + s.IPv4Mask = prefixToMask(prefix) + } + } else { + s.IPv4 = cidr + } + case strings.HasPrefix(line, "inet6 "): + parts := strings.Fields(line) + if len(parts) >= 2 { + s.IPv6 = append(s.IPv6, parts[1]) + } + } + } +} + +func parseRoute(output string, s *InterfaceStats) { + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + // "default via dev ..." + if len(fields) >= 3 && fields[0] == "default" && fields[1] == "via" { + s.Gateway = fields[2] + return + } + } +} + +func parseNetDev(name string, s *InterfaceStats) error { + f, err := os.Open("/proc/net/dev") + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + colon := strings.Index(line, ":") + if colon < 0 { + continue + } + if strings.TrimSpace(line[:colon]) != name { + continue + } + // Fields after colon: + // rx: bytes packets errs drop fifo frame compressed multicast + // tx: bytes packets errs drop fifo colls carrier compressed + fields := strings.Fields(line[colon+1:]) + if len(fields) >= 10 { + s.RxBytes, _ = strconv.ParseUint(fields[0], 10, 64) + s.RxPackets, _ = strconv.ParseUint(fields[1], 10, 64) + s.TxBytes, _ = strconv.ParseUint(fields[8], 10, 64) + s.TxPackets, _ = strconv.ParseUint(fields[9], 10, 64) + } + return nil + } + return scanner.Err() +} + +func prefixToMask(prefix int) string { + if prefix < 0 || prefix > 32 { + return "" + } + mask := ^uint32(0) << uint(32-prefix) + return fmt.Sprintf("%d.%d.%d.%d", + (mask>>24)&0xFF, (mask>>16)&0xFF, (mask>>8)&0xFF, mask&0xFF) +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..652e536 --- /dev/null +++ b/public/app.js @@ -0,0 +1,388 @@ +'use strict'; + +// ── State ──────────────────────────────────────────────────────────────────── + +const state = { + interfaces: [], // latest data from /api/interfaces + pending: [], // interface names with pending config + configModal: null, // name of interface being configured + nat: null, // {installed, interfaces} from /api/nat +}; + +// ── API helpers ────────────────────────────────────────────────────────────── + +async function api(method, path, body) { + const opts = { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }; + 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); +const del = (path) => api('DELETE', path); + +// ── Format helpers ─────────────────────────────────────────────────────────── + +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 stateClass(s) { + if (s === 'up') return 'up'; + if (s === 'down') return 'down'; + return 'unknown'; +} + +function modeLabel(m) { + return { dhcp: 'DHCP', static: 'Static', loopback: 'Loopback', manual: 'Manual' }[m] || (m || '?'); +} + +// ── Render ─────────────────────────────────────────────────────────────────── + +function renderAll() { + const grid = document.getElementById('ifaceGrid'); + grid.innerHTML = ''; + + state.interfaces.forEach(iface => { + grid.appendChild(buildCard(iface)); + }); + + document.getElementById('loading').classList.add('hidden'); + grid.classList.remove('hidden'); + + renderPendingBanner(); +} + +function buildCard(iface) { + const hasPending = state.pending.includes(iface.name); + const sc = stateClass(iface.state); + + const card = document.createElement('div'); + card.className = 'iface-card' + (hasPending ? ' has-pending' : ''); + card.dataset.name = iface.name; + + const ipv6lines = (iface.ipv6 || []).map(a => + `
IPv6${a}
` + ).join(''); + + card.innerHTML = ` +
+
+ + ${iface.name} + ${hasPending ? 'несохранено' : ''} +
+
+ ${modeLabel(iface.mode)} +
+
+ +
+
+ Статус + ${iface.state || 'unknown'} +
+
+ IPv4 + ${iface.ipv4 + ? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '') + : ''} +
+ ${ipv6lines || `
IPv6
`} +
+ Шлюз + ${iface.gateway || ''} +
+ +
+
+ RX + ${fmtBytes(iface.rx_bytes)} +
+
+ TX + ${fmtBytes(iface.tx_bytes)} +
+
+ Пакеты + ${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0} +
+
+
+ +
+ + + + +
+ `; + + return card; +} + +function renderPendingBanner() { + const banner = document.getElementById('pendingBanner'); + const list = document.getElementById('pendingList'); + if (state.pending.length > 0) { + list.textContent = state.pending.join(', '); + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } +} + +// ── Data loading ───────────────────────────────────────────────────────────── + +async function loadAll() { + try { + const [ifaces, pending] = await Promise.all([ + get('/api/interfaces'), + get('/api/pending'), + ]); + state.interfaces = ifaces || []; + state.pending = pending || []; + renderAll(); + } catch (e) { + showToast('Ошибка загрузки: ' + e.message, 'error'); + } +} + +// ── Interface actions ───────────────────────────────────────────────────────── + +async function doAction(name, action) { + const btn = document.querySelector(`[data-action="${action}"][data-iface="${name}"]`); + if (btn) { btn.disabled = true; btn.textContent = '...'; } + + try { + await post(`/api/interfaces/${name}/${action}`); + showToast(`${name}: ${action} выполнено`, 'success'); + await loadAll(); + } catch (e) { + showToast(`${name} ${action}: ${e.message}`, 'error'); + } finally { + if (btn) btn.disabled = false; + } +} + +// ── Config modal ────────────────────────────────────────────────────────────── + +async function openConfig(name) { + state.configModal = name; + document.getElementById('modalTitle').textContent = `Настройка: ${name}`; + + try { + const [{ config, pending }, natData] = await Promise.all([ + get(`/api/config/${name}`), + get('/api/nat').catch(() => null), + ]); + if (natData) state.nat = natData; + fillForm(config, pending, name); + document.getElementById('modal').classList.remove('hidden'); + } catch (e) { + showToast('Ошибка загрузки конфига: ' + e.message, 'error'); + } +} + +function fillForm(cfg, pending, name) { + document.getElementById('cfgAuto').checked = !!cfg.auto; + document.getElementById('cfgAddress').value = cfg.address || ''; + document.getElementById('cfgNetmask').value = cfg.netmask || ''; + document.getElementById('cfgGateway').value = cfg.gateway || ''; + document.getElementById('cfgDNS').value = (cfg.dns || []).join(' '); + + const mode = cfg.mode === 'static' ? 'static' : 'dhcp'; + setMode(mode); + + // Mark pending visually + const title = document.getElementById('modalTitle'); + if (pending) { + title.textContent = `Настройка: ${state.configModal} (несохранённые изменения)`; + } + + // NAT section — show for all non-loopback interfaces + const natSection = document.getElementById('natSection'); + const natNotInstalled = document.getElementById('natNotInstalled'); + const cfgNAT = document.getElementById('cfgNAT'); + + if (cfg.mode === 'loopback' || name === 'lo') { + natSection.classList.add('hidden'); + } else { + natSection.classList.remove('hidden'); + const natInstalled = state.nat?.installed !== false; + cfgNAT.disabled = !natInstalled; + natNotInstalled.classList.toggle('hidden', natInstalled); + cfgNAT.checked = !!(state.nat?.interfaces || []).includes(name); + } +} + +function setMode(mode) { + document.querySelectorAll('.seg-btn').forEach(b => { + b.classList.toggle('active', b.dataset.mode === mode); + }); + document.getElementById('staticFields').classList.toggle('hidden', mode !== 'static'); +} + +function currentMode() { + return document.querySelector('.seg-btn.active')?.dataset.mode ?? 'dhcp'; +} + +function closeModal() { + document.getElementById('modal').classList.add('hidden'); + document.getElementById('configForm').reset(); + state.configModal = null; +} + +async function saveConfig() { + const name = state.configModal; + if (!name) return; + + const mode = currentMode(); + const cfg = { + name, + auto: document.getElementById('cfgAuto').checked, + mode, + address: document.getElementById('cfgAddress').value.trim(), + netmask: document.getElementById('cfgNetmask').value.trim(), + gateway: document.getElementById('cfgGateway').value.trim(), + dns: document.getElementById('cfgDNS').value.trim().split(/\s+/).filter(Boolean), + extra: {}, + }; + + // Basic validation for static + if (mode === 'static' && !cfg.address) { + showToast('Укажите IP-адрес', 'error'); + return; + } + + try { + await post(`/api/config/${name}`, cfg); + + // Save NAT setting if section is visible + const natSection = document.getElementById('natSection'); + if (!natSection.classList.contains('hidden') && state.nat?.installed !== false) { + const natEnabled = document.getElementById('cfgNAT').checked; + const current = state.nat?.interfaces || []; + const updated = natEnabled + ? [...new Set([...current, name])] + : current.filter(i => i !== name); + await post('/api/nat', { interfaces: updated }); + if (state.nat) state.nat.interfaces = updated; + } + + showToast(`${name}: настройки сохранены (ожидают применения)`, 'info'); + closeModal(); + await loadAll(); + } catch (e) { + showToast('Ошибка сохранения: ' + e.message, 'error'); + } +} + +// ── Apply / discard ─────────────────────────────────────────────────────────── + +async function applyAll() { + const btn = document.getElementById('applyBtn'); + btn.disabled = true; + btn.textContent = 'Применяю...'; + try { + await post('/api/apply'); + showToast('Настройки применены', 'success'); + await loadAll(); + } catch (e) { + showToast('Ошибка применения: ' + e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Применить'; + } +} + +async function discardAll() { + for (const name of [...state.pending]) { + try { await del(`/api/config/${name}`); } catch (_) {} + } + state.pending = []; + renderPendingBanner(); + renderAll(); + showToast('Изменения отменены', 'info'); + await loadAll(); +} + +// ── Toast ───────────────────────────────────────────────────────────────────── + +let toastTimer; +function showToast(msg, type = 'info') { + const t = document.getElementById('toast'); + t.textContent = msg; + t.className = `toast ${type}`; + t.classList.remove('hidden'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.add('hidden'), 3500); +} + +// ── Event wiring ────────────────────────────────────────────────────────────── + +document.getElementById('refreshBtn').addEventListener('click', loadAll); + +document.getElementById('applyBtn').addEventListener('click', applyAll); +document.getElementById('discardAllBtn').addEventListener('click', discardAll); + +// Card action buttons (delegated) +document.getElementById('ifaceGrid').addEventListener('click', e => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const { action, iface } = btn.dataset; + if (action === 'config') { + openConfig(iface); + } else { + doAction(iface, action); + } +}); + +// Modal close +document.getElementById('closeModal').addEventListener('click', closeModal); +document.getElementById('cancelConfigBtn').addEventListener('click', closeModal); +document.getElementById('modalBackdrop').addEventListener('click', closeModal); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeModal(); +}); + +// Mode switcher +document.getElementById('modeSwitch').addEventListener('click', e => { + const btn = e.target.closest('.seg-btn'); + if (btn) setMode(btn.dataset.mode); +}); + +// Save config +document.getElementById('saveConfigBtn').addEventListener('click', saveConfig); +document.getElementById('configForm').addEventListener('submit', e => { + e.preventDefault(); + saveConfig(); +}); + +// Auto-refresh every 10 seconds +setInterval(loadAll, 10000); + +// ── Init ────────────────────────────────────────────────────────────────────── + +(async () => { + // Try to get hostname + try { + const res = await fetch('/api/interfaces'); + // hostname from Location header or just skip + } catch (_) {} + + await loadAll(); +})(); diff --git a/public/clients.html b/public/clients.html new file mode 100644 index 0000000..962b19b --- /dev/null +++ b/public/clients.html @@ -0,0 +1,161 @@ + + + + + + Клиенты — AlpineRouter + + + + +
+
+ +

AlpineRouter

+
+
+ +
+
+ + + +
+ +
+
+ +
+ +
+
+ Загрузка... +
+ + + + + +
+ + + + + + + + diff --git a/public/clients.js b/public/clients.js new file mode 100644 index 0000000..6f8c18b --- /dev/null +++ b/public/clients.js @@ -0,0 +1,314 @@ +'use strict'; + +let allClients = []; +let searchQuery = ''; +let editingClient = null; + +async function loadClients() { + try { + const res = await fetch('/api/clients'); + const json = await res.json(); + if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); + allClients = json.data || []; + render(); + } catch (e) { + showToast('Ошибка загрузки: ' + e.message, 'error'); + } +} + +const ONLINE_WINDOW_MS = 5 * 60 * 1000; + +function isOnline(c) { + if (c.last_active) { + return (Date.now() - c.last_active * 1000) < ONLINE_WINDOW_MS; + } + return c.online; +} + +function fmtBytes(n) { + if (!n) return '—'; + const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']; + let i = 0, v = n; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function fmtLease(expiresUnix) { + if (!expiresUnix) return null; + const secs = expiresUnix - Math.floor(Date.now() / 1000); + if (secs <= 0) return { text: 'истекла', cls: 'lease-expired' }; + if (secs < 3600) return { text: `${Math.floor(secs / 60)} мин`, cls: 'lease-soon' }; + if (secs < 86400) return { text: `${Math.floor(secs / 3600)} ч`, cls: '' }; + return { text: `${Math.floor(secs / 86400)} д ${Math.floor((secs % 86400) / 3600)} ч`, cls: '' }; +} + +function fmtLastActive(c) { + const nowSec = Math.floor(Date.now() / 1000); + + if (c.last_active) { + const ago = nowSec - c.last_active; + if (ago < 10) return { text: 'только что', cls: 'active-now' }; + if (ago < 60) return { text: `${ago} с назад`, cls: 'active-now' }; + if (ago < 3600) return { text: `${Math.floor(ago / 60)} мин назад`, cls: ago < ONLINE_WINDOW_MS / 1000 ? 'active-now' : '' }; + if (ago < 86400) return { text: `${Math.floor(ago / 3600)} ч назад`, cls: '' }; + return { text: `${Math.floor(ago / 86400)} д назад`, cls: '' }; + } + + if (c.is_dhcp && c.lease_expires) { + const lease = fmtLease(c.lease_expires); + return lease + ? { text: `аренда: ${lease.text}`, cls: lease.cls } + : { text: '—', cls: '' }; + } + + return { text: '—', cls: '' }; +} + +function matchesSearch(c, q) { + if (!q) return true; + const l = q.toLowerCase(); + return ( + (c.hostname || '').toLowerCase().includes(l) || + (c.ip || '').includes(l) || + (c.mac || '').toLowerCase().includes(l) || + (c.interface || '').toLowerCase().includes(l) + ); +} + +function render() { + const loading = document.getElementById('loading'); + const wrap = document.getElementById('clientsTableWrap'); + const empty = document.getElementById('emptyState'); + const body = document.getElementById('clientsBody'); + const summary = document.getElementById('clientsSummary'); + + loading.classList.add('hidden'); + + const onlineCount = allClients.filter(c => isOnline(c)).length; + const dhcpCount = allClients.filter(c => c.is_dhcp).length; + const blockedCount = allClients.filter(c => c.blocked).length; + + summary.innerHTML = + `${onlineCount} онлайн` + + `${allClients.length} всего` + + `${dhcpCount} по DHCP` + + (blockedCount > 0 ? `${blockedCount} заблокировано` : ''); + + const filtered = allClients.filter(c => matchesSearch(c, searchQuery)); + + if (filtered.length === 0) { + wrap.classList.add('hidden'); + empty.classList.remove('hidden'); + empty.textContent = allClients.length === 0 + ? 'Нет подключённых устройств' + : `Ничего не найдено по запросу «${searchQuery}»`; + return; + } + + empty.classList.add('hidden'); + wrap.classList.remove('hidden'); + + const sorted = [...filtered].sort((a, b) => { + const ao = isOnline(a), bo = isOnline(b); + if (ao !== bo) return ao ? -1 : 1; + return ipToNum(a.ip) - ipToNum(b.ip); + }); + + body.innerHTML = ''; + sorted.forEach(c => body.appendChild(buildRow(c))); +} + +function buildRow(c) { + const online = isOnline(c); + const tr = document.createElement('tr'); + tr.className = 'client-row'; + if (!online) tr.classList.add('row-offline'); + if (c.blocked) tr.classList.add('row-blocked'); + + const activity = fmtLastActive(c); + + const typeCell = c.is_dhcp + ? 'DHCP' + : 'ARP'; + + const hostname = c.hostname + ? `${escHtml(c.hostname)}` + : ''; + + const txHtml = c.tx_bytes + ? `${fmtBytes(c.tx_bytes)}` + : ''; + + const rxHtml = c.rx_bytes + ? `${fmtBytes(c.rx_bytes)}` + : ''; + + const actHtml = activity.text !== '—' + ? `${activity.text}` + : ''; + + const ipDisplay = c.static_ip + ? `${escHtml(c.static_ip)} фикс.` + : `${escHtml(c.ip)}`; + + const blockedBadge = c.blocked + ? ' ЗАБЛОКИРОВАН' + : ''; + + tr.innerHTML = ` + + + + ${hostname}${blockedBadge} + ${ipDisplay} + ${escHtml(c.mac || '—')} + ${escHtml(c.interface || '—')} + ${typeCell} + ${txHtml} + ${rxHtml} + ${actHtml} + `; + + tr.addEventListener('click', () => openModal(c)); + return tr; +} + +function openModal(c) { + editingClient = { ...c }; + const modal = document.getElementById('clientModal'); + + document.getElementById('modalTitle').textContent = c.hostname || c.ip || 'Устройство'; + document.getElementById('modalHostname').value = c.hostname || ''; + document.getElementById('modalHostname').placeholder = c.hostname ? '' : (c.mac || c.ip); + + const currentIP = c.static_ip || c.ip || '—'; + document.getElementById('modalIP').textContent = currentIP; + const currentLabel = document.getElementById('modalIPCurrent'); + if (c.static_ip) { + currentLabel.textContent = '(фиксированный)'; + currentLabel.style.display = ''; + } else if (c.ip) { + currentLabel.textContent = '(DHCP: ' + c.ip + ')'; + currentLabel.style.display = ''; + } else { + currentLabel.textContent = ''; + currentLabel.style.display = 'none'; + } + + document.getElementById('modalStaticIP').value = c.static_ip || ''; + document.getElementById('modalMAC').textContent = c.mac || '—'; + document.getElementById('modalIface').textContent = c.interface || '—'; + + const blocked = document.getElementById('modalBlocked'); + blocked.checked = !c.blocked; + updateBlockedToggle(c.blocked); + + modal.classList.remove('hidden'); + document.getElementById('modalHostname').focus(); +} + +function updateBlockedToggle(isBlocked) { + const hint = document.getElementById('modalBlockHint'); + const toggle = document.getElementById('modalBlockToggle'); + const toggleContainer = document.getElementById('modalBlocked'); + + if (isBlocked) { + hint.textContent = 'Доступ в интернет заблокирован'; + hint.style.color = 'var(--danger)'; + toggleContainer.checked = false; + toggle.classList.add('toggle-blocked'); + } else { + hint.textContent = 'Отключите, чтобы запретить устройству выход в интернет'; + hint.style.color = ''; + toggleContainer.checked = true; + toggle.classList.remove('toggle-blocked'); + } +} + +function closeModal() { + document.getElementById('clientModal').classList.add('hidden'); + editingClient = null; +} + +async function saveClient() { + if (!editingClient) return; + + const mac = editingClient.mac; + if (!mac) { + showToast('У устройства нет MAC-адреса', 'error'); + return; + } + + const hostname = document.getElementById('modalHostname').value.trim(); + const isBlocked = !document.getElementById('modalBlocked').checked; + const staticIP = document.getElementById('modalStaticIP').value.trim(); + + const btn = document.getElementById('modalSave'); + btn.disabled = true; + + try { + const res = await fetch('/api/clients/update/' + encodeURIComponent(mac), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostname, blocked: isBlocked, static_ip: staticIP }) + }); + const json = await res.json(); + if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); + showToast('Настройки сохранены', 'success'); + closeModal(); + loadClients(); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +} + +function ipToNum(ip) { + return (ip || '').split('.').reduce((acc, o) => acc * 256 + (+o || 0), 0); +} + +function escHtml(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>'); +} + +let toastTimer; +function showToast(msg, type = 'info') { + const t = document.getElementById('toast'); + t.textContent = msg; + t.className = `toast ${type}`; + t.classList.remove('hidden'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.add('hidden'), 3500); +} + +document.getElementById('refreshBtn').addEventListener('click', loadClients); + +document.getElementById('clientsSearch').addEventListener('input', e => { + searchQuery = e.target.value.trim(); + render(); +}); + +document.getElementById('modalBackdrop').addEventListener('click', closeModal); +document.getElementById('modalClose').addEventListener('click', closeModal); +document.getElementById('modalCancel').addEventListener('click', closeModal); +document.getElementById('clientForm').addEventListener('submit', e => { + e.preventDefault(); + saveClient(); +}); + +document.getElementById('modalBlocked').addEventListener('change', () => { + if (!editingClient) return; + const isBlocked = !document.getElementById('modalBlocked').checked; + updateBlockedToggle(isBlocked); +}); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeModal(); +}); + +setInterval(loadClients, 10000); + +loadClients(); \ No newline at end of file diff --git a/public/dhcp.html b/public/dhcp.html new file mode 100644 index 0000000..1233874 --- /dev/null +++ b/public/dhcp.html @@ -0,0 +1,179 @@ + + + + + + DHCP сервер — AlpineRouter + + + + +
+
+ +

AlpineRouter

+
+
+ +
+
+ + + +
+ + + + + +
+
+ DHCP сервер (dhcpd) + остановлен +
+
+ + +
+
+ + +
+
+

Пулы адресов

+

+ Каждый пул привязан к одному интерфейсу. Интерфейсы со шлюзом (WAN/uplink) + недоступны для DHCP — они помечены WAN. +

+
+ +
+
+ Загрузка... +
+ + + + +
+ +
+ + + + + + + + + diff --git a/public/dhcp.js b/public/dhcp.js new file mode 100644 index 0000000..7b55013 --- /dev/null +++ b/public/dhcp.js @@ -0,0 +1,344 @@ +'use strict'; + +// ── State ───────────────────────────────────────────────────────────────────── + +const state = { + installed: false, + running: false, + config: { enabled: false, pools: [] }, + ifaces: [], // all non-lo interfaces from the API + dirty: false, + editIface: null, // interface name being edited in modal +}; + +// ── API ─────────────────────────────────────────────────────────────────────── + +async function api(method, path, body) { + const res = await fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + const json = await res.json(); + if (!res.ok || !json.success) throw new Error(json.error || `HTTP ${res.status}`); + return json.data; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function fmtLease(sec) { + if (!sec || sec <= 0) return '24 ч'; + if (sec < 3600) return `${sec} с`; + if (sec < 86400) return `${(sec/3600).toFixed(0)} ч`; + return `${(sec/86400).toFixed(0)} д`; +} + +function poolForIface(name) { + return state.config.pools.find(p => p.interface === name) || null; +} + +// ── Render ──────────────────────────────────────────────────────────────────── + +function render() { + // Not-installed banner + document.getElementById('notInstalledBanner').classList.toggle('hidden', state.installed); + + // Status bar + const svcBadge = document.getElementById('svcStatus'); + svcBadge.textContent = state.running ? 'работает' : 'остановлен'; + svcBadge.className = `svc-badge ${state.running ? 'running' : 'stopped'}`; + + document.getElementById('enableToggle').checked = state.config.enabled; + document.getElementById('applyBtn').disabled = !state.installed || !state.dirty; + + // Pools grid + const grid = document.getElementById('poolsGrid'); + const loading = document.getElementById('poolsLoading'); + const noIface = document.getElementById('noIfaces'); + + loading.classList.add('hidden'); + + // interfaces eligible for DHCP: no gateway + const eligible = state.ifaces.filter(i => !i.has_gateway); + + if (eligible.length === 0) { + grid.classList.add('hidden'); + noIface.classList.remove('hidden'); + return; + } + noIface.classList.add('hidden'); + grid.classList.remove('hidden'); + + grid.innerHTML = ''; + + // Show ALL interfaces (both eligible and WAN), so the user sees the full picture + state.ifaces.forEach(iface => { + grid.appendChild(buildPoolCard(iface)); + }); +} + +function buildPoolCard(iface) { + const pool = poolForIface(iface.name); + const isWAN = iface.has_gateway; + const enabled = pool?.enabled ?? false; + + const card = document.createElement('div'); + card.className = 'pool-card' + (isWAN ? ' pool-card--wan' : '') + (enabled ? ' pool-card--active' : ''); + card.innerHTML = ` +
+
+ + ${iface.name} + ${isWAN ? 'WAN' : ''} + ${(!isWAN && enabled) ? 'DHCP активен' : ''} +
+ ${!isWAN ? `` : ''} +
+ +
+ ${iface.ipv4 + ? `
IP + ${iface.ipv4}${iface.ipv4_mask ? ' / ' + iface.ipv4_mask : ''}
` + : `
нет IP-адреса
`} + + ${isWAN + ? `
Интерфейс имеет шлюз — DHCP не раздаётся
` + : pool + ? ` +
+ Подсеть + ${pool.subnet} / ${pool.netmask || '?'} +
+
+ Диапазон + ${pool.range_start || '—'} — ${pool.range_end || '—'} +
+
+ Шлюз + ${pool.router || '—'} +
+
+ DNS + ${(pool.dns || []).join(', ') || '—'} +
+
+ Аренда + ${fmtLease(pool.lease_time)} +
+ ` + : `
Пул не настроен
` + } +
+ ${(!isWAN && pool) ? ` + ` : ''} + `; + + return card; +} + +// ── Load data ───────────────────────────────────────────────────────────────── + +async function loadAll() { + try { + const [status, data] = await Promise.all([ + api('GET', '/api/dhcp/status'), + api('GET', '/api/dhcp/config'), + ]); + state.installed = status.installed; + state.running = status.running; + state.config = data.config || { enabled: false, pools: [] }; + state.ifaces = data.interfaces || []; + state.dirty = false; + render(); + } catch (e) { + showToast('Ошибка загрузки: ' + e.message, 'error'); + } +} + +// ── Apply ───────────────────────────────────────────────────────────────────── + +async function saveConfig() { + try { + await api('POST', '/api/dhcp/config', state.config); + state.dirty = false; + render(); + } catch (e) { + showToast('Ошибка сохранения: ' + e.message, 'error'); + throw e; + } +} + +async function applyConfig() { + const btn = document.getElementById('applyBtn'); + btn.disabled = true; + btn.textContent = 'Применяю...'; + try { + await saveConfig(); + await api('POST', '/api/dhcp/apply'); + showToast('DHCP конфигурация применена', 'success'); + await loadAll(); + } catch (e) { + showToast('Ошибка применения: ' + e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Применить'; + } +} + +// ── Pool modal ──────────────────────────────────────────────────────────────── + +function openPoolModal(ifaceName) { + state.editIface = ifaceName; + document.getElementById('poolModalTitle').textContent = `Пул для ${ifaceName}`; + document.getElementById('pIface').value = ifaceName; + + const existing = poolForIface(ifaceName); + const iface = state.ifaces.find(i => i.name === ifaceName); + + // Defaults: auto-fill subnet/netmask/router from interface IP if possible + if (existing) { + document.getElementById('pEnabled').checked = existing.enabled; + document.getElementById('pSubnet').value = existing.subnet || ''; + document.getElementById('pNetmask').value = existing.netmask || ''; + document.getElementById('pRangeStart').value = existing.range_start || ''; + document.getElementById('pRangeEnd').value = existing.range_end || ''; + document.getElementById('pRouter').value = existing.router || ''; + document.getElementById('pDNS').value = (existing.dns || []).join(' '); + document.getElementById('pLease').value = existing.lease_time || 86400; + } else { + document.getElementById('poolForm').reset(); + document.getElementById('pEnabled').checked = true; + // auto-suggest subnet and router from interface address + if (iface?.ipv4) { + const parts = iface.ipv4.split('.'); + const subnet = parts.slice(0, 3).join('.') + '.0'; + document.getElementById('pSubnet').value = subnet; + document.getElementById('pRouter').value = iface.ipv4; + const rangeBase = parts.slice(0, 3).join('.'); + document.getElementById('pRangeStart').value = rangeBase + '.100'; + document.getElementById('pRangeEnd').value = rangeBase + '.200'; + } + if (iface?.ipv4_mask) { + document.getElementById('pNetmask').value = iface.ipv4_mask; + } + document.getElementById('pLease').value = 86400; + } + + document.getElementById('poolModal').classList.remove('hidden'); +} + +function closePoolModal() { + document.getElementById('poolModal').classList.add('hidden'); + state.editIface = null; +} + +function savePool() { + const ifaceName = document.getElementById('pIface').value; + if (!ifaceName) return; + + const pool = { + interface: ifaceName, + enabled: document.getElementById('pEnabled').checked, + subnet: document.getElementById('pSubnet').value.trim(), + netmask: document.getElementById('pNetmask').value.trim(), + range_start: document.getElementById('pRangeStart').value.trim(), + range_end: document.getElementById('pRangeEnd').value.trim(), + router: document.getElementById('pRouter').value.trim(), + dns: document.getElementById('pDNS').value.trim().split(/\s+/).filter(Boolean), + lease_time: parseInt(document.getElementById('pLease').value, 10) || 86400, + }; + + if (!pool.subnet || !pool.netmask) { + showToast('Укажите подсеть и маску', 'error'); + return; + } + + // upsert + const idx = state.config.pools.findIndex(p => p.interface === ifaceName); + if (idx >= 0) { + state.config.pools[idx] = pool; + } else { + state.config.pools.push(pool); + } + + state.dirty = true; + closePoolModal(); + render(); + showToast('Пул обновлён. Нажмите «Применить» чтобы активировать.', 'info'); +} + +function removePool(ifaceName) { + state.config.pools = state.config.pools.filter(p => p.interface !== ifaceName); + state.dirty = true; + render(); + showToast('Пул удалён. Нажмите «Применить».', 'info'); +} + +// ── Toast ───────────────────────────────────────────────────────────────────── + +let toastTimer; +function showToast(msg, type = 'info') { + const t = document.getElementById('toast'); + t.textContent = msg; + t.className = `toast ${type}`; + t.classList.remove('hidden'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.add('hidden'), 3500); +} + +// ── Event wiring ────────────────────────────────────────────────────────────── + +document.getElementById('refreshBtn').addEventListener('click', loadAll); + +document.getElementById('enableToggle').addEventListener('change', e => { + state.config.enabled = e.target.checked; + state.dirty = true; + render(); +}); + +document.getElementById('applyBtn').addEventListener('click', applyConfig); + +// Delegated: edit/remove pool buttons + enabled checkboxes +document.getElementById('poolsGrid').addEventListener('click', e => { + const editBtn = e.target.closest('[data-edit]'); + const removeBtn = e.target.closest('[data-remove]'); + if (editBtn) openPoolModal(editBtn.dataset.edit); + if (removeBtn) removePool(removeBtn.dataset.remove); +}); + +document.getElementById('poolsGrid').addEventListener('change', e => { + const chk = e.target.closest('.pool-enabled-chk'); + if (!chk) return; + const ifaceName = chk.dataset.iface; + const pool = poolForIface(ifaceName); + if (pool) { + pool.enabled = chk.checked; + state.dirty = true; + render(); + } +}); + +// Pool modal +document.getElementById('closePoolModal').addEventListener('click', closePoolModal); +document.getElementById('cancelPoolBtn').addEventListener('click', closePoolModal); +document.getElementById('poolModalBackdrop').addEventListener('click', closePoolModal); +document.getElementById('savePoolBtn').addEventListener('click', savePool); +document.getElementById('poolForm').addEventListener('submit', e => { + e.preventDefault(); + savePool(); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closePoolModal(); +}); + +// ── Init ────────────────────────────────────────────────────────────────────── + +loadAll(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4f39151 --- /dev/null +++ b/public/index.html @@ -0,0 +1,151 @@ + + + + + + AlpineRouter + + + + +
+
+ +

AlpineRouter

+
+
+ + +
+
+ + + + + +
+
+
+ Загрузка... +
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/public/proxy.html b/public/proxy.html new file mode 100644 index 0000000..35ea9fd --- /dev/null +++ b/public/proxy.html @@ -0,0 +1,534 @@ + + + + + + AlpineRouter — Прокси + + + + +
+
+ +

AlpineRouter

+
+
+ Остановлен +
+
+ + + +
+
+
+ Загрузка... +
+ + + + + + + + +
+ + + + + + +
+ + +
+
+
+
+

Прокси-ноды

+
Добавьте прокси-серверы для маршрутизации трафика
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/proxy.js b/public/proxy.js new file mode 100644 index 0000000..0e08af7 --- /dev/null +++ b/public/proxy.js @@ -0,0 +1,957 @@ +'use strict'; + +const PS = { + status: null, + config: null, +}; + +async function api(method, path, body) { + const opts = { + method, + headers: body ? (body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) : {}, + }; + if (body && !(body instanceof FormData)) { + opts.body = JSON.stringify(body); + } else if (body instanceof FormData) { + opts.body = 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); +const put = (path, body) => api('PUT', path, body); + +function showToast(msg, type = 'info') { + const t = document.getElementById('toast'); + t.textContent = msg; + t.className = `toast ${type}`; + t.classList.remove('hidden'); + clearTimeout(t._timer); + t._timer = setTimeout(() => t.classList.add('hidden'), 3500); +} + +function typeLabel(t) { + const m = { ss: 'Shadowsocks', vmess: 'VMess', vless: 'VLESS', trojan: 'Trojan', hysteria2: 'Hysteria2', http: 'HTTP', socks5: 'SOCKS5', direct: 'DIRECT' }; + return m[t] || t; +} + +function groupTypeLabel(t) { + const m = { select: 'Выбор', 'url-test': 'Автотест', fallback: 'Резерв', 'load-balance': 'Балансировка' }; + return m[t] || t; +} + +function getProxies() { return PS.config && Array.isArray(PS.config.proxies) ? PS.config.proxies : []; } +function getGroups() { return PS.config && Array.isArray(PS.config['proxy-groups']) ? PS.config['proxy-groups'] : []; } +function getRules() { return PS.config && Array.isArray(PS.config.rules) ? PS.config.rules : []; } +function getGeneral() { return PS.config && PS.config.general ? PS.config.general : {}; } +function getTProxy() { return PS.config && PS.config.tproxy ? PS.config.tproxy : {}; } +function getDNS() { return PS.config && PS.config.dns ? PS.config.dns : {}; } + +async function loadStatus() { + try { + PS.status = await get('/api/mihomo/status'); + renderStatus(); + } catch (e) { + console.error('load status', e); + } +} + +async function loadConfig() { + try { + PS.config = await get('/api/mihomo/config'); + renderAll(); + } catch (e) { + console.error('load config', e); + PS.config = {}; + renderAll(); + showToast('Ошибка загрузки конфига: ' + e.message, 'error'); + } + document.getElementById('loading').classList.add('hidden'); +} + +async function saveFullConfig(restart) { + try { + await put('/api/mihomo/config', PS.config); + showToast('Конфиг сохранён', 'success'); + refreshYAMLPreview(); + if (restart) { + try { + await post('/api/mihomo/restart', null); + showToast('Mihomo перезапущен', 'success'); + } catch (e) { + showToast('Перезапуск не удался: ' + e.message, 'error'); + } + await loadStatus(); + } + } catch (e) { + showToast('Ошибка сохранения: ' + e.message, 'error'); + } +} + +async function refreshYAMLPreview() { + try { + const res = await fetch('/api/mihomo/config.yaml'); + if (res.ok) { + document.getElementById('yamlPreview').value = await res.text(); + } + } catch (e) { /* ignore */ } +} + +function renderStatus() { + const s = PS.status; + if (!s) return; + const text = document.getElementById('statusText'); + const headerBadge = document.getElementById('statusBadge'); + const startBtn = document.getElementById('startBtn'); + const stopBtn = document.getElementById('stopBtn'); + const restartBtn = document.getElementById('restartBtn'); + const coreInfo = document.getElementById('coreInfo'); + + if (s.running) { + text.className = 'svc-badge running'; + text.textContent = 'Запущен (PID ' + (s.pid || '?') + ')'; + headerBadge.className = 'svc-badge running'; + headerBadge.textContent = 'Запущен'; + startBtn.disabled = true; + stopBtn.disabled = false; + restartBtn.disabled = false; + } else { + text.className = 'svc-badge stopped'; + text.textContent = 'Остановлен'; + headerBadge.className = 'svc-badge stopped'; + headerBadge.textContent = 'Остановлен'; + startBtn.disabled = false; + stopBtn.disabled = true; + restartBtn.disabled = true; + } + + document.getElementById('corePath').textContent = s.core_path || '—'; + document.getElementById('coreExists').textContent = s.core_exists ? 'Да' : 'Нет'; + document.getElementById('corePid').textContent = s.running && s.pid ? s.pid : '—'; + + if (!s.core_exists) { + coreInfo.classList.remove('hidden'); + document.getElementById('coreInfoMsg').textContent = 'Ядро Mihomo не найдено. Загрузите бинарный файл в раздел «Ядро».'; + } else { + coreInfo.classList.add('hidden'); + } + + document.getElementById('statusBar').classList.remove('hidden'); + document.getElementById('loading').classList.add('hidden'); +} + +function renderAll() { + renderProxies(); + renderGroups(); + renderRules(); + fillSettings(); + refreshYAMLPreview(); +} + +function renderProxies() { + const list = document.getElementById('proxyList'); + const empty = document.getElementById('proxyEmpty'); + const proxies = getProxies(); + list.innerHTML = ''; + if (proxies.length === 0) { + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + proxies.forEach(p => { + const card = document.createElement('div'); + card.className = 'proxy-card'; + card.innerHTML = ` +
+
+ ${esc(p.name)} + ${esc(typeLabel(p.type))} +
+
+ + +
+
+
+ ${p.type !== 'direct' ? `
Сервер${esc(p.server || '—')}
+
Порт${p.port || '—'}
` : ''} + ${p.udp ? 'UDP' : ''} + ${p.tls ? 'TLS' : ''} +
+ `; + list.appendChild(card); + }); +} + +function renderGroups() { + const list = document.getElementById('groupList'); + const empty = document.getElementById('groupEmpty'); + const groups = getGroups(); + list.innerHTML = ''; + if (groups.length === 0) { + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + groups.forEach(g => { + const card = document.createElement('div'); + card.className = 'proxy-card'; + const proxyList = (g.proxies || []).slice(0, 5).map(p => esc(p)).join(', '); + const more = (g.proxies || []).length > 5 ? ` +${(g.proxies || []).length - 5}` : ''; + card.innerHTML = ` +
+
+ ${esc(g.name)} + ${esc(groupTypeLabel(g.type))} + ${g['include-all'] ? 'Все прокси' : ''} +
+
+ + +
+
+
+
Узлы${proxyList}${more || '—'}
+ ${g.url ? `
URL${esc(g.url)}
` : ''} + ${g.interval ? `
Интервал${g.interval}с
` : ''} +
+ `; + list.appendChild(card); + }); +} + +function renderRules() { + const list = document.getElementById('rulesList'); + const rules = getRules(); + list.innerHTML = ''; + if (rules.length === 0) { + list.innerHTML = '
Нет правил маршрутизации
'; + return; + } + rules.forEach((rule, i) => { + const parts = rule.split(','); + const type = parts[0] || ''; + const value = parts[1] || ''; + const target = parts[2] || ''; + const flags = parts.slice(3).join(','); + + const el = document.createElement('div'); + el.className = 'rule-item'; + el.innerHTML = ` +
+ ${esc(type)} + ${esc(value || (type === 'MATCH' ? '*' : ''))} + ${esc(target)} + ${flags ? `${esc(flags)}` : ''} +
+
+ +
+ `; + list.appendChild(el); + }); +} + +function fillSettings() { + if (!PS.config) { + document.getElementById('loading').classList.add('hidden'); + return; + } + const g = getGeneral(); + const tp = getTProxy(); + const dns = getDNS(); + + document.getElementById('mixedPort').value = g['mixed-port'] || 7890; + document.getElementById('allowLan').checked = g['allow-lan'] !== false; + document.getElementById('ipv6').checked = g.ipv6 !== false; + document.getElementById('tcpConcurrent').checked = g['tcp-concurrent'] !== false; + document.getElementById('externalController').value = g['external-controller'] || '0.0.0.0:9090'; + document.getElementById('secret').value = g.secret || ''; + + setSegBtn('modeSwitch', g.mode || 'rule'); + + document.getElementById('tproxyEnabled').checked = tp.enabled || false; + document.getElementById('tproxyPort').value = tp.port || 7894; + + document.getElementById('dnsEnabled').checked = dns.enable !== false; + document.getElementById('dnsListen').value = dns.listen || '0.0.0.0:1053'; + setSegBtn('dnsModeSwitch', dns['enhanced-mode'] || 'redir-host'); + document.getElementById('dnsNameserver').value = (dns.nameserver || []).join('\n'); + document.getElementById('dnsFallback').value = (dns.fallback || []).join('\n'); +} + +function setSegBtn(id, mode) { + document.querySelectorAll(`#${id} .seg-btn`).forEach(b => { + b.classList.toggle('active', b.dataset.mode === mode); + }); +} + +function getSegBtn(id) { + const active = document.querySelector(`#${id} .seg-btn.active`); + return active ? active.dataset.mode : ''; +} + +function esc(s) { + if (!s) return ''; + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +// ─── Tab switching ─── +document.querySelectorAll('.ptab').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.ptab').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.ptab-content').forEach(c => c.classList.add('hidden')); + btn.classList.add('active'); + document.getElementById('tab-' + btn.dataset.tab).classList.remove('hidden'); + }); +}); + +// ─── Core control ─── +document.getElementById('startBtn').addEventListener('click', async () => { + try { + await post('/api/mihomo/start', null); + showToast('Mihomo запущен', 'success'); + await loadStatus(); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +}); + +document.getElementById('stopBtn').addEventListener('click', async () => { + try { + await post('/api/mihomo/stop', null); + showToast('Mihomo остановлен', 'success'); + await loadStatus(); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +}); + +document.getElementById('restartBtn').addEventListener('click', async () => { + try { + await post('/api/mihomo/restart', null); + showToast('Mihomo перезапущен', 'success'); + await loadStatus(); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +}); + +// ─── Config mode switches ─── +document.getElementById('modeSwitch').addEventListener('click', e => { + const btn = e.target.closest('.seg-btn'); + if (btn) setSegBtn('modeSwitch', btn.dataset.mode); +}); +document.getElementById('dnsModeSwitch').addEventListener('click', e => { + const btn = e.target.closest('.seg-btn'); + if (btn) setSegBtn('dnsModeSwitch', btn.dataset.mode); +}); + +// ─── Settings save ─── +function applySettingsToConfig() { + if (!PS.config) PS.config = {}; + PS.config['mixed-port'] = parseInt(document.getElementById('mixedPort').value) || 7890; + PS.config['allow-lan'] = document.getElementById('allowLan').checked; + PS.config['bind-address'] = '*'; + PS.config.mode = getSegBtn('modeSwitch'); + PS.config['log-level'] = 'info'; + PS.config.ipv6 = document.getElementById('ipv6').checked; + PS.config['external-controller'] = document.getElementById('externalController').value; + PS.config.secret = document.getElementById('secret').value || ''; + PS.config['tcp-concurrent'] = document.getElementById('tcpConcurrent').checked; + PS.config['find-process-mode'] = 'off'; + + const tpEnabled = document.getElementById('tproxyEnabled').checked; + if (tpEnabled) { + PS.config['tproxy-port'] = parseInt(document.getElementById('tproxyPort').value) || 7894; + } else { + delete PS.config['tproxy-port']; + } + + PS.config.dns = { + enable: document.getElementById('dnsEnabled').checked, + ipv6: document.getElementById('ipv6').checked, + listen: document.getElementById('dnsListen').value, + 'enhanced-mode': getSegBtn('dnsModeSwitch'), + 'fake-ip-range': '198.18.0.1/16', + 'fake-ip-filter': ['*.lan', '*.local', '+.market.xiaomi.com'], + 'default-nameserver': ['223.5.5.5', '119.29.29.29'], + nameserver: document.getElementById('dnsNameserver').value.split('\n').map(s => s.trim()).filter(Boolean), + fallback: document.getElementById('dnsFallback').value.split('\n').map(s => s.trim()).filter(Boolean), + }; + + PS.config.profile = { 'store-selected': true, 'store-fake-ip': true }; +} + +document.getElementById('settingsForm').addEventListener('submit', e => { + e.preventDefault(); + applySettingsToConfig(); + saveFullConfig(false); +}); +document.getElementById('saveAndRestartBtn').addEventListener('click', () => { + applySettingsToConfig(); + saveFullConfig(true); +}); + +// ─── Proxy Modal ─── +function updateProxyFields() { + const type = document.getElementById('proxyType').value; + const serverFields = document.getElementById('proxyServerFields'); + const authFields = document.getElementById('proxyAuthFields'); + const cipherField = document.getElementById('proxyCipherField'); + const uuidField = document.getElementById('proxyUUIDField'); + const trojanPassField = document.getElementById('proxyTrojanPassField'); + const hysteria2Fields = document.getElementById('proxyHysteria2Fields'); + const tlsField = document.getElementById('proxyTLSField'); + const vlessFlowField = document.getElementById('proxyVlessFlowField'); + const networkField = document.getElementById('proxyNetworkField'); + + serverFields.classList.remove('hidden'); + authFields.classList.add('hidden'); + cipherField.classList.add('hidden'); + uuidField.classList.add('hidden'); + trojanPassField.classList.add('hidden'); + hysteria2Fields.classList.add('hidden'); + tlsField.classList.add('hidden'); + vlessFlowField.classList.add('hidden'); + networkField.classList.add('hidden'); + + switch (type) { + case 'ss': + cipherField.classList.remove('hidden'); + tlsField.classList.remove('hidden'); + document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden'); + break; + case 'vmess': + uuidField.classList.remove('hidden'); + cipherField.classList.remove('hidden'); + tlsField.classList.remove('hidden'); + networkField.classList.remove('hidden'); + document.getElementById('proxyCipher').innerHTML = ''; + break; + case 'vless': + uuidField.classList.remove('hidden'); + tlsField.classList.remove('hidden'); + vlessFlowField.classList.remove('hidden'); + networkField.classList.remove('hidden'); + break; + case 'trojan': + trojanPassField.classList.remove('hidden'); + tlsField.classList.remove('hidden'); + networkField.classList.remove('hidden'); + break; + case 'hysteria2': + hysteria2Fields.classList.remove('hidden'); + tlsField.classList.add('hidden'); + document.querySelector('#proxyPassword').closest('.form-row').classList.remove('hidden'); + serverFields.classList.remove('hidden'); + break; + case 'http': + authFields.classList.remove('hidden'); + tlsField.classList.remove('hidden'); + break; + case 'socks5': + authFields.classList.remove('hidden'); + tlsField.classList.remove('hidden'); + break; + case 'direct': + serverFields.classList.add('hidden'); + break; + } +} + +document.getElementById('proxyType').addEventListener('change', updateProxyFields); + +let editProxyName = null; + +function openProxyModal(proxy) { + editProxyName = proxy ? proxy.name : null; + document.getElementById('proxyModalTitle').textContent = proxy ? 'Редактировать прокси' : 'Добавить прокси'; + document.getElementById('proxyEditName').value = proxy ? proxy.name : ''; + document.getElementById('proxyName').value = proxy ? proxy.name : ''; + document.getElementById('proxyType').value = proxy ? proxy.type : 'ss'; + document.getElementById('proxyServer').value = proxy ? (proxy.server || '') : ''; + document.getElementById('proxyPort').value = proxy ? (proxy.port || '') : ''; + document.getElementById('proxyUDP').checked = proxy ? (proxy.udp !== false) : true; + document.getElementById('proxyUsername').value = proxy ? (proxy.username || '') : ''; + document.getElementById('proxyPassword').value = proxy ? (proxy.password || '') : ''; + document.getElementById('proxyTLS').checked = proxy ? (proxy.tls || false) : false; + document.getElementById('proxySNI').value = proxy ? (proxy.servername || '') : ''; + document.getElementById('proxySkipCertVerify').checked = proxy ? (proxy['skip-cert-verify'] || false) : false; + document.getElementById('proxyUUID').value = proxy ? (proxy.uuid || '') : ''; + document.getElementById('proxyCipher').value = proxy ? (proxy.cipher || 'auto') : 'auto'; + document.getElementById('proxyFlow').value = proxy ? (proxy.flow || '') : ''; + document.getElementById('proxyNetwork').value = proxy ? (proxy.network || '') : ''; + document.getElementById('proxyTrojanPass').value = proxy ? (proxy.password || '') : ''; + document.getElementById('proxyObfs').value = proxy ? (proxy.obfs || '') : ''; + document.getElementById('proxyObfsPass').value = proxy ? (proxy['obfs-password'] || '') : ''; + + updateProxyFields(); + document.getElementById('proxyModal').classList.remove('hidden'); +} + +function closeProxyModal() { + document.getElementById('proxyModal').classList.add('hidden'); +} + +document.getElementById('addProxyBtn').addEventListener('click', () => openProxyModal(null)); +document.getElementById('closeProxyModal').addEventListener('click', closeProxyModal); +document.getElementById('cancelProxyBtn').addEventListener('click', closeProxyModal); +document.getElementById('proxyModalBackdrop').addEventListener('click', closeProxyModal); + +document.getElementById('saveProxyBtn').addEventListener('click', () => { + const type = document.getElementById('proxyType').value; + const proxy = { + name: document.getElementById('proxyName').value.trim(), + type: type, + server: document.getElementById('proxyServer').value.trim(), + port: parseInt(document.getElementById('proxyPort').value) || 443, + udp: document.getElementById('proxyUDP').checked, + tls: document.getElementById('proxyTLS').checked, + servername: document.getElementById('proxySNI').value.trim(), + 'skip-cert-verify': document.getElementById('proxySkipCertVerify').checked, + network: document.getElementById('proxyNetwork').value || '', + }; + + if (type === 'ss') { + proxy.cipher = document.getElementById('proxyCipher').value; + proxy.password = document.getElementById('proxyPassword').value; + } else if (type === 'vmess' || type === 'vless') { + proxy.uuid = document.getElementById('proxyUUID').value.trim(); + if (type === 'vless') proxy.flow = document.getElementById('proxyFlow').value.trim(); + proxy.cipher = document.getElementById('proxyCipher').value; + } else if (type === 'trojan') { + proxy.password = document.getElementById('proxyTrojanPass').value; + } else if (type === 'hysteria2') { + proxy.password = document.getElementById('proxyPassword').value; + proxy.obfs = document.getElementById('proxyObfs').value.trim(); + proxy['obfs-password'] = document.getElementById('proxyObfsPass').value.trim(); + } else if (type === 'http' || type === 'socks5') { + proxy.username = document.getElementById('proxyUsername').value.trim(); + proxy.password = document.getElementById('proxyPassword').value; + } else if (type === 'direct') { + delete proxy.server; + delete proxy.port; + } + + if (!proxy.name) { + showToast('Имя прокси обязательно', 'error'); + return; + } + + const proxies = getProxies(); + if (editProxyName) { + const idx = proxies.findIndex(p => p.name === editProxyName); + if (idx >= 0) { + const oldName = proxies[idx].name; + proxies[idx] = proxy; + if (proxy.name !== oldName) { + getGroups().forEach(g => { + if (g.proxies) { + g.proxies = g.proxies.map(pn => pn === oldName ? proxy.name : pn); + } + }); + } + } + } else { + if (proxies.some(p => p.name === proxy.name)) { + showToast('Прокси с таким именем уже существует', 'error'); + return; + } + proxies.push(proxy); + } + PS.config.proxies = proxies; + + closeProxyModal(); + renderAll(); + saveFullConfig(false); +}); + +// Proxy list delegated events +document.getElementById('proxyList').addEventListener('click', e => { + const editBtn = e.target.closest('[data-edit-proxy]'); + const delBtn = e.target.closest('[data-delete-proxy]'); + if (editBtn) { + const name = editBtn.dataset.editProxy; + const proxy = getProxies().find(p => p.name === name); + if (proxy) openProxyModal(proxy); + } else if (delBtn) { + const name = delBtn.dataset.deleteProxy; + if (confirm(`Удалить прокси "${name}"?`)) { + const proxies = getProxies(); + const idx = proxies.findIndex(p => p.name === name); + if (idx >= 0) { + proxies.splice(idx, 1); + PS.config.proxies = proxies; + getGroups().forEach(g => { + if (g.proxies) { + g.proxies = g.proxies.filter(pn => pn !== name); + } + }); + renderAll(); + saveFullConfig(false); + } + } + } +}); + +// ─── Group Modal ─── +function updateGroupFields() { + const type = document.getElementById('groupType').value; + const urlField = document.getElementById('groupURLField'); + const lbField = document.getElementById('groupLBStrategy'); + if (type === 'url-test' || type === 'fallback' || type === 'load-balance') { + urlField.classList.remove('hidden'); + } else { + urlField.classList.add('hidden'); + } + if (type === 'load-balance') { + lbField.classList.remove('hidden'); + } else { + lbField.classList.add('hidden'); + } +} + +document.getElementById('groupType').addEventListener('change', updateGroupFields); + +let editGroupName = null; + +function openGroupModal(group) { + editGroupName = group ? group.name : null; + document.getElementById('groupEditName').value = group ? group.name : ''; + document.getElementById('groupModalTitle').textContent = group ? 'Редактировать группу' : 'Добавить группу'; + document.getElementById('groupName').value = group ? group.name : ''; + document.getElementById('groupType').value = group ? group.type : 'select'; + document.getElementById('groupURL').value = group ? (group.url || 'https://www.gstatic.com/generate_204') : 'https://www.gstatic.com/generate_204'; + document.getElementById('groupInterval').value = group ? (group.interval || 300) : 300; + document.getElementById('groupTolerance').value = group ? (group.tolerance || 50) : 50; + document.getElementById('groupIncludeAll').checked = group ? (group['include-all'] || false) : false; + document.getElementById('groupFilter').value = group ? (group.filter || '') : ''; + document.getElementById('groupLBStrategy').value = group ? (group.strategy || 'round-robin') : 'round-robin'; + + const checkboxes = document.getElementById('groupProxyCheckboxes'); + checkboxes.innerHTML = ''; + const builtin = ['DIRECT']; + const allProxies = [...builtin, ...getProxies().map(p => p.name)]; + const selected = group ? (group.proxies || []) : []; + allProxies.forEach(name => { + const label = document.createElement('label'); + label.className = 'checkbox-label'; + label.style.cssText = 'font-size:.85rem;padding:4px 0;display:flex;align-items:center;gap:6px'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = name; + if (selected.includes(name)) cb.checked = true; + label.appendChild(cb); + label.appendChild(document.createTextNode(name)); + checkboxes.appendChild(label); + }); + + updateGroupFields(); + document.getElementById('groupModal').classList.remove('hidden'); +} + +function closeGroupModal() { + document.getElementById('groupModal').classList.add('hidden'); +} + +document.getElementById('addGroupBtn').addEventListener('click', () => openGroupModal(null)); +document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal); +document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal); +document.getElementById('groupModalBackdrop').addEventListener('click', closeGroupModal); + +document.getElementById('saveGroupBtn').addEventListener('click', () => { + const selectedProxies = []; + document.querySelectorAll('#groupProxyCheckboxes input[type="checkbox"]:checked').forEach(cb => { + selectedProxies.push(cb.value); + }); + + const group = { + name: document.getElementById('groupName').value.trim(), + type: document.getElementById('groupType').value, + proxies: selectedProxies, + url: document.getElementById('groupURLField').classList.contains('hidden') ? '' : document.getElementById('groupURL').value.trim(), + interval: parseInt(document.getElementById('groupInterval').value) || 300, + tolerance: parseInt(document.getElementById('groupTolerance').value) || 0, + 'include-all': document.getElementById('groupIncludeAll').checked, + filter: document.getElementById('groupFilter').value.trim(), + }; + + if (group.type === 'load-balance') { + group.strategy = document.getElementById('groupLBStrategy').value; + } + + if (!group.name) { + showToast('Имя группы обязательно', 'error'); + return; + } + + const groups = getGroups(); + if (editGroupName) { + const idx = groups.findIndex(g => g.name === editGroupName); + if (idx >= 0) { + groups[idx] = group; + } + } else { + if (groups.some(g => g.name === group.name)) { + showToast('Группа с таким именем уже существует', 'error'); + return; + } + groups.push(group); + } + PS.config['proxy-groups'] = groups; + + closeGroupModal(); + renderAll(); + saveFullConfig(false); +}); + +// Group list events +document.getElementById('groupList').addEventListener('click', e => { + const editBtn = e.target.closest('[data-edit-group]'); + const delBtn = e.target.closest('[data-delete-group]'); + if (editBtn) { + const name = editBtn.dataset.editGroup; + const group = getGroups().find(g => g.name === name); + if (group) openGroupModal(group); + } else if (delBtn) { + const name = delBtn.dataset.deleteGroup; + if (confirm(`Удалить группу "${name}"?`)) { + const groups = getGroups(); + const idx = groups.findIndex(g => g.name === name); + if (idx >= 0) { + groups.splice(idx, 1); + PS.config['proxy-groups'] = groups; + const rules = getRules(); + PS.config.rules = rules.map(r => { + if (r.endsWith(',' + name)) { + return r.replace(',' + name, ',DIRECT'); + } + return r; + }); + renderAll(); + saveFullConfig(false); + } + } + } +}); + +// ─── Rule Modal ─── +function updateRuleFields() { + const type = document.getElementById('ruleType').value; + const valueDiv = document.getElementById('ruleValueField'); + const noResolveDiv = document.getElementById('ruleNoResolveDiv'); + valueDiv.classList.remove('hidden'); + noResolveDiv.classList.add('hidden'); + + if (type === 'MATCH') { + valueDiv.classList.add('hidden'); + } else if (type === 'IP-CIDR' || type === 'IP-CIDR6' || type === 'SRC-IP-CIDR' || type === 'GEOIP') { + noResolveDiv.classList.remove('hidden'); + } + + const sel = document.getElementById('ruleProxy'); + sel.innerHTML = ''; + getGroups().forEach(g => { + const opt = document.createElement('option'); + opt.value = g.name; + opt.textContent = '📋 ' + g.name; + sel.appendChild(opt); + }); + getProxies().forEach(p => { + if (p.type !== 'direct') { + const opt = document.createElement('option'); + opt.value = p.name; + opt.textContent = '🔗 ' + p.name; + sel.appendChild(opt); + } + }); +} + +document.getElementById('ruleType').addEventListener('change', updateRuleFields); + +function openRuleModal() { + document.getElementById('ruleModalTitle').textContent = 'Добавить правило'; + document.getElementById('ruleType').value = 'DOMAIN-SUFFIX'; + document.getElementById('ruleValue').value = ''; + document.getElementById('ruleProxy').value = 'DIRECT'; + document.getElementById('ruleNoResolve').checked = true; + updateRuleFields(); + document.getElementById('ruleModal').classList.remove('hidden'); +} + +function closeRuleModal() { + document.getElementById('ruleModal').classList.add('hidden'); +} + +document.getElementById('addRuleBtn').addEventListener('click', openRuleModal); +document.getElementById('addBlockBtn').addEventListener('click', () => { + openRuleModal(); + document.getElementById('ruleType').value = 'DOMAIN-KEYWORD'; + document.getElementById('ruleProxy').value = 'REJECT'; + updateRuleFields(); +}); +document.getElementById('closeRuleModal').addEventListener('click', closeRuleModal); +document.getElementById('cancelRuleBtn').addEventListener('click', closeRuleModal); +document.getElementById('ruleModalBackdrop').addEventListener('click', closeRuleModal); + +document.getElementById('saveRuleBtn').addEventListener('click', () => { + const type = document.getElementById('ruleType').value; + const value = document.getElementById('ruleValue').value.trim(); + const proxy = document.getElementById('ruleProxy').value; + const noResolve = document.getElementById('ruleNoResolve').checked; + + if (type !== 'MATCH' && !value) { + showToast('Введите значение правила', 'error'); + return; + } + + let rule; + if (type === 'MATCH') { + rule = `MATCH,${proxy}`; + } else { + rule = `${type},${value},${proxy}`; + const needsNoResolve = ['IP-CIDR', 'IP-CIDR6', 'SRC-IP-CIDR', 'GEOIP'].includes(type); + if (needsNoResolve && noResolve) { + rule += ',no-resolve'; + } + } + + const rules = getRules(); + rules.push(rule); + PS.config.rules = rules; + + closeRuleModal(); + renderAll(); + saveFullConfig(false); +}); + +// Rule delete delegated events +document.getElementById('rulesList').addEventListener('click', e => { + const delBtn = e.target.closest('[data-delete-rule]'); + if (delBtn) { + const idx = parseInt(delBtn.dataset.deleteRule); + if (isNaN(idx)) return; + const rules = getRules(); + rules.splice(idx, 1); + PS.config.rules = rules; + renderAll(); + saveFullConfig(false); + } +}); + +// ─── Core upload ─── +document.getElementById('uploadCoreForm').addEventListener('submit', async e => { + e.preventDefault(); + const fileInput = document.getElementById('coreFile'); + if (!fileInput.files[0]) { + showToast('Выберите файл', 'error'); + return; + } + const fd = new FormData(); + fd.append('core', fileInput.files[0]); + try { + const result = await fetch('/api/mihomo/upload-core', { method: 'POST', body: fd }); + const json = await result.json(); + if (!json.success) { + throw new Error(json.error || 'Upload failed'); + } + showToast('Ядро загружено: ' + (json.data.arch || json.data.path), 'success'); + await loadStatus(); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +}); + +// ─── YAML editor ─── +document.getElementById('yamlLoadBtn').addEventListener('click', async () => { + try { + const res = await fetch('/api/mihomo/config.yaml'); + if (res.status === 404) { + document.getElementById('yamlEditor').value = '# Config not found.'; + return; + } + const text = await res.text(); + document.getElementById('yamlEditor').value = text; + showToast('Конфиг загружен', 'info'); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +}); + +document.getElementById('yamlSaveBtn').addEventListener('click', async () => { + const content = document.getElementById('yamlEditor').value; + try { + const res = await fetch('/api/mihomo/config.yaml', { + method: 'PUT', + headers: { 'Content-Type': 'text/yaml' }, + body: content, + }); + const json = await res.json(); + if (!json.success) throw new Error(json.error || 'Save failed'); + showToast('Конфиг сохранён', 'success'); + await loadConfig(); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +}); + +// ─── Logs polling ─── +let logPollTimer = null; +let lastLogCount = 0; + +async function fetchLogs() { + try { + const lines = await get('/api/mihomo/logs'); + const el = document.getElementById('logOutput'); + if (lines.length !== lastLogCount) { + lastLogCount = lines.length; + el.textContent = lines.join('\n'); + el.scrollTop = el.scrollHeight; + } + } catch (e) { /* ignore */ } +} + +function startLogPoll() { + if (logPollTimer) return; + fetchLogs(); + logPollTimer = setInterval(fetchLogs, 500); +} + +function stopLogPoll() { + if (logPollTimer) { + clearInterval(logPollTimer); + logPollTimer = null; + } +} + +document.querySelectorAll('.ptab').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.tab === 'logs') { + lastLogCount = 0; + startLogPoll(); + } else { + stopLogPoll(); + } + }); +}); + +document.getElementById('clearLogsBtn').addEventListener('click', () => { + document.getElementById('logOutput').textContent = ''; + lastLogCount = 0; +}); + +// ─── Init ─── +(async () => { + try { await loadStatus(); } catch(e) { console.error('status', e); } + try { await loadConfig(); } catch(e) { console.error('config', e); } + document.getElementById('loading').classList.add('hidden'); +})(); \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..be6851d --- /dev/null +++ b/public/style.css @@ -0,0 +1,1140 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #070b14; + --bg-deep: #040710; + --surface: rgba(12, 22, 45, 0.85); + --surface-2: rgba(18, 35, 65, 0.7); + --surface-3: rgba(25, 50, 85, 0.5); + --border: rgba(0, 200, 255, 0.1); + --border-hi: rgba(0, 200, 255, 0.25); + --text: #e0f0ff; + --text-dim: #7aa2cc; + --muted: #4a6d8c; + --accent: #00d4ff; + --accent-h: #40e0ff; + --accent-glow:rgba(0, 212, 255, 0.25); + --success: #00ff88; + --danger: #ff3366; + --warning: #ffaa00; + --radius: 14px; + --radius-sm: 8px; + --shadow: 0 4px 30px rgba(0,0,0,.5); + --glow: 0 0 20px rgba(0, 212, 255, 0.15); + --glow-sm: 0 0 8px rgba(0, 212, 255, 0.12); +} + +html { font-size: 15px; } + +body { + font-family: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + 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%); + color: var(--text); + min-height: 100vh; + line-height: 1.5; + overflow-x: hidden; +} + +::selection { + background: rgba(0, 212, 255, 0.25); + color: #fff; +} + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(0, 212, 255, 0.2); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: rgba(0, 212, 255, 0.4); } + +/* ── Header ── */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 28px; + background: rgba(8, 14, 28, 0.92); + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} +.header-left { display: flex; align-items: center; gap: 12px; } +.header-right { display: flex; align-items: center; gap: 14px; } +.logo { + width: 32px; height: 32px; + stroke: var(--accent); + filter: drop-shadow(0 0 6px var(--accent-glow)); +} +h1 { + font-size: 1.15rem; + font-weight: 700; + color: var(--text); + letter-spacing: 0.02em; + text-shadow: 0 0 20px rgba(0, 212, 255, 0.2); +} +.hostname { + font-size: .78rem; + color: var(--muted); + font-family: "JetBrains Mono", "Fira Code", monospace; + background: var(--surface-2); + padding: 3px 10px; + border-radius: 20px; + border: 1px solid var(--border); +} + +/* ── Buttons ── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + border-radius: var(--radius-sm); + border: none; + cursor: pointer; + font-size: .85rem; + font-weight: 600; + transition: all .2s ease; + position: relative; + overflow: hidden; +} +.btn:disabled { opacity: .35; cursor: not-allowed; filter: saturate(0.3); } +.btn-primary { + background: linear-gradient(135deg, #0090b3, #00d4ff); + color: #fff; + box-shadow: 0 2px 12px rgba(0, 212, 255, 0.2); +} +.btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, #00a8d6, #40e0ff); + box-shadow: 0 4px 20px rgba(0, 212, 255, 0.35); + transform: translateY(-1px); +} +.btn-success { + background: linear-gradient(135deg, #00b86e, #00ff88); + color: #fff; + box-shadow: 0 2px 12px rgba(0, 255, 136, 0.15); +} +.btn-success:hover:not(:disabled) { + box-shadow: 0 4px 20px rgba(0, 255, 136, 0.3); + transform: translateY(-1px); +} +.btn-danger { + background: linear-gradient(135deg, #cc1a44, #ff3366); + color: #fff; + box-shadow: 0 2px 12px rgba(255, 51, 102, 0.15); +} +.btn-danger:hover:not(:disabled) { + box-shadow: 0 4px 20px rgba(255, 51, 102, 0.3); + transform: translateY(-1px); +} +.btn-ghost { + background: var(--surface-3); + color: var(--text-dim); + border: 1px solid var(--border); +} +.btn-ghost:hover:not(:disabled) { + background: var(--surface-2); + color: var(--text); + border-color: var(--border-hi); + transform: translateY(-1px); +} +.btn-sm { padding: 5px 11px; font-size: .8rem; border-radius: 6px; } +.btn-icon { + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: 1rem; padding: 4px 8px; + border-radius: var(--radius-sm); + transition: all .2s ease; +} +.btn-icon:hover { + color: var(--accent); + background: var(--surface-3); + text-shadow: 0 0 8px var(--accent-glow); +} + +/* ── Pending banner ── */ +.pending-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 28px; + background: rgba(255, 170, 0, 0.08); + border-bottom: 1px solid rgba(255, 170, 0, 0.2); + color: var(--warning); + font-size: .875rem; + animation: bannerPulse 2s ease-in-out infinite; +} +@keyframes bannerPulse { + 0%, 100% { background: rgba(255, 170, 0, 0.06); } + 50% { background: rgba(255, 170, 0, 0.12); } +} +.pending-banner strong { color: #ffcc44; } +.banner-actions { display: flex; gap: 8px; margin-left: auto; } + +/* ── Main grid ── */ +main { padding: 28px; } + +.iface-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 20px; +} + +/* ── Interface card ── */ +.iface-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: all .3s ease; + backdrop-filter: blur(10px); + position: relative; +} +.iface-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: 0; + transition: opacity .3s ease; +} +.iface-card:hover { + border-color: var(--border-hi); + box-shadow: var(--glow); + transform: translateY(-2px); +} +.iface-card:hover::before { opacity: 1; } +.iface-card.has-pending { + border-color: rgba(255, 170, 0, 0.4); + animation: cardPendingPulse 2s ease-in-out infinite; +} +@keyframes cardPendingPulse { + 0%, 100% { box-shadow: 0 0 0 rgba(255,170,0,0); } + 50% { box-shadow: 0 0 15px rgba(255,170,0,0.15); } +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; +} +.card-name { + display: flex; + align-items: center; + gap: 10px; + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.01em; +} +.state-dot { + width: 10px; height: 10px; + border-radius: 50%; + flex-shrink: 0; + position: relative; +} +.state-dot.up { + background: var(--success); + box-shadow: 0 0 8px var(--success), 0 0 20px rgba(0, 255, 136, 0.2); + animation: dotPulse 2s ease-in-out infinite; +} +.state-dot.down { + background: var(--danger); + box-shadow: 0 0 4px rgba(255, 51, 102, 0.3); +} +.state-dot.unknown { + background: var(--muted); +} +@keyframes dotPulse { + 0%, 100% { box-shadow: 0 0 8px var(--success), 0 0 20px rgba(0, 255, 136, 0.15); } + 50% { box-shadow: 0 0 14px var(--success), 0 0 30px rgba(0, 255, 136, 0.3); } +} + +.mode-badge { + font-size: .68rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + padding: 3px 10px; + border-radius: 20px; + border: 1px solid; +} +.mode-badge.dhcp { + border-color: rgba(0, 212, 255, 0.4); + color: var(--accent); + background: rgba(0, 212, 255, 0.08); + text-shadow: 0 0 8px rgba(0, 212, 255, 0.3); +} +.mode-badge.static { + border-color: rgba(255, 170, 0, 0.4); + color: var(--warning); + background: rgba(255, 170, 0, 0.08); +} +.mode-badge.loopback { + border-color: rgba(74, 109, 140, 0.4); + color: var(--muted); + background: rgba(74, 109, 140, 0.08); +} + +.pending-badge { + font-size: .68rem; + padding: 2px 8px; + border-radius: 20px; + background: rgba(255, 170, 0, 0.12); + color: var(--warning); + border: 1px solid rgba(255, 170, 0, 0.3); + animation: badgePulse 2s ease-in-out infinite; +} +@keyframes badgePulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .7; } +} + +/* ── Card info ── */ +.card-info { + padding: 0 16px 14px; + display: flex; + flex-direction: column; + gap: 5px; +} +.info-row { + display: flex; + align-items: baseline; + gap: 8px; + font-size: .85rem; +} +.info-label { + color: var(--muted); + min-width: 60px; + font-size: .76rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.info-val { + font-family: "JetBrains Mono", "Fira Code", monospace; + color: var(--text); + font-size: .85rem; + word-break: break-all; +} +.info-val.none { color: var(--muted); font-style: italic; font-family: inherit; } + +/* ── Traffic stats ── */ +.traffic-row { + display: flex; + gap: 16px; + margin-top: 6px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.traffic-item { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} +.traffic-label { + font-size: .7rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .06em; +} +.traffic-val { + font-size: .9rem; + font-family: "JetBrains Mono", "Fira Code", monospace; + color: var(--accent); + text-shadow: 0 0 6px rgba(0, 212, 255, 0.15); +} + +/* ── Card actions ── */ +.card-actions { + display: flex; + gap: 6px; + padding: 10px 16px 14px; + flex-wrap: wrap; +} + +/* ── Loading ── */ +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + height: 200px; + color: var(--muted); +} +.spinner { + width: 36px; height: 36px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .8s linear infinite; + box-shadow: 0 0 15px rgba(0, 212, 255, 0.1); +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Modal ── */ +.modal { position: fixed; inset: 0; z-index: 200; } +.modal-backdrop { + position: absolute; inset: 0; + background: rgba(0, 0, 0, .7); + backdrop-filter: blur(8px); +} +.modal-box { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + background: rgba(10, 18, 40, 0.95); + border: 1px solid var(--border-hi); + border-radius: var(--radius); + box-shadow: var(--shadow), 0 0 40px rgba(0, 212, 255, 0.08); + width: min(480px, calc(100vw - 40px)); + max-height: calc(100vh - 60px); + overflow-y: auto; + animation: modalIn .2s ease; +} +@keyframes modalIn { + from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 20px 14px; + border-bottom: 1px solid var(--border); +} +.modal-header h2 { + font-size: 1rem; + font-weight: 700; + background: linear-gradient(135deg, var(--text), var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 20px 18px; + border-top: 1px solid var(--border); +} + +/* ── Form ── */ +form { padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; } +.form-row { display: flex; flex-direction: column; gap: 5px; } +.form-row > label { + font-size: .76rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: .06em; +} +input[type="text"], input[type="number"], input[type="search"] { + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + padding: 9px 12px; + font-size: .9rem; + font-family: "JetBrains Mono", "Fira Code", monospace; + outline: none; + transition: all .2s ease; + width: 100%; +} +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.15); +} +input::placeholder { color: var(--muted); } + +/* Form divider & hint */ +.form-divider { + border: none; + border-top: 1px solid var(--border); + margin: 2px 0; +} +.form-hint { + font-size: .8rem; + color: var(--warning); + padding: 4px 0 2px; +} +.form-hint code { + font-family: "JetBrains Mono", monospace; + background: rgba(255,170,0,0.1); + border-radius: 4px; + padding: 1px 5px; +} + +/* Checkbox */ +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: .9rem; + color: var(--text); +} +input[type="checkbox"] { + width: 16px; height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +/* Segmented control */ +.segmented { + display: flex; + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 3px; + gap: 2px; +} +.seg-btn { + flex: 1; + padding: 7px; + border: none; + border-radius: calc(var(--radius-sm) - 2px); + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: .85rem; + font-weight: 600; + transition: all .2s ease; +} +.seg-btn.active { + background: linear-gradient(135deg, #0090b3, #00d4ff); + color: #fff; + box-shadow: 0 2px 8px rgba(0, 212, 255, 0.2); +} +.seg-btn:not(.active):hover { background: var(--surface-3); color: var(--text); } + +/* ── Toast ── */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 12px 18px; + border-radius: var(--radius-sm); + font-size: .875rem; + font-weight: 600; + box-shadow: var(--shadow); + z-index: 300; + animation: slideUp .25s ease; + max-width: 340px; + backdrop-filter: blur(10px); +} +.toast.success { + background: rgba(0, 184, 110, 0.9); + color: #fff; + border: 1px solid rgba(0, 255, 136, 0.4); + box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2); +} +.toast.error { + background: rgba(204, 26, 68, 0.9); + color: #fff; + border: 1px solid rgba(255, 51, 102, 0.4); + box-shadow: 0 4px 20px rgba(255, 51, 102, 0.2); +} +.toast.info { + background: rgba(0, 144, 179, 0.9); + color: #fff; + border: 1px solid rgba(0, 212, 255, 0.4); + box-shadow: 0 4px 20px rgba(0, 212, 255, 0.2); +} +@keyframes slideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Utilities ── */ +.hidden { display: none !important; } + +/* ── Tab navigation ── */ +.tab-nav { + display: flex; + gap: 0; + padding: 0 28px; + background: rgba(8, 14, 28, 0.92); + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); +} +.tab-link { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 12px 20px; + font-size: .85rem; + font-weight: 600; + color: var(--muted); + text-decoration: none; + border-bottom: 2px solid transparent; + transition: all .2s ease; + margin-bottom: -1px; + position: relative; +} +.tab-link:hover { + color: var(--text-dim); +} +.tab-link.active { + color: var(--accent); + border-bottom-color: var(--accent); + text-shadow: 0 0 12px rgba(0, 212, 255, 0.3); +} +.tab-link svg { + filter: opacity(0.7); + transition: filter .2s ease; +} +.tab-link.active svg { + filter: opacity(1) drop-shadow(0 0 4px rgba(0, 212, 255, 0.4)); +} + +/* ── DHCP page layout ── */ +.dhcp-main { padding: 28px; display: flex; flex-direction: column; gap: 20px; } + +/* ── Alert banners ── */ +.alert { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 18px; + border-radius: var(--radius); + font-size: .875rem; + line-height: 1.5; + backdrop-filter: blur(10px); +} +.alert-error { + background: rgba(255, 51, 102, 0.08); + border: 1px solid rgba(255, 51, 102, 0.25); + color: #ff8899; +} +.alert code { + font-family: "JetBrains Mono", monospace; + background: rgba(0,0,0,.3); + padding: 2px 6px; + border-radius: 4px; + font-size: .85rem; +} + +/* ── Service status bar ── */ +.dhcp-status-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 20px; + backdrop-filter: blur(10px); +} +.status-info { display: flex; align-items: center; gap: 12px; } +.status-label { font-size: .9rem; font-weight: 700; } +.status-actions { display: flex; align-items: center; gap: 14px; } + +.svc-badge { + font-size: .72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + padding: 3px 12px; + border-radius: 20px; + border: 1px solid; +} +.svc-badge.running { + border-color: rgba(0, 255, 136, 0.4); + color: var(--success); + background: rgba(0, 255, 136, 0.08); + box-shadow: 0 0 10px rgba(0, 255, 136, 0.1); +} +.svc-badge.stopped { + border-color: rgba(74, 109, 140, 0.3); + color: var(--muted); + background: rgba(74, 109, 140, 0.05); +} + +/* Toggle switch */ +.toggle-label { + display: flex; + align-items: center; + gap: 9px; + cursor: pointer; + font-size: .875rem; + color: var(--text); + user-select: none; +} +.toggle-label input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} +.toggle-slider { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + background: var(--surface-3); + border: 1px solid var(--border); + border-radius: 11px; + transition: all .2s ease; + flex-shrink: 0; +} +.toggle-slider::after { + content: ''; + position: absolute; + top: 3px; left: 3px; + width: 15px; height: 15px; + background: var(--text-dim); + border-radius: 50%; + transition: all .2s ease; +} +.toggle-label input:checked + .toggle-slider { + background: rgba(0, 212, 255, 0.2); + border-color: rgba(0, 212, 255, 0.4); +} +.toggle-label input:checked + .toggle-slider::after { + transform: translateX(17px); + background: var(--accent); + box-shadow: 0 0 8px rgba(0, 212, 255, 0.4); +} + +/* ── Pools grid ── */ +.dhcp-section { display: flex; flex-direction: column; gap: 16px; } +.section-header { } +.section-header h2 { + font-size: 1rem; + font-weight: 700; + margin-bottom: 4px; + background: linear-gradient(135deg, var(--text), var(--accent)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.section-desc { font-size: .83rem; color: var(--muted); line-height: 1.5; } + +.pools-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 16px; +} + +.pool-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: all .3s ease; + backdrop-filter: blur(10px); + position: relative; +} +.pool-card:hover { + border-color: var(--border-hi); + box-shadow: var(--glow-sm); + transform: translateY(-1px); +} +.pool-card--active { + border-color: rgba(0, 212, 255, 0.3); + box-shadow: 0 0 15px rgba(0, 212, 255, 0.08); +} +.pool-card--wan { opacity: .6; } + +.pool-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 13px 16px 10px; +} +.pool-iface { display: flex; align-items: center; gap: 9px; } +.pool-iface-name { font-size: 1rem; font-weight: 700; } + +.tag-gw { + font-size: .68rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 20px; + background: rgba(255, 51, 102, 0.1); + color: #ff6688; + border: 1px solid rgba(255, 51, 102, 0.3); + text-transform: uppercase; + letter-spacing: .04em; +} +.tag-active { + font-size: .68rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 20px; + background: rgba(0, 212, 255, 0.1); + color: var(--accent); + border: 1px solid rgba(0, 212, 255, 0.3); + text-transform: uppercase; + letter-spacing: .04em; + text-shadow: 0 0 6px rgba(0, 212, 255, 0.2); +} + +.pool-info { + padding: 0 16px 14px; + display: flex; + flex-direction: column; + gap: 5px; +} +.pool-footer { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px 13px; + border-top: 1px solid var(--border); +} + +.muted { color: var(--muted); font-style: italic; } + +/* ── Pool modal – inline pairs ── */ +.inline-pair { + display: flex; + align-items: center; + gap: 8px; +} +.inline-pair input { flex: 1; } +.pair-sep { color: var(--muted); font-size: .85rem; white-space: nowrap; } +.font-mono { font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace !important; } + +/* ── Empty state ── */ +.empty-state { + text-align: center; + color: var(--muted); + padding: 48px 20px; + font-size: .9rem; +} + +/* ── Clients page ── */ +.clients-main { padding: 28px; display: flex; flex-direction: column; gap: 16px; } + +.clients-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.clients-summary { + display: flex; + align-items: center; + gap: 16px; + font-size: .875rem; +} +.cl-stat { display: flex; align-items: center; gap: 6px; font-weight: 600; } +.cl-stat--muted { color: var(--muted); font-weight: 400; } + +.clients-search { + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + padding: 8px 14px; + font-size: .875rem; + outline: none; + width: 260px; + transition: all .2s ease; +} +.clients-search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.12); +} +.clients-search::placeholder { color: var(--muted); } + +.clients-table-wrap { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + overflow-x: auto; + backdrop-filter: blur(10px); +} + +.clients-table { + width: 100%; + border-collapse: collapse; + font-size: .875rem; +} +.clients-table thead { + background: rgba(0, 212, 255, 0.03); + border-bottom: 1px solid var(--border); +} +.clients-table th { + text-align: left; + padding: 12px 14px; + font-size: .72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--muted); + white-space: nowrap; +} +.clients-table td { + padding: 11px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + vertical-align: middle; +} +.clients-table tr:last-child td { border-bottom: none; } +.clients-table tbody tr { + transition: background .15s ease; +} +.clients-table tbody tr:hover { background: rgba(0, 212, 255, 0.03); } + +.row-offline td { opacity: .45; } + +.col-status { width: 24px; padding-right: 4px; } +.col-host { min-width: 140px; } +.col-ip { min-width: 130px; } +.col-mac { min-width: 150px; } +.col-iface { min-width: 80px; } +.col-type { min-width: 80px; } +.col-lease { min-width: 100px; } + +.client-host { font-weight: 600; } + +.mono { font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; font-size: .83rem; } + +.client-badge { + display: inline-block; + font-size: .68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .04em; + padding: 2px 8px; + border-radius: 20px; + border: 1px solid; +} +.client-badge.dhcp { + background: rgba(0, 212, 255, 0.08); + border-color: rgba(0, 212, 255, 0.35); + color: var(--accent); +} +.client-badge.arp { + background: rgba(74, 109, 140, 0.08); + border-color: rgba(74, 109, 140, 0.25); + color: var(--muted); +} + +.lease-val { font-variant-numeric: tabular-nums; } +.lease-expired { color: var(--danger); } +.lease-soon { color: var(--warning); } + +.col-tx, .col-rx { min-width: 90px; text-align: right; } +.col-activity { min-width: 130px; } +.traffic-num { font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; font-size: .83rem; font-variant-numeric: tabular-nums; } +.activity-val { font-variant-numeric: tabular-nums; } +.active-now { color: var(--success); text-shadow: 0 0 6px rgba(0, 255, 136, 0.3); } + +/* ── Client blocked row ── */ +.row-blocked { background: rgba(255, 51, 102, 0.04); } +.row-blocked:hover { background: rgba(255, 51, 102, 0.08); } + +.client-row { cursor: pointer; transition: all .15s ease; } +.client-row:hover { background: rgba(0, 212, 255, 0.04); } +.client-row.row-blocked:hover { background: rgba(255, 51, 102, 0.12); } + +.blocked-badge { + margin-left: 6px; + background: rgba(255, 51, 102, 0.12) !important; + border-color: rgba(255, 51, 102, 0.5) !important; + color: var(--danger) !important; +} + +.cl-stat--blocked { color: var(--danger); font-weight: 600; } + +.static-badge { + background: rgba(0, 212, 255, 0.1) !important; + border-color: rgba(0, 212, 255, 0.4) !important; + color: var(--accent) !important; +} + +.ip-info-row { display: flex; align-items: baseline; gap: 8px; } +.ip-current-label { font-size: .78rem; color: var(--muted); } + +.form-val { font-size: .9rem; color: var(--text); } + +.toggle-blocked .toggle-slider { + background: rgba(255, 51, 102, 0.2) !important; + border-color: rgba(255, 51, 102, 0.4) !important; +} + +/* ── Proxy page ── */ +.proxy-main { padding: 28px; display: flex; flex-direction: column; gap: 20px; } + +.proxy-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + background: rgba(8, 14, 28, 0.92); + border-radius: var(--radius-sm); + overflow: hidden; +} +.ptab { + padding: 10px 20px; + font-size: .85rem; + font-weight: 600; + color: var(--muted); + background: transparent; + border: none; + cursor: pointer; + transition: all .2s ease; + border-bottom: 2px solid transparent; +} +.ptab:hover { color: var(--text-dim); } +.ptab.active { + color: var(--accent); + border-bottom-color: var(--accent); + text-shadow: 0 0 12px rgba(0, 212, 255, 0.3); +} + +.ptab-content { + min-height: 200px; +} + +.proxy-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + gap: 16px; +} + +.proxy-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + backdrop-filter: blur(10px); + transition: all .3s ease; + position: relative; +} +.proxy-card:hover { + border-color: var(--border-hi); + box-shadow: var(--glow-sm); + transform: translateY(-1px); +} +.proxy-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} +.proxy-name { + font-weight: 700; + font-size: .95rem; + color: var(--text); +} +.proxy-card-info { + padding: 10px 16px 14px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.rules-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.rule-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: .85rem; + transition: all .2s ease; +} +.rule-item:hover { + border-color: var(--border-hi); + box-shadow: var(--glow-sm); +} +.rule-info { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} +.rule-type { + font-size: .68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .04em; + padding: 2px 8px; + border-radius: 20px; + background: rgba(0, 212, 255, 0.1); + border: 1px solid rgba(0, 212, 255, 0.3); + color: var(--accent); + white-space: nowrap; +} +.rule-value { + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: .83rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rule-target { + font-size: .8rem; + font-weight: 600; + color: var(--success); +} +.rule-flags { + font-size: .7rem; + color: var(--muted); +} + +.proxy-form { + display: flex; + flex-direction: column; + gap: 14px; +} +.proxy-form h3 { margin-bottom: 0; } + +textarea { + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + padding: 9px 12px; + font-size: .85rem; + font-family: "JetBrains Mono", "Fira Code", monospace; + outline: none; + transition: all .2s ease; + width: 100%; + resize: vertical; +} +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.15); +} +textarea::placeholder { color: var(--muted); } + +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%237aa2cc' stroke-width='1.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 30px; +} + +/* ── Scanline overlay (subtle futuristic effect) ── */ +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 212, 255, 0.008) 2px, + rgba(0, 212, 255, 0.008) 4px + ); +} \ No newline at end of file diff --git a/traffic/tracker.go b/traffic/tracker.go new file mode 100644 index 0000000..4bd8bc6 --- /dev/null +++ b/traffic/tracker.go @@ -0,0 +1,194 @@ +package traffic + +import ( + "bufio" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const ( + pollInterval = 20 * time.Second + OnlineWindow = 5 * time.Minute + trackerTableName = "alpine-router-traffic" +) + +type IPStats struct { + TxBytes uint64 + RxBytes uint64 + LastActive time.Time +} + +var ( + mu sync.RWMutex + stats = map[string]*IPStats{} + prev = map[string][2]uint64{} + useNFT bool +) + +func Available() bool { + mu.RLock() + v := useNFT + mu.RUnlock() + return v +} + +func Start() { + if _, err := exec.LookPath("nft"); err != nil { + return + } + if err := setupNFTTable(); err != nil { + return + } + mu.Lock() + useNFT = true + mu.Unlock() + + go func() { + poll() + t := time.NewTicker(pollInterval) + defer t.Stop() + for range t.C { + poll() + } + }() +} + +func Get(ip string) IPStats { + mu.RLock() + defer mu.RUnlock() + if s, ok := stats[ip]; ok { + return *s + } + return IPStats{} +} + +func IsOnline(ip string) bool { + s := Get(ip) + return !s.LastActive.IsZero() && time.Since(s.LastActive) < OnlineWindow +} + +func EnsureIPTracked(ip string) { + if ip == "" { + return + } + mu.Lock() + defer mu.Unlock() + if _, ok := prev[ip]; ok { + return + } + prev[ip] = [2]uint64{} + addNFTRule(ip) +} + +func setupNFTTable() error { + exec.Command("nft", "delete", "table", "ip", trackerTableName).Run() + + script := fmt.Sprintf(`table ip %s { + chain tx { + type filter hook forward priority filter + 10; policy accept; + } + chain rx { + type filter hook forward priority filter + 20; policy accept; + } +}`, trackerTableName) + + cmd := exec.Command("nft", "-f", "-") + cmd.Stdin = strings.NewReader(script) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("nft setup: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +func addNFTRule(ip string) { + exec.Command("nft", "add", "rule", "ip", trackerTableName, "tx", + "ip", "saddr", ip, "counter").Run() + exec.Command("nft", "add", "rule", "ip", trackerTableName, "rx", + "ip", "daddr", ip, "counter").Run() +} + +var txCounterRe = regexp.MustCompile(`ip saddr (\S+) counter packets \d+ bytes (\d+)`) +var rxCounterRe = regexp.MustCompile(`ip daddr (\S+) counter packets \d+ bytes (\d+)`) + +func poll() { + mu.RLock() + nft := useNFT + mu.RUnlock() + if !nft { + return + } + + current, err := readNFTCounters() + if err != nil { + return + } + + now := time.Now() + mu.Lock() + defer mu.Unlock() + + for ip, cur := range current { + p := prev[ip] + dTx := delta(p[0], cur[0]) + dRx := delta(p[1], cur[1]) + + s := stats[ip] + if s == nil { + s = &IPStats{} + stats[ip] = s + } + s.TxBytes += dTx + s.RxBytes += dRx + if dTx > 0 || dRx > 0 { + s.LastActive = now + } + prev[ip] = cur + } + + for ip := range prev { + if _, exists := current[ip]; !exists { + prev[ip] = [2]uint64{0, 0} + } + } +} + +func readNFTCounters() (map[string][2]uint64, error) { + cmd := exec.Command("nft", "list", "table", "ip", trackerTableName) + out, err := cmd.Output() + if err != nil { + return nil, err + } + + result := map[string][2]uint64{} + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + line := scanner.Text() + if m := txCounterRe.FindStringSubmatch(line); m != nil { + ip := m[1] + b, _ := strconv.ParseUint(m[2], 10, 64) + cur := result[ip] + cur[0] += b + result[ip] = cur + } else if m := rxCounterRe.FindStringSubmatch(line); m != nil { + ip := m[1] + b, _ := strconv.ParseUint(m[2], 10, 64) + cur := result[ip] + cur[1] += b + result[ip] = cur + } + } + return result, nil +} + +func delta(old, cur uint64) uint64 { + if cur >= old { + return cur - old + } + return cur +} \ No newline at end of file