Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4b6b1e5d | |||
| 323dde03ad | |||
| d9e87a3dfe | |||
| 81ba6768c7 | |||
| b4deb0761a | |||
| 68d854de4e | |||
| 39769e67f4 | |||
| 4da4e27ce8 | |||
| 20d42c01ec | |||
| ef5a2930e0 | |||
| 3482a03707 | |||
| 47762790ef |
@@ -9,13 +9,42 @@ binary, with a web interface, a JSON API and a native iPhone app.
|
|||||||
|
|
||||||
GHOSTWIRE sets up the WireGuard server, manages peers (add, change, disable,
|
GHOSTWIRE sets up the WireGuard server, manages peers (add, change, disable,
|
||||||
remove), hands out client configs as a download or QR code, and records traffic
|
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
|
and connection history per peer. There are no dependencies on the server:
|
||||||
dependencies on the server: the binary installs, updates and removes itself.
|
the binary installs, updates and removes itself.
|
||||||
|
|
||||||

|
## Quick start
|
||||||
|
|
||||||
|
On a Linux server, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://ghostwi.re/install | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The short link leads to the script on Gitea. The same script can also be
|
||||||
|
fetched directly from Gitea or from the GitHub mirror:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://git.redetzke.aero/Redetzke/GHOSTWIRE/raw/branch/main/install.sh | sh
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/danielredetzke/GHOSTWIRE/main/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It downloads the latest release for the server's architecture, checks it
|
||||||
|
against `SHA256SUMS` and starts the [install](#install), which asks a few
|
||||||
|
questions and changes nothing until you confirm.
|
||||||
|
|
||||||
|
> **Coming from pivpn?** GHOSTWIRE takes over a pivpn WireGuard server with
|
||||||
|
> the [quick start](#quick-start) command above. 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## 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
|
- **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
|
reconciled to it, so there is no `/etc/wireguard`, no `wg-quick` and no
|
||||||
`wireguard-tools`.
|
`wireguard-tools`.
|
||||||
@@ -28,6 +57,9 @@ dependencies on the server: the binary installs, updates and removes itself.
|
|||||||
server has a global IPv6 address.
|
server has a global IPv6 address.
|
||||||
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
|
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
|
||||||
400 days by default (Settings → Logs & history).
|
400 days by default (Settings → Logs & history).
|
||||||
|
- **Protection check:** the dashboard shows the address websites see for your
|
||||||
|
browser next to the server's. The same address means you are behind the
|
||||||
|
VPN; a peer that only routes the VPN network counts as not protected.
|
||||||
- **Live view:** the speed of every peer right now, updated every 2 seconds,
|
- **Live view:** the speed of every peer right now, updated every 2 seconds,
|
||||||
with the last 2 minutes as a chart. Kept in memory only.
|
with the last 2 minutes as a chart. Kept in memory only.
|
||||||
- **Connection history:** every online session per peer, with start, duration,
|
- **Connection history:** every online session per peer, with start, duration,
|
||||||
@@ -97,7 +129,15 @@ make test
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
The binary installs itself. Copy it to the server and run it as root:
|
The quickest way is the [one-line install](#quick-start). Arguments after
|
||||||
|
`sh -s --` are passed on to `install` and skip their questions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://ghostwi.re/install | sh -s -- -domain vpn.example.net -email you@example.net
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary installs itself, so you can also copy it to the server and run it
|
||||||
|
as root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scp dist/amd64/GHOSTWIRE server:/tmp/
|
scp dist/amd64/GHOSTWIRE server:/tmp/
|
||||||
@@ -154,6 +194,8 @@ sudo /tmp/GHOSTWIRE install -y -domain vpn.example.net -email you@example.net -p
|
|||||||
| `-email` | none |
|
| `-email` | none |
|
||||||
| `-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) |
|
||||||
|
| `-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
|
||||||
@@ -179,11 +221,35 @@ 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
|
under Settings → Users. Root is needed only for the commands below, never for
|
||||||
the running service.
|
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. 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
|
||||||
|
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)
|
## Commands (as root)
|
||||||
|
|
||||||
| Command | What it does |
|
| 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] [-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. |
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type App struct {
|
|||||||
started time.Time
|
started time.Time
|
||||||
shutdown func() // graceful stop; systemd restarts the service
|
shutdown func() // graceful stop; systemd restarts the service
|
||||||
webAddrs []string // the addresses the web server listens on now
|
webAddrs []string // the addresses the web server listens on now
|
||||||
|
endpoint endpointIPs
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
@@ -389,6 +390,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
|
|||||||
"traffic24h": map[string]int64{"down": d24, "up": u24},
|
"traffic24h": map[string]int64{"down": d24, "up": u24},
|
||||||
"traffic30d": map[string]int64{"down": d30, "up": u30},
|
"traffic30d": map[string]int64{"down": d30, "up": u30},
|
||||||
"topPeer30d": top,
|
"topPeer30d": top,
|
||||||
|
"visitor": a.visitor(r, cfg),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +562,7 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = a.store.Update(func(c *Config) error {
|
err = a.store.Update(func(c *Config) error {
|
||||||
s := &c.Server
|
s := &c.Server
|
||||||
before := s.clientFacing()
|
before := s.clientFacing()
|
||||||
oldV4 := s.IPv4
|
oldV4, oldV6 := s.IPv4, s.IPv6
|
||||||
for _, f := range []struct {
|
for _, f := range []struct {
|
||||||
key string
|
key string
|
||||||
dst any
|
dst any
|
||||||
@@ -583,6 +585,11 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return err
|
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()
|
reissue = before != s.clientFacing()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -703,7 +710,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),
|
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
|
||||||
}
|
}
|
||||||
if c.Server.IPv6Enabled {
|
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
|
return v
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1044,8 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
// A config issued here replaces any pending link.
|
// 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 != "" {
|
if p.PresharedKey != "" {
|
||||||
p.PresharedKey = psk.String()
|
p.PresharedKey = psk.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,22 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
|||||||
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
|
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
|
||||||
@media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } }
|
@media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } }
|
||||||
|
|
||||||
|
/* visitor: the address websites see next to the server's */
|
||||||
|
.visitor { display: flex; flex-wrap: wrap; align-items: stretch; gap: 16px 24px; }
|
||||||
|
.vstate { flex: 1 1 340px; min-width: 0; display: flex; gap: 14px; align-items: flex-start; }
|
||||||
|
.vstate h2 { margin: 2px 0 4px; font-size: 18px; font-weight: 600; }
|
||||||
|
.vstate p { margin: 0; color: var(--ink-2); font-size: 13px; max-width: 62ch; }
|
||||||
|
.vicon { flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; }
|
||||||
|
.visitor.ok .vicon { background: #e6f5e6; color: #0b7a0b; }
|
||||||
|
.visitor.ok h2 { color: #0b6b0b; }
|
||||||
|
.visitor.off .vicon { background: var(--warn-bg); color: var(--warn-ink); }
|
||||||
|
.visitor.off h2 { color: var(--warn-ink); }
|
||||||
|
.vaddrs { flex: 2 1 420px; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
||||||
|
.visitor .hcaddr { padding: 12px 16px; }
|
||||||
|
.visitor .hcaddr .v.mono { font-size: 20px; }
|
||||||
|
.visitor.ok .hcaddr { background: #eef7ee; box-shadow: inset 0 0 0 1px #c5e3c5; }
|
||||||
|
.visitor.off .hcaddr:first-child { background: var(--warn-bg); box-shadow: inset 0 0 0 1px #f0d2ad; }
|
||||||
|
|
||||||
/* updates */
|
/* updates */
|
||||||
.upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; }
|
.upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||||
.upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
.upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
||||||
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||||
|
shield: '<path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6z"/><path d="M8.5 12l2.5 2.5 4.5-5"/>',
|
||||||
|
shieldoff: '<path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6z"/><path d="M12 8v4.5M12 15.8h.01"/>',
|
||||||
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
|
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1033,6 +1035,37 @@
|
|||||||
// RANGES are the time ranges offered above the traffic charts.
|
// RANGES are the time ranges offered above the traffic charts.
|
||||||
const RANGES = [['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']];
|
const RANGES = [['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']];
|
||||||
|
|
||||||
|
// visitorCard shows the address websites see for this browser next to the
|
||||||
|
// server's: the same address means the browser is behind the VPN.
|
||||||
|
function visitorCard(v) {
|
||||||
|
if (!v) return null;
|
||||||
|
const addr = (label, value, note) => h('div', { class: 'hcaddr' },
|
||||||
|
h('span', { class: 'l' }, label), h('span', { class: 'v mono' }, value || 'Unknown'), h('span', { class: 'n' }, note));
|
||||||
|
const server = addr('Server IP address', v.serverIP, v.protected ? 'Same address: you are behind the VPN' : 'Different address: you are not behind the VPN');
|
||||||
|
if (v.protected) {
|
||||||
|
return h('section', { class: 'card visitor ok', 'aria-labelledby': 'vis' },
|
||||||
|
h('div', { class: 'vstate' },
|
||||||
|
h('span', { class: 'vicon' }, icon('shield', 22, 1.8)),
|
||||||
|
h('div', null,
|
||||||
|
h('h2', { id: 'vis' }, 'You are protected'),
|
||||||
|
h('p', null, v.peer
|
||||||
|
? ['This browser is connected through the tunnel as ', peerLink(v.peer), '. All its traffic goes out through this server, so websites see the server\'s address, not yours.']
|
||||||
|
: 'This browser\'s traffic goes out through this server, so websites see the server\'s address, not yours.'))),
|
||||||
|
h('div', { class: 'vaddrs' }, addr('Your IP address', v.ip, 'What websites see'), server));
|
||||||
|
}
|
||||||
|
const where = v.peer ? 'Tunnel address of ' + v.peer.name
|
||||||
|
: v.location ? [v.location.countryName, v.location.network].filter(Boolean).join(' · ') : 'What websites see';
|
||||||
|
return h('section', { class: 'card visitor off', 'aria-labelledby': 'vis' },
|
||||||
|
h('div', { class: 'vstate' },
|
||||||
|
h('span', { class: 'vicon' }, icon('shieldoff', 22, 1.8)),
|
||||||
|
h('div', null,
|
||||||
|
h('h2', { id: 'vis' }, 'Not protected'),
|
||||||
|
h('p', null, v.peer
|
||||||
|
? ['This browser uses the tunnel as ', peerLink(v.peer), ' only for the VPN network. Its other traffic skips the VPN, so websites see your own address.']
|
||||||
|
: 'This browser connects directly, not through the VPN. Websites see your own address. Turn on the tunnel on this device to browse through this server.'))),
|
||||||
|
h('div', { class: 'vaddrs' }, addr('Your IP address', v.ip, where), server));
|
||||||
|
}
|
||||||
|
|
||||||
async function viewDashboard(wrap) {
|
async function viewDashboard(wrap) {
|
||||||
let range = '24h';
|
let range = '24h';
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
@@ -1063,10 +1096,9 @@
|
|||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('div', { class: 'head' },
|
h('div', { class: 'head' },
|
||||||
h('div', null, h('h1', null, 'Dashboard'),
|
h('div', null, h('h1', null, 'Dashboard'),
|
||||||
h('p', { class: 'sub' }, 'Endpoint ', h('span', { class: 'mono' }, st.endpoint), ' · network ', h('span', { class: 'mono' }, st.ipv4))),
|
h('p', { class: 'sub' }, 'Endpoint ', h('span', { class: 'mono' }, st.endpoint), ' · network ', h('span', { class: 'mono' }, st.ipv4)))),
|
||||||
h('div', { class: 'actions' },
|
|
||||||
h('a', { class: 'btn', href: '#/server' }, 'Server config'),
|
visitorCard(st.visitor),
|
||||||
h('a', { class: 'btn primary', href: '#/peers/new' }, icon('plus', 16, 2), 'Add peer'))),
|
|
||||||
|
|
||||||
failing.length ? h('div', { class: 'notice err', role: 'alert' },
|
failing.length ? h('div', { class: 'notice err', role: 'alert' },
|
||||||
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
||||||
|
|||||||
+2
-2
@@ -52,7 +52,7 @@ func peerAddresses(c *Config, p *Peer) []netip.Prefix {
|
|||||||
v4 := netip.MustParseAddr(p.IPv4)
|
v4 := netip.MustParseAddr(p.IPv4)
|
||||||
out := []netip.Prefix{netip.PrefixFrom(v4, 32)}
|
out := []netip.Prefix{netip.PrefixFrom(v4, 32)}
|
||||||
if c.Server.IPv6Enabled {
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func clientConfig(c *Config, p *Peer, privateKey string) string {
|
|||||||
addr := fmt.Sprintf("%s/%d", v4, v4net.Bits())
|
addr := fmt.Sprintf("%s/%d", v4, v4net.Bits())
|
||||||
if c.Server.IPv6Enabled {
|
if c.Server.IPv6Enabled {
|
||||||
v6net := netip.MustParsePrefix(c.Server.IPv6)
|
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 == "" {
|
if privateKey == "" {
|
||||||
privateKey = "<the private key of this device>"
|
privateKey = "<the private key of this device>"
|
||||||
|
|||||||
@@ -172,16 +172,20 @@ type ClientDefaults struct {
|
|||||||
// Peer is one client. Its private key is never stored: it is shown once when
|
// Peer is one client. Its private key is never stored: it is shown once when
|
||||||
// the config is issued.
|
// the config is issued.
|
||||||
type Peer struct {
|
type Peer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
PublicKey string `json:"publicKey"`
|
PublicKey string `json:"publicKey"`
|
||||||
PresharedKey string `json:"presharedKey,omitempty"`
|
PresharedKey string `json:"presharedKey,omitempty"`
|
||||||
IPv4 string `json:"ipv4"`
|
IPv4 string `json:"ipv4"`
|
||||||
DNS []string `json:"dns,omitempty"` // nil = server default
|
// IPv6 is set only for a peer imported from pivpn, which numbers IPv6
|
||||||
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
|
// differently: its device keeps the address until the config is issued
|
||||||
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
|
// 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
|
||||||
// LatencyCheck says when the server pings the peer through the tunnel:
|
// LatencyCheck says when the server pings the peer through the tunnel:
|
||||||
// "" (off), "active" (while the device sends traffic) or "always".
|
// "" (off), "active" (while the device sends traffic) or "always".
|
||||||
LatencyCheck string `json:"latencyCheck,omitempty"`
|
LatencyCheck string `json:"latencyCheck,omitempty"`
|
||||||
@@ -473,6 +477,7 @@ func (c *Config) validate() error {
|
|||||||
|
|
||||||
names := map[string]bool{}
|
names := map[string]bool{}
|
||||||
ips := map[netip.Addr]bool{}
|
ips := map[netip.Addr]bool{}
|
||||||
|
ips6 := map[netip.Addr]bool{}
|
||||||
keys := map[string]bool{}
|
keys := map[string]bool{}
|
||||||
for _, p := range c.Peers {
|
for _, p := range c.Peers {
|
||||||
if err := validatePeerName(p.Name); err != nil {
|
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)
|
return fmt.Errorf("address %s is used twice", ip)
|
||||||
}
|
}
|
||||||
ips[ip] = true
|
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] {
|
if p.hasKey() && keys[p.PublicKey] {
|
||||||
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
|
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Downloads the latest GHOSTWIRE release, checks it against SHA256SUMS and
|
||||||
|
# runs its install command. Usage:
|
||||||
|
#
|
||||||
|
# curl -fsSL https://ghostwi.re/install | sh
|
||||||
|
# curl -fsSL https://ghostwi.re/install | sh -s -- -y -domain vpn.example.net -email you@example.net
|
||||||
|
#
|
||||||
|
# ghostwi.re/install redirects to this file on Gitea; the GitHub mirror has it
|
||||||
|
# at raw.githubusercontent.com/danielredetzke/GHOSTWIRE/main/install.sh.
|
||||||
|
#
|
||||||
|
# Arguments are passed on to "GHOSTWIRE install". Everything is wrapped in
|
||||||
|
# main so that a cut-off download runs nothing.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
GITEA=https://git.redetzke.aero/Redetzke/GHOSTWIRE
|
||||||
|
GITEA_API=https://git.redetzke.aero/api/v1/repos/Redetzke/GHOSTWIRE/releases/latest
|
||||||
|
GITHUB=https://github.com/danielredetzke/GHOSTWIRE
|
||||||
|
GITHUB_API=https://api.github.com/repos/danielredetzke/GHOSTWIRE/releases/latest
|
||||||
|
|
||||||
|
die() { echo "GHOSTWIRE: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
latest() { curl -fsSL "$1" 2>/dev/null | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p'; }
|
||||||
|
|
||||||
|
main() {
|
||||||
|
[ "$(uname -s)" = Linux ] || die "runs on Linux only"
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64 | amd64) arch=amd64 ;;
|
||||||
|
aarch64 | arm64) arch=arm64 ;;
|
||||||
|
armv7l | armv8l) arch=armv7 ;;
|
||||||
|
*) die "no build for $(uname -m)" ;;
|
||||||
|
esac
|
||||||
|
command -v curl >/dev/null || die "needs curl"
|
||||||
|
command -v sha256sum >/dev/null || die "needs sha256sum"
|
||||||
|
|
||||||
|
sudo=
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
command -v sudo >/dev/null || die "run as root or install sudo"
|
||||||
|
sudo=sudo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gitea first, GitHub if it is not reachable.
|
||||||
|
repo=$GITEA
|
||||||
|
version=$(latest "$GITEA_API")
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
repo=$GITHUB
|
||||||
|
version=$(latest "$GITHUB_API")
|
||||||
|
fi
|
||||||
|
[ -n "$version" ] || die "could not find the latest release"
|
||||||
|
|
||||||
|
file=GHOSTWIRE-$version-linux-$arch
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
echo "Downloading GHOSTWIRE $version ($arch) from $repo"
|
||||||
|
curl -fsSL -o "$tmp/$file" "$repo/releases/download/$version/$file"
|
||||||
|
curl -fsSL -o "$tmp/SHA256SUMS" "$repo/releases/download/$version/SHA256SUMS"
|
||||||
|
(cd "$tmp" && grep " $file\$" SHA256SUMS | sha256sum -c --status) ||
|
||||||
|
die "checksum of $file does not match SHA256SUMS"
|
||||||
|
mv "$tmp/$file" "$tmp/GHOSTWIRE"
|
||||||
|
chmod 755 "$tmp/GHOSTWIRE"
|
||||||
|
|
||||||
|
# The script itself arrives on stdin, so the questions read the terminal.
|
||||||
|
if [ -t 1 ] && [ -r /dev/tty ]; then
|
||||||
|
$sudo "$tmp/GHOSTWIRE" install "$@" </dev/tty
|
||||||
|
else
|
||||||
|
$sudo "$tmp/GHOSTWIRE" install "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+52
-7
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,10 +18,11 @@ import (
|
|||||||
type installPlan struct {
|
type installPlan struct {
|
||||||
domain, email, endpoint string
|
domain, email, endpoint string
|
||||||
port int
|
port int
|
||||||
noDomain bool // turn an existing domain off (self-signed certificate)
|
noDomain bool // turn an existing domain off (self-signed certificate)
|
||||||
noEmail bool // remove an existing Let's Encrypt email
|
noEmail bool // remove an existing Let's Encrypt email
|
||||||
passwordHash string // asked in the terminal; empty: asked later
|
passwordHash string // asked in the terminal; empty: asked later
|
||||||
ipv4 string // tunnel network of a new install
|
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}$`)
|
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 != ""
|
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) {
|
func (p installPlan) apply(c *Config) {
|
||||||
if p.noDomain {
|
if p.noDomain {
|
||||||
if c.Web.TLS.Mode == "acme" {
|
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
|
// reissueCount is the number of devices whose config stops working because
|
||||||
// the endpoint host or port changes.
|
// the endpoint host or port changes.
|
||||||
func (p installPlan) reissueCount(cur *Config, existing bool) int {
|
func (p installPlan) reissueCount(cur *Config, existing bool) int {
|
||||||
if !existing {
|
if !existing && p.pivpn == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
next := cur.clone()
|
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].")
|
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
|
// Web interface
|
||||||
fmt.Println("\nWeb interface")
|
fmt.Println("\nWeb interface")
|
||||||
curDomain := ""
|
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) {
|
func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
||||||
next := cur.clone()
|
next := cur.clone()
|
||||||
if !existing {
|
if !existing && p.pivpn == nil {
|
||||||
next.Server.IPv4 = p.ipv4
|
next.Server.IPv4 = p.ipv4
|
||||||
next.Server.IPv6Enabled = hasGlobalIPv6()
|
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)
|
ep += fmt.Sprintf(" (was %s — %d existing device(s) need a new config)", endpointString(cur), n)
|
||||||
}
|
}
|
||||||
tunnel := next.Server.IPv4
|
tunnel := next.Server.IPv4
|
||||||
if !existing {
|
switch {
|
||||||
|
case p.pivpn != nil:
|
||||||
|
tunnel += " (from pivpn)"
|
||||||
|
case !existing:
|
||||||
tunnel += " (random free range)"
|
tunnel += " (random free range)"
|
||||||
}
|
}
|
||||||
if next.Server.IPv6Enabled {
|
if next.Server.IPv6Enabled {
|
||||||
@@ -395,4 +423,21 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
|||||||
if p.passwordHash != "" {
|
if p.passwordHash != "" {
|
||||||
fmt.Printf(" Admin %s (password set)\n", next.Users[0].Username)
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ func nextFreeIPv4(c *Config) (netip.Addr, error) {
|
|||||||
// capacity is the number of peer addresses in the tunnel network.
|
// capacity is the number of peer addresses in the tunnel network.
|
||||||
func capacity(n netip.Prefix) int { return 1<<(32-n.Bits()) - 3 }
|
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
|
// 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.
|
// 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 {
|
func mapIPv6(v6net netip.Prefix, v4 netip.Addr) netip.Addr {
|
||||||
|
|||||||
+4
-2
@@ -368,8 +368,10 @@ func (k *linuxKernel) Checks(c *Config) []Check {
|
|||||||
|
|
||||||
func (k *linuxKernel) Down(c *Config) error {
|
func (k *linuxKernel) Down(c *Config) error {
|
||||||
var errs []error
|
var errs []error
|
||||||
if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
|
if c.Server.Interface != "" {
|
||||||
errs = append(errs, netlink.LinkDel(link))
|
if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
|
||||||
|
errs = append(errs, netlink.LinkDel(link))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
errs = append(errs, removeFirewall())
|
errs = append(errs, removeFirewall())
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
|
|||||||
@@ -1607,3 +1607,42 @@ func TestApplyOrder(t *testing.T) {
|
|||||||
t.Fatalf("the kernel ended with %q, not the newest config; applies: %q", last, k.applied)
|
t.Fatalf("the kernel ended with %q, not the newest config; applies: %q", last, k.applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClassifyVisitor(t *testing.T) {
|
||||||
|
c := testConfig(t)
|
||||||
|
c.Peers = []Peer{
|
||||||
|
{ID: "full", Name: "phone", IPv4: "10.84.12.2", Enabled: true},
|
||||||
|
{ID: "split", Name: "laptop", IPv4: "10.84.12.3", Enabled: true, AllowedIPs: []string{"10.84.12.0/24"}},
|
||||||
|
{ID: "off", Name: "old", IPv4: "10.84.12.4"},
|
||||||
|
}
|
||||||
|
server := netip.MustParseAddr("203.0.113.10")
|
||||||
|
none := func(netip.Addr) bool { return false }
|
||||||
|
lan := func(ip netip.Addr) bool { return ip == netip.MustParseAddr("192.168.1.5") }
|
||||||
|
for _, tc := range []struct {
|
||||||
|
ip string
|
||||||
|
own func(netip.Addr) bool
|
||||||
|
protected bool
|
||||||
|
shown string
|
||||||
|
peer string
|
||||||
|
}{
|
||||||
|
{"10.84.12.2", none, true, "203.0.113.10", "phone"}, // full tunnel
|
||||||
|
{"10.84.12.3", none, false, "10.84.12.3", "laptop"}, // split tunnel
|
||||||
|
{"10.84.12.4", none, false, "10.84.12.4", ""}, // disabled peer
|
||||||
|
{"203.0.113.10", none, true, "203.0.113.10", ""}, // looped back through the router
|
||||||
|
{"192.168.1.5", lan, true, "203.0.113.10", ""}, // the server's own address
|
||||||
|
{"198.51.100.77", none, false, "198.51.100.77", ""}, // directly
|
||||||
|
{"127.0.0.1", func(netip.Addr) bool { return true }, false, "127.0.0.1", ""}, // local proxy
|
||||||
|
} {
|
||||||
|
v := classifyVisitor(c, netip.MustParseAddr(tc.ip), server, tc.own)
|
||||||
|
name := ""
|
||||||
|
if v.Peer != nil {
|
||||||
|
name = v.Peer.Name
|
||||||
|
}
|
||||||
|
if v.Protected != tc.protected || v.IP != tc.shown || name != tc.peer || v.ServerIP != "203.0.113.10" {
|
||||||
|
t.Errorf("%s: got %+v", tc.ip, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !fullTunnel([]string{"0.0.0.0/1", "128.0.0.0/1"}, false) || fullTunnel([]string{"::/0"}, false) || !fullTunnel([]string{"::/0"}, true) {
|
||||||
|
t.Error("fullTunnel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 362 KiB |
@@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -37,9 +39,10 @@ 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] [-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
|
||||||
%s update [-force]
|
%s update [-force]
|
||||||
replace the installed binary with this one and restart
|
replace the installed binary with this one and restart
|
||||||
%s uninstall [-purge] [-y]
|
%s uninstall [-purge] [-y]
|
||||||
@@ -379,6 +382,8 @@ func cmdInstall(args []string) error {
|
|||||||
endpoint := fs.String("endpoint", "", "host or IP clients connect to (default: the domain)")
|
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)")
|
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)")
|
||||||
|
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 })
|
||||||
@@ -402,14 +407,40 @@ func cmdInstall(args []string) error {
|
|||||||
if err := plan.check(); err != nil {
|
if err := plan.check(); err != nil {
|
||||||
return err
|
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 !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)
|
n, err := randomSubnet(24)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
plan.ipv4 = n.String()
|
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 {
|
if plan, err = askInstall(os.Stdin, cur, existing, given, plan); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -453,8 +484,15 @@ func cmdInstall(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config: created with defaults (server key, the chosen subnet) if missing.
|
// Config: created with defaults (server key, the chosen subnet) if
|
||||||
if !existing {
|
// 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)
|
step("Creating %s", configFile)
|
||||||
initial := fmt.Sprintf("{\"server\": {\"ipv4\": %q}}\n", plan.ipv4)
|
initial := fmt.Sprintf("{\"server\": {\"ipv4\": %q}}\n", plan.ipv4)
|
||||||
if err := os.WriteFile(configFile, []byte(initial), 0o600); err != nil {
|
if err := os.WriteFile(configFile, []byte(initial), 0o600); err != nil {
|
||||||
@@ -491,17 +529,179 @@ func cmdInstall(args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
switched = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
step("Starting %s", serviceName)
|
step("Starting %s", serviceName)
|
||||||
if err := sh("systemctl", "enable", serviceName); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err := restartAndVerify(); err != nil {
|
if pv != nil {
|
||||||
return err
|
waitForPeers(pv, connected, switched, *noWait, interactive)
|
||||||
}
|
}
|
||||||
printWhereToGo(store.Get())
|
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
|
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. 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
|
||||||
|
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 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 := sample(); 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) {
|
||||||
|
return back, false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-skip:
|
||||||
|
return back, true
|
||||||
|
case <-deadline:
|
||||||
|
return back, false
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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 {
|
func chownTree(root string, uid, gid int) error {
|
||||||
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
|
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -663,9 +863,11 @@ func cmdUninstall(args []string) error {
|
|||||||
|
|
||||||
step("Removing the WireGuard interface and firewall table")
|
step("Removing the WireGuard interface and firewall table")
|
||||||
c, err := loadConfigFile(configFile)
|
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 = &Config{}
|
||||||
c.applyDefaults()
|
|
||||||
}
|
}
|
||||||
if k, err := newKernel(); err == nil {
|
if k, err := newKernel(); err == nil {
|
||||||
if err := k.Down(c); err != nil {
|
if err := k.Down(c); err != nil {
|
||||||
|
|||||||
+1
-1
@@ -260,7 +260,7 @@ func (a *App) setupRedeem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
id, hadKey = p.ID, p.hasKey()
|
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 != "" {
|
if p.PresharedKey != "" {
|
||||||
p.PresharedKey = psk.String()
|
p.PresharedKey = psk.String()
|
||||||
}
|
}
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// visitorView tells the dashboard whether the browser asking is behind the
|
||||||
|
// VPN. Behind it, websites see the server's address, so the card shows the
|
||||||
|
// visitor's address next to the server's: the same means protected.
|
||||||
|
type visitorView struct {
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
IP string `json:"ip"` // what websites see; the server's address when protected
|
||||||
|
ServerIP string `json:"serverIP"` // empty when the endpoint does not resolve
|
||||||
|
Peer *peerRef `json:"peer"` // the peer whose tunnel the request came through
|
||||||
|
Location *GeoInfo `json:"location"` // of IP, when not protected
|
||||||
|
}
|
||||||
|
|
||||||
|
type peerRef struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyVisitor decides on the address the server sees (remoteIP).
|
||||||
|
// Through the tunnel that is the peer's tunnel address; the peer is
|
||||||
|
// protected when its AllowedIPs send all traffic of that family through
|
||||||
|
// the server. A server behind NAT may instead see its own or its public
|
||||||
|
// address, when the request loops back through the router.
|
||||||
|
func classifyVisitor(c *Config, ip, server netip.Addr, own func(netip.Addr) bool) visitorView {
|
||||||
|
v := visitorView{IP: ip.String()}
|
||||||
|
if server.IsValid() {
|
||||||
|
v.ServerIP = server.String()
|
||||||
|
}
|
||||||
|
if p := peerByTunnelAddr(c, ip); p != nil {
|
||||||
|
v.Peer = &peerRef{ID: p.ID, Name: p.Name}
|
||||||
|
v.Protected = fullTunnel(peerAllowedIPs(c, p), ip.Is6())
|
||||||
|
} else if !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && (ip == server || own(ip)) {
|
||||||
|
v.Protected = true
|
||||||
|
}
|
||||||
|
if v.Protected && server.IsValid() {
|
||||||
|
v.IP = v.ServerIP
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerByTunnelAddr(c *Config, ip netip.Addr) *Peer {
|
||||||
|
for i := range c.Peers {
|
||||||
|
p := &c.Peers[i]
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range peerAddresses(c, p) {
|
||||||
|
if a.Addr() == ip {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullTunnel reports whether AllowedIPs route all IPv4 (or IPv6) traffic
|
||||||
|
// into the tunnel: 0.0.0.0/0, ::/0, or the two halves some clients use.
|
||||||
|
func fullTunnel(allowed []string, v6 bool) bool {
|
||||||
|
halves := 0
|
||||||
|
for _, s := range allowed {
|
||||||
|
p, err := netip.ParsePrefix(s)
|
||||||
|
if err != nil || p.Addr().Is6() != v6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch p.Bits() {
|
||||||
|
case 0:
|
||||||
|
return true
|
||||||
|
case 1:
|
||||||
|
halves++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return halves >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownAddr reports whether ip is an address of this machine.
|
||||||
|
func ownAddr(ip netip.Addr) bool {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if n, ok := a.(*net.IPNet); ok {
|
||||||
|
if x, ok := netip.AddrFromSlice(n.IP); ok && x.Unmap() == ip {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointIPs resolves the WireGuard endpoint, the address websites see
|
||||||
|
// behind the VPN, and keeps the answer for a few minutes.
|
||||||
|
type endpointIPs struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
host string
|
||||||
|
addrs []netip.Addr
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *endpointIPs) lookup(host string, v6 bool) netip.Addr {
|
||||||
|
if ip, err := netip.ParseAddr(host); err == nil {
|
||||||
|
return ip.Unmap()
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if host != e.host || time.Since(e.at) > 10*time.Minute {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
addrs, _ := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||||
|
cancel()
|
||||||
|
e.host, e.addrs, e.at = host, addrs, time.Now()
|
||||||
|
}
|
||||||
|
var other netip.Addr
|
||||||
|
for _, a := range e.addrs {
|
||||||
|
a = a.Unmap()
|
||||||
|
if a.Is6() == v6 {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
if !other.IsValid() {
|
||||||
|
other = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) visitor(r *http.Request, c *Config) visitorView {
|
||||||
|
ip, err := netip.ParseAddr(remoteIP(r))
|
||||||
|
if err != nil {
|
||||||
|
return visitorView{IP: remoteIP(r)}
|
||||||
|
}
|
||||||
|
ip = ip.Unmap()
|
||||||
|
v := classifyVisitor(c, ip, a.endpoint.lookup(c.Server.Endpoint, ip.Is6()), ownAddr)
|
||||||
|
if !v.Protected && v.Peer == nil {
|
||||||
|
v.Location = a.geo.Lookup(v.IP)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user