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
+34 -1
View File
@@ -12,10 +12,19 @@ remove), hands out client configs as a download or QR code, and records traffic
and connection history per peer. There are no install scripts and no
dependencies on the server: the binary installs, updates and removes itself.
> **Coming from pivpn?** GHOSTWIRE takes over a pivpn WireGuard server in one
> command: `sudo ./GHOSTWIRE install`. Your phones and laptops keep their
> current configs and reconnect on their own, with nothing to re-scan or
> re-send. See [Moving from pivpn](#moving-from-pivpn).
![Dashboard with peers online, traffic of the last 24 hours, the peer list and recent activity](screenshots/dashboard.png)
## Features
- **pivpn takeover:** install finds a pivpn WireGuard server and takes over
its key, networks and every client with its keys and addresses, so devices
keep working without new configs. pivpn comes back by itself if the switch
fails. [Details](#moving-from-pivpn).
- **One file of state:** everything lives in `config.json`. The kernel is
reconciled to it, so there is no `/etc/wireguard`, no `wg-quick` and no
`wireguard-tools`.
@@ -154,6 +163,7 @@ sudo /tmp/GHOSTWIRE install -y -domain vpn.example.net -email you@example.net -p
| `-email` | none |
| `-endpoint` | the domain |
| `-port` | 51820, or the current port when already installed |
| `-import-pivpn` | off: see [Moving from pivpn](#moving-from-pivpn) |
The admin password is then read from standard input, e.g.
`echo "$PASSWORD" | sudo ./GHOSTWIRE install -y …`. Every value is checked
@@ -179,11 +189,34 @@ Then open `https://vpn.example.net` and sign in as `admin`. Add more users
under Settings → Users. Root is needed only for the commands below, never for
the running service.
## Moving from pivpn
On a server that runs pivpn's WireGuard, a new install offers to take it
over. Devices keep their current config: GHOSTWIRE takes pivpn's server key,
port, MTU, tunnel networks (IPv4 and IPv6), endpoint, DNS, AllowedIPs and
keepalive, and every client with its public key, preshared key and addresses.
Clients pivpn switched off are imported switched off, with the note
"Imported from pivpn". Client private keys, which pivpn keeps in
`/etc/wireguard/configs`, are not read or stored.
After the summary, install notes which peers are connected, stops pivpn's
WireGuard (`systemctl disable --now wg-quick@wg0`), starts GHOSTWIRE on the
same `wg0` and waits up to 30 s for those peers to come back. Devices that
send traffic reconnect after about 15 s; an idle device reconnects the next
time it sends something. If the service does not stay running, install puts
pivpn back as it was.
Without a terminal, the takeover needs `-import-pivpn`; install refuses to
run next to pivpn otherwise. pivpn's files stay as they were. Manage peers in
GHOSTWIRE from then on, delete `/etc/wireguard/configs` once everything works,
and don't run `pivpn uninstall`, which removes WireGuard packages. To go back
to pivpn: `GHOSTWIRE uninstall`, then `systemctl enable --now wg-quick@wg0`.
## Commands (as root)
| Command | What it does |
|---|---|
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-import-pivpn] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. On a pivpn server it takes over pivpn's WireGuard (see above). |
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
+9 -3
View File
@@ -560,7 +560,7 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
err = a.store.Update(func(c *Config) error {
s := &c.Server
before := s.clientFacing()
oldV4 := s.IPv4
oldV4, oldV6 := s.IPv4, s.IPv6
for _, f := range []struct {
key string
dst any
@@ -583,6 +583,11 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
return err
}
}
if s.IPv6 != oldV6 {
for i := range c.Peers {
c.Peers[i].IPv6 = "" // pivpn's addresses are in the old network
}
}
reissue = before != s.clientFacing()
return nil
})
@@ -703,7 +708,7 @@ func (a *App) peerView(c *Config, p *Peer) peerView {
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
}
if c.Server.IPv6Enabled {
v.IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4)).String()
v.IPv6 = peerIPv6(c, p).String()
}
return v
}
@@ -1037,7 +1042,8 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
}
now := time.Now().UTC()
// A config issued here replaces any pending link.
p.PublicKey, p.ConfigIssued, p.Setup, name = pub, &now, nil, p.Name
// The new config gets the mapped IPv6 address.
p.PublicKey, p.ConfigIssued, p.Setup, p.IPv6, name = pub, &now, nil, "", p.Name
if p.PresharedKey != "" {
p.PresharedKey = psk.String()
}
+2 -2
View File
@@ -52,7 +52,7 @@ func peerAddresses(c *Config, p *Peer) []netip.Prefix {
v4 := netip.MustParseAddr(p.IPv4)
out := []netip.Prefix{netip.PrefixFrom(v4, 32)}
if c.Server.IPv6Enabled {
out = append(out, netip.PrefixFrom(mapIPv6(netip.MustParsePrefix(c.Server.IPv6), v4), 128))
out = append(out, netip.PrefixFrom(peerIPv6(c, p), 128))
}
return out
}
@@ -77,7 +77,7 @@ func clientConfig(c *Config, p *Peer, privateKey string) string {
addr := fmt.Sprintf("%s/%d", v4, v4net.Bits())
if c.Server.IPv6Enabled {
v6net := netip.MustParsePrefix(c.Server.IPv6)
addr += fmt.Sprintf(",%s/%d", mapIPv6(v6net, v4), v6net.Bits())
addr += fmt.Sprintf(",%s/%d", peerIPv6(c, p), v6net.Bits())
}
if privateKey == "" {
privateKey = "<the private key of this device>"
+16
View File
@@ -179,6 +179,10 @@ type Peer struct {
PublicKey string `json:"publicKey"`
PresharedKey string `json:"presharedKey,omitempty"`
IPv4 string `json:"ipv4"`
// IPv6 is set only for a peer imported from pivpn, which numbers IPv6
// differently: its device keeps the address until the config is issued
// here. Empty means the address mapped from IPv4 (see mapIPv6).
IPv6 string `json:"ipv6,omitempty"`
DNS []string `json:"dns,omitempty"` // nil = server default
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
@@ -473,6 +477,7 @@ func (c *Config) validate() error {
names := map[string]bool{}
ips := map[netip.Addr]bool{}
ips6 := map[netip.Addr]bool{}
keys := map[string]bool{}
for _, p := range c.Peers {
if err := validatePeerName(p.Name); err != nil {
@@ -493,6 +498,17 @@ func (c *Config) validate() error {
return fmt.Errorf("address %s is used twice", ip)
}
ips[ip] = true
if p.IPv6 != "" {
a, err := netip.ParseAddr(p.IPv6)
if err != nil || !a.Is6() || !v6.Contains(a) || a == v6.Addr() {
return fmt.Errorf("peer %q: IPv6 address %s is outside %s", p.Name, p.IPv6, v6)
}
}
if a := peerIPv6(c, &p); ips6[a] {
return fmt.Errorf("IPv6 address %s is used twice", a)
} else {
ips6[a] = true
}
if p.hasKey() && keys[p.PublicKey] {
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
}
+48 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"bufio"
"cmp"
"context"
"errors"
"fmt"
@@ -21,6 +22,7 @@ type installPlan struct {
noEmail bool // remove an existing Let's Encrypt email
passwordHash string // asked in the terminal; empty: asked later
ipv4 string // tunnel network of a new install
pivpn *pivpnSetup // pivpn's WireGuard server to take over
}
var domainRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
@@ -80,6 +82,10 @@ func (p installPlan) changes() bool {
return p.domain != "" || p.email != "" || p.endpoint != "" || p.port != 0 || p.noDomain || p.noEmail || p.passwordHash != ""
}
// errPivpnDeclined ends an install whose admin keeps pivpn: both would use
// the same interface.
var errPivpnDeclined = errors.New("install cancelled; nothing was changed. GHOSTWIRE and pivpn cannot both run the WireGuard interface: remove pivpn first, or install again and take it over")
func (p installPlan) apply(c *Config) {
if p.noDomain {
if c.Web.TLS.Mode == "acme" {
@@ -113,7 +119,7 @@ func (p installPlan) apply(c *Config) {
// reissueCount is the number of devices whose config stops working because
// the endpoint host or port changes.
func (p installPlan) reissueCount(cur *Config, existing bool) int {
if !existing {
if !existing && p.pivpn == nil {
return 0
}
next := cur.clone()
@@ -189,6 +195,25 @@ func askInstall(in io.Reader, cur *Config, existing bool, given map[string]bool,
fmt.Println("Press Enter to accept the value in [brackets].")
}
// pivpn: taken over first, so its settings become the defaults below.
if p.pivpn != nil && !given["import-pivpn"] {
s := cur.Server
nets := s.IPv4
if s.IPv6Enabled {
nets += " + " + s.IPv6
}
fmt.Println("\npivpn found")
fmt.Printf(" %s · %s · UDP %d · %s\n", s.Interface, nets, s.ListenPort, cmp.Or(s.Endpoint, "no endpoint"))
fmt.Printf(" %s: %s\n", plural(len(p.pivpn.Peers), "client"), cmp.Or(p.pivpn.names(), "none"))
ok, err := pr.confirm(" Take over this WireGuard server? The devices keep working without new configs.")
if err != nil {
return p, err
}
if !ok {
return p, errPivpnDeclined
}
}
// Web interface
fmt.Println("\nWeb interface")
curDomain := ""
@@ -328,7 +353,7 @@ func askInstall(in io.Reader, cur *Config, existing bool, given map[string]bool,
func printInstallSummary(cur *Config, existing bool, p installPlan) {
next := cur.clone()
if !existing {
if !existing && p.pivpn == nil {
next.Server.IPv4 = p.ipv4
next.Server.IPv6Enabled = hasGlobalIPv6()
}
@@ -366,7 +391,10 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) {
ep += fmt.Sprintf(" (was %s — %d existing device(s) need a new config)", endpointString(cur), n)
}
tunnel := next.Server.IPv4
if !existing {
switch {
case p.pivpn != nil:
tunnel += " (from pivpn)"
case !existing:
tunnel += " (random free range)"
}
if next.Server.IPv6Enabled {
@@ -395,4 +423,21 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) {
if p.passwordHash != "" {
fmt.Printf(" Admin %s (password set)\n", next.Users[0].Username)
}
if pv := p.pivpn; pv != nil {
s := next.Server
fmt.Printf(" From pivpn server key, %s with their keys and addresses,\n", plural(len(pv.Peers), "peer"))
fmt.Printf(" DNS %s · keepalive %d s · MTU %d\n", strings.Join(s.ClientDefaults.DNS, ", "), s.ClientDefaults.Keepalive, s.MTU)
for _, r := range pv.Renamed {
fmt.Printf(" Renamed %s → %s (names here: 1–32 letters, digits, . @ _ -)\n", r[0], r[1])
}
fmt.Printf(" Not taken client private keys in %s (never stored here)\n", pv.ClientKeys)
}
}
// plural writes "1 peer" or "5 peers".
func plural(n int, word string) string {
if n == 1 {
return "1 " + word
}
return strconv.Itoa(n) + " " + word + "s"
}
+9
View File
@@ -122,6 +122,15 @@ func nextFreeIPv4(c *Config) (netip.Addr, error) {
// capacity is the number of peer addresses in the tunnel network.
func capacity(n netip.Prefix) int { return 1<<(32-n.Bits()) - 3 }
// peerIPv6 is the peer's IPv6 tunnel address: the one kept from pivpn, or
// the one mapped from its IPv4 address.
func peerIPv6(c *Config, p *Peer) netip.Addr {
if a, err := netip.ParseAddr(p.IPv6); err == nil {
return a
}
return mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4))
}
// mapIPv6 puts the 32 bits of an IPv4 address into the low bits of the IPv6
// network: 10.84.12.8 in fd11:5ee:bad:c0de::/64 becomes fd11:5ee:bad:c0de::a54:c08.
func mapIPv6(v6net netip.Prefix, v4 netip.Addr) netip.Addr {
+2
View File
@@ -368,9 +368,11 @@ func (k *linuxKernel) Checks(c *Config) []Check {
func (k *linuxKernel) Down(c *Config) error {
var errs []error
if c.Server.Interface != "" {
if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
errs = append(errs, netlink.LinkDel(link))
}
}
errs = append(errs, removeFirewall())
return errors.Join(errs...)
}
+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()
}
+300
View File
@@ -0,0 +1,300 @@
package main
import (
"errors"
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// pivpnFixture writes a pivpn WireGuard layout under a temp root, in the
// format pivpn v4 writes (checked against a real install, Ubuntu 24.04).
type pivpnClient struct {
name, v6 string
ipv4 string
psk bool
disabled bool
}
func pivpnFixture(t *testing.T, ipv6 bool, clients []pivpnClient) (root string, serverKey wgtypes.Key, pubs map[string]string) {
t.Helper()
root = t.TempDir()
must := func(err error) {
if err != nil {
t.Fatal(err)
}
}
for _, d := range []string{"etc/pivpn/wireguard", "etc/wireguard/configs", "etc/wireguard/keys"} {
must(os.MkdirAll(filepath.Join(root, d), 0o755))
}
v6 := "0"
if ipv6 {
v6 = "1"
}
vars := `USING_UFW=0
IPv4dev=eth0
VPN=wireguard
pivpnPORT=51820
pivpnDNS1=9.9.9.9
pivpnDNS2=149.112.112.112
pivpnHOST=vpn.example.net
pivpnPROTO=udp
pivpnMTU=1420
pivpnPERSISTENTKEEPALIVE=25
pivpnDEV=wg0
pivpnNET=10.6.0.0
subnetClass=24
pivpnenableipv6=` + v6 + `
pivpnNETv6="fd11:5ee:bad:c0de::"
subnetClassv6=64
ALLOWED_IPS="0.0.0.0/0, ::0/0"
INSTALLED_PACKAGES=(wireguard-tools qrencode)
`
must(os.WriteFile(filepath.Join(root, pivpnSetupVars), []byte(vars), 0o644))
serverKey, _ = wgtypes.GeneratePrivateKey()
var conf, txt strings.Builder
addr := "10.6.0.1/24"
if ipv6 {
addr += ",fd11:5ee:bad:c0de::a06:1/64"
}
fmt.Fprintf(&conf, "[Interface]\nPrivateKey = %s\nAddress = %s\nMTU = 1420\nListenPort = 51820\n", serverKey, addr)
pubs = map[string]string{}
for _, c := range clients {
k, _ := wgtypes.GeneratePrivateKey()
pubs[c.name] = k.PublicKey().String()
var b strings.Builder
fmt.Fprintf(&b, "### begin %s ###\n[Peer]\nPublicKey = %s\n", c.name, k.PublicKey())
if c.psk {
psk, _ := wgtypes.GenerateKey()
fmt.Fprintf(&b, "PresharedKey = %s\n", psk)
}
fmt.Fprintf(&b, "AllowedIPs = %s/32", c.ipv4)
if ipv6 {
fmt.Fprintf(&b, ",%s/128", c.v6)
}
fmt.Fprintf(&b, "\n### end %s ###\n", c.name)
block := b.String()
if c.disabled {
block = "#[disabled] " + strings.ReplaceAll(strings.TrimSuffix(block, "\n"), "\n", "\n#[disabled] ") + "\n"
}
conf.WriteString(block)
fmt.Fprintf(&txt, "%s %s 1700000000 167116802\n", c.name, k.PublicKey())
}
must(os.WriteFile(filepath.Join(root, "etc/wireguard/wg0.conf"), []byte(conf.String()), 0o644))
must(os.WriteFile(filepath.Join(root, "etc/wireguard/configs/clients.txt"), []byte(txt.String()), 0o644))
return root, serverKey, pubs
}
func importedConfig(t *testing.T, s *pivpnSetup) *Config {
t.Helper()
c := &Config{}
c.applyDefaults()
s.apply(c)
if err := c.validate(); err != nil {
t.Fatalf("imported config does not validate: %v", err)
}
return c
}
func TestPivpnImport(t *testing.T) {
// No pivpn: nothing to import, no error.
if s, err := readPivpn(t.TempDir()); s != nil || err != nil {
t.Fatalf("empty root: %v %v", s, err)
}
clients := []pivpnClient{
{name: "iphone-alex", ipv4: "10.6.0.2", v6: "fd11:5ee:bad:c0de::a06:2", psk: true},
{name: "nas-office", ipv4: "10.6.0.5", v6: "fd11:5ee:bad:c0de::a06:5", psk: false},
{name: "phone-guest", ipv4: "10.6.0.6", v6: "fd11:5ee:bad:c0de::a06:6", psk: true, disabled: true},
// Hand-edited: an IPv6 address that is not the mapped one.
{name: "old-laptop", ipv4: "10.6.0.7", v6: "fd11:5ee:bad:c0de::7", psk: true},
}
root, key, pubs := pivpnFixture(t, true, clients)
s, err := readPivpn(root)
if err != nil || s == nil {
t.Fatal(err)
}
c := importedConfig(t, s)
srv := c.Server
if srv.PrivateKey != key.String() || srv.ListenPort != 51820 || srv.MTU != 1420 || srv.Interface != "wg0" ||
srv.IPv4 != "10.6.0.0/24" || srv.IPv6 != "fd11:5ee:bad:c0de::/64" || !srv.IPv6Enabled ||
srv.Endpoint != "vpn.example.net" || !srv.NAT || !srv.PeerToPeer || !srv.OpenPort {
t.Fatalf("server: %+v", srv)
}
cd := srv.ClientDefaults
if strings.Join(cd.DNS, ",") != "9.9.9.9,149.112.112.112" || strings.Join(cd.AllowedIPs, ",") != "0.0.0.0/0,::/0" || cd.Keepalive != 25 {
t.Fatalf("client defaults: %+v", cd)
}
if len(c.Peers) != 4 {
t.Fatalf("want 4 peers, got %d", len(c.Peers))
}
by := map[string]*Peer{}
for i := range c.Peers {
by[c.Peers[i].Name] = &c.Peers[i]
}
ph := by["iphone-alex"]
if ph.PublicKey != pubs["iphone-alex"] || ph.PresharedKey == "" || ph.IPv4 != "10.6.0.2" || ph.IPv6 != "" ||
!ph.Enabled || ph.Note != pivpnNote || !ph.Created.Equal(time.Unix(1700000000, 0)) || ph.ConfigIssued == nil {
t.Fatalf("iphone-alex: %+v", ph)
}
if by["nas-office"].PresharedKey != "" {
t.Error("nas-office had no preshared key")
}
if by["phone-guest"].Enabled {
t.Error("a #[disabled] client must be imported switched off")
}
if by["old-laptop"].IPv6 != "fd11:5ee:bad:c0de::7" {
t.Errorf("a non-mapped IPv6 address must be kept, got %q", by["old-laptop"].IPv6)
}
// Each device's own pivpn config keeps working: the server accepts its
// key, preshared key and both addresses.
for _, cl := range clients {
p := by[cl.name]
got := []string{}
for _, a := range peerAddresses(c, p) {
got = append(got, a.String())
}
want := cl.ipv4 + "/32 " + cl.v6 + "/128"
if strings.Join(got, " ") != want {
t.Errorf("%s: server allows %v, the device uses %s", cl.name, got, want)
}
}
// A config issued here gets the mapped address and drops the kept one.
conf := clientConfig(c, by["old-laptop"], "")
if !strings.Contains(conf, "fd11:5ee:bad:c0de::7/64") {
t.Errorf("config before re-issue should keep pivpn's address:\n%s", conf)
}
// IPv4-only pivpn.
root4, _, _ := pivpnFixture(t, false, clients[:1])
s4, err := readPivpn(root4)
if err != nil {
t.Fatal(err)
}
c4 := importedConfig(t, s4)
if c4.Server.IPv6Enabled || c4.Peers[0].IPv6 != "" {
t.Fatalf("IPv4-only import: %+v %+v", c4.Server, c4.Peers[0])
}
// A wg0.conf without clients imports the server alone.
root0, _, _ := pivpnFixture(t, true, nil)
s0, err := readPivpn(root0)
if err != nil || len(importedConfig(t, s0).Peers) != 0 {
t.Fatalf("no clients: %v", err)
}
// Broken files are refused before anything changes.
broken := func(edit func(string) string) error {
r, _, _ := pivpnFixture(t, true, clients[:1])
p := filepath.Join(r, "etc/wireguard/wg0.conf")
b, _ := os.ReadFile(p)
_ = os.WriteFile(p, []byte(edit(string(b))), 0o644)
_, err := readPivpn(r)
return err
}
if broken(func(s string) string { return strings.Replace(s, "PrivateKey = ", "PrivateKey = x", 1) }) == nil {
t.Error("a bad server key must be refused")
}
if broken(func(s string) string { return strings.Replace(s, "\nPublicKey = ", "\nPublicKey = x", 1) }) == nil {
t.Error("a bad client key must be refused")
}
if err := broken(func(s string) string { return strings.Replace(s, "AllowedIPs = 10.6.0.2/32,", "AllowedIPs = ", 1) }); err == nil {
t.Error("a client without IPv4 must be refused")
}
r, _, _ := pivpnFixture(t, true, nil)
_ = os.Remove(filepath.Join(r, "etc/wireguard/wg0.conf"))
if _, err := readPivpn(r); err == nil || errors.Is(err, os.ErrNotExist) && !strings.Contains(err.Error(), "pivpn") {
t.Errorf("a missing wg0.conf must be an error: %v", err)
}
}
func TestPivpnNames(t *testing.T) {
taken := map[string]bool{"phone": true}
for in, want := range map[string]string{
"iphone-alex": "iphone-alex",
"phone": "phone-1",
"server": "peer-server",
"12345": "peer-12345",
"-dash": "dash",
"a-very-long-client-name-from-pivpn-2025": "a-very-long-client-name-from-piv",
"Ümlaut": "mlaut",
} {
if got := usableName(in, taken); got != want || validatePeerName(got) != nil {
t.Errorf("usableName(%q) = %q, want %q", in, got, want)
}
}
// Two pivpn names that become the same here are both kept, renamed.
root, _, _ := pivpnFixture(t, true, []pivpnClient{
{name: "Phone", ipv4: "10.6.0.2", v6: "fd11:5ee:bad:c0de::a06:2"},
{name: "phone", ipv4: "10.6.0.3", v6: "fd11:5ee:bad:c0de::a06:3"},
})
s, err := readPivpn(root)
if err != nil {
t.Fatal(err)
}
c := importedConfig(t, s)
if c.Peers[0].Name != "Phone" || c.Peers[1].Name != "phone-1" || len(s.Renamed) != 1 || s.Renamed[0] != [2]string{"phone", "phone-1"} {
t.Fatalf("renames: %v %v", []string{c.Peers[0].Name, c.Peers[1].Name}, s.Renamed)
}
}
func TestPivpnInstallQuestion(t *testing.T) {
root, _, _ := pivpnFixture(t, true, []pivpnClient{
{name: "iphone-alex", ipv4: "10.6.0.2", v6: "fd11:5ee:bad:c0de::a06:2", psk: true},
})
s, err := readPivpn(root)
if err != nil {
t.Fatal(err)
}
cur := importedConfig(t, s)
hash, _ := hashPassword("a long test password")
cur.Users[0].PasswordHash = hash
// Yes, then Enter keeps pivpn's endpoint and port: no device needs a new config.
p, err := askInstall(strings.NewReader("y\n\n\n\ny\n"), cur, false, map[string]bool{}, installPlan{pivpn: s})
if err != nil || p.endpoint != "" || p.port != 0 || p.reissueCount(cur, false) != 0 {
t.Fatalf("Enter should keep pivpn's settings: %+v %v", p, err)
}
// A new port means the device needs a new config.
p, err = askInstall(strings.NewReader("y\n\n\n51900\ny\n"), cur, false, map[string]bool{}, installPlan{pivpn: s})
if err != nil || p.reissueCount(cur, false) != 1 {
t.Fatalf("a new port should need a new config: %+v %v", p, err)
}
// No: nothing changes, and install explains why.
if _, err := askInstall(strings.NewReader("n\n"), cur, false, map[string]bool{}, installPlan{pivpn: s}); !errors.Is(err, errPivpnDeclined) {
t.Fatalf("want errPivpnDeclined, got %v", err)
}
// -import-pivpn answers the question.
if _, err := askInstall(strings.NewReader("\n\n\ny\n"), cur, false, map[string]bool{"import-pivpn": true}, installPlan{pivpn: s}); err != nil {
t.Fatal(err)
}
}
func TestPeerIPv6Kept(t *testing.T) {
c := testConfig(t)
c.Server.IPv6Enabled = true
c.Peers = []Peer{{ID: "a", Name: "a", IPv4: "10.84.12.2", PublicKey: "k1", IPv6: "fd11:5ee:bad:c0de::2"}}
if err := c.validate(); err != nil {
t.Fatal(err)
}
for _, bad := range []string{"10.84.12.9", "fd00::2", "fd11:5ee:bad:c0de::", "not an address"} {
c.Peers[0].IPv6 = bad
if c.validate() == nil {
t.Errorf("IPv6 %q should be refused", bad)
}
}
// Two peers on the same IPv6 address.
c.Peers[0].IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr("10.84.12.3")).String()
c.Peers = append(c.Peers, Peer{ID: "b", Name: "b", IPv4: "10.84.12.3", PublicKey: "k2"})
if c.validate() == nil {
t.Error("an IPv6 address used twice should be refused")
}
}
+166 -10
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"cmp"
"crypto/sha256"
"errors"
"flag"
@@ -37,9 +38,10 @@ 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]
%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
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]
@@ -379,6 +381,7 @@ func cmdInstall(args []string) error {
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 })
@@ -402,14 +405,40 @@ func cmdInstall(args []string) error {
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 !*yes && term.IsTerminal(int(os.Stdin.Fd())) {
if interactive {
if plan, err = askInstall(os.Stdin, cur, existing, given, plan); err != nil {
return err
}
@@ -453,8 +482,15 @@ func cmdInstall(args []string) error {
}
}
// Config: created with defaults (server key, the chosen subnet) if missing.
if !existing {
// 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 {
@@ -491,17 +527,135 @@ func cmdInstall(args []string) error {
return err
}
step("Starting %s", serviceName)
if err := sh("systemctl", "enable", serviceName); err != nil {
// 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
}
if err := restartAndVerify(); err != nil {
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 {
@@ -663,9 +817,11 @@ func cmdUninstall(args []string) error {
step("Removing the WireGuard interface and firewall table")
c, err := loadConfigFile(configFile)
if err != nil {
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{}
c.applyDefaults()
}
if k, err := newKernel(); err == nil {
if err := k.Down(c); err != nil {
+1 -1
View File
@@ -260,7 +260,7 @@ func (a *App) setupRedeem(w http.ResponseWriter, r *http.Request) {
}
now := time.Now().UTC()
id, hadKey = p.ID, p.hasKey()
p.PublicKey, p.ConfigIssued, p.Setup = k.PublicKey().String(), &now, nil
p.PublicKey, p.ConfigIssued, p.Setup, p.IPv6 = k.PublicKey().String(), &now, nil, ""
if p.PresharedKey != "" {
p.PresharedKey = psk.String()
}