wswg/config.go

210 lines
5 KiB
Go

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)
}