Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4b6b1e5d | |||
| 323dde03ad | |||
| d9e87a3dfe | |||
| 81ba6768c7 | |||
| b4deb0761a | |||
| 68d854de4e | |||
| 39769e67f4 | |||
| 4da4e27ce8 | |||
| 20d42c01ec |
@@ -9,15 +9,35 @@ 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.
|
||||||
|
|
||||||
> **Coming from pivpn?** GHOSTWIRE takes over a pivpn WireGuard server in one
|
## Quick start
|
||||||
> command: `sudo ./GHOSTWIRE install`. Your phones and laptops keep their
|
|
||||||
|
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
|
> current configs and reconnect on their own, with nothing to re-scan or
|
||||||
> re-send. See [Moving from pivpn](#moving-from-pivpn).
|
> re-send. See [Moving from pivpn](#moving-from-pivpn).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -37,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,
|
||||||
@@ -106,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/
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
@@ -1065,6 +1098,8 @@
|
|||||||
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)))),
|
||||||
|
|
||||||
|
visitorCard(st.visitor),
|
||||||
|
|
||||||
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(' · ')),
|
||||||
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
|
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
|
||||||
|
|||||||
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 "$@"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 362 KiB |
+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