pivpn takeover: the reconnect wait can be skipped

In a terminal, install now says which peers it waits for and that Enter
skips the wait; Enter ends it at once and names the peers not back yet.
-no-wait skips it in scripts. Without a terminal and without -no-wait,
install still waits up to 30 s. The takeover is done before the wait
starts, so skipping it changes nothing on the server.
This commit is contained in:
Daniel Redetzke
2026-10-06 01:04:47 +03:00
parent 3482a03707
commit ef5a2930e0
3 changed files with 113 additions and 30 deletions
+4 -2
View File
@@ -164,6 +164,7 @@ sudo /tmp/GHOSTWIRE install -y -domain vpn.example.net -email you@example.net -p
| `-endpoint` | the domain | | `-endpoint` | the domain |
| `-port` | 51820, or the current port when already installed | | `-port` | 51820, or the current port when already installed |
| `-import-pivpn` | off: see [Moving from pivpn](#moving-from-pivpn) | | `-import-pivpn` | off: see [Moving from pivpn](#moving-from-pivpn) |
| `-no-wait` | off: after a pivpn takeover, don't wait for devices to reconnect |
The admin password is then read from standard input, e.g. The admin password is then read from standard input, e.g.
`echo "$PASSWORD" | sudo ./GHOSTWIRE install -y …`. Every value is checked `echo "$PASSWORD" | sudo ./GHOSTWIRE install -y …`. Every value is checked
@@ -203,7 +204,8 @@ After the summary, install notes which peers are connected, stops pivpn's
WireGuard (`systemctl disable --now wg-quick@wg0`), starts GHOSTWIRE on the 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 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 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 time it sends something. The wait only reports: Enter skips it, and so does
`-no-wait` in scripts. If the service does not stay running, install puts
pivpn back as it was. pivpn back as it was.
Without a terminal, the takeover needs `-import-pivpn`; install refuses to Without a terminal, the takeover needs `-import-pivpn`; install refuses to
@@ -216,7 +218,7 @@ to pivpn: `GHOSTWIRE uninstall`, then `systemctl enable --now wg-quick@wg0`.
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `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 install [-domain d] [-email e] [-endpoint h] [-port p] [-import-pivpn] [-no-wait] [-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 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 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. | | `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. |
+35
View File
@@ -298,3 +298,38 @@ func TestPeerIPv6Kept(t *testing.T) {
t.Error("an IPv6 address used twice should be refused") t.Error("an IPv6 address used twice should be refused")
} }
} }
func TestPivpnWaitBack(t *testing.T) {
pv := &pivpnSetup{Peers: []Peer{{Name: "a", PublicKey: "ka"}, {Name: "b", PublicKey: "kb"}}}
since := time.Now()
after := since.Add(time.Second)
sample := func(back ...string) func() ([]PeerSample, error) {
return func() ([]PeerSample, error) {
var out []PeerSample
for _, k := range back {
out = append(out, PeerSample{PublicKey: k, LastHandshake: after})
}
// A handshake from before the switch does not count.
return append(out, PeerSample{PublicKey: "kb", LastHandshake: since.Add(-time.Minute)}), nil
}
}
// Everyone back: returns at once.
start := time.Now()
back, skipped := waitBack(pv, []string{"a", "b"}, sample("ka", "kb"), since, time.Minute, time.Millisecond, nil)
if len(back) != 2 || skipped || time.Since(start) > time.Second {
t.Fatalf("all back: %v %v", back, skipped)
}
// One missing: waits for the timeout.
back, skipped = waitBack(pv, []string{"a", "b"}, sample("ka"), since, 50*time.Millisecond, 5*time.Millisecond, nil)
if len(back) != 1 || back[0] != "a" || skipped {
t.Fatalf("timeout: %v %v", back, skipped)
}
// Enter: returns at once, marked skipped.
skip := make(chan struct{})
close(skip)
start = time.Now()
back, skipped = waitBack(pv, []string{"a", "b"}, sample("ka"), time.Now(), time.Minute, time.Second, skip)
if !skipped || time.Since(start) > time.Second {
t.Fatalf("skip: %v %v", back, skipped)
}
}
+73 -27
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bufio"
"bytes" "bytes"
"cmp" "cmp"
"crypto/sha256" "crypto/sha256"
@@ -38,7 +39,7 @@ func usage() {
fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager
Usage (as root): Usage (as root):
%s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host] [-port 51820] [-import-pivpn] [-y] %s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host] [-port 51820] [-import-pivpn] [-no-wait] [-y]
set up user, folder, config, sysctls and systemd service; start it. 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 On a pivpn server it offers to take over pivpn's WireGuard and clients
@@ -382,6 +383,7 @@ func cmdInstall(args []string) error {
port := fs.Int("port", 0, "UDP port WireGuard listens on (default: 51820, or the current port when already installed)") 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") 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)") importPivpn := fs.Bool("import-pivpn", false, "take over pivpn's WireGuard server and clients (new installs only)")
noWait := fs.Bool("no-wait", false, "after a pivpn takeover, do not wait for connected devices to come back")
_ = fs.Parse(args) _ = fs.Parse(args)
given := map[string]bool{} given := map[string]bool{}
fs.Visit(func(f *flag.Flag) { given[f.Name] = true }) fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
@@ -553,7 +555,7 @@ func cmdInstall(args []string) error {
return err return err
} }
if pv != nil { if pv != nil {
waitForPeers(pv, connected, switched) waitForPeers(pv, connected, switched, *noWait, interactive)
} }
printWhereToGo(store.Get()) printWhereToGo(store.Get())
if pv != nil { if pv != nil {
@@ -589,26 +591,70 @@ func pivpnConnected(pv *pivpnSetup) []string {
// waitForPeers waits up to 30 s for the peers that were connected to pivpn // 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 // to make a handshake with the new service. It only reports: a device that
// is idle may take minutes to send its next packet. // is idle may take minutes to send its next packet. Enter in a terminal
func waitForPeers(pv *pivpnSetup, names []string, since time.Time) { // skips the rest of the wait; -no-wait skips it entirely.
if len(names) == 0 { func waitForPeers(pv *pivpnSetup, names []string, since time.Time, noWait, interactive bool) {
switch {
case len(names) == 0:
step("No peer was connected before the switch; devices connect when they come back online.") step("No peer was connected before the switch; devices connect when they come back online.")
return return
} case noWait:
key := map[string]string{} step("Not waiting for %s (-no-wait).", strings.Join(names, ", "))
for _, p := range pv.Peers { fmt.Printf(" %s\n", onlineLater(len(names)))
key[p.Name] = p.PublicKey return
} }
k, err := newKernel() k, err := newKernel()
if err != nil { if err != nil {
return return
} }
defer k.Close() defer k.Close()
var back []string var skip <-chan struct{}
deadline := since.Add(30 * time.Second) hint := ""
if interactive {
ch := make(chan struct{})
go func() {
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
close(ch)
}()
skip, hint = ch, " (Enter skips)"
}
step("Waiting up to 30 s for %s to come back%s", strings.Join(names, ", "), hint)
back, skipped := waitBack(pv, names, func() ([]PeerSample, error) { return k.Sample(pv.Dev) }, since, 30*time.Second, 2*time.Second, skip)
secs := int(time.Since(since).Round(time.Second).Seconds())
var missing []string
for _, n := range names {
if !slices.Contains(back, n) {
missing = append(missing, n)
}
}
switch {
case len(missing) == 0:
step("%d of %d peers that were connected before are back (after %d s)", len(back), len(names), secs)
return
case skipped:
step("Skipped after %d s: %d of %d back so far", secs, len(back), len(names))
fmt.Printf(" %s not back yet.\n %s\n", strings.Join(missing, ", "), onlineLater(len(missing)))
default:
step("%d of %d are back after %d s", len(back), len(names), secs)
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.Printf(" packet. %s\n", onlineLater(len(missing)))
}
}
// waitBack polls the kernel until every named peer made a handshake after
// since, the timeout passes or skip is closed. It returns the peers that are
// back and whether the wait was skipped.
func waitBack(pv *pivpnSetup, names []string, sample func() ([]PeerSample, error), since time.Time, timeout, every time.Duration, skip <-chan struct{}) (back []string, skipped bool) {
key := map[string]string{}
for _, p := range pv.Peers {
key[p.Name] = p.PublicKey
}
deadline := time.After(time.Until(since.Add(timeout)))
tick := time.NewTicker(every)
defer tick.Stop()
for { for {
back = back[:0] back = back[:0]
if samples, err := k.Sample(pv.Dev); err == nil { if samples, err := sample(); err == nil {
for _, n := range names { for _, n := range names {
for _, s := range samples { for _, s := range samples {
if s.PublicKey == key[n] && s.LastHandshake.After(since) { if s.PublicKey == key[n] && s.LastHandshake.After(since) {
@@ -617,25 +663,25 @@ func waitForPeers(pv *pivpnSetup, names []string, since time.Time) {
} }
} }
} }
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) { 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 back, false
return
} }
step("Waiting for them to come back: %d of %d are back after %d s", len(back), len(names), secs) select {
var missing []string case <-skip:
for _, n := range names { return back, true
if !slices.Contains(back, n) { case <-deadline:
missing = append(missing, n) return back, false
case <-tick.C:
} }
} }
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.")
// onlineLater tells where peers that are not back yet will show up.
func onlineLater(n int) string {
if n == 1 {
return "It shows as online on the Peers page once it is back."
}
return "They show as online on the Peers page once they are back."
} }
// pivpnBack undoes the takeover after the service failed to start: it stops // pivpnBack undoes the takeover after the service failed to start: it stops