first commit

This commit is contained in:
2026-04-13 09:46:02 +03:00
commit 7eaa9750b0
33 changed files with 7357 additions and 0 deletions

1
Meta-Docs Submodule

Submodule Meta-Docs added at d31369ab45

165
README.md Normal file
View File

@@ -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://<IP-роутера>: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`

8
RULES.md Normal file
View File

@@ -0,0 +1,8 @@
1. Все настройки обязательно сохранять в config.yaml и восстанавливать оттуда при первом запуске бинарника.
2. Функциональные разделы админки писать отдельными html страницами и добавлять в главное меню.
Установить пакеты:
dnsmasq
nftables

View File

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

BIN
alpine-router Executable file

Binary file not shown.

306
clients/clients.go Normal file
View File

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

229
config/config.go Normal file
View File

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

222
dhcp/config.go Normal file
View File

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

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module alpine-router
go 1.21
require gopkg.in/yaml.v3 v3.0.1 // indirect

3
go.sum Normal file
View File

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

244
handlers/api.go Normal file
View File

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

175
handlers/clients.go Normal file
View File

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

158
handlers/dhcp.go Normal file
View File

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

200
handlers/mihomo.go Normal file
View File

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

71
handlers/nat.go Normal file
View File

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

287
main.go Normal file
View File

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

BIN
mihomo-linux-amd64-v1.19.23 Normal file

Binary file not shown.

35
mihomo/default.yaml Normal file
View File

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

322
mihomo/mihomo.go Normal file
View File

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

123
nat/nat.go Normal file
View File

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

86
network/apply.go Normal file
View File

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

195
network/config.go Normal file
View File

@@ -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 <name> <family> <method>
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)
}

143
network/interfaces.go Normal file
View File

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

388
public/app.js Normal file
View File

@@ -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 =>
`<div class="info-row"><span class="info-label">IPv6</span><span class="info-val">${a}</span></div>`
).join('');
card.innerHTML = `
<div class="card-header">
<div class="card-name">
<span class="state-dot ${sc}"></span>
<span>${iface.name}</span>
${hasPending ? '<span class="pending-badge">несохранено</span>' : ''}
</div>
<div style="display:flex;gap:6px;align-items:center">
<span class="mode-badge ${iface.mode || 'unknown'}">${modeLabel(iface.mode)}</span>
</div>
</div>
<div class="card-info">
<div class="info-row">
<span class="info-label">Статус</span>
<span class="info-val">${iface.state || 'unknown'}</span>
</div>
<div class="info-row">
<span class="info-label">IPv4</span>
<span class="info-val">${iface.ipv4
? iface.ipv4 + (iface.ipv4_mask ? ' / ' + iface.ipv4_mask : '')
: '<span class="none">&mdash;</span>'}</span>
</div>
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">&mdash;</span></div>`}
<div class="info-row">
<span class="info-label">Шлюз</span>
<span class="info-val">${iface.gateway || '<span class="none">&mdash;</span>'}</span>
</div>
<div class="traffic-row">
<div class="traffic-item">
<span class="traffic-label">RX</span>
<span class="traffic-val">${fmtBytes(iface.rx_bytes)}</span>
</div>
<div class="traffic-item">
<span class="traffic-label">TX</span>
<span class="traffic-val">${fmtBytes(iface.tx_bytes)}</span>
</div>
<div class="traffic-item">
<span class="traffic-label">Пакеты</span>
<span class="traffic-val">${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0}</span>
</div>
</div>
</div>
<div class="card-actions">
<button class="btn btn-success btn-sm" data-action="up" data-iface="${iface.name}">ON</button>
<button class="btn btn-danger btn-sm" data-action="down" data-iface="${iface.name}">OFF</button>
<button class="btn btn-ghost btn-sm" data-action="restart" data-iface="${iface.name}">RESTART</button>
<button class="btn btn-primary btn-sm" data-action="config" data-iface="${iface.name}" style="margin-left:auto">CONFIG</button>
</div>
`;
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();
})();

161
public/clients.html Normal file
View File

@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Клиенты — AlpineRouter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>AlpineRouter</h1>
</div>
<div class="header-right">
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
Обновить
</button>
</div>
</header>
<nav class="tab-nav">
<a href="/" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Прокси
</a>
</nav>
<main class="clients-main">
<div class="clients-toolbar">
<div class="clients-summary" id="clientsSummary"></div>
<input type="search" id="clientsSearch" class="clients-search" placeholder="Поиск по хосту, IP, MAC…">
</div>
<div id="loading" class="loading">
<div class="spinner"></div>
<span>Загрузка...</span>
</div>
<div id="emptyState" class="empty-state hidden">
Нет подключённых устройств
</div>
<div id="clientsTableWrap" class="clients-table-wrap hidden">
<table class="clients-table">
<thead>
<tr>
<th></th>
<th>Хост</th>
<th>IP-адрес</th>
<th>MAC-адрес</th>
<th>Интерфейс</th>
<th>Тип</th>
<th class="col-tx">↑ Отправлено</th>
<th class="col-rx">↓ Получено</th>
<th>Активность</th>
</tr>
</thead>
<tbody id="clientsBody"></tbody>
</table>
</div>
</main>
<div id="clientModal" class="modal hidden">
<div class="modal-backdrop" id="modalBackdrop"></div>
<div class="modal-box">
<div class="modal-header">
<h2 id="modalTitle">Устройство</h2>
<button class="btn-icon" id="modalClose" title="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<form id="clientForm" autocomplete="off">
<div class="form-row">
<label>Имя устройства</label>
<input type="text" id="modalHostname" placeholder="Например: Ноутбук Анны">
</div>
<hr class="form-divider">
<div class="form-row">
<label>IP-адрес</label>
<div class="ip-info-row">
<span class="form-val mono" id="modalIP"></span>
<span class="ip-current-label" id="modalIPCurrent"></span>
</div>
</div>
<div class="form-row">
<label for="modalStaticIP">Фиксированный IP</label>
<input type="text" id="modalStaticIP" placeholder="Например: 192.168.1.100">
<span class="form-hint" id="modalStaticHint">Оставьте пустым для динамического назначения через DHCP</span>
</div>
<div class="form-row">
<label>MAC-адрес</label>
<span class="form-val mono" id="modalMAC"></span>
</div>
<div class="form-row">
<label>Интерфейс</label>
<span class="form-val" id="modalIface"></span>
</div>
<hr class="form-divider">
<div class="form-row" style="flex-direction:row; align-items:center; justify-content:space-between;">
<div>
<div style="font-weight:600;">Доступ в интернет</div>
<div style="font-size:.8rem;color:var(--muted);margin-top:2px;" id="modalBlockHint">Отключите, чтобы запретить устройству выход в интернет</div>
</div>
<label class="toggle-label" id="modalBlockToggle">
<input type="checkbox" id="modalBlocked">
<span class="toggle-slider"></span>
</label>
</div>
<div class="modal-footer" style="padding:18px 0 0;">
<button type="button" class="btn btn-ghost" id="modalCancel">Отмена</button>
<button type="submit" class="btn btn-primary" id="modalSave">Сохранить</button>
</div>
</form>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="clients.js"></script>
</body>
</html>

314
public/clients.js Normal file
View File

@@ -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 =
`<span class="cl-stat"><span class="state-dot up"></span>${onlineCount} онлайн</span>` +
`<span class="cl-stat cl-stat--muted">${allClients.length} всего</span>` +
`<span class="cl-stat cl-stat--muted">${dhcpCount} по DHCP</span>` +
(blockedCount > 0 ? `<span class="cl-stat cl-stat--blocked">${blockedCount} заблокировано</span>` : '');
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
? '<span class="client-badge dhcp">DHCP</span>'
: '<span class="client-badge arp">ARP</span>';
const hostname = c.hostname
? `<span class="client-host">${escHtml(c.hostname)}</span>`
: '<span class="none">—</span>';
const txHtml = c.tx_bytes
? `<span class="traffic-num">${fmtBytes(c.tx_bytes)}</span>`
: '<span class="none">—</span>';
const rxHtml = c.rx_bytes
? `<span class="traffic-num">${fmtBytes(c.rx_bytes)}</span>`
: '<span class="none">—</span>';
const actHtml = activity.text !== '—'
? `<span class="activity-val ${activity.cls}">${activity.text}</span>`
: '<span class="none">—</span>';
const ipDisplay = c.static_ip
? `<span class="mono">${escHtml(c.static_ip)}</span> <span class="client-badge static-badge">фикс.</span>`
: `<span class="mono">${escHtml(c.ip)}</span>`;
const blockedBadge = c.blocked
? ' <span class="client-badge blocked-badge">ЗАБЛОКИРОВАН</span>'
: '';
tr.innerHTML = `
<td class="col-status">
<span class="state-dot ${online ? 'up' : 'down'}"
title="${online ? 'онлайн' : 'офлайн'}"></span>
</td>
<td class="col-host">${hostname}${blockedBadge}</td>
<td class="col-ip">${ipDisplay}</td>
<td class="col-mac"><span class="mono muted">${escHtml(c.mac || '—')}</span></td>
<td class="col-iface">${escHtml(c.interface || '—')}</td>
<td class="col-type">${typeCell}</td>
<td class="col-tx">${txHtml}</td>
<td class="col-rx">${rxHtml}</td>
<td class="col-activity">${actHtml}</td>
`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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();

179
public/dhcp.html Normal file
View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DHCP сервер — AlpineRouter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>AlpineRouter</h1>
</div>
<div class="header-right">
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
Обновить
</button>
</div>
</header>
<nav class="tab-nav">
<a href="/" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Прокси
</a>
</nav>
<main class="dhcp-main">
<!-- Not-installed warning -->
<div id="notInstalledBanner" class="alert alert-error hidden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
</svg>
<div>
<strong>Пакет dnsmasq не установлен.</strong>
Для работы DHCP-сервера выполните на роутере:
<code>apk add dnsmasq</code>
</div>
</div>
<!-- Service status bar -->
<div class="dhcp-status-bar" id="statusBar">
<div class="status-info">
<span class="status-label">DHCP сервер (dhcpd)</span>
<span id="svcStatus" class="svc-badge stopped">остановлен</span>
</div>
<div class="status-actions">
<label class="toggle-label" id="enableToggleWrap" title="Включить/выключить DHCP сервер">
<span>Включить сервер</span>
<input type="checkbox" id="enableToggle">
<span class="toggle-slider"></span>
</label>
<button class="btn btn-primary" id="applyBtn" disabled>Применить</button>
</div>
</div>
<!-- Pools section -->
<section class="dhcp-section">
<div class="section-header">
<h2>Пулы адресов</h2>
<p class="section-desc">
Каждый пул привязан к одному интерфейсу. Интерфейсы со шлюзом (WAN/uplink)
недоступны для DHCP — они помечены <span class="tag-gw">WAN</span>.
</p>
</div>
<div id="poolsLoading" class="loading" style="height:120px">
<div class="spinner"></div>
<span>Загрузка...</span>
</div>
<div id="poolsGrid" class="pools-grid hidden"></div>
<div id="noIfaces" class="empty-state hidden">
Нет подходящих интерфейсов (с IP-адресом и без шлюза)
</div>
</section>
</main>
<!-- Pool edit modal -->
<div id="poolModal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" id="poolModalBackdrop"></div>
<div class="modal-box">
<div class="modal-header">
<h2 id="poolModalTitle">Настройка пула</h2>
<button class="btn-icon" id="closePoolModal" title="Закрыть"></button>
</div>
<form id="poolForm" autocomplete="off">
<input type="hidden" id="pIface">
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="pEnabled">
<span>Пул активен</span>
</label>
</div>
<div class="form-row">
<label>Подсеть</label>
<div class="inline-pair">
<input type="text" id="pSubnet" placeholder="172.16.54.0" class="font-mono">
<span class="pair-sep">маска</span>
<input type="text" id="pNetmask" placeholder="255.255.255.0" class="font-mono">
</div>
</div>
<div class="form-row">
<label>Диапазон адресов</label>
<div class="inline-pair">
<input type="text" id="pRangeStart" placeholder="172.16.54.100" class="font-mono">
<span class="pair-sep"></span>
<input type="text" id="pRangeEnd" placeholder="172.16.54.200" class="font-mono">
</div>
</div>
<div class="form-row">
<label for="pRouter">Шлюз (option routers)</label>
<input type="text" id="pRouter" placeholder="172.16.54.1" class="font-mono">
</div>
<div class="form-row">
<label for="pDNS">DNS-серверы (через пробел)</label>
<input type="text" id="pDNS" placeholder="8.8.8.8 8.8.4.4" class="font-mono">
</div>
<div class="form-row">
<label for="pLease">Время аренды (секунды)</label>
<input type="number" id="pLease" placeholder="86400" min="60" max="604800">
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancelPoolBtn">Отмена</button>
<button class="btn btn-primary" id="savePoolBtn">Сохранить</button>
</div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="dhcp.js"></script>
</body>
</html>

344
public/dhcp.js Normal file
View File

@@ -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 = `
<div class="pool-header">
<div class="pool-iface">
<span class="state-dot ${iface.ipv4 ? 'up' : 'unknown'}"></span>
<span class="pool-iface-name">${iface.name}</span>
${isWAN ? '<span class="tag-gw">WAN</span>' : ''}
${(!isWAN && enabled) ? '<span class="tag-active">DHCP активен</span>' : ''}
</div>
${!isWAN ? `<button class="btn btn-primary btn-sm" data-edit="${iface.name}">
${pool ? '⚙ Изменить' : '+ Добавить пул'}
</button>` : ''}
</div>
<div class="pool-info">
${iface.ipv4
? `<div class="info-row"><span class="info-label">IP</span>
<span class="info-val">${iface.ipv4}${iface.ipv4_mask ? ' / ' + iface.ipv4_mask : ''}</span></div>`
: `<div class="info-row"><span class="info-val none">нет IP-адреса</span></div>`}
${isWAN
? `<div class="info-row"><span class="info-val muted">Интерфейс имеет шлюз — DHCP не раздаётся</span></div>`
: pool
? `
<div class="info-row">
<span class="info-label">Подсеть</span>
<span class="info-val">${pool.subnet} / ${pool.netmask || '?'}</span>
</div>
<div class="info-row">
<span class="info-label">Диапазон</span>
<span class="info-val">${pool.range_start || '—'}${pool.range_end || '—'}</span>
</div>
<div class="info-row">
<span class="info-label">Шлюз</span>
<span class="info-val">${pool.router || '—'}</span>
</div>
<div class="info-row">
<span class="info-label">DNS</span>
<span class="info-val">${(pool.dns || []).join(', ') || '—'}</span>
</div>
<div class="info-row">
<span class="info-label">Аренда</span>
<span class="info-val">${fmtLease(pool.lease_time)}</span>
</div>
`
: `<div class="info-row"><span class="info-val muted">Пул не настроен</span></div>`
}
</div>
${(!isWAN && pool) ? `
<div class="pool-footer">
<label class="checkbox-label" style="font-size:.83rem">
<input type="checkbox" class="pool-enabled-chk" data-iface="${iface.name}" ${enabled ? 'checked' : ''}>
<span>Активен</span>
</label>
<button class="btn btn-danger btn-sm" data-remove="${iface.name}" style="margin-left:auto">Удалить пул</button>
</div>` : ''}
`;
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();

151
public/index.html Normal file
View File

@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AlpineRouter</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>AlpineRouter</h1>
</div>
<div class="header-right">
<span id="hostname" class="hostname"></span>
<button class="btn btn-ghost" id="refreshBtn" title="Обновить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
Обновить
</button>
</div>
</header>
<div id="pendingBanner" class="pending-banner hidden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/>
</svg>
<span>Есть несохранённые изменения: <strong id="pendingList"></strong></span>
<div class="banner-actions">
<button class="btn btn-success" id="applyBtn">Применить</button>
<button class="btn btn-ghost" id="discardAllBtn">Отменить всё</button>
</div>
</div>
<nav class="tab-nav">
<a href="/" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Прокси
</a>
</nav>
<main>
<div id="loading" class="loading">
<div class="spinner"></div>
<span>Загрузка...</span>
</div>
<div id="ifaceGrid" class="iface-grid hidden"></div>
</main>
<!-- Config Modal -->
<div id="modal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" id="modalBackdrop"></div>
<div class="modal-box">
<div class="modal-header">
<h2 id="modalTitle">Настройка интерфейса</h2>
<button class="btn-icon" id="closeModal" title="Закрыть"></button>
</div>
<form id="configForm" autocomplete="off">
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="cfgAuto">
<span>Автозапуск (auto)</span>
</label>
</div>
<div class="form-row">
<label>Режим</label>
<div class="segmented" id="modeSwitch">
<button type="button" class="seg-btn active" data-mode="dhcp">DHCP</button>
<button type="button" class="seg-btn" data-mode="static">Статический</button>
</div>
</div>
<div id="staticFields" class="hidden">
<div class="form-row">
<label for="cfgAddress">IP-адрес</label>
<input type="text" id="cfgAddress" placeholder="192.168.1.100" pattern="[\d\.]+">
</div>
<div class="form-row">
<label for="cfgNetmask">Маска сети</label>
<input type="text" id="cfgNetmask" placeholder="255.255.255.0">
</div>
<div class="form-row">
<label for="cfgGateway">Шлюз</label>
<input type="text" id="cfgGateway" placeholder="192.168.1.1">
</div>
<div class="form-row">
<label for="cfgDNS">DNS (через пробел)</label>
<input type="text" id="cfgDNS" placeholder="8.8.8.8 8.8.4.4">
</div>
</div>
<div id="natSection" class="hidden">
<div class="form-divider"></div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="cfgNAT">
<span>NAT / Masquerade — выход клиентов в интернет</span>
</label>
</div>
<div id="natNotInstalled" class="form-hint hidden">
⚠ nftables не установлен — выполните: <code>apk add nftables</code>
</div>
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancelConfigBtn">Отмена</button>
<button class="btn btn-primary" id="saveConfigBtn">Сохранить</button>
</div>
</div>
</div>
<!-- Notification toast -->
<div id="toast" class="toast hidden"></div>
<script src="app.js"></script>
</body>
</html>

534
public/proxy.html Normal file
View File

@@ -0,0 +1,534 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AlpineRouter — Прокси</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>AlpineRouter</h1>
</div>
<div class="header-right">
<span id="statusBadge" class="svc-badge stopped">Остановлен</span>
</div>
</header>
<nav class="tab-nav">
<a href="/" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/proxy.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Прокси
</a>
</nav>
<main class="proxy-main">
<div id="loading" class="loading">
<div class="spinner"></div>
<span>Загрузка...</span>
</div>
<!-- Status Bar -->
<div id="statusBar" class="dhcp-status-bar hidden">
<div class="status-info">
<span class="status-label">Mihomo</span>
<span id="statusText" class="svc-badge stopped">Остановлен</span>
</div>
<div class="status-actions">
<button class="btn btn-success btn-sm" id="startBtn">Запустить</button>
<button class="btn btn-danger btn-sm" id="stopBtn" disabled>Остановить</button>
<button class="btn btn-ghost btn-sm" id="restartBtn" disabled>Перезапуск</button>
</div>
</div>
<!-- Core Info -->
<div id="coreInfo" class="alert alert-error hidden" style="margin-top:16px">
<span id="coreInfoMsg">Ядро Mihomo не найдено. Загрузите бинарный файл в раздел «Ядро».</span>
</div>
<!-- Tabs inside proxy page -->
<div class="proxy-tabs">
<button class="ptab active" data-tab="proxies">Прокси</button>
<button class="ptab" data-tab="groups">Группы</button>
<button class="ptab" data-tab="rules">Правила</button>
<button class="ptab" data-tab="settings">Настройки</button>
<button class="ptab" data-tab="logs">Логи</button>
<button class="ptab" data-tab="core">Ядро</button>
</div>
<!-- Proxies Tab -->
<div id="tab-proxies" class="ptab-content">
<div class="section-header" style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<h2>Прокси-ноды</h2>
<div class="section-desc">Добавьте прокси-серверы для маршрутизации трафика</div>
</div>
<button class="btn btn-primary btn-sm" id="addProxyBtn">+ Добавить</button>
</div>
</div>
<div id="proxyList" class="proxy-list"></div>
<div id="proxyEmpty" class="empty-state hidden">Нет прокси-нод. Нажмите «Добавить» для создания.</div>
</div>
<!-- Groups Tab -->
<div id="tab-groups" class="ptab-content hidden">
<div class="section-header" style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<h2>Группы прокси</h2>
<div class="section-desc">Балансировщики, селекторы и URL-тесты</div>
</div>
<button class="btn btn-primary btn-sm" id="addGroupBtn">+ Добавить группу</button>
</div>
</div>
<div id="groupList" class="proxy-list"></div>
<div id="groupEmpty" class="empty-state hidden">Нет групп. Нажмите «Добавить группу» для создания.</div>
</div>
<!-- Rules Tab -->
<div id="tab-rules" class="ptab-content hidden">
<div class="section-header" style="margin-bottom:16px">
<h2>Правила маршрутизации</h2>
<div class="section-desc">Определите, какой трафик куда направляется. Правила apply сверху вниз.</div>
</div>
<div class="form-row" style="margin-bottom:12px">
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Добавить правило</button>
<button class="btn btn-ghost btn-sm" id="addBlockBtn" style="margin-left:8px">+ Блокировка домена</button>
</div>
<div id="rulesList" class="rules-list"></div>
</div>
<!-- Settings Tab -->
<div id="tab-settings" class="ptab-content hidden">
<div class="section-header" style="margin-bottom:16px">
<h2>Настройки Mihomo</h2>
</div>
<form id="settingsForm" class="proxy-form">
<div class="form-row">
<label>Режим</label>
<div class="segmented" id="modeSwitch">
<button type="button" class="seg-btn active" data-mode="rule">Правила</button>
<button type="button" class="seg-btn" data-mode="global">Глобальный</button>
<button type="button" class="seg-btn" data-mode="direct">Прямой</button>
</div>
</div>
<div class="form-row">
<label for="mixedPort">Mixed Port (HTTP+SOCKS)</label>
<input type="number" id="mixedPort" value="7890">
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="allowLan" checked>
<span>Разрешить LAN</span>
</label>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="ipv6" checked>
<span>IPv6</span>
</label>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="tcpConcurrent" checked>
<span>TCP Concurrency</span>
</label>
</div>
<div class="form-divider"></div>
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">TProxy (прозрачный прокси)</h3>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="tproxyEnabled">
<span>Включить TProxy</span>
</label>
</div>
<div class="form-row">
<label for="tproxyPort">TProxy порт</label>
<input type="number" id="tproxyPort" value="7894">
</div>
<div class="form-divider"></div>
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">DNS</h3>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="dnsEnabled" checked>
<span>DNS сервер</span>
</label>
</div>
<div class="form-row">
<label for="dnsListen">DNS адрес</label>
<input type="text" id="dnsListen" value="0.0.0.0:1053">
</div>
<div class="form-row">
<label for="dnsMode">DNS режим</label>
<div class="segmented" id="dnsModeSwitch">
<button type="button" class="seg-btn active" data-mode="redir-host">redir-host</button>
<button type="button" class="seg-btn" data-mode="fake-ip">fake-ip</button>
</div>
</div>
<div class="form-row">
<label for="dnsNameserver">DNS серверы (по строке)</label>
<textarea id="dnsNameserver" rows="3" style="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;width:100%;resize:vertical">https://doh.pub/dns-query
https://dns.alidns.com/dns-query</textarea>
</div>
<div class="form-row">
<label for="dnsFallback">Fallback DNS (по строке)</label>
<textarea id="dnsFallback" rows="2" style="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;width:100%;resize:vertical">tls://8.8.8.8:853
tls://1.1.1.1:853</textarea>
</div>
<div class="form-divider"></div>
<div class="form-row">
<label for="externalController">External Controller</label>
<input type="text" id="externalController" value="0.0.0.0:9090">
</div>
<div class="form-row">
<label for="secret">Secret (API ключ)</label>
<input type="text" id="secret" placeholder="опционально">
</div>
<div style="margin-top:16px;display:flex;gap:8px">
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
<button type="button" class="btn btn-ghost" id="saveAndRestartBtn">Сохранить и перезапустить</button>
</div>
</form>
<div class="form-divider" style="margin:20px 0"></div>
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:8px">config.yaml (только чтение)</h3>
<div class="section-desc" style="margin-bottom:8px">Текущий конфиг mihomo. Обновляется автоматически после сохранения настроек.</div>
<div class="form-row">
<textarea id="yamlPreview" rows="18" readonly style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-muted);padding:9px 12px;font-size:.8rem;font-family:'JetBrains Mono','Fira Code',monospace;width:100%;resize:vertical;opacity:.85"></textarea>
</div>
</div>
<!-- Logs Tab -->
<div id="tab-logs" class="ptab-content hidden">
<div class="section-header" style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<h2>Логи ядра</h2>
<div class="section-desc">Вывод процесса mihomo (обновление каждые 500мс)</div>
</div>
<button class="btn btn-ghost btn-sm" id="clearLogsBtn">Очистить</button>
</div>
</div>
<div id="logOutput" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:'JetBrains Mono','Fira Code',monospace;font-size:.78rem;color:var(--text-muted);min-height:200px;max-height:calc(100vh - 300px);overflow-y:auto;white-space:pre-wrap;word-break:break-all"></div>
</div>
<!-- Core Tab -->
<div id="tab-core" class="ptab-content hidden">
<div class="section-header" style="margin-bottom:16px">
<h2>Ядро Mihomo</h2>
<div class="section-desc">Управление бинарным файлом ядра</div>
</div>
<div id="coreStatus" class="proxy-card" style="margin-bottom:16px">
<div class="card-info">
<div class="info-row"><span class="info-label">Путь</span><span class="info-val" id="corePath"></span></div>
<div class="info-row"><span class="info-label">Наличие</span><span class="info-val" id="coreExists"></span></div>
<div class="info-row"><span class="info-label">PID</span><span class="info-val" id="corePid"></span></div>
</div>
</div>
<div class="form-divider" style="margin-bottom:16px"></div>
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">Загрузить ядро</h3>
<div class="section-desc" style="margin-bottom:12px">Загрузите бинарный файл mihomo (например, mihomo-linux-amd64). Файл автоматически определит архитектуру по имени.</div>
<form id="uploadCoreForm">
<div class="form-row">
<input type="file" id="coreFile" accept=".gz,.zip,application/octet-stream" style="color:var(--text)">
</div>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:8px">Загрузить</button>
</form>
<div class="form-divider" style="margin:20px 0"></div>
<h3 style="color:var(--text);font-size:.95rem;margin-bottom:12px">Ручная конфигурация (config.yaml)</h3>
<div class="section-desc" style="margin-bottom:8px">Редактировать конфигурационный файл напрямую</div>
<div class="form-row">
<textarea id="yamlEditor" rows="20" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.8rem;font-family:'JetBrains Mono','Fira Code',monospace;width:100%;resize:vertical"></textarea>
</div>
<div style="margin-top:8px;display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="yamlLoadBtn">Загрузить</button>
<button class="btn btn-success btn-sm" id="yamlSaveBtn">Сохранить</button>
</div>
</div>
</div>
<!-- Proxy Modal -->
<div id="proxyModal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" id="proxyModalBackdrop"></div>
<div class="modal-box" style="width:min(560px,calc(100vw - 40px))">
<div class="modal-header">
<h2 id="proxyModalTitle">Добавить прокси</h2>
<button class="btn-icon" id="closeProxyModal" title="Закрыть"></button>
</div>
<form id="proxyForm" autocomplete="off" style="max-height:60vh;overflow-y:auto">
<div class="form-row">
<label for="proxyType">Тип</label>
<select id="proxyType" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
<option value="ss">Shadowsocks</option>
<option value="vmess">VMess</option>
<option value="vless">VLESS</option>
<option value="trojan">Trojan</option>
<option value="hysteria2">Hysteria2</option>
<option value="http">HTTP</option>
<option value="socks5">SOCKS5</option>
<option value="direct">DIRECT</option>
</select>
</div>
<div class="form-row">
<label for="proxyName">Имя</label>
<input type="text" id="proxyName" placeholder="my-proxy">
</div>
<div id="proxyServerFields">
<div class="form-row">
<label for="proxyServer">Сервер</label>
<input type="text" id="proxyServer" placeholder="example.com">
</div>
<div class="form-row">
<label for="proxyPort">Порт</label>
<input type="number" id="proxyPort" placeholder="443">
</div>
</div>
<div id="proxyAuthFields" class="hidden">
<div class="form-row">
<label for="proxyUsername">Имя пользователя</label>
<input type="text" id="proxyUsername" placeholder="username">
</div>
<div class="form-row">
<label for="proxyPassword">Пароль</label>
<input type="text" id="proxyPassword" placeholder="password">
</div>
</div>
<div id="proxyCipherField" class="hidden">
<div class="form-row">
<label for="proxyCipher">Шифр</label>
<select id="proxyCipher" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
<option value="auto">auto</option>
<option value="aes-128-gcm">aes-128-gcm</option>
<option value="aes-256-gcm">aes-256-gcm</option>
<option value="chacha20-ietf-poly1305">chacha20-ietf-poly1305</option>
<option value="none">none</option>
</select>
</div>
</div>
<div id="proxyUUIDField" class="hidden">
<div class="form-row">
<label for="proxyUUID">UUID</label>
<input type="text" id="proxyUUID" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
</div>
</div>
<div id="proxyTrojanPassField" class="hidden">
<div class="form-row">
<label for="proxyTrojanPass">Пароль (Trojan)</label>
<input type="text" id="proxyTrojanPass" placeholder="password">
</div>
</div>
<div id="proxyHysteria2Fields" class="hidden">
<div class="form-row">
<label for="proxyObfs">Obfs тип</label>
<input type="text" id="proxyObfs" placeholder="salamander или пусто">
</div>
<div class="form-row">
<label for="proxyObfsPass">Obfs пароль</label>
<input type="text" id="proxyObfsPass" placeholder="obfs password">
</div>
</div>
<div id="proxyTLSField" class="hidden">
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="proxyTLS">
<span>TLS</span>
</label>
</div>
<div class="form-row">
<label for="proxySNI">SNI (ServerName)</label>
<input type="text" id="proxySNI" placeholder="example.com">
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="proxySkipCertVerify">
<span>Пропустить проверку сертификата</span>
</label>
</div>
</div>
<div id="proxyVlessFlowField" class="hidden">
<div class="form-row">
<label for="proxyFlow">Flow</label>
<input type="text" id="proxyFlow" placeholder="xtls-rprx-vision (опционально)">
</div>
</div>
<div id="proxyNetworkField" class="hidden">
<div class="form-row">
<label for="proxyNetwork">Транспорт</label>
<select id="proxyNetwork" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
<option value="">tcp (по умолчанию)</option>
<option value="ws">WebSocket</option>
<option value="grpc">gRPC</option>
<option value="h2">HTTP/2</option>
</select>
</div>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="proxyUDP" checked>
<span>UDP</span>
</label>
</div>
<input type="hidden" id="proxyEditName" value="">
</form>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancelProxyBtn">Отмена</button>
<button class="btn btn-primary" id="saveProxyBtn">Сохранить</button>
</div>
</div>
</div>
<!-- Group Modal -->
<div id="groupModal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" id="groupModalBackdrop"></div>
<div class="modal-box" style="width:min(560px,calc(100vw - 40px))">
<div class="modal-header">
<h2 id="groupModalTitle">Добавить группу</h2>
<button class="btn-icon" id="closeGroupModal" title="Закрыть"></button>
</div>
<form id="groupForm" autocomplete="off">
<div class="form-row">
<label for="groupName">Имя группы</label>
<input type="text" id="groupName" placeholder="proxy">
</div>
<div class="form-row">
<label for="groupType">Тип</label>
<select id="groupType" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
<option value="select">Select (ручной выбор)</option>
<option value="url-test">URL-test (автовыбор по задержке)</option>
<option value="fallback">Fallback (резервный)</option>
<option value="load-balance">Load Balance (балансировка)</option>
</select>
</div>
<div id="groupURLField" class="hidden">
<div class="form-row">
<label for="groupURL">URL тестирования</label>
<input type="text" id="groupURL" value="https://www.gstatic.com/generate_204" placeholder="https://www.gstatic.com/generate_204">
</div>
<div class="form-row">
<label for="groupInterval">Интервал тестирования (сек)</label>
<input type="number" id="groupInterval" value="300">
</div>
<div class="form-row">
<label for="groupTolerance">Допуск (мс)</label>
<input type="number" id="groupTolerance" value="50" placeholder="50">
</div>
</div>
<div id="groupLBStrategy" class="hidden">
<div class="form-row">
<label>Стратегия балансировки</label>
<select id="groupLBStrategy" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
<option value="round-robin">Round Robin</option>
<option value="consistent-hashing">Consistent Hashing</option>
<option value="sticky-sessions">Sticky Sessions</option>
</select>
</div>
</div>
<div class="form-row">
<label>Прокси-ноды в группе</label>
<div id="groupProxyCheckboxes" style="max-height:200px;overflow-y:auto;background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px"></div>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="groupIncludeAll">
<span>Включить все прокси автоматически</span>
</label>
</div>
<div class="form-row">
<label for="groupFilter">Фильтр (regex)</label>
<input type="text" id="groupFilter" placeholder="напр. (?i)hk|hongkong">
</div>
<input type="hidden" id="groupEditName" value="">
</form>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancelGroupBtn">Отмена</button>
<button class="btn btn-primary" id="saveGroupBtn">Сохранить</button>
</div>
</div>
</div>
<!-- Rule Modal -->
<div id="ruleModal" class="modal hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" id="ruleModalBackdrop"></div>
<div class="modal-box" style="width:min(520px,calc(100vw - 40px))">
<div class="modal-header">
<h2 id="ruleModalTitle">Добавить правило</h2>
<button class="btn-icon" id="closeRuleModal" title="Закрыть"></button>
</div>
<form id="ruleForm" autocomplete="off">
<div class="form-row">
<label for="ruleType">Тип правила</label>
<select id="ruleType" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
<option value="DOMAIN">DOMAIN (точный домен)</option>
<option value="DOMAIN-SUFFIX">DOMAIN-SUFFIX (домен суффикс)</option>
<option value="DOMAIN-KEYWORD">DOMAIN-KEYWORD (ключевое слово)</option>
<option value="GEOSITE">GEOSITE (геосайт)</option>
<option value="GEOIP">GEOIP (гео IP)</option>
<option value="IP-CIDR">IP-CIDR (подсеть)</option>
<option value="SRC-IP-CIDR">SRC-IP-CIDR (источник подсеть)</option>
<option value="DST-PORT">DST-PORT (порт назначения)</option>
<option value="SRC-PORT">SRC-PORT (порт источника)</option>
<option value="MATCH">MATCH (всё)</option>
<option value="RULE-SET">RULE-SET (набор правил)</option>
</select>
</div>
<div id="ruleValueField" class="form-row">
<label for="ruleValue">Значение</label>
<input type="text" id="ruleValue" placeholder="напр. google.com или 192.168.0.0/16">
</div>
<div class="form-row">
<label for="ruleProxy">Прокси / Группа</label>
<select id="ruleProxy" style="background:var(--bg-deep);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);padding:9px 12px;font-size:.9rem;width:100%">
<option value="DIRECT">DIRECT</option>
<option value="REJECT">REJECT</option>
</select>
</div>
<div id="ruleNoResolveDiv" class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="ruleNoResolve" checked>
<span>no-resolve</span>
</label>
</div>
</form>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancelRuleBtn">Отмена</button>
<button class="btn btn-primary" id="saveRuleBtn">Добавить</button>
</div>
</div>
</div>
<!-- Notification toast -->
<div id="toast" class="toast hidden"></div>
<script src="proxy.js"></script>
</body>
</html>

957
public/proxy.js Normal file
View File

@@ -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 = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(p.name)}</span>
<span class="tag-active">${esc(typeLabel(p.type))}</span>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-proxy="${esc(p.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-proxy="${esc(p.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
${p.type !== 'direct' ? `<div class="info-row"><span class="info-label">Сервер</span><span class="info-val mono">${esc(p.server || '—')}</span></div>
<div class="info-row"><span class="info-label">Порт</span><span class="info-val mono">${p.port || '—'}</span></div>` : ''}
${p.udp ? '<span class="tag-active" style="margin-left:0">UDP</span>' : ''}
${p.tls ? '<span class="tag-active" style="margin-left:4px">TLS</span>' : ''}
</div>
`;
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 = `
<div class="proxy-card-header">
<div style="display:flex;align-items:center;gap:8px">
<span class="proxy-name">${esc(g.name)}</span>
<span class="tag-gw">${esc(groupTypeLabel(g.type))}</span>
${g['include-all'] ? '<span class="tag-active">Все прокси</span>' : ''}
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-ghost btn-sm" data-edit-group="${esc(g.name)}">Изменить</button>
<button class="btn btn-danger btn-sm" data-delete-group="${esc(g.name)}">Удалить</button>
</div>
</div>
<div class="proxy-card-info">
<div class="info-row"><span class="info-label">Узлы</span><span class="info-val">${proxyList}${more || '—'}</span></div>
${g.url ? `<div class="info-row"><span class="info-label">URL</span><span class="info-val mono" style="font-size:.75rem">${esc(g.url)}</span></div>` : ''}
${g.interval ? `<div class="info-row"><span class="info-label">Интервал</span><span class="info-val">${g.interval}с</span></div>` : ''}
</div>
`;
list.appendChild(card);
});
}
function renderRules() {
const list = document.getElementById('rulesList');
const rules = getRules();
list.innerHTML = '';
if (rules.length === 0) {
list.innerHTML = '<div class="empty-state">Нет правил маршрутизации</div>';
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 = `
<div class="rule-info">
<span class="rule-type">${esc(type)}</span>
<span class="rule-value">${esc(value || (type === 'MATCH' ? '*' : ''))}</span>
<span class="rule-target">${esc(target)}</span>
${flags ? `<span class="rule-flags">${esc(flags)}</span>` : ''}
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-danger btn-sm" data-delete-rule="${i}">✕</button>
</div>
`;
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 = '<option value="auto">auto</option><option value="none">none</option><option value="zero">zero</option><option value="aes-128-gcm">aes-128-gcm</option><option value="chacha20-poly1305">chacha20-poly1305</option>';
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 = '<option value="DIRECT">DIRECT</option><option value="REJECT">REJECT</option>';
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');
})();

1140
public/style.css Normal file

File diff suppressed because it is too large Load Diff

194
traffic/tracker.go Normal file
View File

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