2026-04-13 09:46:02 +03:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type InterfaceConfig struct {
|
2026-04-13 12:40:49 +03:00
|
|
|
Label string `yaml:"label,omitempty"`
|
2026-04-15 11:38:26 +03:00
|
|
|
Type string `yaml:"type,omitempty"`
|
2026-04-13 09:46:02 +03:00
|
|
|
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"`
|
2026-04-15 11:38:26 +03:00
|
|
|
Policy string `yaml:"policy,omitempty"` // "disabled" | "direct" | "vpn" | "" (use default)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ClientPolicyConfig holds the default routing policy for newly discovered clients.
|
|
|
|
|
type ClientPolicyConfig struct {
|
|
|
|
|
Default string `yaml:"default"` // "disabled" | "direct" | "vpn"
|
2026-04-13 09:46:02 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MihomoConfig struct {
|
|
|
|
|
Enabled bool `yaml:"enabled"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:40:49 +03:00
|
|
|
type FirewallRule struct {
|
|
|
|
|
ID string `yaml:"id" json:"id"`
|
|
|
|
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
|
|
|
|
Action string `yaml:"action" json:"action"`
|
|
|
|
|
Protocol string `yaml:"protocol" json:"protocol"`
|
|
|
|
|
SrcAddr string `yaml:"src_addr" json:"src_addr"`
|
|
|
|
|
SrcPort string `yaml:"src_port" json:"src_port"`
|
|
|
|
|
DstAddr string `yaml:"dst_addr" json:"dst_addr"`
|
|
|
|
|
DstPort string `yaml:"dst_port" json:"dst_port"`
|
|
|
|
|
InIface string `yaml:"in_iface" json:"in_iface"`
|
|
|
|
|
OutIface string `yaml:"out_iface" json:"out_iface"`
|
|
|
|
|
Comment string `yaml:"comment" json:"comment"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type FirewallConfig struct {
|
|
|
|
|
Rules []FirewallRule `yaml:"rules" json:"rules"`
|
|
|
|
|
VLANIsolation bool `yaml:"vlan_isolation" json:"vlan_isolation"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 11:38:26 +03:00
|
|
|
type CheckEndpoint struct {
|
|
|
|
|
Name string `yaml:"name" json:"name"`
|
|
|
|
|
URL string `yaml:"url" json:"url"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ConnectivityConfig struct {
|
|
|
|
|
Direct []CheckEndpoint `yaml:"direct" json:"direct"`
|
|
|
|
|
ViaProxy []CheckEndpoint `yaml:"via_proxy" json:"via_proxy"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AuthConfig struct {
|
|
|
|
|
Username string `yaml:"username,omitempty"`
|
|
|
|
|
PasswordHash string `yaml:"password_hash,omitempty"`
|
|
|
|
|
APIKey string `yaml:"api_key,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 09:46:02 +03:00
|
|
|
type AppConfig struct {
|
2026-04-15 11:38:26 +03:00
|
|
|
Interfaces map[string]*InterfaceConfig `yaml:"interfaces"`
|
|
|
|
|
DHCP DHCPConfig `yaml:"dhcp"`
|
|
|
|
|
NAT NATConfig `yaml:"nat"`
|
|
|
|
|
Firewall FirewallConfig `yaml:"firewall"`
|
|
|
|
|
KnownDevices []KnownDevice `yaml:"known_devices"`
|
|
|
|
|
Mihomo MihomoConfig `yaml:"mihomo"`
|
|
|
|
|
ClientPolicy ClientPolicyConfig `yaml:"client_policy,omitempty"`
|
|
|
|
|
Connectivity ConnectivityConfig `yaml:"connectivity,omitempty" json:"connectivity,omitempty"`
|
|
|
|
|
Auth AuthConfig `yaml:"auth,omitempty"`
|
2026-04-13 09:46:02 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 11:38:26 +03:00
|
|
|
func DefaultConnectivity() ConnectivityConfig {
|
|
|
|
|
return ConnectivityConfig{
|
|
|
|
|
Direct: []CheckEndpoint{
|
|
|
|
|
{Name: "Cloudflare", URL: "http://cp.cloudflare.com/generate_204"},
|
|
|
|
|
{Name: "Google", URL: "http://connectivitycheck.gstatic.com/generate_204"},
|
|
|
|
|
},
|
|
|
|
|
ViaProxy: []CheckEndpoint{
|
|
|
|
|
{Name: "Cloudflare", URL: "http://cp.cloudflare.com/generate_204"},
|
|
|
|
|
{Name: "Google", URL: "http://connectivitycheck.gstatic.com/generate_204"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 09:46:02 +03:00
|
|
|
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{}
|
|
|
|
|
}
|
2026-04-13 12:40:49 +03:00
|
|
|
if cfg.Firewall.Rules == nil {
|
|
|
|
|
cfg.Firewall.Rules = []FirewallRule{}
|
|
|
|
|
}
|
2026-04-13 09:46:02 +03:00
|
|
|
if cfg.KnownDevices == nil {
|
|
|
|
|
cfg.KnownDevices = []KnownDevice{}
|
|
|
|
|
}
|
2026-04-15 11:38:26 +03:00
|
|
|
if cfg.ClientPolicy.Default == "" {
|
|
|
|
|
cfg.ClientPolicy.Default = "direct"
|
|
|
|
|
}
|
|
|
|
|
if len(cfg.Connectivity.Direct) == 0 {
|
|
|
|
|
cfg.Connectivity = DefaultConnectivity()
|
|
|
|
|
}
|
2026-04-13 09:46:02 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-04-15 11:38:26 +03:00
|
|
|
// Policy is always preserved from existingDev; never overwritten by discovery
|
2026-04-13 09:46:02 +03:00
|
|
|
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)
|
|
|
|
|
}
|