Files
GHOSTWIRE/config.go
T
Daniel Redetzke 4793e8dfba GHOSTWIRE 0.1.2: WireGuard server manager with web UI and API
Single Go binary that manages a WireGuard server based on pivpn's defaults:
- config.json as the single source of truth, reconciled to the kernel via
  netlink, wgctrl and its own nftables table (NAT, forward, input)
- web interface (dashboard, peers, peer detail, add peer, server, settings)
  and a JSON API for the future iOS app, with session and API-token auth
- client private keys are never stored; configs and QR codes shown once
- per-peer traffic statistics in stats.json, logs in GHOSTWIRE.jsonl
- HTTPS via Let's Encrypt, self-signed, certificate files or off
- self-managing: install, update (restores the old binary on failure),
  uninstall and passwd subcommands; systemd unit generated by the binary

Tested end to end on Ubuntu 26.04 (kernel 7.0) at dev.redetzke.aero.

Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
2026-10-03 16:54:37 +03:00

486 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"syscall"
"time"
)
// Config is the complete desired state of the service. It is persisted as
// config.json and is the single source of truth: the kernel (interface, peers,
// firewall) is reconciled to match it.
type Config struct {
Version int `json:"version"`
Web WebConfig `json:"web"`
Admin Admin `json:"admin"`
APITokens []APIToken `json:"apiTokens"`
Server Server `json:"server"`
Peers []Peer `json:"peers"`
Log LogConfig `json:"log"`
}
type WebConfig struct {
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
TLS TLSConfig `json:"tls"`
SessionHours int `json:"sessionHours"`
}
type TLSConfig struct {
Mode string `json:"mode"` // acme | selfsigned | files | off
Domain string `json:"domain,omitempty"`
Email string `json:"email,omitempty"`
Staging bool `json:"staging,omitempty"` // use the Let's Encrypt staging CA
CertFile string `json:"certFile,omitempty"`
KeyFile string `json:"keyFile,omitempty"`
}
type Admin struct {
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
}
type APIToken struct {
ID string `json:"id"`
Name string `json:"name"`
Hash string `json:"hash"`
Scope string `json:"scope"` // rw | ro
Created time.Time `json:"created"`
}
type Server struct {
Interface string `json:"interface"`
PrivateKey string `json:"privateKey"`
KeyCreated time.Time `json:"keyCreated"`
ListenPort int `json:"listenPort"`
MTU int `json:"mtu"`
IPv4 string `json:"ipv4"` // tunnel network, e.g. 10.84.12.0/24
IPv6 string `json:"ipv6"` // tunnel network, e.g. fd11:5ee:bad:c0de::/64
IPv6Enabled bool `json:"ipv6Enabled"`
Endpoint string `json:"endpoint"` // host name or IP clients connect to
EndpointPort int `json:"endpointPort"` // 0 = listenPort
UplinkV4 string `json:"uplinkV4"` // "" = interface of the default route
UplinkV6 string `json:"uplinkV6"`
NAT bool `json:"nat"`
PeerToPeer bool `json:"peerToPeer"`
LANAccess bool `json:"lanAccess"`
OpenPort bool `json:"openPort"`
ClientDefaults ClientDefaults `json:"clientDefaults"`
}
type ClientDefaults struct {
DNS []string `json:"dns"`
AllowedIPs []string `json:"allowedIPs"`
Keepalive int `json:"keepalive"`
}
// Peer is one client. Its private key is never stored: it is shown once when
// the config is issued.
type Peer struct {
ID string `json:"id"`
Name string `json:"name"`
Note string `json:"note"`
Enabled bool `json:"enabled"`
PublicKey string `json:"publicKey"`
PresharedKey string `json:"presharedKey,omitempty"`
IPv4 string `json:"ipv4"`
DNS []string `json:"dns,omitempty"` // nil = server default
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
Created time.Time `json:"created"`
ConfigIssued *time.Time `json:"configIssued,omitempty"`
}
type LogConfig struct {
Level string `json:"level"` // debug | info | warn | error
MaxSizeMB int `json:"maxSizeMB"`
MaxFiles int `json:"maxFiles"`
}
const configVersion = 1
// applyDefaults fills zero values. It never overwrites values that are set,
// so a minimal hand-written config.json grows into a complete one.
func (c *Config) applyDefaults() {
if c.Version == 0 {
c.Version = configVersion
}
if c.Web.Listen == "" {
c.Web.Listen = ":443"
}
if c.Web.TLS.Mode == "" {
if c.Web.TLS.Domain != "" {
c.Web.TLS.Mode = "acme"
} else {
c.Web.TLS.Mode = "selfsigned"
}
}
if c.Web.TLS.Mode == "acme" && c.Web.HTTPListen == "" {
c.Web.HTTPListen = ":80"
}
if c.Web.SessionHours == 0 {
c.Web.SessionHours = 12
}
if c.Admin.Username == "" {
c.Admin.Username = "admin"
}
s := &c.Server
if s.Interface == "" {
s.Interface = "wg0"
}
if s.ListenPort == 0 {
s.ListenPort = 51820
}
if s.MTU == 0 {
s.MTU = 1420
}
if s.ClientDefaults.DNS == nil {
s.ClientDefaults.DNS = []string{"9.9.9.9", "149.112.112.112"}
}
if s.ClientDefaults.AllowedIPs == nil {
s.ClientDefaults.AllowedIPs = []string{"0.0.0.0/0", "::/0"}
}
if c.Log.Level == "" {
c.Log.Level = "info"
}
if c.Log.MaxSizeMB == 0 {
c.Log.MaxSizeMB = 10
}
if c.Log.MaxFiles == 0 {
c.Log.MaxFiles = 5
}
if c.APITokens == nil {
c.APITokens = []APIToken{}
}
if c.Peers == nil {
c.Peers = []Peer{}
}
}
// initServer runs once, when the server has no key yet: it generates the key,
// picks a free tunnel subnet and turns on the defaults that are booleans.
func (c *Config) initServer() (bool, error) {
s := &c.Server
if s.PrivateKey != "" {
return false, nil
}
key, err := newPrivateKey()
if err != nil {
return false, err
}
s.PrivateKey = key.String()
s.KeyCreated = time.Now().UTC()
if s.IPv4 == "" {
n, err := randomSubnet(24)
if err != nil {
return false, err
}
s.IPv4 = n.String()
}
if s.IPv6 == "" {
s.IPv6 = "fd11:5ee:bad:c0de::/64"
s.IPv6Enabled = hasGlobalIPv6()
}
s.NAT = true
s.PeerToPeer = true
s.OpenPort = true
if s.Endpoint == "" {
s.Endpoint = c.Web.TLS.Domain
}
return true, nil
}
var peerNameRe = regexp.MustCompile(`^[a-zA-Z0-9.@_-]{1,32}$`)
func validatePeerName(name string) error {
switch {
case !peerNameRe.MatchString(name):
return errors.New("name must be 1–32 characters: letters, digits and . @ _ -")
case strings.Trim(name, "0123456789") == "":
return errors.New("name cannot be only digits")
case strings.HasPrefix(name, "-") || strings.HasPrefix(name, "."):
return errors.New("name cannot start with - or .")
case name == "server":
return errors.New("name \"server\" is reserved")
}
return nil
}
func validateHostList(list []string, field string, wantCIDR bool) error {
for _, v := range list {
if wantCIDR {
if _, err := netip.ParsePrefix(v); err != nil {
return fmt.Errorf("%s: %q is not a network in CIDR notation", field, v)
}
} else if _, err := netip.ParseAddr(v); err != nil {
return fmt.Errorf("%s: %q is not an IP address", field, v)
}
}
return nil
}
// validate checks the whole config for consistency. It runs before every save.
func (c *Config) validate() error {
s := &c.Server
if s.ListenPort < 1 || s.ListenPort > 65535 {
return errors.New("listen port must be 1–65535")
}
if s.EndpointPort < 0 || s.EndpointPort > 65535 {
return errors.New("endpoint port must be 0–65535")
}
if s.MTU < 1280 || s.MTU > 9000 {
return errors.New("MTU must be 1280–9000")
}
if !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,15}$`).MatchString(s.Interface) {
return errors.New("interface name must be 1–15 characters: letters, digits, _ and -")
}
v4, err := netip.ParsePrefix(s.IPv4)
if err != nil || !v4.Addr().Is4() || v4.Bits() > 30 || v4.Bits() < 8 {
return errors.New("IPv4 network must be an IPv4 CIDR between /8 and /30")
}
if v4.Masked() != v4 {
return fmt.Errorf("IPv4 network must be the network address, e.g. %s", v4.Masked())
}
v6, err := netip.ParsePrefix(s.IPv6)
if err != nil || !v6.Addr().Is6() || v6.Bits() > 96 {
return errors.New("IPv6 network must be an IPv6 CIDR of /96 or larger")
}
if v6.Masked() != v6 {
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
}
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil {
return errors.New("endpoint must be a host name or IP address without port")
}
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
return err
}
if err := validateHostList(s.ClientDefaults.AllowedIPs, "AllowedIPs", true); err != nil {
return err
}
if s.ClientDefaults.Keepalive < 0 || s.ClientDefaults.Keepalive > 3600 {
return errors.New("keepalive must be 0–3600 seconds")
}
switch c.Web.TLS.Mode {
case "acme":
if c.Web.TLS.Domain == "" {
return errors.New("tls.domain is required for Let's Encrypt")
}
case "files":
if c.Web.TLS.CertFile == "" || c.Web.TLS.KeyFile == "" {
return errors.New("tls.certFile and tls.keyFile are required for mode \"files\"")
}
case "selfsigned", "off":
default:
return fmt.Errorf("unknown tls.mode %q", c.Web.TLS.Mode)
}
names := map[string]bool{}
ips := map[netip.Addr]bool{}
keys := map[string]bool{}
for _, p := range c.Peers {
if err := validatePeerName(p.Name); err != nil {
return fmt.Errorf("peer %q: %w", p.Name, err)
}
if names[p.Name] {
return fmt.Errorf("peer name %q is used twice", p.Name)
}
names[p.Name] = true
ip, err := netip.ParseAddr(p.IPv4)
if err != nil || !v4.Contains(ip) {
return fmt.Errorf("peer %q: address %s is outside %s", p.Name, p.IPv4, v4)
}
if ip == v4.Addr() || ip == serverIPv4(v4) || ip == lastAddr(v4) {
return fmt.Errorf("peer %q: address %s is reserved", p.Name, ip)
}
if ips[ip] {
return fmt.Errorf("address %s is used twice", ip)
}
ips[ip] = true
if keys[p.PublicKey] {
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
}
keys[p.PublicKey] = true
if err := validateHostList(p.DNS, "DNS", false); err != nil {
return fmt.Errorf("peer %q: %w", p.Name, err)
}
if err := validateHostList(p.AllowedIPs, "AllowedIPs", true); err != nil {
return fmt.Errorf("peer %q: %w", p.Name, err)
}
if p.Keepalive != nil && (*p.Keepalive < 0 || *p.Keepalive > 3600) {
return fmt.Errorf("peer %q: keepalive must be 0–3600 seconds", p.Name)
}
}
return nil
}
func (c *Config) peerByID(id string) (int, *Peer) {
for i := range c.Peers {
if c.Peers[i].ID == id {
return i, &c.Peers[i]
}
}
return -1, nil
}
func (c *Config) clone() *Config {
b, _ := json.Marshal(c)
var out Config
_ = json.Unmarshal(b, &out)
return &out
}
// Store guards the config and persists every change atomically.
type Store struct {
mu sync.Mutex
path string
cfg *Config
// onChange is called after a successful update, outside the lock.
onChange func(old, new *Config)
}
func loadConfigFile(path string) (*Config, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
b = []byte("{}")
} else if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(b, &c); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if c.Version > configVersion {
return nil, fmt.Errorf("%s: config version %d is newer than this program supports", path, c.Version)
}
c.applyDefaults()
return &c, nil
}
func openStore(path string) (*Store, error) {
c, err := loadConfigFile(path)
if err != nil {
return nil, err
}
if _, err := c.initServer(); err != nil {
return nil, err
}
if err := c.validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
s := &Store{path: path, cfg: c}
if err := writeFileAtomic(path, c, 0o600); err != nil {
return nil, err
}
return s, nil
}
// Get returns a deep copy that the caller may read freely.
func (s *Store) Get() *Config {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg.clone()
}
// Update applies fn to a copy, validates and saves it, then swaps it in.
func (s *Store) Update(fn func(c *Config) error) error {
s.mu.Lock()
old := s.cfg
next := old.clone()
if err := fn(next); err != nil {
s.mu.Unlock()
return err
}
next.applyDefaults()
if err := next.validate(); err != nil {
s.mu.Unlock()
return &userError{err.Error()}
}
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
s.mu.Unlock()
return err
}
s.cfg = next
s.mu.Unlock()
if s.onChange != nil {
s.onChange(old.clone(), next.clone())
}
return nil
}
// Reload re-reads config.json from disk, e.g. after "-passwd" changed it.
func (s *Store) Reload() error {
c, err := loadConfigFile(s.path)
if err != nil {
return err
}
if err := c.validate(); err != nil {
return err
}
s.mu.Lock()
old := s.cfg
s.cfg = c
s.mu.Unlock()
if s.onChange != nil {
s.onChange(old.clone(), c.clone())
}
return nil
}
// writeFileAtomic writes JSON to a temp file in the same directory, syncs it
// and renames it over the target, so a crash never leaves a partial file.
func writeFileAtomic(path string, v any, mode os.FileMode) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp)
if err := f.Chmod(mode); err != nil {
f.Close()
return err
}
// When root edits the file (e.g. "-passwd" under sudo), keep the owner so
// the service user can still read it.
if os.Geteuid() == 0 {
if st, err := os.Stat(path); err == nil {
if sys, ok := st.Sys().(*syscall.Stat_t); ok {
_ = f.Chown(int(sys.Uid), int(sys.Gid))
}
}
}
if _, err := f.Write(b); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, path)
}
// userError marks errors caused by invalid input; the API returns them as 400.
type userError struct{ msg string }
func (e *userError) Error() string { return e.msg }
func badRequest(format string, a ...any) error { return &userError{fmt.Sprintf(format, a...)} }