Files
GHOSTWIRE/setup.go
T
Daniel Redetzke a59a095691 Keep IPv6 router announcements working with forwarding on
With net.ipv6.conf.all.forwarding=1, Linux ignores router announcements
unless accept_ra is 2, so a server that gets its IPv6 route by SLAAC
(e.g. a Raspberry Pi at home) lost IPv6 once the route expired.

The sysctl file now also sets accept_ra=2 for the default and for every
network card and the IPv6 default-route interface, except where
accept_ra is 0. "update" rewrites the file, which fixes existing
installs. A new health check warns while the uplink still has
accept_ra=1.
2026-10-05 00:10:13 +03:00

714 lines
21 KiB
Go

package main
import (
"bytes"
"crypto/sha256"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"golang.org/x/term"
)
// The binary installs, updates and removes itself. These commands need root;
// the service they set up runs as the unprivileged user serviceName.
var (
installDir = "/opt/" + serviceName
installBin = filepath.Join(installDir, appName)
oldBin = installBin + ".old" // exists only while an update runs
configFile = filepath.Join(installDir, "config.json")
unitPath = "/etc/systemd/system/" + serviceName + ".service"
sysctlPath = "/etc/sysctl.d/99-" + serviceName + ".conf"
modulesPath = "/etc/modules-load.d/" + serviceName + ".conf"
)
func usage() {
fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager
Usage (as root):
%s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host] [-port 51820] [-y]
set up user, folder, config, sysctls and systemd service; start it.
In a terminal it asks for the settings no flag gave; -y never asks
%s update [-force]
replace the installed binary with this one and restart
%s uninstall [-purge] [-y]
remove the service, interface and firewall table (-purge also deletes %s)
%s passwd [username]
set a user's password (default: the first user) of the installed service
%s version
Without a command it runs the service:
%s [-config path] [-passwd] [-check] [-down]
`, appName, version, appName, appName, appName, installDir, appName, appName, appName)
flag.PrintDefaults()
}
func runCommand(cmd string, args []string) error {
switch cmd {
case "install":
return cmdInstall(args)
case "update":
return cmdUpdate(args)
case "uninstall":
return cmdUninstall(args)
case "passwd":
return cmdPasswd(args)
case "version":
fmt.Println(appName, version)
return nil
case "help":
usage()
return nil
}
usage()
return fmt.Errorf("unknown command %q", cmd)
}
// ---------- helpers ----------
func step(format string, a ...any) { fmt.Printf("• "+format+"\n", a...) }
func requireRoot() error {
if runtime.GOOS != "linux" {
return errors.New("this command only works on Linux")
}
if os.Geteuid() != 0 {
return errors.New("run as root, e.g. with sudo")
}
if _, err := exec.LookPath("systemctl"); err != nil {
return errors.New("systemd is required (systemctl not found)")
}
return nil
}
func sh(name string, args ...string) error {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func shOut(name string, args ...string) string {
out, _ := exec.Command(name, args...).Output()
return strings.TrimSpace(string(out))
}
func serviceUser() (uid, gid int, err error) {
u, err := user.Lookup(serviceName)
if err != nil {
return 0, 0, err
}
uid, _ = strconv.Atoi(u.Uid)
gid, _ = strconv.Atoi(u.Gid)
return uid, gid, nil
}
func fileHash(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
_, _ = io.Copy(h, f)
return fmt.Sprintf("%x", h.Sum(nil))
}
// copyFile writes src to dst through a temp file and a rename, so a running
// binary is never overwritten in place ("text file busy") or half-written.
func copyFile(src, dst string, mode os.FileMode, uid, gid int) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, in); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := tmp.Chown(uid, gid); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
// writeIfChanged writes content to path when it differs and reports whether
// it did.
func writeIfChanged(path, content string, mode os.FileMode) (bool, error) {
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, []byte(content)) {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return false, err
}
return true, os.WriteFile(path, []byte(content), mode)
}
// installedVersion asks the installed binary for its version.
func installedVersion(path string) string {
f := strings.Fields(shOut(path, "-version"))
if len(f) == 2 {
return f[1]
}
return "unknown"
}
// compareVersions compares "1.2.3" style versions (an optional "v" prefix
// and a git-describe suffix like "-4-gabc123" are ignored). ok is false when
// either side is not such a version.
func compareVersions(a, b string) (cmp int, ok bool) {
parse := func(s string) ([3]int, bool) {
var v [3]int
s = strings.TrimPrefix(s, "v")
if i := strings.IndexByte(s, '-'); i >= 0 {
s = s[:i]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return v, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return v, false
}
v[i] = n
}
return v, true
}
va, ok1 := parse(a)
vb, ok2 := parse(b)
if !ok1 || !ok2 {
return 0, false
}
for i := range 3 {
if va[i] != vb[i] {
if va[i] < vb[i] {
return -1, true
}
return 1, true
}
}
return 0, true
}
// ---------- system files ----------
func unitFile() string {
return fmt.Sprintf(`# Written by %[1]s %[5]s. Changes are overwritten by "%[1]s update".
[Unit]
Description=%[1]s WireGuard manager (web interface and API)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=%[2]s
Group=%[2]s
WorkingDirectory=%[3]s
ExecStart=%[4]s -config %[3]s/config.json
ExecReload=/bin/kill -HUP $MAINPID
# Exit code 0 is used by "Restart now" in the web interface.
Restart=always
RestartSec=2
# Runs unprivileged: CAP_NET_ADMIN for netlink, wgctrl and nftables,
# CAP_NET_BIND_SERVICE for ports 443 and 80.
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=%[3]s
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
UMask=0077
[Install]
WantedBy=multi-user.target
`, appName, serviceName, installDir, installBin, unitVersion)
}
// unitVersion changes only when the unit text changes, so "update" does not
// rewrite the unit for every release.
const unitVersion = "unit-1"
// sysctlConf turns on forwarding. With IPv6 forwarding on, Linux ignores
// router announcements unless accept_ra is 2, and a server that gets its
// IPv6 route from them (SLAAC, e.g. a Raspberry Pi at home) loses IPv6 when
// the route expires. So every interface in ras keeps accepting them, as
// pivpn does for its uplink.
func sysctlConf(ras []string) string {
var b strings.Builder
b.WriteString("net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\nnet.ipv6.conf.default.accept_ra=2\n")
for _, name := range ras {
fmt.Fprintf(&b, "net.ipv6.conf.%s.accept_ra=2\n", name)
}
return b.String()
}
// raInterfaces returns the network cards and the interface of the IPv6
// default route, except those where router announcements are switched off
// (accept_ra 0). The directories are /proc/sys/net/ipv6/conf and
// /sys/class/net, routes is /proc/net/ipv6_route.
func raInterfaces(confDir, netDir, routes string) []string {
want := map[string]bool{}
if b, err := os.ReadFile(routes); err == nil {
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) == 10 && f[0] == strings.Repeat("0", 32) && f[1] == "00" && f[9] != "lo" {
want[f[9]] = true
}
}
}
entries, _ := os.ReadDir(netDir)
for _, e := range entries {
// Only real devices: bridges, veth and tunnels come and go.
if _, err := os.Stat(filepath.Join(netDir, e.Name(), "device")); err == nil {
want[e.Name()] = true
}
}
var out []string
for name := range want {
v := readSysctl(filepath.Join(confDir, name, "accept_ra"))
if v == "1" || v == "2" {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// writeSystemFiles writes the unit, sysctl and module files. It reports
// whether the unit changed (systemd must then reload).
func writeSystemFiles() (unitChanged bool, err error) {
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
return false, err
}
ras := raInterfaces("/proc/sys/net/ipv6/conf", "/sys/class/net", "/proc/net/ipv6_route")
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf(ras), 0o644)
if err != nil {
return false, err
}
if sysChanged {
step("Enabling IP forwarding (%s)", sysctlPath)
if err := sh("sysctl", "-p", sysctlPath); err != nil {
return false, err
}
}
if _, err := writeIfChanged(modulesPath, "wireguard\n", 0o644); err != nil {
return false, err
}
if err := sh("modprobe", "wireguard"); err != nil {
fmt.Fprintln(os.Stderr, " warning: could not load the wireguard kernel module:", err)
}
if unitChanged {
step("Writing %s", unitPath)
if err := sh("systemctl", "daemon-reload"); err != nil {
return false, err
}
}
return unitChanged, nil
}
// restartAndVerify restarts the service and checks that it stays up: the
// main process must still be the same a few seconds later (Restart=always
// would otherwise hide a crash loop).
func restartAndVerify() error {
if err := sh("systemctl", "restart", serviceName); err != nil {
return err
}
time.Sleep(1500 * time.Millisecond)
pid1 := shOut("systemctl", "show", "-p", "MainPID", "--value", serviceName)
time.Sleep(3 * time.Second)
state := shOut("systemctl", "is-active", serviceName)
pid2 := shOut("systemctl", "show", "-p", "MainPID", "--value", serviceName)
if state != "active" || pid1 == "0" || pid1 == "" || pid1 != pid2 {
return fmt.Errorf("the service did not stay running (state %s); see: journalctl -u %s -n 50", state, serviceName)
}
return nil
}
// ---------- install ----------
func cmdInstall(args []string) error {
fs := flag.NewFlagSet("install", flag.ExitOnError)
domain := fs.String("domain", "", "domain for the web interface; enables Let's Encrypt")
email := fs.String("email", "", "contact email for Let's Encrypt (optional)")
endpoint := fs.String("endpoint", "", "host or IP clients connect to (default: the domain)")
port := fs.Int("port", 0, "UDP port WireGuard listens on (default: 51820, or the current port when already installed)")
yes := fs.Bool("y", false, "do not ask; use the flags and defaults")
_ = fs.Parse(args)
given := map[string]bool{}
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
if err := requireRoot(); err != nil {
return err
}
self, err := os.Executable()
if err != nil {
return err
}
// Read the current settings without changing anything, so questions
// and checks happen before the system is touched.
_, statErr := os.Stat(configFile)
existing := statErr == nil
cur, err := loadConfigFile(configFile)
if err != nil {
return err
}
plan := installPlan{domain: *domain, email: *email, endpoint: *endpoint, port: *port}
if err := plan.check(); err != nil {
return err
}
if !existing {
n, err := randomSubnet(24)
if err != nil {
return err
}
plan.ipv4 = n.String()
}
if !*yes && term.IsTerminal(int(os.Stdin.Fd())) {
if plan, err = askInstall(os.Stdin, cur, existing, given, plan); err != nil {
return err
}
} else if n := plan.reissueCount(cur, existing); n > 0 {
fmt.Printf("Note: %d existing device(s) need a new config: the endpoint or port changes.\n", n)
}
// User and folder
if _, err := user.Lookup(serviceName); err != nil {
step("Creating system user %s", serviceName)
shell := "/usr/sbin/nologin"
for _, p := range []string{"/usr/sbin/nologin", "/sbin/nologin", "/bin/false"} {
if _, err := os.Stat(p); err == nil {
shell = p
break
}
}
if err := sh("useradd", "--system", "--home-dir", installDir, "--no-create-home", "--shell", shell, serviceName); err != nil {
return err
}
}
uid, gid, err := serviceUser()
if err != nil {
return err
}
if err := os.MkdirAll(installDir, 0o750); err != nil {
return err
}
if err := os.Chown(installDir, uid, gid); err != nil {
return err
}
if err := os.Chmod(installDir, 0o750); err != nil {
return err
}
// Binary
if self != installBin {
step("Installing %s %s to %s", appName, version, installBin)
if err := copyFile(self, installBin, 0o755, 0, 0); err != nil {
return err
}
}
// Config: created with defaults (server key, the chosen subnet) if missing.
if !existing {
step("Creating %s", configFile)
initial := fmt.Sprintf("{\"server\": {\"ipv4\": %q}}\n", plan.ipv4)
if err := os.WriteFile(configFile, []byte(initial), 0o600); err != nil {
return err
}
}
if err := os.Chown(configFile, uid, gid); err != nil {
return err
}
store, err := openStore(configFile)
if err != nil {
return err
}
if plan.changes() {
step("Saving the settings")
if err := store.Update(func(c *Config) error { plan.apply(c); return nil }); err != nil {
return err
}
}
if _, err := writeSystemFiles(); err != nil {
return err
}
if cfg := store.Get(); !cfg.passwordSet() {
fmt.Printf("\nChoose the password for the web interface (user %q, at least 12 characters).\n", cfg.Users[0].Username)
if err := setPassword(configFile, ""); err != nil {
return err
}
}
// Everything in the folder belongs to the service user.
if err := chownTree(installDir, uid, gid); err != nil {
return err
}
step("Starting %s", serviceName)
if err := sh("systemctl", "enable", serviceName); err != nil {
return err
}
if err := restartAndVerify(); err != nil {
return err
}
printWhereToGo(store.Get())
return nil
}
func chownTree(root string, uid, gid int) error {
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
if err != nil {
return err
}
return os.Lchown(p, uid, gid)
})
}
func printWhereToGo(c *Config) {
host := c.Web.TLS.Domain
if host == "" {
host = c.Server.Endpoint
}
if host == "" {
host = "<server address>"
}
scheme, port := "https", c.Web.Listen
if c.Web.TLS.Mode == "off" {
scheme = "http"
}
if _, p, _ := strings.Cut(port, ":"); p != "" && p != "443" && p != "80" {
host += ":" + p
}
fmt.Printf("\nDone. %s %s is running.\n Web interface: %s://%s/\n", appName, version, scheme, host)
switch c.Web.TLS.Mode {
case "acme":
fmt.Println(" The Let's Encrypt certificate is requested on the first visit; ports 443 (and 80) must be reachable.")
case "selfsigned":
fmt.Println(" It uses a self-signed certificate, so the browser shows a warning the first time.")
}
fmt.Printf(" WireGuard: UDP %d · Log: %s/%s.jsonl · Status: systemctl status %s\n", c.Server.ListenPort, installDir, appName, serviceName)
}
// ---------- update ----------
func requireInstalled() error {
if _, err := os.Stat(installBin); err != nil {
return fmt.Errorf("%s is not installed; run: sudo ./%s install", appName, appName)
}
if _, err := os.Stat(unitPath); err != nil {
return fmt.Errorf("%s is missing; run: sudo ./%s install", unitPath, appName)
}
return nil
}
func cmdUpdate(args []string) error {
fs := flag.NewFlagSet("update", flag.ExitOnError)
force := fs.Bool("force", false, "install even if it is the same build or an older version")
_ = fs.Parse(args)
if err := requireRoot(); err != nil {
return err
}
if err := requireInstalled(); err != nil {
return err
}
self, err := os.Executable()
if err != nil {
return err
}
if self, _ = filepath.EvalSymlinks(self); self == installBin {
return fmt.Errorf("run update from the new binary, e.g.: sudo /tmp/%s update", appName)
}
// 1. Is this an update?
oldVersion := installedVersion(installBin)
if fileHash(self) == fileHash(installBin) && !*force {
fmt.Printf("Already up to date (%s %s).\n", appName, oldVersion)
return nil
}
if c, ok := compareVersions(version, oldVersion); ok && c < 0 && !*force {
return fmt.Errorf("this is %s, older than the installed %s; use -force to downgrade", version, oldVersion)
}
fmt.Printf("Updating %s %s → %s\n", appName, oldVersion, version)
// 2. Can this version read the current config?
if c, err := loadConfigFile(configFile); err != nil {
return fmt.Errorf("this version cannot read %s, nothing changed: %w", configFile, err)
} else if err := c.validate(); err != nil {
return fmt.Errorf("this version rejects %s, nothing changed: %w", configFile, err)
}
// 3. Back up the config, and keep the old binary until the new one runs.
uid, gid, err := serviceUser()
if err != nil {
return err
}
backup := configFile + ".bak-" + oldVersion
step("Backing up config to %s", backup)
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
return err
}
if err := copyFile(installBin, oldBin, 0o755, uid, gid); err != nil {
return err
}
defer os.Remove(oldBin)
// 4. Install.
step("Installing the new binary")
if err := copyFile(self, installBin, 0o755, uid, gid); err != nil {
return err
}
if _, err := writeSystemFiles(); err != nil {
return err
}
// 5. Restart. If the new version does not stay up, put the old one back
// so the web interface stays reachable.
step("Restarting %s", serviceName)
if err := restartAndVerify(); err != nil {
fmt.Fprintln(os.Stderr, " The new version failed to start:", err)
step("Restoring %s %s", appName, oldVersion)
if rErr := copyFile(oldBin, installBin, 0o755, uid, gid); rErr != nil {
return fmt.Errorf("update failed and restoring the old binary failed too: %v (original error: %w)", rErr, err)
}
// The new version may have upgraded config.json to a format the old
// one cannot read.
if rErr := copyFile(backup, configFile, 0o600, uid, gid); rErr != nil {
return fmt.Errorf("update failed and restoring %s failed too: %v (original error: %w)", configFile, rErr, err)
}
if rErr := restartAndVerify(); rErr != nil {
return fmt.Errorf("update failed and the old version does not start either: %v (original error: %w)", rErr, err)
}
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
}
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
return nil
}
// ---------- uninstall ----------
func cmdUninstall(args []string) error {
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
purge := fs.Bool("purge", false, "also delete "+installDir+" (config, keys, logs) and the user "+serviceName)
yes := fs.Bool("y", false, "do not ask for confirmation")
_ = fs.Parse(args)
if err := requireRoot(); err != nil {
return err
}
if *purge && !*yes {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return errors.New("-purge deletes all peers and keys; add -y to confirm")
}
fmt.Printf("This deletes %s with all peers, keys and logs. Type \"delete\" to continue: ", installDir)
var answer string
_, _ = fmt.Scanln(&answer)
if answer != "delete" {
return errors.New("cancelled")
}
}
step("Stopping %s", serviceName)
_ = sh("systemctl", "disable", "--now", serviceName)
step("Removing the WireGuard interface and firewall table")
c, err := loadConfigFile(configFile)
if err != nil {
c = &Config{}
c.applyDefaults()
}
if k, err := newKernel(); err == nil {
if err := k.Down(c); err != nil {
fmt.Fprintln(os.Stderr, " warning:", err)
}
k.Close()
}
step("Removing system files")
for _, p := range []string{unitPath, sysctlPath, modulesPath} {
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
_ = sh("systemctl", "daemon-reload")
if *purge {
step("Deleting %s and the user %s", installDir, serviceName)
if err := os.RemoveAll(installDir); err != nil {
return err
}
_ = sh("userdel", serviceName)
fmt.Println("\nRemoved everything.")
} else {
fmt.Printf("\nRemoved. Config, keys and logs are still in %s (uninstall -purge deletes them).\n", installDir)
}
fmt.Println("IP forwarding stays on until reboot; run 'sysctl -w net.ipv4.ip_forward=0' if nothing else needs it.")
return nil
}
// ---------- passwd ----------
func cmdPasswd(args []string) error {
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
path := fs.String("config", configFile, "config.json to change")
_ = fs.Parse(args)
if os.Geteuid() != 0 && runtime.GOOS == "linux" {
return errors.New("run as root, e.g. with sudo")
}
if err := setPassword(*path, fs.Arg(0)); err != nil {
return err
}
if *path == configFile && shOut("systemctl", "is-active", serviceName) == "active" {
if err := sh("systemctl", "reload", serviceName); err != nil {
return err
}
fmt.Println("The running service uses the new password now.")
}
return nil
}