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.
This commit is contained in:
Daniel Redetzke
2026-10-06 00:54:08 +03:00
parent 47762790ef
commit 3482a03707
11 changed files with 957 additions and 36 deletions
+354
View File
@@ -0,0 +1,354 @@
package main
import (
"bufio"
"cmp"
"errors"
"fmt"
"net/netip"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// A new install can take over a WireGuard server set up by pivpn: the
// server key, the network and every client with its public key, preshared
// key and addresses, so the devices keep their configs. pivpn keeps the
// client private keys in /etc/wireguard/configs; they are not read.
const (
pivpnSetupVars = "etc/pivpn/wireguard/setupVars.conf"
pivpnNote = "Imported from pivpn"
)
// pivpnSetup is what install takes over from pivpn.
type pivpnSetup struct {
Dev string // the interface, wg0
Server Server
Peers []Peer
Renamed [][2]string // pivpn name, name here
ClientKeys string // where pivpn keeps the client configs with private keys
}
// readPivpn reads pivpn's WireGuard setup under root ("/" on a server). It
// returns nil and no error when pivpn's WireGuard is not installed.
func readPivpn(root string) (*pivpnSetup, error) {
vars, err := readSetupVars(filepath.Join(root, pivpnSetupVars))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
s := &pivpnSetup{Dev: cmp.Or(vars["pivpnDEV"], "wg0")}
if checkIfName(s.Dev) != nil {
return nil, fmt.Errorf("pivpn: interface name %q is not usable", s.Dev)
}
confPath := filepath.Join(root, "etc/wireguard", s.Dev+".conf")
conf, err := parseWgConf(confPath)
if err != nil {
return nil, fmt.Errorf("pivpn: %w", err)
}
s.ClientKeys = "/etc/wireguard/configs"
created := readClientsTxt(filepath.Join(root, "etc/wireguard/configs/clients.txt"))
// Server
srv := &s.Server
srv.Interface = s.Dev
if _, err := wgtypes.ParseKey(conf.privateKey); err != nil {
return nil, fmt.Errorf("pivpn: %s: the server key is missing or invalid", confPath)
}
srv.PrivateKey = conf.privateKey
srv.KeyCreated = fileTime(filepath.Join(root, "etc/wireguard/keys/server_priv"), confPath)
if srv.ListenPort = conf.listenPort; srv.ListenPort == 0 {
srv.ListenPort, _ = strconv.Atoi(vars["pivpnPORT"])
}
if srv.MTU = conf.mtu; srv.MTU == 0 {
srv.MTU, _ = strconv.Atoi(vars["pivpnMTU"])
}
for _, a := range conf.address {
if a.Addr().Is4() {
srv.IPv4 = a.Masked().String()
} else {
srv.IPv6, srv.IPv6Enabled = a.Masked().String(), true
}
}
if srv.IPv4 == "" {
return nil, fmt.Errorf("pivpn: %s has no IPv4 Address line", confPath)
}
if h := vars["pivpnHOST"]; checkEndpoint(h) == nil {
srv.Endpoint = h
}
srv.NAT, srv.PeerToPeer, srv.OpenPort = true, true, true
for _, k := range []string{"pivpnDNS1", "pivpnDNS2"} {
if a, err := netip.ParseAddr(vars[k]); err == nil {
srv.ClientDefaults.DNS = append(srv.ClientDefaults.DNS, a.String())
}
}
for _, v := range strings.Split(vars["ALLOWED_IPS"], ",") {
if p, err := netip.ParsePrefix(strings.TrimSpace(v)); err == nil {
srv.ClientDefaults.AllowedIPs = append(srv.ClientDefaults.AllowedIPs, p.Masked().String())
}
}
srv.ClientDefaults.Keepalive, _ = strconv.Atoi(vars["pivpnPERSISTENTKEEPALIVE"])
// Clients
v6net, _ := netip.ParsePrefix(srv.IPv6)
taken := map[string]bool{}
for _, cl := range conf.clients {
pub, err := wgtypes.ParseKey(cl.publicKey)
if err != nil {
return nil, fmt.Errorf("pivpn: client %q has no valid public key", cl.name)
}
p := Peer{ID: newID(), Name: cl.name, Note: pivpnNote, Enabled: !cl.disabled, PublicKey: pub.String()}
if cl.presharedKey != "" {
psk, err := wgtypes.ParseKey(cl.presharedKey)
if err != nil {
return nil, fmt.Errorf("pivpn: client %q has an invalid preshared key", cl.name)
}
p.PresharedKey = psk.String()
}
for _, a := range cl.allowedIPs {
switch {
case a.Addr().Is4() && p.IPv4 == "":
p.IPv4 = a.Addr().String()
case a.Addr().Is6() && p.IPv6 == "" && v6net.IsValid() && v6net.Contains(a.Addr()):
p.IPv6 = a.Addr().String()
}
}
if p.IPv4 == "" {
return nil, fmt.Errorf("pivpn: client %q has no IPv4 address", cl.name)
}
// Keep pivpn's IPv6 address only where it differs from the mapped one.
if v4, err := netip.ParseAddr(p.IPv4); err == nil && v6net.IsValid() && p.IPv6 == mapIPv6(v6net, v4).String() {
p.IPv6 = ""
}
t, ok := created[cl.name]
if !ok {
t = srv.KeyCreated
}
t = t.UTC()
p.Created, p.ConfigIssued = t, &t
if name := usableName(cl.name, taken); name != cl.name {
s.Renamed = append(s.Renamed, [2]string{cl.name, name})
p.Name = name
}
taken[strings.ToLower(p.Name)] = true
s.Peers = append(s.Peers, p)
}
return s, nil
}
// apply puts the pivpn setup into a fresh config.
func (s *pivpnSetup) apply(c *Config) {
cd := c.Server.ClientDefaults
c.Server = s.Server
// Settings pivpn left empty keep GHOSTWIRE's defaults.
if c.Server.ClientDefaults.DNS == nil {
c.Server.ClientDefaults.DNS = cd.DNS
}
if c.Server.ClientDefaults.AllowedIPs == nil {
c.Server.ClientDefaults.AllowedIPs = cd.AllowedIPs
}
if c.Server.IPv6 == "" {
// IPv4-only pivpn: IPv6 stays off, with the network GHOSTWIRE
// would pick, ready for when it is switched on.
c.Server.IPv6 = "fd11:5ee:bad:c0de::/64"
}
c.Peers = slices.Clone(s.Peers)
c.applyDefaults()
}
// names lists the clients for the takeover question.
func (s *pivpnSetup) names() string {
var out []string
for _, p := range s.Peers {
n := p.Name
if !p.Enabled {
n += " (off)"
}
out = append(out, n)
}
return strings.Join(out, ", ")
}
func checkIfName(n string) error {
if n == "" || len(n) > 15 || strings.ContainsAny(n, "/ \t") {
return errors.New("bad interface name")
}
return nil
}
// usableName turns a pivpn client name into one GHOSTWIRE accepts and that
// is not taken yet.
func usableName(name string, taken map[string]bool) string {
b := []rune{}
for _, r := range name {
if r < 128 && (r == '.' || r == '@' || r == '_' || r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') {
b = append(b, r)
} else {
b = append(b, '-')
}
}
n := strings.TrimLeft(string(b), "-.")
if n == "" || strings.Trim(n, "0123456789") == "" || n == "server" {
n = "peer-" + n
}
n = strings.TrimRight(n, "-")
if len(n) > 32 {
n = n[:32]
}
base := n
for i := 1; taken[strings.ToLower(n)] || validatePeerName(n) != nil; i++ {
suffix := "-" + strconv.Itoa(i)
n = base
if len(n)+len(suffix) > 32 {
n = n[:32-len(suffix)]
}
n += suffix
if i > 1000 {
break
}
}
return n
}
// readSetupVars reads pivpn's KEY=VALUE file; values may be quoted.
func readSetupVars(path string) (map[string]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
out := map[string]string{}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || strings.HasPrefix(k, "#") {
continue
}
v = strings.TrimSpace(v)
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
v = v[1 : len(v)-1]
}
out[strings.TrimSpace(k)] = v
}
return out, nil
}
type wgClient struct {
name, publicKey, presharedKey string
allowedIPs []netip.Prefix
disabled bool
}
type wgConf struct {
privateKey string
listenPort, mtu int
address []netip.Prefix
clients []wgClient
}
// parseWgConf reads pivpn's wg0.conf: the [Interface] section, then one
// "### begin NAME ###" … "### end NAME ###" block per client. pivpn turns a
// client off by prefixing each line of its block with "#[disabled] ".
func parseWgConf(path string) (*wgConf, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
c := &wgConf{}
var cur *wgClient
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
disabled := false
if rest, ok := strings.CutPrefix(line, "#[disabled]"); ok {
line, disabled = strings.TrimSpace(rest), true
}
if name, ok := strings.CutPrefix(line, "### begin "); ok {
c.clients = append(c.clients, wgClient{name: strings.TrimSpace(strings.TrimSuffix(name, "###"))})
cur = &c.clients[len(c.clients)-1]
continue
}
if strings.HasPrefix(line, "### end ") {
cur = nil
continue
}
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.ToLower(strings.TrimSpace(k)), strings.TrimSpace(v)
if cur != nil {
cur.disabled = cur.disabled || disabled
switch k {
case "publickey":
cur.publicKey = v
case "presharedkey":
cur.presharedKey = v
case "allowedips":
cur.allowedIPs = parsePrefixes(v)
}
continue
}
switch k {
case "privatekey":
c.privateKey = v
case "listenport":
c.listenPort, _ = strconv.Atoi(v)
case "mtu":
c.mtu, _ = strconv.Atoi(v)
case "address":
c.address = parsePrefixes(v)
}
}
return c, sc.Err()
}
func parsePrefixes(v string) []netip.Prefix {
var out []netip.Prefix
for _, s := range strings.Split(v, ",") {
if p, err := netip.ParsePrefix(strings.TrimSpace(s)); err == nil {
out = append(out, p)
}
}
return out
}
// readClientsTxt returns when each client was created: clients.txt has
// "NAME PUBLICKEY UNIXTIME" per line.
func readClientsTxt(path string) map[string]time.Time {
out := map[string]time.Time{}
b, err := os.ReadFile(path)
if err != nil {
return out
}
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
if n, err := strconv.ParseInt(f[2], 10, 64); err == nil && n > 0 {
out[f[0]] = time.Unix(n, 0)
}
}
return out
}
// fileTime is the modification time of the first file that exists.
func fileTime(paths ...string) time.Time {
for _, p := range paths {
if st, err := os.Stat(p); err == nil {
return st.ModTime().UTC()
}
}
return time.Now().UTC()
}