package app import ( "encoding/json" "os" "path/filepath" "strings" "time" ) // --------------------------------------------------------------------- // smartdns wildcard canonical store // --------------------------------------------------------------------- // EN: Canonical SmartDNS wildcard storage is JSON in stateDir. // EN: `/etc/selective-vpn/smartdns.conf` is generated as a runtime artifact. // RU: Каноничное хранилище wildcard-доменов SmartDNS — JSON в stateDir. // RU: `/etc/selective-vpn/smartdns.conf` генерируется как runtime-артефакт. type smartDNSWildcardState struct { Version int `json:"version"` UpdatedAt string `json:"updated_at"` Domains []string `json:"domains"` } func normalizeWildcardDomains(raw []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(raw)) for _, ln := range raw { d := normalizeWildcardDomain(ln) if d == "" { continue } if _, ok := seen[d]; ok { continue } seen[d] = struct{}{} out = append(out, d) } return out } func parseSmartDNSDomainsContent(content string) []string { return normalizeWildcardDomains(strings.Split(content, "\n")) } func renderSmartDNSDomainsContent(domains []string) string { header := strings.TrimSpace(` # Auto-generated by selective-vpn API. # SmartDNS wildcard rules for selective VPN / AGVPN. `) + "\n" if len(domains) == 0 { return header } return header + "\n" + strings.Join(domains, "\n") + "\n" } func loadSmartDNSWildcardDomainsState(logf func(string, ...any)) ([]string, string) { if data, err := os.ReadFile(smartdnsWLPath); err == nil { // preferred shape: object with metadata var st smartDNSWildcardState if json.Unmarshal(data, &st) == nil { domains := normalizeWildcardDomains(st.Domains) _ = writeSmartDNSDomainsArtifact(domains) return domains, "state" } // backward-compat shape: plain []string var arr []string if json.Unmarshal(data, &arr) == nil { domains := normalizeWildcardDomains(arr) _ = saveSmartDNSWildcardDomainsState(domains) return domains, "state-legacy" } if logf != nil { logf("smartdns wildcards: invalid state json at %s, fallback to conf", smartdnsWLPath) } } // migration path: parse legacy .conf file if state json is missing/broken. confData, err := os.ReadFile(smartdnsDomainsFile) if err == nil { domains := parseSmartDNSDomainsContent(string(confData)) if saveErr := saveSmartDNSWildcardDomainsState(domains); saveErr != nil && logf != nil { logf("smartdns wildcards: migration from conf failed: %v", saveErr) } return domains, "migrated-conf" } // bootstrap empty canonical state + artifact. _ = saveSmartDNSWildcardDomainsState(nil) return nil, "default" } func saveSmartDNSWildcardDomainsState(domains []string) error { normalized := normalizeWildcardDomains(domains) state := smartDNSWildcardState{ Version: 1, UpdatedAt: time.Now().UTC().Format(time.RFC3339), Domains: normalized, } data, err := json.MarshalIndent(state, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(smartdnsWLPath), 0o755); err != nil { return err } if err := os.MkdirAll(filepath.Dir(smartdnsDomainsFile), 0o755); err != nil { return err } stateTmp := smartdnsWLPath + ".tmp" if err := os.WriteFile(stateTmp, data, 0o644); err != nil { return err } if err := os.Rename(stateTmp, smartdnsWLPath); err != nil { return err } return writeSmartDNSDomainsArtifact(normalized) } func writeSmartDNSDomainsArtifact(domains []string) error { content := renderSmartDNSDomainsContent(domains) tmp := smartdnsDomainsFile + ".tmp" if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { return err } return os.Rename(tmp, smartdnsDomainsFile) }