Files
GHOSTWIRE/setup.go
T
Daniel Redetzke 3482a03707 Install takes over a pivpn WireGuard server
On a server running pivpn's WireGuard, a new install offers to take it
over: the server key, port, MTU, tunnel networks, endpoint, DNS,
AllowedIPs and keepalive, and every client with its public key,
preshared key and addresses. Devices keep their configs. Clients pivpn
switched off are imported switched off, with the note "Imported from
pivpn". Client private keys in /etc/wireguard/configs are not read.

- Install notes which peers are connected, stops wg-quick@wg0, starts
  GHOSTWIRE on the same wg0 and waits up to 30 s for those peers. The
  wait only reports; idle devices reconnect when they next send.
- If the service does not stay running, install removes what it set up,
  including config.json, and starts pivpn's WireGuard again.
- Without a terminal the takeover needs -import-pivpn; install refuses
  to run next to pivpn otherwise, and the flag is refused on an
  existing install.
- Names GHOSTWIRE does not accept are renamed and listed in the
  summary. An IPv6 address that differs from the mapped one is kept on
  the peer until its config is issued again.
- uninstall without a config of its own (e.g. after a takeover was
  undone) leaves the WireGuard interface alone and removes only the
  firewall table.
- README: "Coming from pivpn?" under the intro, a Features entry and a
  "Moving from pivpn" section.

Tested end to end on Ubuntu 24.04 with pivpn aa96de7.
2026-10-06 00:54:08 +03:00

875 lines
26 KiB
Go

package main
import (
"bytes"
"cmp"
"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] [-import-pivpn] [-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.
On a pivpn server it offers to take over pivpn's WireGuard and clients
%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")
importPivpn := fs.Bool("import-pivpn", false, "take over pivpn's WireGuard server and clients (new installs only)")
_ = 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
}
interactive := !*yes && term.IsTerminal(int(os.Stdin.Fd()))
// pivpn: a new install takes over its WireGuard server, or stops, since
// both would run the same interface.
var pv *pivpnSetup
if !existing {
if pv, err = readPivpn("/"); err != nil {
return err
}
}
switch {
case *importPivpn && existing:
return fmt.Errorf("-import-pivpn works only on a new install, and %s exists", configFile)
case *importPivpn && pv == nil:
return fmt.Errorf("-import-pivpn: pivpn's WireGuard setup was not found (/%s)", pivpnSetupVars)
case pv != nil && !interactive && !*importPivpn:
return fmt.Errorf("pivpn runs WireGuard on %s here; add -import-pivpn to take it over, or remove pivpn first", pv.Dev)
}
if pv != nil {
pv.apply(cur)
if err := cur.validate(); err != nil {
return fmt.Errorf("pivpn's setup cannot be taken over, nothing changed: %w", err)
}
plan.pivpn = pv
}
if !existing && pv == nil {
n, err := randomSubnet(24)
if err != nil {
return err
}
plan.ipv4 = n.String()
}
if interactive {
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, or with everything taken over from pivpn.
switch {
case pv != nil:
step("Creating %s from pivpn (%s)", configFile, plural(len(pv.Peers), "peer"))
if err := writeFileAtomic(configFile, cur, 0o600); err != nil {
return err
}
case !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
}
// pivpn hands over its interface: note who is connected, then stop it.
var connected []string
var switched time.Time
if pv != nil {
connected = pivpnConnected(pv)
step("Peers connected to pivpn right now: %s", cmp.Or(strings.Join(connected, ", "), "none"))
step("Stopping pivpn's WireGuard (systemctl disable --now wg-quick@%s)", pv.Dev)
if err := sh("systemctl", "disable", "--now", "wg-quick@"+pv.Dev); err != nil {
return err
}
switched = time.Now()
}
step("Starting %s", serviceName)
err = sh("systemctl", "enable", serviceName)
if err == nil {
err = restartAndVerify()
}
if err != nil {
if pv != nil {
fmt.Fprintln(os.Stderr, " ", err)
return pivpnBack(pv, store.Get(), err)
}
return err
}
if pv != nil {
waitForPeers(pv, connected, switched)
}
printWhereToGo(store.Get())
if pv != nil {
fmt.Printf("\npivpn is still installed but no longer runs %s. Manage the peers here from now on.\n", pv.Dev)
fmt.Printf("Its files in /etc/wireguard and /etc/pivpn are untouched, including the client\n")
fmt.Printf("configs with private keys. Once everything works, delete %s.\n", pv.ClientKeys)
fmt.Printf("Don't run \"pivpn uninstall\": it removes WireGuard packages and firewall rules.\n")
}
return nil
}
// pivpnConnected names the peers with a handshake in the last 3 minutes.
func pivpnConnected(pv *pivpnSetup) []string {
k, err := newKernel()
if err != nil {
return nil
}
defer k.Close()
samples, err := k.Sample(pv.Dev)
if err != nil {
return nil
}
var out []string
for _, p := range pv.Peers {
for _, s := range samples {
if s.PublicKey == p.PublicKey && !s.LastHandshake.IsZero() && time.Since(s.LastHandshake) < onlineWindow {
out = append(out, p.Name)
}
}
}
return out
}
// waitForPeers waits up to 30 s for the peers that were connected to pivpn
// to make a handshake with the new service. It only reports: a device that
// is idle may take minutes to send its next packet.
func waitForPeers(pv *pivpnSetup, names []string, since time.Time) {
if len(names) == 0 {
step("No peer was connected before the switch; devices connect when they come back online.")
return
}
key := map[string]string{}
for _, p := range pv.Peers {
key[p.Name] = p.PublicKey
}
k, err := newKernel()
if err != nil {
return
}
defer k.Close()
var back []string
deadline := since.Add(30 * time.Second)
for {
back = back[:0]
if samples, err := k.Sample(pv.Dev); err == nil {
for _, n := range names {
for _, s := range samples {
if s.PublicKey == key[n] && s.LastHandshake.After(since) {
back = append(back, n)
}
}
}
}
if len(back) == len(names) || time.Now().After(deadline) {
break
}
time.Sleep(2 * time.Second)
}
secs := int(time.Since(since).Round(time.Second).Seconds())
if len(back) == len(names) {
step("Waiting for them to come back: %d of %d peers that were connected before are back (after %d s)", len(back), len(names), secs)
return
}
step("Waiting for them to come back: %d of %d are back after %d s", len(back), len(names), secs)
var missing []string
for _, n := range names {
if !slices.Contains(back, n) {
missing = append(missing, n)
}
}
fmt.Printf(" %s not back yet. A device that is idle can take a few minutes to send its next\n", strings.Join(missing, ", "))
fmt.Println(" packet. It shows as online on the Peers page once it is back.")
}
// pivpnBack undoes the takeover after the service failed to start: it stops
// the service, removes its interface, firewall table and the config it was
// given, and starts pivpn's WireGuard again. Without the config, the next
// install offers the takeover again instead of fighting pivpn for wg0.
func pivpnBack(pv *pivpnSetup, c *Config, cause error) error {
_ = sh("systemctl", "disable", "--now", serviceName)
if k, err := newKernel(); err == nil {
_ = k.Down(c)
k.Close()
}
_ = os.Remove(configFile)
step("Starting pivpn's WireGuard again (systemctl enable --now wg-quick@%s)", pv.Dev)
if err := sh("systemctl", "enable", "--now", "wg-quick@"+pv.Dev); err != nil {
return fmt.Errorf("install failed, and starting pivpn's WireGuard again failed too: %v (original error: %w)", err, cause)
}
return fmt.Errorf("install failed; pivpn runs %s as before: %w", pv.Dev, cause)
}
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 := newUpdateBackupPath(configFile, oldVersion, time.Now())
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)
}
if n, err := pruneUpdateBackups(configFile, keepUpdateBackups); err != nil {
fmt.Fprintln(os.Stderr, " Could not remove older config backups:", err)
} else if n > 0 {
step("Removed %d older config backups, kept the newest %d", n, keepUpdateBackups)
}
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 _, statErr := os.Stat(configFile); err != nil || statErr != nil {
// Without a config of ours, e.g. after a pivpn takeover was undone,
// the interface may belong to someone else: only the firewall table
// goes.
c = &Config{}
}
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
}