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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user