first commit
This commit is contained in:
1
Meta-Docs
Submodule
1
Meta-Docs
Submodule
Submodule Meta-Docs added at d31369ab45
165
README.md
Normal file
165
README.md
Normal 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
8
RULES.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
1. Все настройки обязательно сохранять в config.yaml и восстанавливать оттуда при первом запуске бинарника.
|
||||||
|
2. Функциональные разделы админки писать отдельными html страницами и добавлять в главное меню.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Установить пакеты:
|
||||||
|
dnsmasq
|
||||||
|
nftables
|
||||||
17
alpine-init/network-manager
Normal file
17
alpine-init/network-manager
Normal 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
BIN
alpine-router
Executable file
Binary file not shown.
306
clients/clients.go
Normal file
306
clients/clients.go
Normal 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
229
config/config.go
Normal 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
222
dhcp/config.go
Normal 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
5
go.mod
Normal 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
3
go.sum
Normal 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
244
handlers/api.go
Normal 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
175
handlers/clients.go
Normal 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
158
handlers/dhcp.go
Normal 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
200
handlers/mihomo.go
Normal 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
71
handlers/nat.go
Normal 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
287
main.go
Normal 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
BIN
mihomo-linux-amd64-v1.19.23
Normal file
Binary file not shown.
35
mihomo/default.yaml
Normal file
35
mihomo/default.yaml
Normal 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
322
mihomo/mihomo.go
Normal 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
123
nat/nat.go
Normal 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
86
network/apply.go
Normal 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
195
network/config.go
Normal 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
143
network/interfaces.go
Normal 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
388
public/app.js
Normal 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">—</span>'}</span>
|
||||||
|
</div>
|
||||||
|
${ipv6lines || `<div class="info-row"><span class="info-label">IPv6</span><span class="info-val none">—</span></div>`}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Шлюз</span>
|
||||||
|
<span class="info-val">${iface.gateway || '<span class="none">—</span>'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="traffic-row">
|
||||||
|
<div class="traffic-item">
|
||||||
|
<span class="traffic-label">RX</span>
|
||||||
|
<span class="traffic-val">${fmtBytes(iface.rx_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-item">
|
||||||
|
<span class="traffic-label">TX</span>
|
||||||
|
<span class="traffic-val">${fmtBytes(iface.tx_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="traffic-item">
|
||||||
|
<span class="traffic-label">Пакеты</span>
|
||||||
|
<span class="traffic-val">${iface.rx_packets ?? 0} / ${iface.tx_packets ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<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
161
public/clients.html
Normal 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
314
public/clients.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
function showToast(msg, type = 'info') {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = `toast ${type}`;
|
||||||
|
t.classList.remove('hidden');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('refreshBtn').addEventListener('click', loadClients);
|
||||||
|
|
||||||
|
document.getElementById('clientsSearch').addEventListener('input', e => {
|
||||||
|
searchQuery = e.target.value.trim();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modalBackdrop').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('modalClose').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('modalCancel').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('clientForm').addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modalBlocked').addEventListener('change', () => {
|
||||||
|
if (!editingClient) return;
|
||||||
|
const isBlocked = !document.getElementById('modalBlocked').checked;
|
||||||
|
updateBlockedToggle(isBlocked);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(loadClients, 10000);
|
||||||
|
|
||||||
|
loadClients();
|
||||||
179
public/dhcp.html
Normal file
179
public/dhcp.html
Normal 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
344
public/dhcp.js
Normal 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
151
public/index.html
Normal 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
534
public/proxy.html
Normal 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
957
public/proxy.js
Normal 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
1140
public/style.css
Normal file
File diff suppressed because it is too large
Load Diff
194
traffic/tracker.go
Normal file
194
traffic/tracker.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user