package config import ( "fmt" "os" "path/filepath" "sync" "gopkg.in/yaml.v3" ) type InterfaceConfig struct { Label string `yaml:"label,omitempty"` Type string `yaml:"type,omitempty"` 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"` 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" } type MihomoConfig struct { Enabled bool `yaml:"enabled"` } 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"` } 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"` } type AppConfig struct { 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"` ListenAddresses []string `yaml:"listen_addresses,omitempty" json:"listen_addresses,omitempty"` } 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 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"}, }, } } 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.Firewall.Rules == nil { cfg.Firewall.Rules = []FirewallRule{} } if cfg.KnownDevices == nil { cfg.KnownDevices = []KnownDevice{} } if cfg.ClientPolicy.Default == "" { cfg.ClientPolicy.Default = "direct" } if len(cfg.Connectivity.Direct) == 0 { cfg.Connectivity = DefaultConnectivity() } } 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 } // Policy is always preserved from existingDev; never overwritten by discovery 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) }