first commit

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

244
handlers/api.go Normal file
View File

@@ -0,0 +1,244 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"alpine-router/config"
"alpine-router/network"
)
type apiResp struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func ok(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusOK, apiResp{Success: true, Data: data})
}
func fail(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, apiResp{Error: msg})
}
func HandleInterfaces(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
names, err := network.GetInterfaces()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
fileCfg, _ := network.ParseConfig()
type iface struct {
*network.InterfaceStats
Pending bool `json:"pending"`
}
result := make([]iface, 0, len(names))
for _, name := range names {
s, err := network.GetInterfaceStats(name)
if err != nil {
continue
}
if cfg, ok := fileCfg[name]; ok {
s.Mode = cfg.Mode
}
_, hasPending := network.GetPendingConfig(name), network.GetPendingConfig(name) != nil
result = append(result, iface{s, hasPending})
}
ok(w, result)
}
func HandleInterfaceSingle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/interfaces/")
s, err := network.GetInterfaceStats(name)
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, s)
}
func HandleInterfaceAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
suffix := strings.TrimPrefix(r.URL.Path, "/api/interfaces/")
parts := strings.SplitN(suffix, "/", 2)
if len(parts) != 2 {
fail(w, http.StatusBadRequest, "invalid path")
return
}
name, action := parts[0], parts[1]
var err error
switch action {
case "up":
err = network.IfUp(name)
case "down":
err = network.IfDown(name)
case "restart":
err = network.IfRestart(name)
default:
fail(w, http.StatusBadRequest, "unknown action: "+action)
return
}
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]string{"message": action + " ok"})
}
func HandleConfig(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/config/")
if name == "" {
fail(w, http.StatusBadRequest, "interface name required")
return
}
switch r.Method {
case http.MethodGet:
if cfg := network.GetPendingConfig(name); cfg != nil {
ok(w, map[string]interface{}{"config": cfg, "pending": true})
return
}
fileCfg, err := network.ParseConfig()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if cfg, exists := fileCfg[name]; exists {
ok(w, map[string]interface{}{"config": cfg, "pending": false})
} else {
ok(w, map[string]interface{}{
"config": &network.InterfaceConfig{Name: name, Auto: true, Mode: "dhcp", Extra: map[string]string{}},
"pending": false,
})
}
case http.MethodPost:
var cfg network.InterfaceConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
cfg.Name = name
if cfg.Extra == nil {
cfg.Extra = map[string]string{}
}
network.SetPendingConfig(&cfg)
appCfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, "load config: "+err.Error())
return
}
if appCfg.Interfaces == nil {
appCfg.Interfaces = map[string]*config.InterfaceConfig{}
}
appCfg.Interfaces[name] = &config.InterfaceConfig{
Auto: cfg.Auto,
Mode: cfg.Mode,
Address: cfg.Address,
Netmask: cfg.Netmask,
Gateway: cfg.Gateway,
DNS: cfg.DNS,
Extra: cfg.Extra,
}
if err := config.Save(appCfg); err != nil {
fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error())
return
}
ok(w, map[string]string{"message": "saved as pending"})
case http.MethodDelete:
network.ClearPendingConfig(name)
ok(w, map[string]string{"message": "pending cleared"})
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func HandlePending(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
p := network.GetAllPending()
names := make([]string, 0, len(p))
for n := range p {
names = append(names, n)
}
ok(w, names)
}
func HandleApply(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
errs := network.ApplyPending()
if len(errs) > 0 {
msgs := map[string]string{}
for k, e := range errs {
msgs[k] = e.Error()
}
writeJSON(w, http.StatusInternalServerError, apiResp{Error: "partial failure", Data: msgs})
return
}
ok(w, map[string]string{"message": "applied"})
}
func HandleConfigYAML(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, data)
case http.MethodPut:
var newCfg config.AppConfig
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
config.EnsureDefaults(&newCfg)
if err := config.Save(&newCfg); err != nil {
fail(w, http.StatusInternalServerError, "save: "+err.Error())
return
}
ok(w, map[string]string{"message": "config updated"})
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}

175
handlers/clients.go Normal file
View File

@@ -0,0 +1,175 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strings"
"alpine-router/clients"
"alpine-router/config"
"alpine-router/dhcp"
"alpine-router/nat"
)
func HandleClients(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
list, err := clients.GetAll()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, list)
}
func HandleClientUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
mac := strings.TrimPrefix(r.URL.Path, "/api/clients/update/")
if mac == "" {
fail(w, http.StatusBadRequest, "mac address required")
return
}
var req struct {
Hostname string `json:"hostname"`
Blocked bool `json:"blocked"`
StaticIP string `json:"static_ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if err := updateClient(mac, req.Hostname, req.Blocked, req.StaticIP); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
go applyBlockedFirewall()
go applyDHCPStaticBindings()
ok(w, map[string]string{"message": "updated"})
}
func updateClient(mac, hostname string, blocked bool, staticIP string) error {
cfg, err := config.Load()
if err != nil {
return err
}
found := false
for i := range cfg.KnownDevices {
if cfg.KnownDevices[i].MAC == mac {
cfg.KnownDevices[i].Blocked = blocked
cfg.KnownDevices[i].Hostname = hostname
cfg.KnownDevices[i].StaticIP = staticIP
found = true
break
}
}
if !found {
cfg.KnownDevices = append(cfg.KnownDevices, config.KnownDevice{
MAC: mac,
Hostname: hostname,
Blocked: blocked,
StaticIP: staticIP,
})
}
return config.Save(cfg)
}
func applyBlockedFirewall() {
if !nat.IsInstalled() {
return
}
cfg, err := config.Load()
if err != nil {
log.Printf("Warning: load config for blocked firewall: %v", err)
return
}
var blockedIPs []string
for _, kd := range cfg.KnownDevices {
if kd.Blocked {
ip := kd.IP
if kd.StaticIP != "" {
ip = kd.StaticIP
}
if ip != "" {
blockedIPs = append(blockedIPs, ip)
}
}
}
natCfg := &nat.Config{Interfaces: cfg.NAT.Interfaces}
if err := nat.ApplyRulesWithBlocked(natCfg, blockedIPs); err != nil {
log.Printf("Warning: apply blocked firewall rules: %v", err)
} else {
log.Printf("Applied firewall rules (%d blocked clients)", len(blockedIPs))
}
}
func applyDHCPStaticBindings() {
if !dhcp.IsInstalled() {
return
}
cfg, err := config.Load()
if err != nil {
log.Printf("Warning: load config for DHCP static bindings: %v", err)
return
}
var bindings []dhcp.StaticBinding
for _, kd := range cfg.KnownDevices {
if kd.StaticIP != "" && kd.MAC != "" {
bindings = append(bindings, dhcp.StaticBinding{
MAC: kd.MAC,
Host: kd.Hostname,
IP: kd.StaticIP,
})
}
}
dhcpCfg := &dhcp.Config{
Enabled: cfg.DHCP.Enabled,
Pools: make([]dhcp.Pool, len(cfg.DHCP.Pools)),
}
for i, p := range cfg.DHCP.Pools {
dhcpCfg.Pools[i] = dhcp.Pool{
Interface: p.Interface,
Enabled: p.Enabled,
Subnet: p.Subnet,
Netmask: p.Netmask,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Router: p.Router,
DNS: p.DNS,
LeaseTime: p.LeaseTime,
}
}
if err := dhcp.WriteConfigsWithBindings(dhcpCfg, bindings); err != nil {
log.Printf("Warning: write dnsmasq config with static bindings: %v", err)
return
}
if dhcpCfg.Enabled {
if err := dhcp.ServiceRestart(); err != nil {
log.Printf("Warning: restart dnsmasq after static binding update: %v", err)
} else {
log.Printf("dnsmasq restarted with %d static bindings", len(bindings))
}
}
}

158
handlers/dhcp.go Normal file
View File

@@ -0,0 +1,158 @@
package handlers
import (
"encoding/json"
"net/http"
"alpine-router/config"
"alpine-router/dhcp"
"alpine-router/network"
)
func HandleDHCPStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
installed := dhcp.IsInstalled()
running := false
if installed {
running = dhcp.ServiceStatus()
}
ok(w, map[string]interface{}{
"installed": installed,
"running": running,
})
}
type ifaceInfo struct {
Name string `json:"name"`
IPv4 string `json:"ipv4"`
Netmask string `json:"ipv4_mask"`
HasGW bool `json:"has_gateway"`
}
func HandleDHCPConfigGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
cfg, err := dhcp.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
names, _ := network.GetInterfaces()
fileCfg, _ := network.ParseConfig()
ifaces := []ifaceInfo{}
for _, name := range names {
if name == "lo" {
continue
}
s, err := network.GetInterfaceStats(name)
if err != nil {
continue
}
hasGW := s.Gateway != ""
if ncfg, exists := fileCfg[name]; exists && ncfg.Gateway != "" {
hasGW = true
}
ifaces = append(ifaces, ifaceInfo{
Name: name,
IPv4: s.IPv4,
Netmask: s.IPv4Mask,
HasGW: hasGW,
})
}
ok(w, map[string]interface{}{
"config": cfg,
"interfaces": ifaces,
})
}
func HandleDHCPConfigSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var cfg dhcp.Config
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if cfg.Pools == nil {
cfg.Pools = []dhcp.Pool{}
}
if err := dhcp.Save(&cfg); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
appCfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, "load config.yaml: "+err.Error())
return
}
appCfg.DHCP.Enabled = cfg.Enabled
appCfg.DHCP.Pools = make([]config.DHCPPool, len(cfg.Pools))
for i, p := range cfg.Pools {
appCfg.DHCP.Pools[i] = config.DHCPPool{
Interface: p.Interface,
Enabled: p.Enabled,
Subnet: p.Subnet,
Netmask: p.Netmask,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Router: p.Router,
DNS: p.DNS,
LeaseTime: p.LeaseTime,
}
}
if err := config.Save(appCfg); err != nil {
fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error())
return
}
ok(w, map[string]string{"message": "saved"})
}
func HandleDHCPApply(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !dhcp.IsInstalled() {
fail(w, http.StatusBadRequest, "dnsmasq не установлен — выполните: apk add dnsmasq")
return
}
cfg, err := dhcp.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if err := dhcp.WriteConfigs(cfg); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if cfg.Enabled {
if err := dhcp.ServiceRestart(); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
} else {
_ = dhcp.ServiceStop()
}
ok(w, map[string]string{"message": "applied"})
}

200
handlers/mihomo.go Normal file
View File

@@ -0,0 +1,200 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"alpine-router/mihomo"
)
func HandleMihomoStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ok(w, mihomo.Status())
}
func HandleMihomoStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if err := mihomo.Start(); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]string{"message": "mihomo started"})
}
func HandleMihomoStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if err := mihomo.Stop(); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]string{"message": "mihomo stopped"})
}
func HandleMihomoRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if err := mihomo.Restart(); err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]string{"message": "mihomo restarted"})
}
func HandleMihomoConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := mihomo.LoadConfig()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, cfg)
case http.MethodPut:
var cfg map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if err := mihomo.SaveConfig(cfg); err != nil {
fail(w, http.StatusInternalServerError, "save config: "+err.Error())
return
}
ok(w, map[string]string{"message": "config saved"})
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func HandleMihomoLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ok(w, mihomo.Logs())
}
func HandleMihomoConfigYAML(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data, err := os.ReadFile(mihomo.ConfigPath())
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
fail(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write(data)
case http.MethodPut:
data, err := io.ReadAll(r.Body)
if err != nil {
fail(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
if err := os.MkdirAll(mihomo.DataDir(), 0755); err != nil {
fail(w, http.StatusInternalServerError, "mkdir: "+err.Error())
return
}
tmp := mihomo.ConfigPath() + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
fail(w, http.StatusInternalServerError, "write: "+err.Error())
return
}
if err := os.Rename(tmp, mihomo.ConfigPath()); err != nil {
fail(w, http.StatusInternalServerError, "rename: "+err.Error())
return
}
ok(w, map[string]string{"message": "config.yaml updated"})
default:
fail(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func HandleMihomoUploadCore(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if err := r.ParseMultipartForm(64 << 20); err != nil {
fail(w, http.StatusBadRequest, "parse form: "+err.Error())
return
}
file, header, err := r.FormFile("core")
if err != nil {
fail(w, http.StatusBadRequest, "file required: "+err.Error())
return
}
defer file.Close()
name := header.Filename
for _, arch := range []string{"amd64", "arm64", "armv7"} {
if strings.Contains(name, arch) {
dstPath := filepath.Join(mihomo.CoresDir(), fmt.Sprintf("mihomo-linux-%s", arch))
if err := os.MkdirAll(mihomo.CoresDir(), 0755); err != nil {
fail(w, http.StatusInternalServerError, "mkdir: "+err.Error())
return
}
dst, err := os.Create(dstPath)
if err != nil {
fail(w, http.StatusInternalServerError, "create: "+err.Error())
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
fail(w, http.StatusInternalServerError, "write: "+err.Error())
return
}
if err := os.Chmod(dstPath, 0755); err != nil {
fail(w, http.StatusInternalServerError, "chmod: "+err.Error())
return
}
ok(w, map[string]string{"message": "core uploaded", "arch": arch})
return
}
}
dstPath := filepath.Join(mihomo.CoresDir(), "mihomo-linux-amd64")
if err := os.MkdirAll(mihomo.CoresDir(), 0755); err != nil {
fail(w, http.StatusInternalServerError, "mkdir: "+err.Error())
return
}
dst, err := os.Create(dstPath)
if err != nil {
fail(w, http.StatusInternalServerError, "create: "+err.Error())
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
fail(w, http.StatusInternalServerError, "write: "+err.Error())
return
}
if err := os.Chmod(dstPath, 0755); err != nil {
fail(w, http.StatusInternalServerError, "chmod: "+err.Error())
return
}
ok(w, map[string]string{"message": "core uploaded", "path": dstPath})
}

71
handlers/nat.go Normal file
View File

@@ -0,0 +1,71 @@
package handlers
import (
"encoding/json"
"net/http"
"alpine-router/config"
"alpine-router/nat"
)
func HandleNATGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
cfg, err := nat.Load()
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]interface{}{
"installed": nat.IsInstalled(),
"interfaces": cfg.Interfaces,
})
}
func HandleNATSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var cfg nat.Config
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
fail(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if cfg.Interfaces == nil {
cfg.Interfaces = []string{}
}
if !nat.IsInstalled() {
fail(w, http.StatusServiceUnavailable, "nftables (nft) не установлен — выполните: apk add nftables")
return
}
if err := nat.Save(&cfg); err != nil {
fail(w, http.StatusInternalServerError, "save: "+err.Error())
return
}
appCfg, err := config.Load()
if err != nil {
fail(w, http.StatusInternalServerError, "load config.yaml: "+err.Error())
return
}
appCfg.NAT.Interfaces = cfg.Interfaces
if err := config.Save(appCfg); err != nil {
fail(w, http.StatusInternalServerError, "save config.yaml: "+err.Error())
return
}
if err := nat.ApplyRules(&cfg); err != nil {
fail(w, http.StatusInternalServerError, "apply: "+err.Error())
return
}
ok(w, map[string]string{"message": "nat applied"})
}