From ef5a2930e075562b160ad76a586e262a4f8cb9b8 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Tue, 6 Oct 2026 01:04:47 +0300 Subject: [PATCH] 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. --- README.md | 6 ++- pivpn_test.go | 35 +++++++++++++++++ setup.go | 102 ++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 113 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 67cdb0f..7ad8f0d 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ sudo /tmp/GHOSTWIRE install -y -domain vpn.example.net -email you@example.net -p | `-endpoint` | the domain | | `-port` | 51820, or the current port when already installed | | `-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. `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 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 +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. 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 | |---|---| -| `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-` (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. | diff --git a/pivpn_test.go b/pivpn_test.go index e0c210a..0194ad7 100644 --- a/pivpn_test.go +++ b/pivpn_test.go @@ -298,3 +298,38 @@ func TestPeerIPv6Kept(t *testing.T) { 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) + } +} diff --git a/setup.go b/setup.go index 3d564a8..f36ca60 100644 --- a/setup.go +++ b/setup.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "cmp" "crypto/sha256" @@ -38,7 +39,7 @@ 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] + %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. 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 @@ -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)") 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)") + noWait := fs.Bool("no-wait", false, "after a pivpn takeover, do not wait for connected devices to come back") _ = fs.Parse(args) given := map[string]bool{} fs.Visit(func(f *flag.Flag) { given[f.Name] = true }) @@ -553,7 +555,7 @@ func cmdInstall(args []string) error { return err } if pv != nil { - waitForPeers(pv, connected, switched) + waitForPeers(pv, connected, switched, *noWait, interactive) } printWhereToGo(store.Get()) 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 // 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 { +// is idle may take minutes to send its next packet. Enter in a terminal +// skips the rest of the wait; -no-wait skips it entirely. +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.") return - } - key := map[string]string{} - for _, p := range pv.Peers { - key[p.Name] = p.PublicKey + case noWait: + step("Not waiting for %s (-no-wait).", strings.Join(names, ", ")) + fmt.Printf(" %s\n", onlineLater(len(names))) + return } k, err := newKernel() if err != nil { return } defer k.Close() - var back []string - deadline := since.Add(30 * time.Second) + var skip <-chan struct{} + 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 { back = back[:0] - if samples, err := k.Sample(pv.Dev); err == nil { + if samples, err := sample(); err == nil { for _, n := range names { for _, s := range samples { 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 + if len(back) == len(names) { + return back, false } - 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) + select { + case <-skip: + return back, true + case <-deadline: + 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