package main import ( "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "net" "os" "path/filepath" "strconv" "strings" ) type Tunnel struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` HTTPS bool `json:"https"` } type Profile struct { ID string `json:"id"` Name string `json:"name"` WireGuard string `json:"wireguard"` DNS bool `json:"dns"` Tunnel Tunnel `json:"tunnel"` } type Settings struct { Profiles []Profile `json:"profiles"` Last string `json:"last"` Autorun bool `json:"autorun"` OnBoot bool `json:"onBoot"` } type WG struct { Endpoint string Host string Port int Peers int } func parseWG(raw string) (WG, error) { var w WG section := "" keys := map[string]bool{} private := false address := false for n, line := range strings.Split(raw, "\n") { line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0]) if line == "" { continue } if strings.HasPrefix(line, "[") { section = strings.ToLower(line) if section == "[peer]" { w.Peers++ keys = map[string]bool{} } else if section != "[interface]" { return w, fmt.Errorf("line %d: unknown section", n+1) } continue } kv := strings.SplitN(line, "=", 2) if len(kv) != 2 { return w, fmt.Errorf("line %d: expected key = value", n+1) } key, val := strings.ToLower(strings.TrimSpace(kv[0])), strings.TrimSpace(kv[1]) if section == "" { return w, errors.New("missing [Interface]") } // Hooks execute shell code as root. Profiles are data, never scripts. switch key { case "preup", "postup", "predown", "postdown", "saveconfig": return w, fmt.Errorf("%s is not supported: remove executable hooks and SaveConfig", key) } if keys[key] { return w, fmt.Errorf("duplicate %s", key) } keys[key] = true if key == "privatekey" || key == "publickey" || key == "presharedkey" { b, e := base64.StdEncoding.DecodeString(val) if e != nil || len(b) != 32 { return w, fmt.Errorf("invalid %s", key) } } if section == "[interface]" && key == "privatekey" { private = true } if section == "[interface]" && key == "address" { address = true for _, a := range strings.Split(val, ",") { if _, _, e := net.ParseCIDR(strings.TrimSpace(a)); e != nil { return w, e } } } if section == "[peer]" && key == "endpoint" { h, p, e := net.SplitHostPort(val) if e != nil { return w, fmt.Errorf("Endpoint: %w", e) } port, e := strconv.Atoi(p) if e != nil || port < 1 || port > 65535 || h == "" { return w, errors.New("invalid Endpoint port") } w.Endpoint = val w.Host = h w.Port = port } } if !private || !address || w.Peers == 0 || w.Endpoint == "" { return w, errors.New("config requires PrivateKey, Address, [Peer] and Endpoint") } return w, nil } func validateProfile(p Profile) error { if strings.TrimSpace(p.Name) == "" { return errors.New("name is empty") } w, e := parseWG(p.WireGuard) if e != nil { return e } if p.Tunnel.Enabled { if w.Peers != 1 { return errors.New("wstunnel supports exactly one peer per profile") } if p.Tunnel.Port < 1 || p.Tunnel.Port > 65535 { return errors.New("wstunnel port must be 1..65535") } h := p.Tunnel.Host if h != "" && net.ParseIP(h) == nil { if strings.ContainsAny(h, " /\\:@?#\r\n\t") || strings.HasPrefix(h, "-") { return errors.New("invalid wstunnel host") } } } return nil } func renderWG(p Profile, endpoint string) string { var out []string for _, line := range strings.Split(p.WireGuard, "\n") { key := strings.ToLower(strings.TrimSpace(strings.SplitN(line, "=", 2)[0])) if key == "dns" && !p.DNS { continue } if key == "endpoint" && endpoint != "" { line = "Endpoint = " + endpoint } out = append(out, line) } return strings.Join(out, "\n") + "\n" } func newID() string { var b [8]byte if _, e := rand.Read(b[:]); e != nil { panic(e) } return hex.EncodeToString(b[:]) } func loadSettings(dir string) (Settings, error) { s := Settings{Profiles: []Profile{}} b, e := os.ReadFile(filepath.Join(dir, "settings.json")) if os.IsNotExist(e) { return s, nil } if e != nil { return s, e } e = json.Unmarshal(b, &s) return s, e } func saveSettings(dir string, s Settings) error { b, e := json.MarshalIndent(s, "", " ") if e != nil { return e } f, e := os.CreateTemp(dir, ".settings-*") if e != nil { return e } tmp := f.Name() defer os.Remove(tmp) if e = f.Chmod(0600); e == nil { _, e = f.Write(b) } if e == nil { e = f.Sync() } ce := f.Close() if e != nil { return e } if ce != nil { return ce } return replaceFile(tmp, filepath.Join(dir, "settings.json")) } func (s Settings) find(v string) (Profile, error) { if n, e := strconv.Atoi(v); e == nil && n > 0 && n <= len(s.Profiles) { return s.Profiles[n-1], nil } for _, p := range s.Profiles { if p.ID == v || p.Name == v { return p, nil } } return Profile{}, fmt.Errorf("config %q not found", v) }