package config import ( "bytes" "encoding/json" "fmt" "os" "strings" ) const ( ServersFileFormat = "navis-servers" ServersFileVersion = 1 ) // ServersFile is the on-disk export/import format for the profile list. type ServersFile struct { Format string `json:"format,omitempty"` Version int `json:"version,omitempty"` Active string `json:"active,omitempty"` Profiles []Profile `json:"profiles"` } // ExportServersFile builds a portable snapshot of profiles + active name. func ExportServersFile(cfg *Config) ServersFile { profiles := append([]Profile(nil), cfg.Profiles...) return ServersFile{ Format: ServersFileFormat, Version: ServersFileVersion, Active: cfg.Active, Profiles: profiles, } } // WriteServersFile writes the export snapshot as indented JSON (0600). func WriteServersFile(path string, cfg *Config) (int, error) { doc := ExportServersFile(cfg) data, err := json.MarshalIndent(doc, "", " ") if err != nil { return 0, err } data = append(data, '\n') if err := os.WriteFile(path, data, 0o600); err != nil { return 0, err } return len(doc.Profiles), nil } // ParseServersFile parses export JSON. Accepts: // - { "format":"navis-servers", "profiles":[...], "active":"..." } // - { "profiles":[...] } // - a bare JSON array of profiles func ParseServersFile(data []byte) (ServersFile, error) { data = bytes.TrimSpace(stripUTF8BOM(data)) if len(data) == 0 { return ServersFile{}, fmt.Errorf("пустой файл списка серверов") } var doc ServersFile if data[0] == '[' { var profiles []Profile if err := json.Unmarshal(data, &profiles); err != nil { return ServersFile{}, fmt.Errorf("разбор списка серверов: %w", err) } doc.Profiles = profiles } else { if err := json.Unmarshal(data, &doc); err != nil { return ServersFile{}, fmt.Errorf("разбор файла серверов: %w", err) } } if doc.Format != "" && doc.Format != ServersFileFormat { return ServersFile{}, fmt.Errorf("неизвестный формат %q (ожидается %s)", doc.Format, ServersFileFormat) } if err := normalizeServersFile(&doc); err != nil { return ServersFile{}, err } return doc, nil } // ReadServersFile loads and validates a servers export file. func ReadServersFile(path string) (ServersFile, error) { data, err := os.ReadFile(path) if err != nil { return ServersFile{}, err } return ParseServersFile(data) } func normalizeServersFile(doc *ServersFile) error { if len(doc.Profiles) == 0 { return fmt.Errorf("в файле нет серверов") } seen := make(map[string]struct{}, len(doc.Profiles)) for i := range doc.Profiles { p := &doc.Profiles[i] p.Name = strings.TrimSpace(p.Name) if p.Name == "" { return fmt.Errorf("профиль[%d]: пустое имя", i) } if _, ok := seen[p.Name]; ok { return fmt.Errorf("дублируется имя профиля %q", p.Name) } seen[p.Name] = struct{}{} switch p.Protocol { case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan: case "": p.Protocol = ProtocolNaive default: return fmt.Errorf("профиль %q: неподдерживаемый протокол %q", p.Name, p.Protocol) } } doc.Active = strings.TrimSpace(doc.Active) if doc.Active == "" { doc.Active = doc.Profiles[0].Name } else if _, ok := seen[doc.Active]; !ok { doc.Active = doc.Profiles[0].Name } return nil } // ApplyServersReplace replaces profiles with the import document (prefs untouched). func (c *Config) ApplyServersReplace(doc ServersFile) error { if err := normalizeServersFile(&doc); err != nil { return err } c.Profiles = append([]Profile(nil), doc.Profiles...) c.Active = doc.Active c.SubscriptionNames = nil InvalidateHostCache() return c.Validate() }