210 lines
5 KiB
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)
|
|
}
|