8 Commits

Author SHA1 Message Date
Daniel Redetzke 323dde03ad README: dashboard screenshot with the protection check 2026-10-06 14:00:42 +03:00
Daniel Redetzke d9e87a3dfe Dashboard: shows whether this browser is behind the VPN
The card puts the address websites see next to the server's address.
Through a full tunnel both are the server's, and the card says
"You are protected"; directly or through a split tunnel it says
"Not protected", with the visitor's address and its location.
2026-10-06 13:43:56 +03:00
Daniel Redetzke 81ba6768c7 README: dashboard screenshot from v0.8.0 2026-10-06 13:34:05 +03:00
Daniel Redetzke b4deb0761a install.sh: usage with the short link 2026-10-06 13:19:40 +03:00
Daniel Redetzke 68d854de4e README: install from the short link ghostwi.re/install 2026-10-06 13:18:45 +03:00
Daniel Redetzke 39769e67f4 README: quick start also from the GitHub mirror 2026-10-06 01:38:29 +03:00
Daniel Redetzke 4da4e27ce8 README: one-line install as quick start 2026-10-06 01:24:36 +03:00
Daniel Redetzke 20d42c01ec One-line install script
curl -fsSL https://git.redetzke.aero/Redetzke/GHOSTWIRE/raw/branch/main/install.sh | sh
downloads the latest release for the machine's architecture (Gitea, or
GitHub as fallback), checks it against SHA256SUMS and runs install with
the given arguments.
2026-10-06 01:16:26 +03:00
8 changed files with 343 additions and 6 deletions
+37 -6
View File
@@ -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).
![Dashboard with peers online, traffic of the last 24 hours, the peer list and recent activity](screenshots/dashboard.png) ![Dashboard with the protection check, peers online, traffic of the last 24 hours, the peer list and recent activity](screenshots/dashboard.png)
## 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/
+2
View File
@@ -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),
}) })
} }
+16
View File
@@ -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; }
+35
View File
@@ -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
View File
@@ -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 "$@"
+39
View File
@@ -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
View File
@@ -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
}