GHOSTWIRE 0.1.2: WireGuard server manager with web UI and API
Single Go binary that manages a WireGuard server based on pivpn's defaults: - config.json as the single source of truth, reconciled to the kernel via netlink, wgctrl and its own nftables table (NAT, forward, input) - web interface (dashboard, peers, peer detail, add peer, server, settings) and a JSON API for the future iOS app, with session and API-token auth - client private keys are never stored; configs and QR codes shown once - per-peer traffic statistics in stats.json, logs in GHOSTWIRE.jsonl - HTTPS via Let's Encrypt, self-signed, certificate files or off - self-managing: install, update (restores the old binary on failure), uninstall and passwd subcommands; systemd unit generated by the binary Tested end to end on Ubuntu 26.04 (kernel 7.0) at dev.redetzke.aero. Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/GHOSTWIRE
|
||||
/dist/
|
||||
/dev/
|
||||
@@ -0,0 +1,32 @@
|
||||
APP := GHOSTWIRE
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo 0.1.0)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
|
||||
.PHONY: build linux-amd64 linux-arm64 linux-arm test dev clean
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(APP) .
|
||||
|
||||
linux-amd64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o dist/amd64/$(APP) .
|
||||
|
||||
linux-arm64:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o dist/arm64/$(APP) .
|
||||
|
||||
# Raspberry Pi OS 32-bit
|
||||
linux-arm:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -ldflags "$(LDFLAGS)" -o dist/armv7/$(APP) .
|
||||
|
||||
test:
|
||||
go vet ./...
|
||||
GOOS=linux go vet ./...
|
||||
go test ./...
|
||||
|
||||
# Runs locally with the traffic simulator (non-Linux) on http://127.0.0.1:8080
|
||||
dev: build
|
||||
mkdir -p dev
|
||||
test -f dev/config.json || echo '{"web":{"listen":"127.0.0.1:8080","tls":{"mode":"off"}},"server":{"endpoint":"vpn.example.net"}}' > dev/config.json
|
||||
./$(APP) -config dev/config.json
|
||||
|
||||
clean:
|
||||
rm -rf $(APP) dist dev
|
||||
@@ -0,0 +1,152 @@
|
||||
# GHOSTWIRE
|
||||
|
||||
A small WireGuard server manager: one Go binary with a web interface and a JSON
|
||||
API (also meant for a future iOS app). It configures the WireGuard server,
|
||||
manages peers (add, change, disable, remove) and records traffic per peer.
|
||||
|
||||
- **State:** everything lives in `config.json`. The kernel is reconciled to it,
|
||||
so there is no `/etc/wireguard`, no `wg-quick` and no `wireguard-tools`.
|
||||
- **Kernel access:** netlink creates `wg0` and sets its addresses and MTU;
|
||||
wgctrl sets keys and peers; nftables holds the rules in its own
|
||||
`inet GHOSTWIRE` table.
|
||||
- **Live peer changes:** only peers that changed are touched, the same effect
|
||||
as `wg syncconf`, so connected peers stay connected.
|
||||
- **Logs:** written to `GHOSTWIRE.jsonl` and rotated at 10 MB, keeping 5 files.
|
||||
- **Traffic history:** kept in `stats.json`: hourly for 48 h, daily for 400 days.
|
||||
- **Client private keys are never stored.** A config is shown once, as a
|
||||
download or QR code. "Issue new config" makes new keys.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with kernel 5.6 or newer (WireGuard built in), nftables and systemd
|
||||
- Ports: UDP 51820 (WireGuard), TCP 443 (web), TCP 80 (optional, Let's Encrypt
|
||||
http-01 and redirect)
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make linux-amd64 # dist/amd64/GHOSTWIRE
|
||||
make linux-arm64 # dist/arm64/GHOSTWIRE (Raspberry Pi 64-bit, ARM servers)
|
||||
make linux-arm # dist/armv7/GHOSTWIRE (Raspberry Pi OS 32-bit)
|
||||
make test
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
The binary installs itself. Copy it to the server and run it as root:
|
||||
|
||||
```sh
|
||||
scp dist/amd64/GHOSTWIRE server:/tmp/
|
||||
ssh server
|
||||
sudo /tmp/GHOSTWIRE install -domain vpn.example.net -email you@example.net
|
||||
```
|
||||
|
||||
`-domain` turns on Let's Encrypt and is also used as the WireGuard endpoint.
|
||||
Without it, the web interface uses a self-signed certificate; set the endpoint
|
||||
later in the web interface or with `-endpoint`.
|
||||
|
||||
`install`:
|
||||
|
||||
1. creates the system user `ghostwire` and `/opt/ghostwire`
|
||||
2. copies itself to `/opt/ghostwire/GHOSTWIRE`
|
||||
3. creates `config.json` with defaults, if missing
|
||||
4. writes `/etc/sysctl.d/99-ghostwire.conf` (IP forwarding) and
|
||||
`/etc/modules-load.d/ghostwire.conf`, and loads the kernel module
|
||||
5. writes `/etc/systemd/system/ghostwire.service`
|
||||
6. asks for the admin password (first install only)
|
||||
7. enables and starts the service, and checks that it stays up
|
||||
|
||||
Running it again is safe: steps that are already done are skipped.
|
||||
|
||||
The service runs as user `ghostwire` with only `CAP_NET_ADMIN` and
|
||||
`CAP_NET_BIND_SERVICE`, and can write only to `/opt/ghostwire`. Root is needed
|
||||
only for the commands below, never for the running service.
|
||||
|
||||
## Commands (as root)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h]` | Sets up and starts the service, as 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>`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary is put back and restarted. It refuses older versions without `-force`. |
|
||||
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
||||
| `GHOSTWIRE passwd` | Sets the admin password and reloads the running service. |
|
||||
| `GHOSTWIRE version` | Prints the version. |
|
||||
|
||||
Updating restarts only the management service. VPN connections stay up,
|
||||
because `wg0` lives in the kernel.
|
||||
|
||||
## config.json
|
||||
|
||||
A minimal file is enough. Missing values are filled with defaults on first
|
||||
start: server key, a random free /24 subnet, port 51820, MTU 1420, Quad9 DNS,
|
||||
full tunnel.
|
||||
|
||||
```json
|
||||
{
|
||||
"web": {
|
||||
"listen": ":443",
|
||||
"httpListen": ":80",
|
||||
"tls": { "mode": "acme", "domain": "vpn.example.net", "email": "admin@example.net" }
|
||||
},
|
||||
"server": { "endpoint": "vpn.example.net" }
|
||||
}
|
||||
```
|
||||
|
||||
`web.tls.mode` can be:
|
||||
|
||||
| Mode | What it does |
|
||||
|---|---|
|
||||
| `acme` | Let's Encrypt, automatic. Uses tls-alpn-01 on :443, or http-01 when `httpListen` is set. Certificates are cached in `/opt/ghostwire/acme`. `"staging": true` uses the test CA. |
|
||||
| `selfsigned` | Generates a certificate in `/opt/ghostwire/tls`. The iOS app pins its fingerprint. |
|
||||
| `files` | Uses `certFile` and `keyFile`, and reloads them when they change. |
|
||||
| `off` | Plain HTTP, for running behind a reverse proxy on localhost. |
|
||||
|
||||
After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
|
||||
## Files in /opt/ghostwire
|
||||
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `GHOSTWIRE` | the program |
|
||||
| `config.json` | all settings, server key, peers, token hashes (0600) |
|
||||
| `stats.json` | traffic history per peer |
|
||||
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
||||
| `acme/`, `tls/` | certificates |
|
||||
|
||||
## API
|
||||
|
||||
Base path `/api/v1`. The web interface signs in with a session cookie. Apps and
|
||||
scripts use `Authorization: Bearer <token>`; create the token under Settings →
|
||||
Pair iOS app. A read-only token may only use GET. Tokens cannot use the admin
|
||||
endpoints.
|
||||
|
||||
```
|
||||
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (admin)
|
||||
GET /status GET /stats?range=24h|7d|30d|90d
|
||||
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||
GET /peers POST /peers (returns the config and QR once)
|
||||
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
|
||||
POST /peers/{id}/enable | /disable | /issue-config
|
||||
GET /peers/{id}/stats?range=…
|
||||
admin: GET|PATCH /settings · POST /restart · GET|POST /tokens · DELETE /tokens/{id}
|
||||
GET /logs?level=&limit=&audit=1 · GET /logs/download · GET /backup · POST /restore
|
||||
```
|
||||
|
||||
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||
downloaded, `up` is what it uploaded.
|
||||
|
||||
## Firewall note
|
||||
|
||||
GHOSTWIRE's rules sit in their own nftables table. An accept there cannot
|
||||
override a drop in another table, so if ufw or firewalld is active, allow UDP
|
||||
51820 (and TCP 443/80) in that firewall too.
|
||||
|
||||
## Development
|
||||
|
||||
On macOS (or any non-Linux system), `make dev` starts the app on
|
||||
http://127.0.0.1:8080 with a traffic simulator in place of the kernel. Set a
|
||||
password first:
|
||||
|
||||
```sh
|
||||
make build && mkdir -p dev && ./GHOSTWIRE -config dev/config.json -passwd
|
||||
```
|
||||
@@ -0,0 +1,964 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// App wires the parts together and serves the HTTP API.
|
||||
type App struct {
|
||||
store *Store
|
||||
kernel Kernel
|
||||
recon *Reconciler
|
||||
stats *Stats
|
||||
auth *Auth
|
||||
tls *webTLS
|
||||
logPath string
|
||||
started time.Time
|
||||
shutdown func() // graceful stop; systemd restarts the service
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, err error) {
|
||||
var ue *userError
|
||||
switch {
|
||||
case errors.As(err, &ue):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": ue.msg})
|
||||
default:
|
||||
slog.Error("request failed", "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func who(r *http.Request) *principal { return r.Context().Value(ctxKey{}).(*principal) }
|
||||
|
||||
func (a *App) audit(r *http.Request, msg string, args ...any) {
|
||||
p := who(r)
|
||||
slog.Info(msg, append([]any{"audit", true, "actor", p.Name, "remote", p.RemoteIP}, args...)...)
|
||||
}
|
||||
|
||||
// guard requires authentication. adminOnly endpoints refuse API tokens;
|
||||
// read-only tokens may only use GET.
|
||||
func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := a.auth.Authenticate(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"})
|
||||
return
|
||||
}
|
||||
if adminOnly && !p.IsAdmin {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the admin account can do this"})
|
||||
return
|
||||
}
|
||||
if p.Scope == "ro" && r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
|
||||
return
|
||||
}
|
||||
h(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p)))
|
||||
}
|
||||
}
|
||||
|
||||
// applyResult saves-then-applies: the config is already stored, so a kernel
|
||||
// error is reported but does not undo the change.
|
||||
func (a *App) apply() string {
|
||||
if err := a.recon.ApplyNow(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
||||
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
||||
|
||||
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
||||
g("GET /api/v1/auth/me", a.me)
|
||||
adm("POST /api/v1/auth/password", a.changePassword)
|
||||
|
||||
g("GET /api/v1/status", a.status)
|
||||
g("GET /api/v1/stats", a.allStats)
|
||||
|
||||
g("GET /api/v1/server", a.getServer)
|
||||
g("PATCH /api/v1/server", a.patchServer)
|
||||
g("POST /api/v1/server/rotate-key", a.rotateServerKey)
|
||||
g("GET /api/v1/server/detect-ip", a.detectIP)
|
||||
|
||||
g("GET /api/v1/peers", a.listPeers)
|
||||
g("POST /api/v1/peers", a.createPeer)
|
||||
g("GET /api/v1/peers/{id}", a.getPeer)
|
||||
g("PATCH /api/v1/peers/{id}", a.patchPeer)
|
||||
g("DELETE /api/v1/peers/{id}", a.deletePeer)
|
||||
g("POST /api/v1/peers/{id}/enable", a.setEnabled(true))
|
||||
g("POST /api/v1/peers/{id}/disable", a.setEnabled(false))
|
||||
g("POST /api/v1/peers/{id}/issue-config", a.issueConfig)
|
||||
g("GET /api/v1/peers/{id}/stats", a.peerStats)
|
||||
|
||||
adm("GET /api/v1/settings", a.getSettings)
|
||||
adm("PATCH /api/v1/settings", a.patchSettings)
|
||||
adm("POST /api/v1/restart", a.restart)
|
||||
adm("GET /api/v1/tokens", a.listTokens)
|
||||
adm("POST /api/v1/tokens", a.createToken)
|
||||
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||
adm("GET /api/v1/logs", a.logs)
|
||||
adm("GET /api/v1/logs/download", a.downloadLog)
|
||||
adm("GET /api/v1/backup", a.backup)
|
||||
adm("POST /api/v1/restore", a.restore)
|
||||
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||
})
|
||||
mux.Handle("/", webHandler())
|
||||
|
||||
csrf := http.NewCrossOriginProtection()
|
||||
return securityHeaders(csrf.Handler(mux))
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
if r.TLS != nil {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
|
||||
func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Username, Password string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
ip := remoteIP(r)
|
||||
id, err := a.auth.Login(in.Username, in.Password, ip)
|
||||
if err != nil {
|
||||
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
||||
code := http.StatusUnauthorized
|
||||
if errors.Is(err, errLocked) {
|
||||
code = http.StatusTooManyRequests
|
||||
}
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := a.store.Get()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName(), Value: id, Path: "/", HttpOnly: true, Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode, MaxAge: cfg.Web.SessionHours * 3600,
|
||||
})
|
||||
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName()); err == nil {
|
||||
a.auth.Logout(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName(), Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: r.TLS != nil})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "version": version})
|
||||
}
|
||||
|
||||
func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Current, New string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if !verifyPassword(a.store.Get().Admin.PasswordHash, in.Current) {
|
||||
writeErr(w, badRequest("current password is wrong"))
|
||||
return
|
||||
}
|
||||
if err := validatePassword(in.New); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.New)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; return nil }); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "password changed")
|
||||
a.auth.DropSessions()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// --- status & stats ---
|
||||
|
||||
func (a *App) status(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
var online, enabled, never int
|
||||
var top string
|
||||
var topBytes int64
|
||||
for _, p := range cfg.Peers {
|
||||
s := a.stats.Summary(p.ID)
|
||||
if p.Enabled {
|
||||
enabled++
|
||||
if s.Online {
|
||||
online++
|
||||
}
|
||||
}
|
||||
if s.LastHandshake == nil {
|
||||
never++
|
||||
}
|
||||
if t := s.Down30d + s.Up30d; t > topBytes {
|
||||
topBytes, top = t, p.Name
|
||||
}
|
||||
}
|
||||
d24, u24 := sumPoints(a.stats.series(nil, "24h"))
|
||||
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
|
||||
checks := a.kernel.Checks(cfg)
|
||||
last, applyErr := a.recon.Status()
|
||||
ac := Check{Name: "Last apply", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
|
||||
if applyErr != nil {
|
||||
ac.Detail = applyErr.Error()
|
||||
}
|
||||
checks = append(checks, ac)
|
||||
healthy := true
|
||||
for _, c := range checks {
|
||||
healthy = healthy && c.OK
|
||||
}
|
||||
v4 := netip.MustParsePrefix(cfg.Server.IPv4)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"version": version,
|
||||
"interface": cfg.Server.Interface,
|
||||
"listenPort": cfg.Server.ListenPort,
|
||||
"endpoint": endpointString(cfg),
|
||||
"ipv4": cfg.Server.IPv4,
|
||||
"ipv6": cfg.Server.IPv6,
|
||||
"ipv6Enabled": cfg.Server.IPv6Enabled,
|
||||
"capacity": capacity(v4),
|
||||
"started": a.started,
|
||||
"healthy": healthy,
|
||||
"checks": checks,
|
||||
"peers": map[string]int{
|
||||
"total": len(cfg.Peers), "enabled": enabled, "online": online,
|
||||
"disabled": len(cfg.Peers) - enabled, "never": never,
|
||||
},
|
||||
"traffic24h": map[string]int64{"down": d24, "up": u24},
|
||||
"traffic30d": map[string]int64{"down": d30, "up": u30},
|
||||
"topPeer30d": top,
|
||||
})
|
||||
}
|
||||
|
||||
func validRange(r *http.Request) string {
|
||||
rng := r.URL.Query().Get("range")
|
||||
if !slices.Contains([]string{"24h", "7d", "30d", "90d"}, rng) {
|
||||
rng = "24h"
|
||||
}
|
||||
return rng
|
||||
}
|
||||
|
||||
func (a *App) allStats(w http.ResponseWriter, r *http.Request) {
|
||||
rng := validRange(r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)})
|
||||
}
|
||||
|
||||
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"})
|
||||
return
|
||||
}
|
||||
rng := validRange(r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series([]string{r.PathValue("id")}, rng)})
|
||||
}
|
||||
|
||||
// --- server ---
|
||||
|
||||
type serverView struct {
|
||||
Interface string `json:"interface"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
KeyCreated time.Time `json:"keyCreated"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
MTU int `json:"mtu"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
IPv6Enabled bool `json:"ipv6Enabled"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
EndpointPort int `json:"endpointPort"`
|
||||
UplinkV4 string `json:"uplinkV4"`
|
||||
UplinkV6 string `json:"uplinkV6"`
|
||||
DetectedV4 string `json:"detectedUplinkV4"`
|
||||
DetectedV6 string `json:"detectedUplinkV6"`
|
||||
NAT bool `json:"nat"`
|
||||
PeerToPeer bool `json:"peerToPeer"`
|
||||
LANAccess bool `json:"lanAccess"`
|
||||
OpenPort bool `json:"openPort"`
|
||||
ClientDefaults ClientDefaults `json:"clientDefaults"`
|
||||
}
|
||||
|
||||
func (a *App) serverView(cfg *Config) serverView {
|
||||
s := cfg.Server
|
||||
return serverView{
|
||||
Interface: s.Interface, PublicKey: serverPublicKey(cfg), KeyCreated: s.KeyCreated,
|
||||
ListenPort: s.ListenPort, MTU: s.MTU, IPv4: s.IPv4, IPv6: s.IPv6, IPv6Enabled: s.IPv6Enabled,
|
||||
Endpoint: s.Endpoint, EndpointPort: s.EndpointPort, UplinkV4: s.UplinkV4, UplinkV6: s.UplinkV6,
|
||||
DetectedV4: a.kernel.Uplink(&Config{}, false), DetectedV6: a.kernel.Uplink(&Config{}, true),
|
||||
NAT: s.NAT, PeerToPeer: s.PeerToPeer, LANAccess: s.LANAccess, OpenPort: s.OpenPort,
|
||||
ClientDefaults: s.ClientDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) getServer(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.serverView(a.store.Get()))
|
||||
}
|
||||
|
||||
// decodeFields reads a PATCH body as raw fields so absent and null differ.
|
||||
func decodeFields(r *http.Request) (map[string]json.RawMessage, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := readJSON(r, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func field[T any](m map[string]json.RawMessage, key string, dst *T) error {
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
return badRequest("%s: %v", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := decodeFields(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var changed []string
|
||||
var reissue bool
|
||||
err = a.store.Update(func(c *Config) error {
|
||||
s := &c.Server
|
||||
before := s.clientFacing()
|
||||
oldV4 := s.IPv4
|
||||
for _, f := range []struct {
|
||||
key string
|
||||
dst any
|
||||
}{
|
||||
{"listenPort", &s.ListenPort}, {"mtu", &s.MTU}, {"ipv4", &s.IPv4}, {"ipv6", &s.IPv6},
|
||||
{"ipv6Enabled", &s.IPv6Enabled}, {"endpoint", &s.Endpoint}, {"endpointPort", &s.EndpointPort},
|
||||
{"uplinkV4", &s.UplinkV4}, {"uplinkV6", &s.UplinkV6}, {"nat", &s.NAT}, {"peerToPeer", &s.PeerToPeer},
|
||||
{"lanAccess", &s.LANAccess}, {"openPort", &s.OpenPort}, {"clientDefaults", &s.ClientDefaults},
|
||||
} {
|
||||
if _, ok := m[f.key]; ok {
|
||||
if err := json.Unmarshal(m[f.key], f.dst); err != nil {
|
||||
return badRequest("%s: %v", f.key, err)
|
||||
}
|
||||
changed = append(changed, f.key)
|
||||
}
|
||||
}
|
||||
s.Endpoint = strings.TrimSpace(s.Endpoint)
|
||||
if s.IPv4 != oldV4 {
|
||||
if err := renumberPeers(c, oldV4); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
reissue = before != s.clientFacing()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "server settings changed", "fields", changed)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"server": a.serverView(a.store.Get()), "applyError": a.apply(), "reissueNeeded": reissue,
|
||||
})
|
||||
}
|
||||
|
||||
// clientFacing captures the settings baked into issued client configs;
|
||||
// changing any of them means existing devices need a new config.
|
||||
func (s *Server) clientFacing() string {
|
||||
return fmt.Sprint(s.ListenPort, s.EndpointPort, s.Endpoint, s.IPv4, s.IPv6, s.IPv6Enabled)
|
||||
}
|
||||
|
||||
// renumberPeers moves peers into a new IPv4 network, keeping each host part.
|
||||
func renumberPeers(c *Config, oldNet string) error {
|
||||
oldP, err := netip.ParsePrefix(oldNet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newP, err := netip.ParsePrefix(c.Server.IPv4)
|
||||
if err != nil || !newP.Addr().Is4() {
|
||||
return badRequest("IPv4 network must be an IPv4 CIDR")
|
||||
}
|
||||
newP = newP.Masked()
|
||||
c.Server.IPv4 = newP.String()
|
||||
for i := range c.Peers {
|
||||
old := netip.MustParseAddr(c.Peers[i].IPv4)
|
||||
host := addrToU32(old) - addrToU32(oldP.Addr())
|
||||
if host >= 1<<(32-newP.Bits())-1 {
|
||||
return badRequest("%s is too small for the existing peers", newP)
|
||||
}
|
||||
c.Peers[i].IPv4 = u32ToAddr(addrToU32(newP.Addr()) + host).String()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) rotateServerKey(w http.ResponseWriter, r *http.Request) {
|
||||
key, err := newPrivateKey()
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
c.Server.PrivateKey = key.String()
|
||||
c.Server.KeyCreated = time.Now().UTC()
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "server key rotated")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"server": a.serverView(a.store.Get()), "applyError": a.apply()})
|
||||
}
|
||||
|
||||
func (a *App) detectIP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://checkip.amazonaws.com", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeErr(w, badRequest("could not detect the public IP: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 100))
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(string(b)))
|
||||
if err != nil {
|
||||
writeErr(w, badRequest("unexpected answer from the IP service"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ip": ip.String()})
|
||||
}
|
||||
|
||||
// --- peers ---
|
||||
|
||||
type peerView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note"`
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
HasPSK bool `json:"hasPresharedKey"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
DNS []string `json:"dns"` // null = server default
|
||||
AllowedIPs []string `json:"allowedIPs"` // null = server default
|
||||
Keepalive *int `json:"keepalive"` // null = server default
|
||||
EffDNS []string `json:"effectiveDNS"`
|
||||
EffAllowed []string `json:"effectiveAllowedIPs"`
|
||||
EffKeepalive int `json:"effectiveKeepalive"`
|
||||
Created time.Time `json:"created"`
|
||||
ConfigIssued *time.Time `json:"configIssued"`
|
||||
Stats PeerSummary `json:"stats"`
|
||||
}
|
||||
|
||||
func (a *App) peerView(c *Config, p *Peer) peerView {
|
||||
v := peerView{
|
||||
ID: p.ID, Name: p.Name, Note: p.Note, Enabled: p.Enabled, PublicKey: p.PublicKey,
|
||||
HasPSK: p.PresharedKey != "", IPv4: p.IPv4, DNS: p.DNS, AllowedIPs: p.AllowedIPs, Keepalive: p.Keepalive,
|
||||
EffDNS: peerDNS(c, p), EffAllowed: peerAllowedIPs(c, p), EffKeepalive: peerKeepalive(c, p),
|
||||
Created: p.Created, ConfigIssued: p.ConfigIssued, Stats: a.stats.Summary(p.ID),
|
||||
}
|
||||
if c.Server.IPv6Enabled {
|
||||
v.IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4)).String()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (a *App) listPeers(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
out := make([]peerView, 0, len(cfg.Peers))
|
||||
for i := range cfg.Peers {
|
||||
out = append(out, a.peerView(cfg, &cfg.Peers[i]))
|
||||
}
|
||||
v4 := netip.MustParsePrefix(cfg.Server.IPv4)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"peers": out, "capacity": capacity(v4), "network": cfg.Server.IPv4})
|
||||
}
|
||||
|
||||
func (a *App) getPeer(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
_, p := cfg.peerByID(r.PathValue("id"))
|
||||
if p == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a.peerView(cfg, p))
|
||||
}
|
||||
|
||||
// issuedConfig is returned exactly once; the private key is not stored.
|
||||
type issuedConfig struct {
|
||||
Peer peerView `json:"peer"`
|
||||
Config string `json:"config"`
|
||||
QR string `json:"qr,omitempty"`
|
||||
HasPrivKey bool `json:"includesPrivateKey"`
|
||||
ApplyError string `json:"applyError"`
|
||||
}
|
||||
|
||||
// newKeys returns a fresh key pair, or only the given public key when the
|
||||
// client made its own keys.
|
||||
func newKeys(clientPublic string) (priv, pub string, err error) {
|
||||
if clientPublic != "" {
|
||||
k, err := wgtypes.ParseKey(strings.TrimSpace(clientPublic))
|
||||
if err != nil {
|
||||
return "", "", badRequest("public key is not a valid WireGuard key")
|
||||
}
|
||||
return "", k.String(), nil
|
||||
}
|
||||
k, err := newPrivateKey()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return k.String(), k.PublicKey().String(), nil
|
||||
}
|
||||
|
||||
func (a *App) issue(id, priv string) (issuedConfig, error) {
|
||||
cfg := a.store.Get()
|
||||
_, p := cfg.peerByID(id)
|
||||
out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: priv != ""}
|
||||
if priv != "" {
|
||||
qr, err := qrDataURL(out.Config)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.QR = qr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
DNS []string `json:"dns"`
|
||||
AllowedIPs []string `json:"allowedIPs"`
|
||||
Keepalive *int `json:"keepalive"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
PresharedKey *bool `json:"presharedKey"`
|
||||
}
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
priv, pub, err := newKeys(in.PublicKey)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
p := Peer{
|
||||
ID: newID(), Name: in.Name, Note: strings.TrimSpace(in.Note), Enabled: true, PublicKey: pub,
|
||||
DNS: in.DNS, AllowedIPs: in.AllowedIPs, Keepalive: in.Keepalive, Created: time.Now().UTC(),
|
||||
}
|
||||
if in.PresharedKey == nil || *in.PresharedKey {
|
||||
psk, err := newPresharedKey()
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
p.PresharedKey = psk.String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
p.ConfigIssued = &now
|
||||
err = a.store.Update(func(c *Config) error {
|
||||
if err := validatePeerName(p.Name); err != nil {
|
||||
return &userError{err.Error()}
|
||||
}
|
||||
if in.IPv4 == "" || in.IPv4 == "auto" {
|
||||
ip, err := nextFreeIPv4(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.IPv4 = ip.String()
|
||||
} else {
|
||||
p.IPv4 = strings.TrimSpace(in.IPv4)
|
||||
}
|
||||
c.Peers = append(c.Peers, p)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "peer created", "peer", p.Name, "ip", p.IPv4)
|
||||
out, err := a.issue(p.ID, priv)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
out.ApplyError = a.apply()
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func (a *App) patchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
m, err := decodeFields(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var name string
|
||||
var changed []string
|
||||
err = a.store.Update(func(c *Config) error {
|
||||
_, p := c.peerByID(id)
|
||||
if p == nil {
|
||||
return badRequest("no such peer")
|
||||
}
|
||||
for _, f := range []struct {
|
||||
key string
|
||||
dst any
|
||||
}{
|
||||
{"name", &p.Name}, {"note", &p.Note}, {"ipv4", &p.IPv4}, {"enabled", &p.Enabled},
|
||||
} {
|
||||
if raw, ok := m[f.key]; ok {
|
||||
if err := json.Unmarshal(raw, f.dst); err != nil {
|
||||
return badRequest("%s: %v", f.key, err)
|
||||
}
|
||||
changed = append(changed, f.key)
|
||||
}
|
||||
}
|
||||
// For the overrides, null means "use the server default".
|
||||
if raw, ok := m["dns"]; ok {
|
||||
p.DNS = nil
|
||||
if err := json.Unmarshal(raw, &p.DNS); err != nil {
|
||||
return badRequest("dns: %v", err)
|
||||
}
|
||||
changed = append(changed, "dns")
|
||||
}
|
||||
if raw, ok := m["allowedIPs"]; ok {
|
||||
p.AllowedIPs = nil
|
||||
if err := json.Unmarshal(raw, &p.AllowedIPs); err != nil {
|
||||
return badRequest("allowedIPs: %v", err)
|
||||
}
|
||||
changed = append(changed, "allowedIPs")
|
||||
}
|
||||
if raw, ok := m["keepalive"]; ok {
|
||||
p.Keepalive = nil
|
||||
if err := json.Unmarshal(raw, &p.Keepalive); err != nil {
|
||||
return badRequest("keepalive: %v", err)
|
||||
}
|
||||
changed = append(changed, "keepalive")
|
||||
}
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.Note = strings.TrimSpace(p.Note)
|
||||
name = p.Name
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "peer updated", "peer", name, "fields", changed)
|
||||
cfg := a.store.Get()
|
||||
_, p := cfg.peerByID(id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"peer": a.peerView(cfg, p), "applyError": a.apply()})
|
||||
}
|
||||
|
||||
func (a *App) setEnabled(on bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, p := c.peerByID(id)
|
||||
if p == nil {
|
||||
return badRequest("no such peer")
|
||||
}
|
||||
p.Enabled, name = on, p.Name
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, map[bool]string{true: "peer enabled", false: "peer disabled"}[on], "peer", name)
|
||||
cfg := a.store.Get()
|
||||
_, p := cfg.peerByID(id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"peer": a.peerView(cfg, p), "applyError": a.apply()})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) deletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
i, p := c.peerByID(id)
|
||||
if p == nil {
|
||||
return badRequest("no such peer")
|
||||
}
|
||||
name = p.Name
|
||||
c.Peers = slices.Delete(c.Peers, i, i+1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "peer deleted", "peer", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply()})
|
||||
}
|
||||
|
||||
// issueConfig replaces the peer's keys. The old device stops working.
|
||||
func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var in struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
if r.ContentLength > 0 {
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
priv, pub, err := newKeys(in.PublicKey)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
psk, err := newPresharedKey()
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, p := c.peerByID(id)
|
||||
if p == nil {
|
||||
return badRequest("no such peer")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
p.PublicKey, p.ConfigIssued, name = pub, &now, p.Name
|
||||
if p.PresharedKey != "" {
|
||||
p.PresharedKey = psk.String()
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.stats.Forget(id)
|
||||
a.audit(r, "peer config issued", "peer", name)
|
||||
out, err := a.issue(id, priv)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
out.ApplyError = a.apply()
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// --- settings, tokens, logs, backup ---
|
||||
|
||||
func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"web": cfg.Web,
|
||||
"log": cfg.Log,
|
||||
"adminUsername": cfg.Admin.Username,
|
||||
"fingerprint": a.tls.Fingerprint(),
|
||||
"logPath": a.logPath,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := decodeFields(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
var restart bool
|
||||
err = a.store.Update(func(c *Config) error {
|
||||
if err := field(m, "adminUsername", &c.Admin.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(c.Admin.Username) == "" {
|
||||
return badRequest("username cannot be empty")
|
||||
}
|
||||
before, _ := json.Marshal(c.Web)
|
||||
if err := field(m, "web", &c.Web); err != nil {
|
||||
return err
|
||||
}
|
||||
after, _ := json.Marshal(c.Web)
|
||||
restart = string(before) != string(after)
|
||||
return field(m, "log", &c.Log)
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
cfg := a.store.Get()
|
||||
logLevel.Set(parseLevel(cfg.Log.Level))
|
||||
a.audit(r, "app settings changed", "restartRequired", restart)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restartRequired": restart})
|
||||
}
|
||||
|
||||
func (a *App) restart(w http.ResponseWriter, r *http.Request) {
|
||||
a.audit(r, "service restart requested")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
a.shutdown()
|
||||
}()
|
||||
}
|
||||
|
||||
type tokenView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed *tokenUse `json:"lastUsed"`
|
||||
}
|
||||
|
||||
func (a *App) listTokens(w http.ResponseWriter, r *http.Request) {
|
||||
out := []tokenView{}
|
||||
for _, t := range a.store.Get().APITokens {
|
||||
out = append(out, tokenView{t.ID, t.Name, t.Scope, t.Created, a.auth.TokenUse(t.ID)})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
|
||||
}
|
||||
|
||||
func (a *App) createToken(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Name, Scope string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" || len(in.Name) > 64 {
|
||||
writeErr(w, badRequest("token name must be 1–64 characters"))
|
||||
return
|
||||
}
|
||||
if in.Scope != "ro" {
|
||||
in.Scope = "rw"
|
||||
}
|
||||
secret := tokenPrefix + randomString(32)
|
||||
t := APIToken{ID: newID(), Name: in.Name, Hash: hashToken(secret), Scope: in.Scope, Created: time.Now().UTC()}
|
||||
if err := a.store.Update(func(c *Config) error { c.APITokens = append(c.APITokens, t); return nil }); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "api token created", "token", t.Name, "scope", t.Scope)
|
||||
// The pairing payload lets the iOS app connect by scanning one QR code.
|
||||
pairing, _ := json.Marshal(map[string]string{
|
||||
"url": "https://" + r.Host, "token": secret, "fingerprint": a.tls.Fingerprint(),
|
||||
})
|
||||
qr, _ := qrDataURL(string(pairing))
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"token": secret, "id": t.ID, "name": t.Name, "scope": t.Scope, "pairing": string(pairing), "qr": qr,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) deleteToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
i := slices.IndexFunc(c.APITokens, func(t APIToken) bool { return t.ID == id })
|
||||
if i < 0 {
|
||||
return badRequest("no such token")
|
||||
}
|
||||
name = c.APITokens[i].Name
|
||||
c.APITokens = slices.Delete(c.APITokens, i, i+1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "api token revoked", "token", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) logs(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit := 200
|
||||
if _, err := fmt.Sscan(q.Get("limit"), &limit); err != nil || limit < 1 || limit > 2000 {
|
||||
limit = 200
|
||||
}
|
||||
lines, err := readLogTail(a.logPath, limit, q.Get("level"), q.Get("audit") == "1")
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"lines": lines})
|
||||
}
|
||||
|
||||
func (a *App) downloadLog(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", appName+".jsonl"))
|
||||
http.ServeFile(w, r, a.logPath)
|
||||
}
|
||||
|
||||
func (a *App) backup(w http.ResponseWriter, r *http.Request) {
|
||||
a.audit(r, "backup downloaded")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", appName+"-backup-"+time.Now().Format("2006-01-02")+".json"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(a.store.Get())
|
||||
}
|
||||
|
||||
func (a *App) restore(w http.ResponseWriter, r *http.Request) {
|
||||
var in Config
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if in.Server.PrivateKey == "" {
|
||||
writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
|
||||
return
|
||||
}
|
||||
if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "backup restored", "peers", len(in.Peers))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
:root {
|
||||
--ink: #16171a;
|
||||
--ink-2: #5b5c61;
|
||||
--ink-3: #6a6b70;
|
||||
--ground: #f4f4f1;
|
||||
--surface: #fff;
|
||||
--line: #e3e3de;
|
||||
--line-2: #efefeb;
|
||||
--field: #d3d3cd;
|
||||
--link: #1c5cab;
|
||||
--link-hover: #104281;
|
||||
--focus: #2a78d6;
|
||||
--down: #2a78d6;
|
||||
--down-strong: #1c5cab;
|
||||
--up: #eb6834;
|
||||
--good: #0ca30c;
|
||||
--bad: #d03b3b;
|
||||
--bad-ink: #b4232a;
|
||||
--warn-bg: #fdf0e1;
|
||||
--warn-ink: #7a3d00;
|
||||
--grid: #e1e0d9;
|
||||
--axis: #c3c2b7;
|
||||
--axis-ink: #898781;
|
||||
--sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
|
||||
a { color: var(--link); }
|
||||
a:hover { color: var(--link-hover); }
|
||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
[hidden] { display: none !important; }
|
||||
.mono { font-family: var(--mono); font-size: 13px; }
|
||||
.muted { color: var(--ink-3); }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
|
||||
/* shell */
|
||||
.shell { display: flex; flex-wrap: wrap; min-height: 100vh; }
|
||||
.side { flex: 1 1 230px; background: var(--ink); color: #c9c9c3; padding: 20px 12px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 10px 18px; color: #fff; font-family: var(--mono); font-weight: 500; font-size: 16px; }
|
||||
.mark { width: 22px; height: 22px; border-radius: 6px; background: var(--down); display: grid; place-items: center; color: #fff; }
|
||||
.srv { display: flex; align-items: center; gap: 8px; margin: 0 6px 16px; padding: 10px; border: 1px solid #2c2d32; border-radius: 8px; font-size: 12px; color: #c9c9c3; }
|
||||
.srv strong { color: #fff; font-weight: 500; }
|
||||
.side a.nav { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; font-weight: 500; }
|
||||
.side a.nav:hover { background: #222328; color: #fff; }
|
||||
.side a.nav.on { background: #2a2b31; color: #fff; }
|
||||
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
|
||||
.side .foot { margin-top: auto; padding: 16px 12px 0; font-size: 12px; color: #8d8e93; }
|
||||
.side .foot button { background: none; border: 0; padding: 0; font: inherit; color: #c9c9c3; text-decoration: underline; cursor: pointer; }
|
||||
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
||||
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
|
||||
|
||||
/* page head */
|
||||
.head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 12px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
||||
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
|
||||
.sub { margin: 4px 0 0; color: var(--ink-2); }
|
||||
.back { font-size: 13px; margin-bottom: -8px; }
|
||||
|
||||
/* cards */
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
|
||||
.card.flush { padding: 8px; }
|
||||
.card h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.cardhead { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.card.flush .cardhead { padding: 12px 12px 0; }
|
||||
.lead { margin: 4px 0 16px; color: var(--ink-2); font-size: 13px; }
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
||||
.tile .k { color: var(--ink-2); font-size: 13px; }
|
||||
.tile .v { font-size: 30px; font-weight: 600; margin-top: 6px; letter-spacing: -0.01em; display: flex; align-items: center; gap: 10px; }
|
||||
.tile .v small { font-size: 18px; color: var(--ink-3); font-weight: 500; }
|
||||
.tile .s { font-size: 13px; color: var(--ink-2); margin-top: 2px; }
|
||||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 20px; }
|
||||
.split { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; }
|
||||
.split > .grow { flex: 3 1 440px; min-width: 0; }
|
||||
.split > .aside { flex: 2 1 340px; min-width: 0; }
|
||||
|
||||
/* buttons */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--field); background: var(--surface); color: var(--ink); font: inherit; font-weight: 500; cursor: pointer; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: var(--ground); color: var(--ink); }
|
||||
.btn:disabled { opacity: .5; cursor: default; }
|
||||
.btn.primary { background: var(--ink); border-color: var(--ink); color: #fff; }
|
||||
.btn.primary:hover { background: #2a2b31; color: #fff; }
|
||||
.btn.danger { color: var(--bad-ink); border-color: #ecc4c1; }
|
||||
.btn.danger:hover { background: #fbefee; color: var(--bad-ink); }
|
||||
.btn.small { min-height: 36px; padding: 0 12px; }
|
||||
.pill { min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--field); background: var(--surface); font: inherit; font-size: 13px; font-weight: 500; color: #3d3e42; cursor: pointer; }
|
||||
.pill.on { background: var(--ink); border-color: var(--ink); color: #fff; }
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
/* status */
|
||||
.badge { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; padding: 3px 10px; border-radius: 999px; background: #efefeb; color: #3d3e42; white-space: nowrap; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #9a9b97; display: inline-block; flex: none; }
|
||||
.dot.ok { background: var(--good); }
|
||||
.dot.off { background: transparent; border: 1.5px solid #9a9b97; }
|
||||
.dot.bad { background: var(--bad); }
|
||||
.dot.big { width: 10px; height: 10px; }
|
||||
.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; }
|
||||
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
|
||||
.notice.err { background: #fbefee; color: var(--bad-ink); }
|
||||
.notice .btn { margin-left: auto; }
|
||||
|
||||
/* tables */
|
||||
.tbl { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
table.narrow { min-width: 520px; }
|
||||
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
td .note { font-size: 12px; color: var(--ink-3); }
|
||||
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
||||
|
||||
/* forms */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.field > label, .legend { font-size: 13px; font-weight: 500; }
|
||||
input, select, textarea { font: inherit; min-height: 40px; padding: 8px 12px; border: 1px solid var(--field); border-radius: 8px; background: var(--surface); color: var(--ink); width: 100%; }
|
||||
textarea { min-height: 120px; font-family: var(--mono); font-size: 12px; }
|
||||
input[readonly] { background: #f7f7f4; color: var(--ink-2); }
|
||||
.hint { font-size: 12px; color: var(--ink-3); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px 20px; }
|
||||
.row { display: flex; gap: 8px; }
|
||||
.row > input { flex: 1; min-width: 0; }
|
||||
.check { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; cursor: pointer; }
|
||||
.check input, .opt input { width: 18px; height: 18px; min-height: 0; margin: 2px 0 0; accent-color: var(--ink); flex: none; }
|
||||
.sw { width: 18px; height: 18px; min-height: 0; margin: 0; accent-color: var(--ink); }
|
||||
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.opt { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; }
|
||||
.opt:has(input:checked) { border-color: var(--ink); background: #fafaf8; }
|
||||
.opt strong { font-weight: 500; }
|
||||
.formfoot { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
|
||||
.section { margin-top: 24px; }
|
||||
|
||||
/* key/value lists */
|
||||
.kv { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 10px 16px; margin: 16px 0 0; font-size: 13px; }
|
||||
.kv dt { color: var(--ink-2); }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* checks */
|
||||
.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.chk:last-child { border-bottom: 0; }
|
||||
.chk b { font-weight: 500; min-width: 160px; }
|
||||
|
||||
/* activity */
|
||||
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.ev:last-child { border-bottom: 0; }
|
||||
.ev time { flex: none; width: 72px; white-space: nowrap; color: var(--ink-3); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* chart */
|
||||
.legend-row { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 6px; font-size: 13px; color: var(--ink-2); }
|
||||
.legend-row strong { color: var(--ink); }
|
||||
.key { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: -1px; margin-right: 6px; }
|
||||
.key.down { background: var(--down); }
|
||||
.key.up { background: var(--up); }
|
||||
.readout { min-height: 22px; margin-top: 14px; font-size: 13px; color: var(--ink-2); }
|
||||
.readout strong { color: var(--ink); font-weight: 600; }
|
||||
.chart { position: relative; height: 200px; margin-top: 6px; }
|
||||
.chart.small { height: 180px; margin-top: 18px; }
|
||||
.chart .gl { position: absolute; left: 0; right: 0; border-top: 1px solid var(--grid); }
|
||||
.chart .gl.top { top: 0; }
|
||||
.chart .gl.mid { top: 50%; }
|
||||
.chart .gl.base { bottom: 0; border-color: var(--axis); }
|
||||
.chart .yl { position: absolute; left: 0; font-size: 11px; color: var(--axis-ink); }
|
||||
.chart .yl.top { top: -9px; }
|
||||
.chart .yl.mid { top: calc(50% - 9px); }
|
||||
.chart .bars { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; display: flex; gap: 2px; }
|
||||
.chart .grp { flex: 1; min-width: 0; height: 100%; display: flex; align-items: flex-end; gap: 2px; padding: 0 1px; border: 0; background: transparent; cursor: default; border-radius: 4px; }
|
||||
.chart .grp.on { background: #f0efec; }
|
||||
.chart .grp span { display: block; flex: 1; min-width: 0; border-radius: 4px 4px 0 0; }
|
||||
.chart .grp span.down { background: var(--down); }
|
||||
.chart .grp span.up { background: var(--up); }
|
||||
.chart .grp span.total { background: var(--down); }
|
||||
.chart .grp.on span.total { background: var(--down-strong); }
|
||||
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); }
|
||||
.chart.loading { opacity: .5; }
|
||||
|
||||
/* config / log blocks */
|
||||
pre.code, pre.log { margin: 0; padding: 14px; background: var(--ink); color: #e6e6e1; border-radius: 10px; font-family: var(--mono); font-size: 12px; line-height: 1.6; overflow-x: auto; white-space: pre; }
|
||||
pre.log { line-height: 1.7; max-height: 420px; overflow-y: auto; }
|
||||
.qr { width: 220px; height: 220px; border-radius: 10px; border: 1px solid var(--line); image-rendering: pixelated; background: #fff; }
|
||||
.ph { display: grid; place-items: center; width: 200px; height: 200px; border: 1.5px dashed #b9b9b3; border-radius: 10px; color: var(--ink-3); font-size: 13px; text-align: center; background: #fafaf8; }
|
||||
|
||||
/* apply bar */
|
||||
.applybar { position: sticky; bottom: 16px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px 16px; background: var(--ink); color: #e6e6e1; border-radius: 12px; z-index: 5; }
|
||||
.applybar .btn { border-color: #3a3b41; background: #2a2b31; color: #fff; }
|
||||
.applybar .btn.primary { background: #fff; color: var(--ink); border-color: #fff; }
|
||||
|
||||
/* dialog */
|
||||
dialog { border: 0; border-radius: 14px; padding: 0; width: min(640px, calc(100vw - 32px)); max-height: calc(100vh - 32px); color: var(--ink); box-shadow: 0 20px 60px rgba(0,0,0,.25); }
|
||||
dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.dlg { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.dlg h2 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.dlg p { margin: 0; color: var(--ink-2); }
|
||||
.dlg .foot { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
|
||||
.qrrow { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
|
||||
.qrrow .col { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
/* toast */
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; max-width: calc(100vw - 32px); }
|
||||
.toast { background: var(--ink); color: #fff; padding: 12px 16px; border-radius: 10px; font-size: 13px; box-shadow: 0 8px 24px rgba(0,0,0,.2); max-width: 420px; }
|
||||
.toast.err { background: var(--bad-ink); }
|
||||
|
||||
/* login */
|
||||
.loginpage { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; background: var(--ink); }
|
||||
.loginbox { width: 100%; max-width: 380px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.loginbox .brand { justify-content: center; font-size: 18px; padding: 0; }
|
||||
.loginform { background: var(--surface); border-radius: 14px; padding: 28px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.loginform h1 { font-size: 20px; }
|
||||
.loginform input { min-height: 44px; }
|
||||
.loginform .btn { min-height: 44px; width: 100%; }
|
||||
.loginform .hint { margin: 0; }
|
||||
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
|
||||
.loading-page { padding: 40px; color: var(--ink-3); }
|
||||
@@ -0,0 +1,262 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// --- passwords (argon2id, PHC string format) ---
|
||||
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 2
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
func hashPassword(pw string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
b64 := base64.RawStdEncoding
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
func verifyPassword(encoded, pw string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false
|
||||
}
|
||||
var m uint32
|
||||
var t uint32
|
||||
var p uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
||||
return false
|
||||
}
|
||||
b64 := base64.RawStdEncoding
|
||||
salt, err1 := b64.DecodeString(parts[4])
|
||||
want, err2 := b64.DecodeString(parts[5])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
func validatePassword(pw string) error {
|
||||
if len([]rune(pw)) < 12 {
|
||||
return badRequest("password must be at least 12 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- random secrets ---
|
||||
|
||||
func randomString(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = b[6]&0x0f | 0x40
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
h := hex.EncodeToString(b)
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
||||
}
|
||||
|
||||
const tokenPrefix = "wgt_"
|
||||
|
||||
func hashToken(tok string) string {
|
||||
sum := sha256.Sum256([]byte(tok))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// --- sessions, token use and login throttling (in memory) ---
|
||||
|
||||
type principal struct {
|
||||
Name string // "admin" or the token name
|
||||
Scope string // rw | ro
|
||||
TokenID string
|
||||
IsAdmin bool
|
||||
RemoteIP string
|
||||
}
|
||||
|
||||
type session struct {
|
||||
user string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type tokenUse struct {
|
||||
At time.Time `json:"at"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type failState struct {
|
||||
count int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
store *Store
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*session
|
||||
used map[string]tokenUse
|
||||
fails map[string]*failState
|
||||
}
|
||||
|
||||
const (
|
||||
maxFailures = 5
|
||||
lockoutTime = 15 * time.Minute
|
||||
)
|
||||
|
||||
func newAuth(s *Store) *Auth {
|
||||
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, fails: map[string]*failState{}}
|
||||
}
|
||||
|
||||
func cookieName() string { return appName + "_session" }
|
||||
|
||||
var errLocked = errors.New("too many failed attempts, try again later")
|
||||
|
||||
// Login checks the credentials and returns a new session id.
|
||||
func (a *Auth) Login(user, pw, ip string) (string, error) {
|
||||
a.mu.Lock()
|
||||
f := a.fails[ip]
|
||||
if f != nil && time.Now().Before(f.until) {
|
||||
a.mu.Unlock()
|
||||
return "", errLocked
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
cfg := a.store.Get()
|
||||
if cfg.Admin.PasswordHash == "" {
|
||||
return "", errors.New("no admin password is set; run: " + appName + " -passwd")
|
||||
}
|
||||
okUser := subtle.ConstantTimeCompare([]byte(user), []byte(cfg.Admin.Username)) == 1
|
||||
okPw := verifyPassword(cfg.Admin.PasswordHash, pw)
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !okUser || !okPw {
|
||||
if f == nil {
|
||||
f = &failState{}
|
||||
a.fails[ip] = f
|
||||
}
|
||||
f.count++
|
||||
if f.count >= maxFailures {
|
||||
f.count = 0
|
||||
f.until = time.Now().Add(lockoutTime)
|
||||
}
|
||||
return "", errors.New("wrong username or password")
|
||||
}
|
||||
delete(a.fails, ip)
|
||||
id := randomString(32)
|
||||
a.sessions[id] = &session{user: cfg.Admin.Username, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour)}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *Auth) Logout(id string) {
|
||||
a.mu.Lock()
|
||||
delete(a.sessions, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// DropSessions signs everyone out, e.g. after a password change.
|
||||
func (a *Auth) DropSessions() {
|
||||
a.mu.Lock()
|
||||
a.sessions = map[string]*session{}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func remoteIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
// Behind a local reverse proxy the real client is in X-Forwarded-For.
|
||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Authenticate accepts a session cookie or "Authorization: Bearer wgt_...".
|
||||
func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
|
||||
ip := remoteIP(r)
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
tok := strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||
if !strings.HasPrefix(tok, tokenPrefix) {
|
||||
return nil, false
|
||||
}
|
||||
want := hashToken(tok)
|
||||
for _, t := range a.store.Get().APITokens {
|
||||
if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(want)) == 1 {
|
||||
a.mu.Lock()
|
||||
a.used[t.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||
a.mu.Unlock()
|
||||
return &principal{Name: t.Name, Scope: t.Scope, TokenID: t.ID, RemoteIP: ip}, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
c, err := r.Cookie(cookieName())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
s := a.sessions[c.Value]
|
||||
if s == nil || time.Now().After(s.expires) {
|
||||
delete(a.sessions, c.Value)
|
||||
return nil, false
|
||||
}
|
||||
return &principal{Name: s.user, Scope: "rw", IsAdmin: true, RemoteIP: ip}, true
|
||||
}
|
||||
|
||||
func (a *Auth) TokenUse(id string) *tokenUse {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if u, ok := a.used[id]; ok {
|
||||
return &u
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sweep removes expired sessions and stale lockouts.
|
||||
func (a *Auth) sweep() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
for id, s := range a.sessions {
|
||||
if now.After(s.expires) {
|
||||
delete(a.sessions, id)
|
||||
}
|
||||
}
|
||||
for ip, f := range a.fails {
|
||||
if now.After(f.until) && f.count == 0 {
|
||||
delete(a.fails, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
func newPrivateKey() (wgtypes.Key, error) { return wgtypes.GeneratePrivateKey() }
|
||||
|
||||
func newPresharedKey() (wgtypes.Key, error) { return wgtypes.GenerateKey() }
|
||||
|
||||
func serverPublicKey(c *Config) string {
|
||||
k, err := wgtypes.ParseKey(c.Server.PrivateKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return k.PublicKey().String()
|
||||
}
|
||||
|
||||
// effective returns the peer's value or the server default.
|
||||
func peerDNS(c *Config, p *Peer) []string {
|
||||
if p.DNS != nil {
|
||||
return p.DNS
|
||||
}
|
||||
return c.Server.ClientDefaults.DNS
|
||||
}
|
||||
|
||||
func peerAllowedIPs(c *Config, p *Peer) []string {
|
||||
if p.AllowedIPs != nil {
|
||||
return p.AllowedIPs
|
||||
}
|
||||
return c.Server.ClientDefaults.AllowedIPs
|
||||
}
|
||||
|
||||
func peerKeepalive(c *Config, p *Peer) int {
|
||||
if p.Keepalive != nil {
|
||||
return *p.Keepalive
|
||||
}
|
||||
return c.Server.ClientDefaults.Keepalive
|
||||
}
|
||||
|
||||
// peerAddresses returns the peer's tunnel addresses as host routes for the
|
||||
// server side (/32, /128).
|
||||
func peerAddresses(c *Config, p *Peer) []netip.Prefix {
|
||||
v4 := netip.MustParseAddr(p.IPv4)
|
||||
out := []netip.Prefix{netip.PrefixFrom(v4, 32)}
|
||||
if c.Server.IPv6Enabled {
|
||||
out = append(out, netip.PrefixFrom(mapIPv6(netip.MustParsePrefix(c.Server.IPv6), v4), 128))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func endpointString(c *Config) string {
|
||||
port := c.Server.EndpointPort
|
||||
if port == 0 {
|
||||
port = c.Server.ListenPort
|
||||
}
|
||||
host := c.Server.Endpoint
|
||||
if host == "" {
|
||||
host = "SET-ENDPOINT-IN-SERVER-SETTINGS"
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// clientConfig renders the wg-quick file for a peer. privateKey may be empty
|
||||
// when the client generated its own keys.
|
||||
func clientConfig(c *Config, p *Peer, privateKey string) string {
|
||||
v4net := netip.MustParsePrefix(c.Server.IPv4)
|
||||
v4 := netip.MustParseAddr(p.IPv4)
|
||||
addr := fmt.Sprintf("%s/%d", v4, v4net.Bits())
|
||||
if c.Server.IPv6Enabled {
|
||||
v6net := netip.MustParsePrefix(c.Server.IPv6)
|
||||
addr += fmt.Sprintf(",%s/%d", mapIPv6(v6net, v4), v6net.Bits())
|
||||
}
|
||||
if privateKey == "" {
|
||||
privateKey = "<the private key of this device>"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s\n", privateKey, addr)
|
||||
if dns := peerDNS(c, p); len(dns) > 0 {
|
||||
fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", "))
|
||||
}
|
||||
// No MTU line: the client picks one for the network it is on, as pivpn does.
|
||||
fmt.Fprintf(&b, "\n[Peer]\nPublicKey = %s\n", serverPublicKey(c))
|
||||
if p.PresharedKey != "" {
|
||||
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
|
||||
}
|
||||
fmt.Fprintf(&b, "Endpoint = %s\nAllowedIPs = %s\n", endpointString(c), strings.Join(peerAllowedIPs(c, p), ", "))
|
||||
if ka := peerKeepalive(c, p); ka > 0 {
|
||||
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", ka)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// qrDataURL returns a PNG QR code as a data: URL for direct use in <img src>.
|
||||
func qrDataURL(text string) (string, error) {
|
||||
png, err := qrcode.Encode(text, qrcode.Medium, 512)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"web": {
|
||||
"listen": ":443",
|
||||
"httpListen": ":80",
|
||||
"tls": {
|
||||
"mode": "acme",
|
||||
"domain": "vpn.example.net",
|
||||
"email": "admin@example.net"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"endpoint": "vpn.example.net"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the complete desired state of the service. It is persisted as
|
||||
// config.json and is the single source of truth: the kernel (interface, peers,
|
||||
// firewall) is reconciled to match it.
|
||||
type Config struct {
|
||||
Version int `json:"version"`
|
||||
Web WebConfig `json:"web"`
|
||||
Admin Admin `json:"admin"`
|
||||
APITokens []APIToken `json:"apiTokens"`
|
||||
Server Server `json:"server"`
|
||||
Peers []Peer `json:"peers"`
|
||||
Log LogConfig `json:"log"`
|
||||
}
|
||||
|
||||
type WebConfig struct {
|
||||
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
|
||||
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
|
||||
TLS TLSConfig `json:"tls"`
|
||||
SessionHours int `json:"sessionHours"`
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
Mode string `json:"mode"` // acme | selfsigned | files | off
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Staging bool `json:"staging,omitempty"` // use the Let's Encrypt staging CA
|
||||
CertFile string `json:"certFile,omitempty"`
|
||||
KeyFile string `json:"keyFile,omitempty"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
}
|
||||
|
||||
type APIToken struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Scope string `json:"scope"` // rw | ro
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Interface string `json:"interface"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
KeyCreated time.Time `json:"keyCreated"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
MTU int `json:"mtu"`
|
||||
IPv4 string `json:"ipv4"` // tunnel network, e.g. 10.84.12.0/24
|
||||
IPv6 string `json:"ipv6"` // tunnel network, e.g. fd11:5ee:bad:c0de::/64
|
||||
IPv6Enabled bool `json:"ipv6Enabled"`
|
||||
Endpoint string `json:"endpoint"` // host name or IP clients connect to
|
||||
EndpointPort int `json:"endpointPort"` // 0 = listenPort
|
||||
UplinkV4 string `json:"uplinkV4"` // "" = interface of the default route
|
||||
UplinkV6 string `json:"uplinkV6"`
|
||||
NAT bool `json:"nat"`
|
||||
PeerToPeer bool `json:"peerToPeer"`
|
||||
LANAccess bool `json:"lanAccess"`
|
||||
OpenPort bool `json:"openPort"`
|
||||
ClientDefaults ClientDefaults `json:"clientDefaults"`
|
||||
}
|
||||
|
||||
type ClientDefaults struct {
|
||||
DNS []string `json:"dns"`
|
||||
AllowedIPs []string `json:"allowedIPs"`
|
||||
Keepalive int `json:"keepalive"`
|
||||
}
|
||||
|
||||
// Peer is one client. Its private key is never stored: it is shown once when
|
||||
// the config is issued.
|
||||
type Peer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note"`
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
PresharedKey string `json:"presharedKey,omitempty"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
DNS []string `json:"dns,omitempty"` // nil = server default
|
||||
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
|
||||
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
|
||||
Created time.Time `json:"created"`
|
||||
ConfigIssued *time.Time `json:"configIssued,omitempty"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `json:"level"` // debug | info | warn | error
|
||||
MaxSizeMB int `json:"maxSizeMB"`
|
||||
MaxFiles int `json:"maxFiles"`
|
||||
}
|
||||
|
||||
const configVersion = 1
|
||||
|
||||
// applyDefaults fills zero values. It never overwrites values that are set,
|
||||
// so a minimal hand-written config.json grows into a complete one.
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Version == 0 {
|
||||
c.Version = configVersion
|
||||
}
|
||||
if c.Web.Listen == "" {
|
||||
c.Web.Listen = ":443"
|
||||
}
|
||||
if c.Web.TLS.Mode == "" {
|
||||
if c.Web.TLS.Domain != "" {
|
||||
c.Web.TLS.Mode = "acme"
|
||||
} else {
|
||||
c.Web.TLS.Mode = "selfsigned"
|
||||
}
|
||||
}
|
||||
if c.Web.TLS.Mode == "acme" && c.Web.HTTPListen == "" {
|
||||
c.Web.HTTPListen = ":80"
|
||||
}
|
||||
if c.Web.SessionHours == 0 {
|
||||
c.Web.SessionHours = 12
|
||||
}
|
||||
if c.Admin.Username == "" {
|
||||
c.Admin.Username = "admin"
|
||||
}
|
||||
s := &c.Server
|
||||
if s.Interface == "" {
|
||||
s.Interface = "wg0"
|
||||
}
|
||||
if s.ListenPort == 0 {
|
||||
s.ListenPort = 51820
|
||||
}
|
||||
if s.MTU == 0 {
|
||||
s.MTU = 1420
|
||||
}
|
||||
if s.ClientDefaults.DNS == nil {
|
||||
s.ClientDefaults.DNS = []string{"9.9.9.9", "149.112.112.112"}
|
||||
}
|
||||
if s.ClientDefaults.AllowedIPs == nil {
|
||||
s.ClientDefaults.AllowedIPs = []string{"0.0.0.0/0", "::/0"}
|
||||
}
|
||||
if c.Log.Level == "" {
|
||||
c.Log.Level = "info"
|
||||
}
|
||||
if c.Log.MaxSizeMB == 0 {
|
||||
c.Log.MaxSizeMB = 10
|
||||
}
|
||||
if c.Log.MaxFiles == 0 {
|
||||
c.Log.MaxFiles = 5
|
||||
}
|
||||
if c.APITokens == nil {
|
||||
c.APITokens = []APIToken{}
|
||||
}
|
||||
if c.Peers == nil {
|
||||
c.Peers = []Peer{}
|
||||
}
|
||||
}
|
||||
|
||||
// initServer runs once, when the server has no key yet: it generates the key,
|
||||
// picks a free tunnel subnet and turns on the defaults that are booleans.
|
||||
func (c *Config) initServer() (bool, error) {
|
||||
s := &c.Server
|
||||
if s.PrivateKey != "" {
|
||||
return false, nil
|
||||
}
|
||||
key, err := newPrivateKey()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.PrivateKey = key.String()
|
||||
s.KeyCreated = time.Now().UTC()
|
||||
if s.IPv4 == "" {
|
||||
n, err := randomSubnet(24)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.IPv4 = n.String()
|
||||
}
|
||||
if s.IPv6 == "" {
|
||||
s.IPv6 = "fd11:5ee:bad:c0de::/64"
|
||||
s.IPv6Enabled = hasGlobalIPv6()
|
||||
}
|
||||
s.NAT = true
|
||||
s.PeerToPeer = true
|
||||
s.OpenPort = true
|
||||
if s.Endpoint == "" {
|
||||
s.Endpoint = c.Web.TLS.Domain
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var peerNameRe = regexp.MustCompile(`^[a-zA-Z0-9.@_-]{1,32}$`)
|
||||
|
||||
func validatePeerName(name string) error {
|
||||
switch {
|
||||
case !peerNameRe.MatchString(name):
|
||||
return errors.New("name must be 1–32 characters: letters, digits and . @ _ -")
|
||||
case strings.Trim(name, "0123456789") == "":
|
||||
return errors.New("name cannot be only digits")
|
||||
case strings.HasPrefix(name, "-") || strings.HasPrefix(name, "."):
|
||||
return errors.New("name cannot start with - or .")
|
||||
case name == "server":
|
||||
return errors.New("name \"server\" is reserved")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHostList(list []string, field string, wantCIDR bool) error {
|
||||
for _, v := range list {
|
||||
if wantCIDR {
|
||||
if _, err := netip.ParsePrefix(v); err != nil {
|
||||
return fmt.Errorf("%s: %q is not a network in CIDR notation", field, v)
|
||||
}
|
||||
} else if _, err := netip.ParseAddr(v); err != nil {
|
||||
return fmt.Errorf("%s: %q is not an IP address", field, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate checks the whole config for consistency. It runs before every save.
|
||||
func (c *Config) validate() error {
|
||||
s := &c.Server
|
||||
if s.ListenPort < 1 || s.ListenPort > 65535 {
|
||||
return errors.New("listen port must be 1–65535")
|
||||
}
|
||||
if s.EndpointPort < 0 || s.EndpointPort > 65535 {
|
||||
return errors.New("endpoint port must be 0–65535")
|
||||
}
|
||||
if s.MTU < 1280 || s.MTU > 9000 {
|
||||
return errors.New("MTU must be 1280–9000")
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,15}$`).MatchString(s.Interface) {
|
||||
return errors.New("interface name must be 1–15 characters: letters, digits, _ and -")
|
||||
}
|
||||
v4, err := netip.ParsePrefix(s.IPv4)
|
||||
if err != nil || !v4.Addr().Is4() || v4.Bits() > 30 || v4.Bits() < 8 {
|
||||
return errors.New("IPv4 network must be an IPv4 CIDR between /8 and /30")
|
||||
}
|
||||
if v4.Masked() != v4 {
|
||||
return fmt.Errorf("IPv4 network must be the network address, e.g. %s", v4.Masked())
|
||||
}
|
||||
v6, err := netip.ParsePrefix(s.IPv6)
|
||||
if err != nil || !v6.Addr().Is6() || v6.Bits() > 96 {
|
||||
return errors.New("IPv6 network must be an IPv6 CIDR of /96 or larger")
|
||||
}
|
||||
if v6.Masked() != v6 {
|
||||
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
|
||||
}
|
||||
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil {
|
||||
return errors.New("endpoint must be a host name or IP address without port")
|
||||
}
|
||||
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHostList(s.ClientDefaults.AllowedIPs, "AllowedIPs", true); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.ClientDefaults.Keepalive < 0 || s.ClientDefaults.Keepalive > 3600 {
|
||||
return errors.New("keepalive must be 0–3600 seconds")
|
||||
}
|
||||
switch c.Web.TLS.Mode {
|
||||
case "acme":
|
||||
if c.Web.TLS.Domain == "" {
|
||||
return errors.New("tls.domain is required for Let's Encrypt")
|
||||
}
|
||||
case "files":
|
||||
if c.Web.TLS.CertFile == "" || c.Web.TLS.KeyFile == "" {
|
||||
return errors.New("tls.certFile and tls.keyFile are required for mode \"files\"")
|
||||
}
|
||||
case "selfsigned", "off":
|
||||
default:
|
||||
return fmt.Errorf("unknown tls.mode %q", c.Web.TLS.Mode)
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
ips := map[netip.Addr]bool{}
|
||||
keys := map[string]bool{}
|
||||
for _, p := range c.Peers {
|
||||
if err := validatePeerName(p.Name); err != nil {
|
||||
return fmt.Errorf("peer %q: %w", p.Name, err)
|
||||
}
|
||||
if names[p.Name] {
|
||||
return fmt.Errorf("peer name %q is used twice", p.Name)
|
||||
}
|
||||
names[p.Name] = true
|
||||
ip, err := netip.ParseAddr(p.IPv4)
|
||||
if err != nil || !v4.Contains(ip) {
|
||||
return fmt.Errorf("peer %q: address %s is outside %s", p.Name, p.IPv4, v4)
|
||||
}
|
||||
if ip == v4.Addr() || ip == serverIPv4(v4) || ip == lastAddr(v4) {
|
||||
return fmt.Errorf("peer %q: address %s is reserved", p.Name, ip)
|
||||
}
|
||||
if ips[ip] {
|
||||
return fmt.Errorf("address %s is used twice", ip)
|
||||
}
|
||||
ips[ip] = true
|
||||
if keys[p.PublicKey] {
|
||||
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
|
||||
}
|
||||
keys[p.PublicKey] = true
|
||||
if err := validateHostList(p.DNS, "DNS", false); err != nil {
|
||||
return fmt.Errorf("peer %q: %w", p.Name, err)
|
||||
}
|
||||
if err := validateHostList(p.AllowedIPs, "AllowedIPs", true); err != nil {
|
||||
return fmt.Errorf("peer %q: %w", p.Name, err)
|
||||
}
|
||||
if p.Keepalive != nil && (*p.Keepalive < 0 || *p.Keepalive > 3600) {
|
||||
return fmt.Errorf("peer %q: keepalive must be 0–3600 seconds", p.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) peerByID(id string) (int, *Peer) {
|
||||
for i := range c.Peers {
|
||||
if c.Peers[i].ID == id {
|
||||
return i, &c.Peers[i]
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func (c *Config) clone() *Config {
|
||||
b, _ := json.Marshal(c)
|
||||
var out Config
|
||||
_ = json.Unmarshal(b, &out)
|
||||
return &out
|
||||
}
|
||||
|
||||
// Store guards the config and persists every change atomically.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
cfg *Config
|
||||
// onChange is called after a successful update, outside the lock.
|
||||
onChange func(old, new *Config)
|
||||
}
|
||||
|
||||
func loadConfigFile(path string) (*Config, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
b = []byte("{}")
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if c.Version > configVersion {
|
||||
return nil, fmt.Errorf("%s: config version %d is newer than this program supports", path, c.Version)
|
||||
}
|
||||
c.applyDefaults()
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func openStore(path string) (*Store, error) {
|
||||
c, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.initServer(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
s := &Store{path: path, cfg: c}
|
||||
if err := writeFileAtomic(path, c, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get returns a deep copy that the caller may read freely.
|
||||
func (s *Store) Get() *Config {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.cfg.clone()
|
||||
}
|
||||
|
||||
// Update applies fn to a copy, validates and saves it, then swaps it in.
|
||||
func (s *Store) Update(fn func(c *Config) error) error {
|
||||
s.mu.Lock()
|
||||
old := s.cfg
|
||||
next := old.clone()
|
||||
if err := fn(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
next.applyDefaults()
|
||||
if err := next.validate(); err != nil {
|
||||
s.mu.Unlock()
|
||||
return &userError{err.Error()}
|
||||
}
|
||||
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
s.cfg = next
|
||||
s.mu.Unlock()
|
||||
if s.onChange != nil {
|
||||
s.onChange(old.clone(), next.clone())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload re-reads config.json from disk, e.g. after "-passwd" changed it.
|
||||
func (s *Store) Reload() error {
|
||||
c, err := loadConfigFile(s.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
old := s.cfg
|
||||
s.cfg = c
|
||||
s.mu.Unlock()
|
||||
if s.onChange != nil {
|
||||
s.onChange(old.clone(), c.clone())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes JSON to a temp file in the same directory, syncs it
|
||||
// and renames it over the target, so a crash never leaves a partial file.
|
||||
func writeFileAtomic(path string, v any, mode os.FileMode) error {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp)
|
||||
if err := f.Chmod(mode); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
// When root edits the file (e.g. "-passwd" under sudo), keep the owner so
|
||||
// the service user can still read it.
|
||||
if os.Geteuid() == 0 {
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
if sys, ok := st.Sys().(*syscall.Stat_t); ok {
|
||||
_ = f.Chown(int(sys.Uid), int(sys.Gid))
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := f.Write(b); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// userError marks errors caused by invalid input; the API returns them as 400.
|
||||
type userError struct{ msg string }
|
||||
|
||||
func (e *userError) Error() string { return e.msg }
|
||||
|
||||
func badRequest(format string, a ...any) error { return &userError{fmt.Sprintf(format, a...)} }
|
||||
@@ -0,0 +1,176 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"github.com/google/nftables"
|
||||
"github.com/google/nftables/expr"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// The service owns one nftables table and rewrites it completely on every
|
||||
// apply, in a single atomic transaction. Rules of other tables are untouched.
|
||||
// Note: an accept here cannot override a drop in another table (for example
|
||||
// ufw or firewalld); those firewalls must allow the port themselves.
|
||||
|
||||
func fwTable() *nftables.Table {
|
||||
return &nftables.Table{Family: nftables.TableFamilyINet, Name: appName}
|
||||
}
|
||||
|
||||
func ifname(n string) []byte {
|
||||
b := make([]byte, 16)
|
||||
copy(b, n+"\x00")
|
||||
return b
|
||||
}
|
||||
|
||||
func metaEq(key expr.MetaKey, data []byte) []expr.Any {
|
||||
return []expr.Any{
|
||||
&expr.Meta{Key: key, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: data},
|
||||
}
|
||||
}
|
||||
|
||||
func iif(n string) []expr.Any { return metaEq(expr.MetaKeyIIFNAME, ifname(n)) }
|
||||
func oif(n string) []expr.Any { return metaEq(expr.MetaKeyOIFNAME, ifname(n)) }
|
||||
|
||||
// addrMatch matches the source (src=true) or destination address against a
|
||||
// prefix, including the protocol check an inet table needs.
|
||||
func addrMatch(p netip.Prefix, src bool) []expr.Any {
|
||||
proto, offset, size := byte(unix.NFPROTO_IPV4), uint32(16), uint32(4)
|
||||
if src {
|
||||
offset = 12
|
||||
}
|
||||
if p.Addr().Is6() {
|
||||
proto, offset, size = unix.NFPROTO_IPV6, 24, 16
|
||||
if src {
|
||||
offset = 8
|
||||
}
|
||||
}
|
||||
mask := make([]byte, size)
|
||||
for i := 0; i < p.Bits(); i++ {
|
||||
mask[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
return append(metaEq(expr.MetaKeyNFPROTO, []byte{proto}),
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset, Len: size},
|
||||
&expr.Bitwise{SourceRegister: 1, DestRegister: 1, Len: size, Mask: mask, Xor: make([]byte, size)},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: p.Masked().Addr().AsSlice()},
|
||||
)
|
||||
}
|
||||
|
||||
func udpDport(port int) []expr.Any {
|
||||
b := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(b, uint16(port))
|
||||
return append(metaEq(expr.MetaKeyL4PROTO, []byte{unix.IPPROTO_UDP}),
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: b},
|
||||
)
|
||||
}
|
||||
|
||||
func ctEstablished() []expr.Any {
|
||||
mask := make([]byte, 4)
|
||||
binary.NativeEndian.PutUint32(mask, expr.CtStateBitESTABLISHED|expr.CtStateBitRELATED)
|
||||
return []expr.Any{
|
||||
&expr.Ct{Register: 1, Key: expr.CtKeySTATE},
|
||||
&expr.Bitwise{SourceRegister: 1, DestRegister: 1, Len: 4, Mask: mask, Xor: make([]byte, 4)},
|
||||
&expr.Cmp{Op: expr.CmpOpNeq, Register: 1, Data: make([]byte, 4)},
|
||||
}
|
||||
}
|
||||
|
||||
func rule(parts ...[]expr.Any) []expr.Any {
|
||||
var out []expr.Any
|
||||
for _, p := range parts {
|
||||
out = append(out, p...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
accept = []expr.Any{&expr.Verdict{Kind: expr.VerdictAccept}}
|
||||
drop = []expr.Any{&expr.Verdict{Kind: expr.VerdictDrop}}
|
||||
masq = []expr.Any{&expr.Masq{}}
|
||||
)
|
||||
|
||||
func applyFirewall(c *Config, up4, up6 string, lan []netip.Prefix) error {
|
||||
conn, err := nftables.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("nftables: %w", err)
|
||||
}
|
||||
s := c.Server
|
||||
wg := s.Interface
|
||||
t := conn.AddTable(fwTable())
|
||||
conn.FlushTable(t)
|
||||
|
||||
policy := nftables.ChainPolicyAccept
|
||||
input := conn.AddChain(&nftables.Chain{Name: "input", Table: t, Type: nftables.ChainTypeFilter,
|
||||
Hooknum: nftables.ChainHookInput, Priority: nftables.ChainPriorityFilter, Policy: &policy})
|
||||
forward := conn.AddChain(&nftables.Chain{Name: "forward", Table: t, Type: nftables.ChainTypeFilter,
|
||||
Hooknum: nftables.ChainHookForward, Priority: nftables.ChainPriorityFilter, Policy: &policy})
|
||||
post := conn.AddChain(&nftables.Chain{Name: "postrouting", Table: t, Type: nftables.ChainTypeNAT,
|
||||
Hooknum: nftables.ChainHookPostrouting, Priority: nftables.ChainPriorityNATSource, Policy: &policy})
|
||||
|
||||
add := func(ch *nftables.Chain, e []expr.Any) { conn.AddRule(&nftables.Rule{Table: t, Chain: ch, Exprs: e}) }
|
||||
|
||||
if s.OpenPort {
|
||||
add(input, rule(udpDport(s.ListenPort), accept))
|
||||
}
|
||||
|
||||
if s.PeerToPeer {
|
||||
add(forward, rule(iif(wg), oif(wg), accept))
|
||||
} else {
|
||||
add(forward, rule(iif(wg), oif(wg), drop))
|
||||
}
|
||||
if !s.LANAccess {
|
||||
for _, n := range lan {
|
||||
add(forward, rule(iif(wg), addrMatch(n, false), drop))
|
||||
}
|
||||
}
|
||||
add(forward, rule(iif(wg), accept))
|
||||
add(forward, rule(oif(wg), ctEstablished(), accept))
|
||||
|
||||
if s.NAT {
|
||||
if up4 != "" {
|
||||
add(post, rule(addrMatch(netip.MustParsePrefix(s.IPv4), true), oif(up4), masq))
|
||||
}
|
||||
if s.IPv6Enabled && up6 != "" {
|
||||
add(post, rule(addrMatch(netip.MustParsePrefix(s.IPv6), true), oif(up6), masq))
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.Flush(); err != nil {
|
||||
return fmt.Errorf("nftables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firewallPresent() (bool, string) {
|
||||
conn, err := nftables.New()
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
tables, err := conn.ListTablesOfFamily(nftables.TableFamilyINet)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
for _, t := range tables {
|
||||
if t.Name == appName {
|
||||
return true, "table inet " + appName + " present"
|
||||
}
|
||||
}
|
||||
return false, "table inet " + appName + " missing"
|
||||
}
|
||||
|
||||
func removeFirewall() error {
|
||||
if ok, _ := firewallPresent(); !ok {
|
||||
return nil
|
||||
}
|
||||
conn, err := nftables.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.DelTable(fwTable())
|
||||
return conn.Flush()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module ghostwire
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/google/nftables v0.3.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
||||
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
|
||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>GHOSTWIRE</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><p class="loading-page">Loading…</p></div>
|
||||
<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
|
||||
<noscript><p class="loading-page">This interface needs JavaScript.</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// Subnets that commonly appear on home and office networks. A tunnel network
|
||||
// overlapping one of them breaks routing for clients that sit on such a LAN.
|
||||
// Source: pivpn, https://community.openvpn.net/openvpn/wiki/AvoidRoutingConflicts
|
||||
var avoidedSubnets = mustPrefixes(
|
||||
"10.0.0.0/24", "10.0.1.0/24", "10.1.1.0/24", "10.1.10.0/24", "10.2.0.0/24",
|
||||
"10.8.0.0/24", "10.10.1.0/24", "10.90.90.0/24", "10.100.1.0/24",
|
||||
"10.255.255.0/24", "192.168.0.0/24", "192.168.1.0/24",
|
||||
)
|
||||
|
||||
func mustPrefixes(s ...string) []netip.Prefix {
|
||||
out := make([]netip.Prefix, len(s))
|
||||
for i, v := range s {
|
||||
out[i] = netip.MustParsePrefix(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hostNetworks returns the networks of all addresses configured on this host.
|
||||
func hostNetworks() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
addrs, _ := net.InterfaceAddrs()
|
||||
for _, a := range addrs {
|
||||
if n, ok := a.(*net.IPNet); ok {
|
||||
if p, err := netip.ParsePrefix(n.String()); err == nil {
|
||||
out = append(out, p.Masked())
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasGlobalIPv6() bool {
|
||||
addrs, _ := net.InterfaceAddrs()
|
||||
for _, a := range addrs {
|
||||
if n, ok := a.(*net.IPNet); ok && n.IP.To4() == nil && n.IP.IsGlobalUnicast() && !n.IP.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// randomSubnet picks a random, unused /bits network from 10/8, then
|
||||
// 172.16/12, then 192.168/16, like pivpn does.
|
||||
func randomSubnet(bits int) (netip.Prefix, error) {
|
||||
taken := append(append([]netip.Prefix{}, avoidedSubnets...), hostNetworks()...)
|
||||
for _, pool := range mustPrefixes("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") {
|
||||
if bits < pool.Bits() {
|
||||
continue
|
||||
}
|
||||
count := int64(1) << (bits - pool.Bits())
|
||||
base := binary.BigEndian.Uint32(pool.Addr().AsSlice())
|
||||
size := uint32(1) << (32 - bits)
|
||||
for range 2000 {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(count))
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], base+uint32(n.Int64())*size)
|
||||
cand := netip.PrefixFrom(netip.AddrFrom4(b), bits)
|
||||
if !overlapsAny(cand, taken) {
|
||||
return cand, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return netip.Prefix{}, errors.New("no free private IPv4 subnet found")
|
||||
}
|
||||
|
||||
func overlapsAny(p netip.Prefix, list []netip.Prefix) bool {
|
||||
for _, q := range list {
|
||||
if p.Overlaps(q) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addrToU32(a netip.Addr) uint32 { return binary.BigEndian.Uint32(a.AsSlice()) }
|
||||
|
||||
func u32ToAddr(v uint32) netip.Addr {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], v)
|
||||
return netip.AddrFrom4(b)
|
||||
}
|
||||
|
||||
// serverIPv4 is the first host address of the tunnel network.
|
||||
func serverIPv4(n netip.Prefix) netip.Addr { return n.Addr().Next() }
|
||||
|
||||
func lastAddr(n netip.Prefix) netip.Addr {
|
||||
return u32ToAddr(addrToU32(n.Addr()) | (1<<(32-n.Bits()) - 1))
|
||||
}
|
||||
|
||||
// nextFreeIPv4 returns the lowest unused peer address (server is .1).
|
||||
func nextFreeIPv4(c *Config) (netip.Addr, error) {
|
||||
n := netip.MustParsePrefix(c.Server.IPv4)
|
||||
used := map[netip.Addr]bool{}
|
||||
for _, p := range c.Peers {
|
||||
if a, err := netip.ParseAddr(p.IPv4); err == nil {
|
||||
used[a] = true
|
||||
}
|
||||
}
|
||||
last := lastAddr(n)
|
||||
for a := serverIPv4(n).Next(); a.Less(last); a = a.Next() {
|
||||
if !used[a] {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return netip.Addr{}, badRequest("no free address left in %s", n)
|
||||
}
|
||||
|
||||
// capacity is the number of peer addresses in the tunnel network.
|
||||
func capacity(n netip.Prefix) int { return 1<<(32-n.Bits()) - 3 }
|
||||
|
||||
// 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.
|
||||
func mapIPv6(v6net netip.Prefix, v4 netip.Addr) netip.Addr {
|
||||
b := v6net.Addr().As16()
|
||||
copy(b[12:], v4.AsSlice())
|
||||
return netip.AddrFrom16(b)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PeerSample is one reading of a peer's kernel counters.
|
||||
type PeerSample struct {
|
||||
PublicKey string
|
||||
RxBytes int64 // received by the server = uploaded by the peer
|
||||
TxBytes int64 // sent by the server = downloaded by the peer
|
||||
LastHandshake time.Time
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
// Check is one line of the health report.
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// Kernel applies the desired state to the system. The Linux implementation
|
||||
// uses netlink, wgctrl and nftables; other platforms get a simulator so the
|
||||
// web UI can be developed without a Linux box.
|
||||
type Kernel interface {
|
||||
Apply(c *Config) error
|
||||
Sample(iface string) ([]PeerSample, error)
|
||||
Checks(c *Config) []Check
|
||||
Uplink(c *Config, v6 bool) string
|
||||
Down(c *Config) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Reconciler applies the config to the kernel whenever it is triggered and
|
||||
// remembers the outcome for the health report.
|
||||
type Reconciler struct {
|
||||
kernel Kernel
|
||||
store *Store
|
||||
trigger chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
lastErr error
|
||||
lastApply time.Time
|
||||
}
|
||||
|
||||
func newReconciler(k Kernel, s *Store) *Reconciler {
|
||||
return &Reconciler{kernel: k, store: s, trigger: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// Kick schedules an apply; several kicks in a row collapse into one.
|
||||
func (r *Reconciler) Kick() {
|
||||
select {
|
||||
case r.trigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyNow applies synchronously and returns the result, so an API call can
|
||||
// report kernel errors to the user.
|
||||
func (r *Reconciler) ApplyNow() error {
|
||||
err := r.kernel.Apply(r.store.Get())
|
||||
r.mu.Lock()
|
||||
r.lastErr, r.lastApply = err, time.Now()
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("apply failed", "err", err)
|
||||
} else {
|
||||
slog.Debug("config applied to kernel")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Run applies on every kick and re-applies every 5 minutes, which repairs
|
||||
// drift such as a flushed nftables ruleset or a deleted interface.
|
||||
func (r *Reconciler) Run(stop <-chan struct{}) {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-r.trigger:
|
||||
case <-t.C:
|
||||
}
|
||||
_ = r.ApplyNow()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) Status() (time.Time, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.lastApply, r.lastErr
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
type linuxKernel struct {
|
||||
wg *wgctrl.Client
|
||||
}
|
||||
|
||||
func newKernel() (Kernel, error) {
|
||||
c, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wgctrl: %w", err)
|
||||
}
|
||||
return &linuxKernel{wg: c}, nil
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Close() error { return k.wg.Close() }
|
||||
|
||||
// serverAddrs returns the addresses the interface carries: the first host of
|
||||
// each tunnel network with the network's prefix length, which also installs
|
||||
// the route to the peers.
|
||||
func serverAddrs(c *Config) []netip.Prefix {
|
||||
v4 := netip.MustParsePrefix(c.Server.IPv4)
|
||||
out := []netip.Prefix{netip.PrefixFrom(serverIPv4(v4), v4.Bits())}
|
||||
if c.Server.IPv6Enabled {
|
||||
v6 := netip.MustParsePrefix(c.Server.IPv6)
|
||||
out = append(out, netip.PrefixFrom(mapIPv6(v6, serverIPv4(v4)), v6.Bits()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toIPNet(p netip.Prefix) *net.IPNet {
|
||||
return &net.IPNet{IP: p.Addr().AsSlice(), Mask: net.CIDRMask(p.Bits(), p.Addr().BitLen())}
|
||||
}
|
||||
|
||||
func (k *linuxKernel) ensureLink(c *Config) (netlink.Link, error) {
|
||||
s := c.Server
|
||||
link, err := netlink.LinkByName(s.Interface)
|
||||
var nf netlink.LinkNotFoundError
|
||||
if errors.As(err, &nf) {
|
||||
la := netlink.NewLinkAttrs()
|
||||
la.Name = s.Interface
|
||||
la.MTU = s.MTU
|
||||
if err := netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: la}); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w (is the wireguard kernel module available?)", s.Interface, err)
|
||||
}
|
||||
link, err = netlink.LinkByName(s.Interface)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find %s: %w", s.Interface, err)
|
||||
}
|
||||
if link.Type() != "wireguard" {
|
||||
return nil, fmt.Errorf("%s exists but is a %s interface, not wireguard", s.Interface, link.Type())
|
||||
}
|
||||
if link.Attrs().MTU != s.MTU {
|
||||
if err := netlink.LinkSetMTU(link, s.MTU); err != nil {
|
||||
return nil, fmt.Errorf("set MTU: %w", err)
|
||||
}
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (k *linuxKernel) syncAddrs(link netlink.Link, want []netip.Prefix) error {
|
||||
have, err := netlink.AddrList(link, netlink.FAMILY_ALL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list addresses: %w", err)
|
||||
}
|
||||
var present []netip.Prefix
|
||||
for _, a := range have {
|
||||
if a.IP.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
ones, _ := a.Mask.Size()
|
||||
ip, _ := netip.AddrFromSlice(a.IP)
|
||||
p := netip.PrefixFrom(ip.Unmap(), ones)
|
||||
if slices.Contains(want, p) {
|
||||
present = append(present, p)
|
||||
continue
|
||||
}
|
||||
if err := netlink.AddrDel(link, &a); err != nil {
|
||||
return fmt.Errorf("remove address %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range want {
|
||||
if slices.Contains(present, p) {
|
||||
continue
|
||||
}
|
||||
if err := netlink.AddrAdd(link, &netlink.Addr{IPNet: toIPNet(p)}); err != nil {
|
||||
return fmt.Errorf("add address %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncPeers changes only peers that differ, so unchanged peers keep their
|
||||
// sessions — the same effect as "wg syncconf".
|
||||
func (k *linuxKernel) syncDevice(c *Config) error {
|
||||
s := c.Server
|
||||
priv, err := wgtypes.ParseKey(s.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server private key: %w", err)
|
||||
}
|
||||
dev, err := k.wg.Device(s.Interface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", s.Interface, err)
|
||||
}
|
||||
|
||||
type want struct {
|
||||
psk wgtypes.Key
|
||||
ips []net.IPNet
|
||||
}
|
||||
desired := map[wgtypes.Key]want{}
|
||||
for i := range c.Peers {
|
||||
p := &c.Peers[i]
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
pub, err := wgtypes.ParseKey(p.PublicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("peer %s: %w", p.Name, err)
|
||||
}
|
||||
var w want
|
||||
if p.PresharedKey != "" {
|
||||
if w.psk, err = wgtypes.ParseKey(p.PresharedKey); err != nil {
|
||||
return fmt.Errorf("peer %s preshared key: %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
for _, a := range peerAddresses(c, p) {
|
||||
w.ips = append(w.ips, *toIPNet(a))
|
||||
}
|
||||
desired[pub] = w
|
||||
}
|
||||
|
||||
var changes []wgtypes.PeerConfig
|
||||
existing := map[wgtypes.Key]wgtypes.Peer{}
|
||||
for _, p := range dev.Peers {
|
||||
existing[p.PublicKey] = p
|
||||
if _, ok := desired[p.PublicKey]; !ok {
|
||||
changes = append(changes, wgtypes.PeerConfig{PublicKey: p.PublicKey, Remove: true})
|
||||
}
|
||||
}
|
||||
for pub, w := range desired {
|
||||
if cur, ok := existing[pub]; ok && cur.PresharedKey == w.psk && sameIPNets(cur.AllowedIPs, w.ips) {
|
||||
continue
|
||||
}
|
||||
psk := w.psk
|
||||
changes = append(changes, wgtypes.PeerConfig{
|
||||
PublicKey: pub,
|
||||
PresharedKey: &psk,
|
||||
ReplaceAllowedIPs: true,
|
||||
AllowedIPs: w.ips,
|
||||
})
|
||||
}
|
||||
|
||||
cfg := wgtypes.Config{Peers: changes}
|
||||
if dev.PrivateKey != priv {
|
||||
cfg.PrivateKey = &priv
|
||||
}
|
||||
if dev.ListenPort != s.ListenPort {
|
||||
port := s.ListenPort
|
||||
cfg.ListenPort = &port
|
||||
}
|
||||
if cfg.PrivateKey == nil && cfg.ListenPort == nil && len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := k.wg.ConfigureDevice(s.Interface, cfg); err != nil {
|
||||
return fmt.Errorf("configure %s: %w", s.Interface, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameIPNets(a, b []net.IPNet) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
key := func(n net.IPNet) string { return n.String() }
|
||||
as, bs := make([]string, len(a)), make([]string, len(b))
|
||||
for i := range a {
|
||||
as[i], bs[i] = key(a[i]), key(b[i])
|
||||
}
|
||||
slices.Sort(as)
|
||||
slices.Sort(bs)
|
||||
return slices.Equal(as, bs)
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Apply(c *Config) error {
|
||||
link, err := k.ensureLink(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.syncAddrs(link, serverAddrs(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := k.syncDevice(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := netlink.LinkSetUp(link); err != nil {
|
||||
return fmt.Errorf("bring %s up: %w", c.Server.Interface, err)
|
||||
}
|
||||
// Forwarding is normally set by /etc/sysctl.d at install time; this only
|
||||
// succeeds when running as root.
|
||||
_ = os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0o644)
|
||||
if c.Server.IPv6Enabled {
|
||||
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
|
||||
}
|
||||
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false)))
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
|
||||
dev, err := k.wg.Device(iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PeerSample, 0, len(dev.Peers))
|
||||
for _, p := range dev.Peers {
|
||||
s := PeerSample{
|
||||
PublicKey: p.PublicKey.String(),
|
||||
RxBytes: p.ReceiveBytes,
|
||||
TxBytes: p.TransmitBytes,
|
||||
LastHandshake: p.LastHandshakeTime,
|
||||
}
|
||||
if p.Endpoint != nil {
|
||||
s.Endpoint = p.Endpoint.String()
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Uplink returns the configured uplink or the interface of the default route.
|
||||
func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
|
||||
if !v6 && c.Server.UplinkV4 != "" {
|
||||
return c.Server.UplinkV4
|
||||
}
|
||||
if v6 && c.Server.UplinkV6 != "" {
|
||||
return c.Server.UplinkV6
|
||||
}
|
||||
dst := net.ParseIP("1.1.1.1")
|
||||
if v6 {
|
||||
dst = net.ParseIP("2606:4700:4700::1111")
|
||||
}
|
||||
routes, err := netlink.RouteGet(dst)
|
||||
if err != nil || len(routes) == 0 {
|
||||
return ""
|
||||
}
|
||||
l, err := netlink.LinkByIndex(routes[0].LinkIndex)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return l.Attrs().Name
|
||||
}
|
||||
|
||||
// lanNetworks returns the private IPv4 networks on the uplink, used to block
|
||||
// peers from the server's LAN when LAN access is off.
|
||||
func lanNetworks(uplink string) []netip.Prefix {
|
||||
if uplink == "" {
|
||||
return nil
|
||||
}
|
||||
l, err := netlink.LinkByName(uplink)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4)
|
||||
var out []netip.Prefix
|
||||
for _, a := range addrs {
|
||||
if !a.IP.IsPrivate() {
|
||||
continue
|
||||
}
|
||||
ones, _ := a.Mask.Size()
|
||||
ip, _ := netip.AddrFromSlice(a.IP.To4())
|
||||
out = append(out, netip.PrefixFrom(ip, ones).Masked())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
var out []Check
|
||||
link, err := netlink.LinkByName(c.Server.Interface)
|
||||
if err != nil {
|
||||
out = append(out, Check{"WireGuard interface", false, c.Server.Interface + " does not exist"})
|
||||
} else {
|
||||
up := link.Attrs().Flags&net.FlagUp != 0
|
||||
out = append(out, Check{"WireGuard interface", up, c.Server.Interface + map[bool]string{true: " is up", false: " is down"}[up]})
|
||||
}
|
||||
fwd := readSysctl("/proc/sys/net/ipv4/ip_forward") == "1"
|
||||
out = append(out, Check{"IPv4 forwarding", fwd, "net.ipv4.ip_forward=" + readSysctl("/proc/sys/net/ipv4/ip_forward")})
|
||||
if c.Server.IPv6Enabled {
|
||||
v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
|
||||
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v})
|
||||
}
|
||||
ok, detail := firewallPresent()
|
||||
out = append(out, Check{"nftables rules", ok, detail})
|
||||
up4 := k.Uplink(c, false)
|
||||
out = append(out, Check{"Uplink", up4 != "", map[bool]string{true: "IPv4 via " + up4, false: "no default route found"}[up4 != ""]})
|
||||
return out
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Down(c *Config) error {
|
||||
var errs []error
|
||||
if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
|
||||
errs = append(errs, netlink.LinkDel(link))
|
||||
}
|
||||
errs = append(errs, removeFirewall())
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// simKernel stands in for the Linux kernel on other platforms. It does not
|
||||
// touch the system; it invents traffic for enabled peers so the web UI has
|
||||
// data during development.
|
||||
type simKernel struct {
|
||||
mu sync.Mutex
|
||||
peers map[string]*PeerSample
|
||||
}
|
||||
|
||||
func newKernel() (Kernel, error) {
|
||||
slog.Warn("not running on Linux: using the traffic simulator, no WireGuard interface is created")
|
||||
return &simKernel{peers: map[string]*PeerSample{}}, nil
|
||||
}
|
||||
|
||||
func (k *simKernel) Close() error { return nil }
|
||||
|
||||
func (k *simKernel) Apply(c *Config) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
keep := map[string]bool{}
|
||||
for i, p := range c.Peers {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
keep[p.PublicKey] = true
|
||||
if k.peers[p.PublicKey] == nil {
|
||||
k.peers[p.PublicKey] = &PeerSample{
|
||||
PublicKey: p.PublicKey,
|
||||
Endpoint: fmt.Sprintf("198.51.100.%d:%d", 10+i, 40000+i*7),
|
||||
}
|
||||
}
|
||||
}
|
||||
for key := range k.peers {
|
||||
if !keep[key] {
|
||||
delete(k.peers, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *simKernel) Sample(string) ([]PeerSample, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
var out []PeerSample
|
||||
i := 0
|
||||
for _, p := range k.peers {
|
||||
// Every third peer stays idle; the others move some data.
|
||||
if i%3 != 2 {
|
||||
p.TxBytes += rand.Int64N(40 << 20)
|
||||
p.RxBytes += rand.Int64N(6 << 20)
|
||||
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||
}
|
||||
out = append(out, *p)
|
||||
i++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *simKernel) Checks(c *Config) []Check {
|
||||
return []Check{
|
||||
{"WireGuard interface", true, "simulated (not Linux)"},
|
||||
{"IPv4 forwarding", true, "simulated"},
|
||||
{"nftables rules", true, "simulated"},
|
||||
{"Uplink", true, "IPv4 via eth0 (simulated)"},
|
||||
}
|
||||
}
|
||||
|
||||
func (k *simKernel) Uplink(c *Config, v6 bool) string {
|
||||
if v6 && c.Server.UplinkV6 != "" {
|
||||
return c.Server.UplinkV6
|
||||
}
|
||||
if !v6 && c.Server.UplinkV4 != "" {
|
||||
return c.Server.UplinkV4
|
||||
}
|
||||
return "eth0"
|
||||
}
|
||||
|
||||
func (k *simKernel) Down(*Config) error { return nil }
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var logLevel = new(slog.LevelVar)
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
var l slog.Level
|
||||
if err := l.UnmarshalText([]byte(s)); err != nil {
|
||||
return slog.LevelInfo
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// rotatingWriter appends to <app>.jsonl and rotates it to .1, .2, ... when it
|
||||
// grows past maxBytes, keeping maxFiles old files.
|
||||
type rotatingWriter struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
maxBytes int64
|
||||
maxFiles int
|
||||
f *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
func newRotatingWriter(path string, maxMB, maxFiles int) (*rotatingWriter, error) {
|
||||
w := &rotatingWriter{path: path, maxBytes: int64(maxMB) << 20, maxFiles: maxFiles}
|
||||
return w, w.open()
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) open() error {
|
||||
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
w.f, w.size = f, st.Size()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) rotate() error {
|
||||
w.f.Close()
|
||||
for i := w.maxFiles - 1; i >= 1; i-- {
|
||||
_ = os.Rename(fmt.Sprintf("%s.%d", w.path, i), fmt.Sprintf("%s.%d", w.path, i+1))
|
||||
}
|
||||
_ = os.Remove(fmt.Sprintf("%s.%d", w.path, w.maxFiles+1))
|
||||
if err := os.Rename(w.path, w.path+".1"); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.open()
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.size+int64(len(p)) > w.maxBytes && w.size > 0 {
|
||||
if err := w.rotate(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "log rotation failed:", err)
|
||||
}
|
||||
}
|
||||
n, err := w.f.Write(p)
|
||||
w.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *rotatingWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.f.Close()
|
||||
}
|
||||
|
||||
func setupLogging(path string, c LogConfig) (*rotatingWriter, error) {
|
||||
w, err := newRotatingWriter(path, c.MaxSizeMB, c.MaxFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logLevel.Set(parseLevel(c.Level))
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: logLevel})))
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// readLogTail returns up to limit lines, newest first. level filters to that
|
||||
// level and above; auditOnly keeps only change records.
|
||||
func readLogTail(path string, limit int, level string, auditOnly bool) ([]json.RawMessage, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
const window = 1 << 20
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
off := max(st.Size()-window, 0)
|
||||
if _, err := f.Seek(off, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := bytes.Split(b, []byte("\n"))
|
||||
if off > 0 && len(lines) > 0 {
|
||||
lines = lines[1:] // first line is probably cut
|
||||
}
|
||||
min := slog.Level(-100)
|
||||
if level != "" && level != "all" {
|
||||
min = parseLevel(level)
|
||||
}
|
||||
out := []json.RawMessage{}
|
||||
for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
line := bytes.TrimSpace(lines[i])
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var rec struct {
|
||||
Level string `json:"level"`
|
||||
Audit bool `json:"audit"`
|
||||
}
|
||||
if json.Unmarshal(line, &rec) != nil {
|
||||
continue
|
||||
}
|
||||
if auditOnly && !rec.Audit {
|
||||
continue
|
||||
}
|
||||
if parseLevel(strings.ToLower(rec.Level)) < min {
|
||||
continue
|
||||
}
|
||||
out = append(out, json.RawMessage(bytes.Clone(line)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Command GHOSTWIRE is a small WireGuard server manager with a web interface
|
||||
// and a JSON API. It keeps its whole state in config.json, applies it to the
|
||||
// kernel with netlink, wgctrl and nftables, and logs to <appName>.jsonl.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// appName names the binary, the log file (GHOSTWIRE.jsonl), the session
|
||||
// cookie and the nftables table. It is fixed on purpose.
|
||||
const appName = "GHOSTWIRE"
|
||||
|
||||
// serviceName is the systemd unit, system user and folder under /opt.
|
||||
const serviceName = "ghostwire"
|
||||
|
||||
var version = "0.1.0" // set with -ldflags "-X main.version=..."
|
||||
|
||||
func defaultConfigPath() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "config.json"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "config.json")
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Subcommands (install, update, ...) manage the installation; without
|
||||
// one, the binary runs the service.
|
||||
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
||||
if err := runCommand(os.Args[1], os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, appName+":", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
flag.Usage = usage
|
||||
configPath := flag.String("config", defaultConfigPath(), "path to config.json; logs and stats are kept next to it")
|
||||
passwd := flag.Bool("passwd", false, "set the admin password and exit")
|
||||
down := flag.Bool("down", false, "remove the WireGuard interface and firewall rules and exit")
|
||||
check := flag.Bool("check", false, "check that config.json is valid for this version and exit")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(appName, version)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
switch {
|
||||
case *passwd:
|
||||
if err = setPassword(*configPath); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "If the service is running: systemctl reload %s\n", serviceName)
|
||||
}
|
||||
case *down:
|
||||
err = teardown(*configPath)
|
||||
case *check:
|
||||
err = checkConfig(*configPath)
|
||||
default:
|
||||
err = run(*configPath)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, appName+":", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// checkConfig loads and validates config.json without changing it.
|
||||
func checkConfig(path string) error {
|
||||
c, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
fmt.Println("config OK")
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSecret(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return string(b), err
|
||||
}
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
return strings.TrimRight(line, "\r\n"), err
|
||||
}
|
||||
|
||||
func setPassword(path string) error {
|
||||
store, err := openStore(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readSecret("New admin password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePassword(pw); err != nil {
|
||||
return err
|
||||
}
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
again, err := readSecret("Repeat password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if again != pw {
|
||||
return errors.New("passwords do not match")
|
||||
}
|
||||
}
|
||||
hash, err := hashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Password for %q saved.\n", store.Get().Admin.Username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardown(path string) error {
|
||||
c, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, err := newKernel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
return k.Down(c)
|
||||
}
|
||||
|
||||
func run(configPath string) error {
|
||||
configPath, _ = filepath.Abs(configPath)
|
||||
dataDir := filepath.Dir(configPath)
|
||||
|
||||
store, err := openStore(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := store.Get()
|
||||
logPath := filepath.Join(dataDir, appName+".jsonl")
|
||||
logw, err := setupLogging(logPath, cfg.Log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log: %w", err)
|
||||
}
|
||||
defer logw.Close()
|
||||
slog.Info("starting", "version", version, "config", configPath)
|
||||
if cfg.Admin.PasswordHash == "" {
|
||||
slog.Warn("no admin password set; run: sudo " + installBin + " passwd")
|
||||
fmt.Fprintf(os.Stderr, "No admin password set. Run: sudo %s passwd\n", installBin)
|
||||
}
|
||||
|
||||
kernel, err := newKernel()
|
||||
if err != nil {
|
||||
slog.Error("kernel access failed", "err", err)
|
||||
return err
|
||||
}
|
||||
defer kernel.Close()
|
||||
|
||||
recon := newReconciler(kernel, store)
|
||||
_ = recon.ApplyNow() // errors are shown in the UI; the UI must stay reachable
|
||||
|
||||
stats, err := openStats(filepath.Join(dataDir, "stats.json"), store, kernel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open stats: %w", err)
|
||||
}
|
||||
|
||||
webTLS, err := setupTLS(cfg, dataDir)
|
||||
if err != nil {
|
||||
slog.Error("tls setup failed", "err", err)
|
||||
return fmt.Errorf("tls: %w", err)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
|
||||
|
||||
auth := newAuth(store)
|
||||
app := &App{
|
||||
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
||||
logPath: logPath, started: time.Now(), shutdown: shutdown,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); recon.Run(stop) }()
|
||||
go func() { defer wg.Done(); stats.Run(stop) }()
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
auth.sweep()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Web.Listen,
|
||||
Handler: app.routes(),
|
||||
TLSConfig: webTLS.Config,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
|
||||
}
|
||||
servers := []*http.Server{srv}
|
||||
errc := make(chan error, 2)
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.Web.Listen)
|
||||
if err != nil {
|
||||
slog.Error("listen failed", "addr", cfg.Web.Listen, "err", err)
|
||||
return fmt.Errorf("listen on %s: %w", cfg.Web.Listen, err)
|
||||
}
|
||||
go func() {
|
||||
if webTLS.Config != nil {
|
||||
errc <- srv.ServeTLS(ln, "", "")
|
||||
} else {
|
||||
errc <- srv.Serve(ln)
|
||||
}
|
||||
}()
|
||||
slog.Info("web interface listening", "addr", cfg.Web.Listen, "tls", cfg.Web.TLS.Mode)
|
||||
|
||||
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
|
||||
var h http.Handler = http.HandlerFunc(redirectToHTTPS(cfg.Web.Listen))
|
||||
if webTLS.ACME != nil {
|
||||
h = webTLS.ACME.HTTPHandler(h) // also answers http-01 challenges
|
||||
}
|
||||
hs := &http.Server{Addr: cfg.Web.HTTPListen, Handler: h, ReadHeaderTimeout: 10 * time.Second}
|
||||
servers = append(servers, hs)
|
||||
go func() {
|
||||
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("http listener failed", "addr", cfg.Web.HTTPListen, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
for running := true; running; {
|
||||
select {
|
||||
case s := <-sigs:
|
||||
if s == syscall.SIGHUP {
|
||||
if err := store.Reload(); err != nil {
|
||||
slog.Error("reload failed", "err", err)
|
||||
} else {
|
||||
logLevel.Set(parseLevel(store.Get().Log.Level))
|
||||
recon.Kick()
|
||||
slog.Info("config reloaded")
|
||||
}
|
||||
continue
|
||||
}
|
||||
slog.Info("stopping", "signal", s.String())
|
||||
running = false
|
||||
case <-stop:
|
||||
running = false
|
||||
case err := <-errc:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("web server failed", "err", err)
|
||||
shutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for _, s := range servers {
|
||||
_ = s.Shutdown(ctx)
|
||||
}
|
||||
wg.Wait()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
shutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for _, s := range servers {
|
||||
_ = s.Shutdown(ctx)
|
||||
}
|
||||
wg.Wait()
|
||||
slog.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func redirectToHTTPS(listen string) http.HandlerFunc {
|
||||
_, port, _ := net.SplitHostPort(listen)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
if port != "" && port != "443" {
|
||||
host = net.JoinHostPort(host, port)
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMapIPv6(t *testing.T) {
|
||||
got := mapIPv6(netip.MustParsePrefix("fd11:5ee:bad:c0de::/64"), netip.MustParseAddr("10.84.12.8"))
|
||||
if got.String() != "fd11:5ee:bad:c0de::a54:c08" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
c := &Config{}
|
||||
c.applyDefaults()
|
||||
c.Server.IPv4 = "10.84.12.0/24"
|
||||
if _, err := c.initServer(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Server.Endpoint = "vpn.example.net"
|
||||
return c
|
||||
}
|
||||
|
||||
func TestNextFreeIPv4(t *testing.T) {
|
||||
c := testConfig(t)
|
||||
ip, err := nextFreeIPv4(c)
|
||||
if err != nil || ip.String() != "10.84.12.2" {
|
||||
t.Fatalf("first free: %v %v", ip, err)
|
||||
}
|
||||
c.Peers = []Peer{{IPv4: "10.84.12.2"}, {IPv4: "10.84.12.4"}}
|
||||
ip, _ = nextFreeIPv4(c)
|
||||
if ip.String() != "10.84.12.3" {
|
||||
t.Fatalf("gap not reused: %v", ip)
|
||||
}
|
||||
c.Server.IPv4 = "10.84.12.0/30" // .1 server, .2 the only peer address
|
||||
c.Peers = []Peer{{IPv4: "10.84.12.2"}}
|
||||
if _, err := nextFreeIPv4(c); err == nil {
|
||||
t.Fatal("expected full network")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
c := testConfig(t)
|
||||
key, _ := newPrivateKey()
|
||||
ok := Peer{ID: "a", Name: "phone", IPv4: "10.84.12.2", PublicKey: key.PublicKey().String()}
|
||||
c.Peers = []Peer{ok}
|
||||
if err := c.validate(); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func(c *Config){
|
||||
"duplicate name": func(c *Config) {
|
||||
p := ok
|
||||
p.ID, p.IPv4, p.PublicKey = "b", "10.84.12.3", "x"
|
||||
c.Peers = append(c.Peers, p)
|
||||
},
|
||||
"duplicate ip": func(c *Config) { p := ok; p.ID, p.Name, p.PublicKey = "b", "other", "x"; c.Peers = append(c.Peers, p) },
|
||||
"server address": func(c *Config) { c.Peers[0].IPv4 = "10.84.12.1" },
|
||||
"outside network": func(c *Config) { c.Peers[0].IPv4 = "10.84.13.2" },
|
||||
"bad name": func(c *Config) { c.Peers[0].Name = "has space" },
|
||||
"digits only": func(c *Config) { c.Peers[0].Name = "1234" },
|
||||
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
|
||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||
} {
|
||||
cc := c.clone()
|
||||
mutate(cc)
|
||||
if err := cc.validate(); err == nil {
|
||||
t.Errorf("%s: expected an error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientConfig(t *testing.T) {
|
||||
c := testConfig(t)
|
||||
c.Server.IPv6Enabled = true
|
||||
c.Server.ClientDefaults.Keepalive = 25
|
||||
p := Peer{Name: "phone", IPv4: "10.84.12.8", PresharedKey: "psk="}
|
||||
out := clientConfig(c, &p, "priv=")
|
||||
for _, want := range []string{
|
||||
"PrivateKey = priv=",
|
||||
"Address = 10.84.12.8/24,fd11:5ee:bad:c0de::a54:c08/64",
|
||||
"DNS = 9.9.9.9, 149.112.112.112",
|
||||
"PresharedKey = psk=",
|
||||
"Endpoint = vpn.example.net:51820",
|
||||
"AllowedIPs = 0.0.0.0/0, ::/0",
|
||||
"PersistentKeepalive = 25",
|
||||
"PublicKey = " + serverPublicKey(c),
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("config lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "MTU") {
|
||||
t.Error("client config must not set an MTU")
|
||||
}
|
||||
zero := 0
|
||||
p.Keepalive = &zero
|
||||
if strings.Contains(clientConfig(c, &p, ""), "PersistentKeepalive") {
|
||||
t.Error("keepalive override 0 should remove the line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassword(t *testing.T) {
|
||||
h, err := hashPassword("correct horse battery")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !verifyPassword(h, "correct horse battery") || verifyPassword(h, "wrong password!") {
|
||||
t.Fatal("password verification is wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenumber(t *testing.T) {
|
||||
c := testConfig(t)
|
||||
c.Peers = []Peer{{IPv4: "10.84.12.7"}}
|
||||
c.Server.IPv4 = "172.20.5.0/24"
|
||||
if err := renumberPeers(c, "10.84.12.0/24"); err != nil || c.Peers[0].IPv4 != "172.20.5.7" {
|
||||
t.Fatalf("renumber: %v %v", c.Peers[0].IPv4, err)
|
||||
}
|
||||
c.Server.IPv4 = "172.20.5.0/30"
|
||||
if err := renumberPeers(c, "172.20.5.0/24"); err == nil {
|
||||
t.Fatal("expected too-small error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomSubnet(t *testing.T) {
|
||||
for range 50 {
|
||||
n, err := randomSubnet(24)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !n.Addr().IsPrivate() || overlapsAny(n, avoidedSubnets) {
|
||||
t.Fatalf("bad subnet %s", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKernel records applies and returns scripted counters.
|
||||
type fakeKernel struct {
|
||||
applied int
|
||||
samples []PeerSample
|
||||
}
|
||||
|
||||
func (k *fakeKernel) Apply(*Config) error { k.applied++; return nil }
|
||||
func (k *fakeKernel) Sample(string) ([]PeerSample, error) { return k.samples, nil }
|
||||
func (k *fakeKernel) Checks(*Config) []Check { return []Check{{"fake", true, ""}} }
|
||||
func (k *fakeKernel) Uplink(*Config, bool) string { return "eth0" }
|
||||
func (k *fakeKernel) Down(*Config) error { return nil }
|
||||
func (k *fakeKernel) Close() error { return nil }
|
||||
|
||||
func TestStatsDeltas(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := newPrivateKey()
|
||||
pub := key.PublicKey().String()
|
||||
if err := store.Update(func(c *Config) error {
|
||||
c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String(), PublicKey: pub, Enabled: true})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k := &fakeKernel{}
|
||||
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
|
||||
|
||||
hs := time.Now()
|
||||
step := func(rx, tx int64) {
|
||||
k.samples = []PeerSample{{PublicKey: pub, RxBytes: rx, TxBytes: tx, LastHandshake: hs}}
|
||||
st.sample()
|
||||
}
|
||||
step(100, 1000)
|
||||
step(150, 1500) // +50 / +500
|
||||
step(20, 30) // counter reset: counts 20 / 30
|
||||
s := st.Summary("p1")
|
||||
if s.UpTotal != 170 || s.DownTotal != 1530 {
|
||||
t.Fatalf("totals: up %d down %d", s.UpTotal, s.DownTotal)
|
||||
}
|
||||
if s.Down24h != 1530 || !s.Online {
|
||||
t.Fatalf("24h %d online %v", s.Down24h, s.Online)
|
||||
}
|
||||
st.save()
|
||||
st2, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
|
||||
if st2.Summary("p1").DownTotal != 1530 {
|
||||
t.Fatal("stats not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI runs a full flow over HTTP: login, create, list, patch, issue,
|
||||
// disable, delete, tokens.
|
||||
func TestAPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := hashPassword("a long test password")
|
||||
_ = store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; c.Server.Endpoint = "vpn.example.net"; return nil })
|
||||
k := &fakeKernel{}
|
||||
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
|
||||
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
|
||||
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
|
||||
srv := httptest.NewServer(app.routes())
|
||||
defer srv.Close()
|
||||
jar, _ := cookiejar.New(nil)
|
||||
cl := &http.Client{Jar: jar}
|
||||
|
||||
call := func(method, path string, body any, want int) map[string]any {
|
||||
t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
call("GET", "/peers", nil, 401)
|
||||
call("POST", "/auth/login", map[string]string{"username": "admin", "password": "wrong"}, 401)
|
||||
call("POST", "/auth/login", map[string]string{"username": "admin", "password": "a long test password"}, 200)
|
||||
|
||||
created := call("POST", "/peers", map[string]any{"name": "phone-alex", "note": "iPhone"}, 201)
|
||||
peer := created["peer"].(map[string]any)
|
||||
id := peer["id"].(string)
|
||||
if peer["ipv4"] != serverIPv4(netip.MustParsePrefix(store.Get().Server.IPv4)).Next().String() {
|
||||
t.Fatalf("unexpected address %v", peer["ipv4"])
|
||||
}
|
||||
conf := created["config"].(string)
|
||||
if !strings.Contains(conf, "PrivateKey = ") || strings.Contains(conf, "<the private key") || created["qr"] == "" {
|
||||
t.Fatal("issued config lacks the private key or QR")
|
||||
}
|
||||
// The private key must not be stored anywhere in config.json.
|
||||
priv := strings.TrimSpace(strings.SplitN(strings.SplitN(conf, "PrivateKey = ", 2)[1], "\n", 2)[0])
|
||||
raw, _ := json.Marshal(store.Get())
|
||||
if bytes.Contains(raw, []byte(priv)) {
|
||||
t.Fatal("client private key was stored")
|
||||
}
|
||||
|
||||
call("POST", "/peers", map[string]any{"name": "phone-alex"}, 400) // duplicate name
|
||||
call("POST", "/peers", map[string]any{"name": "bad name"}, 400)
|
||||
|
||||
call("PATCH", "/peers/"+id, map[string]any{"name": "phone-a", "keepalive": 0, "dns": []string{"1.1.1.1"}}, 200)
|
||||
got := call("GET", "/peers/"+id, nil, 200)
|
||||
if got["name"] != "phone-a" || got["effectiveKeepalive"].(float64) != 0 {
|
||||
t.Fatalf("patch not applied: %v", got)
|
||||
}
|
||||
call("PATCH", "/peers/"+id, map[string]any{"dns": nil}, 200)
|
||||
if call("GET", "/peers/"+id, nil, 200)["dns"] != nil {
|
||||
t.Fatal("dns override not cleared")
|
||||
}
|
||||
|
||||
oldKey := got["publicKey"]
|
||||
re := call("POST", "/peers/"+id+"/issue-config", nil, 200)
|
||||
if re["peer"].(map[string]any)["publicKey"] == oldKey {
|
||||
t.Fatal("issue-config did not change the key")
|
||||
}
|
||||
|
||||
call("POST", "/peers/"+id+"/disable", nil, 200)
|
||||
if call("GET", "/peers/"+id, nil, 200)["enabled"] != false {
|
||||
t.Fatal("not disabled")
|
||||
}
|
||||
call("GET", "/peers/"+id+"/stats?range=7d", nil, 200)
|
||||
call("GET", "/status", nil, 200)
|
||||
|
||||
call("PATCH", "/server", map[string]any{"listenPort": 51821, "clientDefaults": map[string]any{"dns": []string{"1.1.1.1"}, "allowedIPs": []string{"0.0.0.0/0"}, "keepalive": 25}}, 200)
|
||||
if store.Get().Server.ListenPort != 51821 {
|
||||
t.Fatal("server patch not saved")
|
||||
}
|
||||
call("PATCH", "/server", map[string]any{"mtu": 100}, 400)
|
||||
|
||||
tok := call("POST", "/tokens", map[string]string{"name": "iPhone app", "scope": "ro"}, 201)
|
||||
secret := tok["token"].(string)
|
||||
|
||||
// Read-only token: GET works, changes are refused, admin endpoints too.
|
||||
bearer := func(method, path string, want int) {
|
||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("token %s %s: %d, want %d", method, path, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
bearer("GET", "/peers", 200)
|
||||
bearer("DELETE", "/peers/"+id, 403)
|
||||
bearer("GET", "/tokens", 403)
|
||||
|
||||
call("DELETE", "/peers/"+id, nil, 200)
|
||||
if len(store.Get().Peers) != 0 {
|
||||
t.Fatal("peer not deleted")
|
||||
}
|
||||
if k.applied == 0 {
|
||||
t.Fatal("kernel never applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
a, b string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"0.2.0", "0.1.0", 1, true},
|
||||
{"v0.1.0", "0.1.0", 0, true},
|
||||
{"0.1.0-4-gabc123", "0.1.1", -1, true},
|
||||
{"1.10.0", "1.9.3", 1, true},
|
||||
{"abc123", "0.1.0", 0, false},
|
||||
} {
|
||||
got, ok := compareVersions(c.a, c.b)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("compare(%q, %q) = %d %v, want %d %v", c.a, c.b, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitFile(t *testing.T) {
|
||||
u := unitFile()
|
||||
for _, want := range []string{
|
||||
"User=ghostwire",
|
||||
"ExecStart=/opt/ghostwire/GHOSTWIRE -config /opt/ghostwire/config.json",
|
||||
"ReadWritePaths=/opt/ghostwire",
|
||||
"AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE",
|
||||
} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Errorf("unit lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteIfChanged(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "x.conf")
|
||||
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
||||
t.Fatal("first write should change", err)
|
||||
}
|
||||
if ch, _ := writeIfChanged(p, "a\n", 0o644); ch {
|
||||
t.Fatal("same content should not change")
|
||||
}
|
||||
if ch, _ := writeIfChanged(p, "b\n", 0o644); !ch {
|
||||
t.Fatal("new content should change")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// The binary installs, updates and removes itself. These commands need root;
|
||||
// the service they set up runs as the unprivileged user serviceName.
|
||||
|
||||
var (
|
||||
installDir = "/opt/" + serviceName
|
||||
installBin = filepath.Join(installDir, appName)
|
||||
oldBin = installBin + ".old" // exists only while an update runs
|
||||
configFile = filepath.Join(installDir, "config.json")
|
||||
unitPath = "/etc/systemd/system/" + serviceName + ".service"
|
||||
sysctlPath = "/etc/sysctl.d/99-" + serviceName + ".conf"
|
||||
modulesPath = "/etc/modules-load.d/" + serviceName + ".conf"
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager
|
||||
|
||||
Usage (as root):
|
||||
%s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host]
|
||||
set up user, folder, config, sysctls and systemd service; start it
|
||||
%s update [-force]
|
||||
replace the installed binary with this one and restart
|
||||
%s uninstall [-purge] [-y]
|
||||
remove the service, interface and firewall table (-purge also deletes %s)
|
||||
%s passwd
|
||||
set the admin password of the installed service
|
||||
%s version
|
||||
|
||||
Without a command it runs the service:
|
||||
%s [-config path] [-passwd] [-check] [-down]
|
||||
`, appName, version, appName, appName, appName, installDir, appName, appName, appName)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func runCommand(cmd string, args []string) error {
|
||||
switch cmd {
|
||||
case "install":
|
||||
return cmdInstall(args)
|
||||
case "update":
|
||||
return cmdUpdate(args)
|
||||
case "uninstall":
|
||||
return cmdUninstall(args)
|
||||
case "passwd":
|
||||
return cmdPasswd(args)
|
||||
case "version":
|
||||
fmt.Println(appName, version)
|
||||
return nil
|
||||
case "help":
|
||||
usage()
|
||||
return nil
|
||||
}
|
||||
usage()
|
||||
return fmt.Errorf("unknown command %q", cmd)
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func step(format string, a ...any) { fmt.Printf("• "+format+"\n", a...) }
|
||||
|
||||
func requireRoot() error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return errors.New("this command only works on Linux")
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("run as root, e.g. with sudo")
|
||||
}
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return errors.New("systemd is required (systemctl not found)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sh(name string, args ...string) error {
|
||||
out, err := exec.Command(name, args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shOut(name string, args ...string) string {
|
||||
out, _ := exec.Command(name, args...).Output()
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func serviceUser() (uid, gid int, err error) {
|
||||
u, err := user.Lookup(serviceName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
uid, _ = strconv.Atoi(u.Uid)
|
||||
gid, _ = strconv.Atoi(u.Gid)
|
||||
return uid, gid, nil
|
||||
}
|
||||
|
||||
func fileHash(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
_, _ = io.Copy(h, f)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// copyFile writes src to dst through a temp file and a rename, so a running
|
||||
// binary is never overwritten in place ("text file busy") or half-written.
|
||||
func copyFile(src, dst string, mode os.FileMode, uid, gid int) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, in); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chown(uid, gid); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
// writeIfChanged writes content to path when it differs and reports whether
|
||||
// it did.
|
||||
func writeIfChanged(path, content string, mode os.FileMode) (bool, error) {
|
||||
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, []byte(content)) {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, os.WriteFile(path, []byte(content), mode)
|
||||
}
|
||||
|
||||
// installedVersion asks the installed binary for its version.
|
||||
func installedVersion(path string) string {
|
||||
f := strings.Fields(shOut(path, "-version"))
|
||||
if len(f) == 2 {
|
||||
return f[1]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// compareVersions compares "1.2.3" style versions (an optional "v" prefix
|
||||
// and a git-describe suffix like "-4-gabc123" are ignored). ok is false when
|
||||
// either side is not such a version.
|
||||
func compareVersions(a, b string) (cmp int, ok bool) {
|
||||
parse := func(s string) ([3]int, bool) {
|
||||
var v [3]int
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
if i := strings.IndexByte(s, '-'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return v, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return v, false
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
va, ok1 := parse(a)
|
||||
vb, ok2 := parse(b)
|
||||
if !ok1 || !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
for i := range 3 {
|
||||
if va[i] != vb[i] {
|
||||
if va[i] < vb[i] {
|
||||
return -1, true
|
||||
}
|
||||
return 1, true
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// ---------- system files ----------
|
||||
|
||||
func unitFile() string {
|
||||
return fmt.Sprintf(`# Written by %[1]s %[5]s. Changes are overwritten by "%[1]s update".
|
||||
[Unit]
|
||||
Description=%[1]s WireGuard manager (web interface and API)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%[2]s
|
||||
Group=%[2]s
|
||||
WorkingDirectory=%[3]s
|
||||
ExecStart=%[4]s -config %[3]s/config.json
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
# Exit code 0 is used by "Restart now" in the web interface.
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
# Runs unprivileged: CAP_NET_ADMIN for netlink, wgctrl and nftables,
|
||||
# CAP_NET_BIND_SERVICE for ports 443 and 80.
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=yes
|
||||
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=%[3]s
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_UNIX
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallArchitectures=native
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, appName, serviceName, installDir, installBin, unitVersion)
|
||||
}
|
||||
|
||||
// unitVersion changes only when the unit text changes, so "update" does not
|
||||
// rewrite the unit for every release.
|
||||
const unitVersion = "unit-1"
|
||||
|
||||
const sysctlConf = "net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\n"
|
||||
|
||||
// writeSystemFiles writes the unit, sysctl and module files. It reports
|
||||
// whether the unit changed (systemd must then reload).
|
||||
func writeSystemFiles() (unitChanged bool, err error) {
|
||||
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf, 0o644)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if sysChanged {
|
||||
step("Enabling IP forwarding (%s)", sysctlPath)
|
||||
if err := sh("sysctl", "-p", sysctlPath); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if _, err := writeIfChanged(modulesPath, "wireguard\n", 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := sh("modprobe", "wireguard"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " warning: could not load the wireguard kernel module:", err)
|
||||
}
|
||||
if unitChanged {
|
||||
step("Writing %s", unitPath)
|
||||
if err := sh("systemctl", "daemon-reload"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return unitChanged, nil
|
||||
}
|
||||
|
||||
// restartAndVerify restarts the service and checks that it stays up: the
|
||||
// main process must still be the same a few seconds later (Restart=always
|
||||
// would otherwise hide a crash loop).
|
||||
func restartAndVerify() error {
|
||||
if err := sh("systemctl", "restart", serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
pid1 := shOut("systemctl", "show", "-p", "MainPID", "--value", serviceName)
|
||||
time.Sleep(3 * time.Second)
|
||||
state := shOut("systemctl", "is-active", serviceName)
|
||||
pid2 := shOut("systemctl", "show", "-p", "MainPID", "--value", serviceName)
|
||||
if state != "active" || pid1 == "0" || pid1 == "" || pid1 != pid2 {
|
||||
return fmt.Errorf("the service did not stay running (state %s); see: journalctl -u %s -n 50", state, serviceName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- install ----------
|
||||
|
||||
func cmdInstall(args []string) error {
|
||||
fs := flag.NewFlagSet("install", flag.ExitOnError)
|
||||
domain := fs.String("domain", "", "domain for the web interface; enables Let's Encrypt")
|
||||
email := fs.String("email", "", "contact email for Let's Encrypt (optional)")
|
||||
endpoint := fs.String("endpoint", "", "host or IP clients connect to (default: the domain)")
|
||||
_ = fs.Parse(args)
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// User and folder
|
||||
if _, err := user.Lookup(serviceName); err != nil {
|
||||
step("Creating system user %s", serviceName)
|
||||
shell := "/usr/sbin/nologin"
|
||||
for _, p := range []string{"/usr/sbin/nologin", "/sbin/nologin", "/bin/false"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
shell = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := sh("useradd", "--system", "--home-dir", installDir, "--no-create-home", "--shell", shell, serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
uid, gid, err := serviceUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(installDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chown(installDir, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(installDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Binary
|
||||
if self != installBin {
|
||||
step("Installing %s %s to %s", appName, version, installBin)
|
||||
if err := copyFile(self, installBin, 0o755, 0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Config: created with defaults (server key, free subnet) if missing.
|
||||
_, statErr := os.Stat(configFile)
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
step("Creating %s", configFile)
|
||||
if err := os.WriteFile(configFile, []byte("{}\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Chown(configFile, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := openStore(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *domain != "" || *email != "" || *endpoint != "" {
|
||||
step("Saving domain and endpoint settings")
|
||||
if err := store.Update(func(c *Config) error {
|
||||
if *domain != "" {
|
||||
c.Web.TLS.Mode, c.Web.TLS.Domain = "acme", *domain
|
||||
if c.Server.Endpoint == "" {
|
||||
c.Server.Endpoint = *domain
|
||||
}
|
||||
}
|
||||
if *email != "" {
|
||||
c.Web.TLS.Email = *email
|
||||
}
|
||||
if *endpoint != "" {
|
||||
c.Server.Endpoint = *endpoint
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := writeSystemFiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if store.Get().Admin.PasswordHash == "" {
|
||||
fmt.Println("\nChoose the admin password for the web interface (user \"admin\", at least 12 characters).")
|
||||
if err := setPassword(configFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Everything in the folder belongs to the service user.
|
||||
if err := chownTree(installDir, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
step("Starting %s", serviceName)
|
||||
if err := sh("systemctl", "enable", serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := restartAndVerify(); err != nil {
|
||||
return err
|
||||
}
|
||||
printWhereToGo(store.Get())
|
||||
return nil
|
||||
}
|
||||
|
||||
func chownTree(root string, uid, gid int) error {
|
||||
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Lchown(p, uid, gid)
|
||||
})
|
||||
}
|
||||
|
||||
func printWhereToGo(c *Config) {
|
||||
host := c.Web.TLS.Domain
|
||||
if host == "" {
|
||||
host = c.Server.Endpoint
|
||||
}
|
||||
if host == "" {
|
||||
host = "<server address>"
|
||||
}
|
||||
scheme, port := "https", c.Web.Listen
|
||||
if c.Web.TLS.Mode == "off" {
|
||||
scheme = "http"
|
||||
}
|
||||
if _, p, _ := strings.Cut(port, ":"); p != "" && p != "443" && p != "80" {
|
||||
host += ":" + p
|
||||
}
|
||||
fmt.Printf("\nDone. %s %s is running.\n Web interface: %s://%s/\n", appName, version, scheme, host)
|
||||
switch c.Web.TLS.Mode {
|
||||
case "acme":
|
||||
fmt.Println(" The Let's Encrypt certificate is requested on the first visit; ports 443 (and 80) must be reachable.")
|
||||
case "selfsigned":
|
||||
fmt.Println(" It uses a self-signed certificate, so the browser shows a warning the first time.")
|
||||
}
|
||||
fmt.Printf(" WireGuard: UDP %d · Log: %s/%s.jsonl · Status: systemctl status %s\n", c.Server.ListenPort, installDir, appName, serviceName)
|
||||
}
|
||||
|
||||
// ---------- update ----------
|
||||
|
||||
func requireInstalled() error {
|
||||
if _, err := os.Stat(installBin); err != nil {
|
||||
return fmt.Errorf("%s is not installed; run: sudo ./%s install", appName, appName)
|
||||
}
|
||||
if _, err := os.Stat(unitPath); err != nil {
|
||||
return fmt.Errorf("%s is missing; run: sudo ./%s install", unitPath, appName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdUpdate(args []string) error {
|
||||
fs := flag.NewFlagSet("update", flag.ExitOnError)
|
||||
force := fs.Bool("force", false, "install even if it is the same build or an older version")
|
||||
_ = fs.Parse(args)
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireInstalled(); err != nil {
|
||||
return err
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if self, _ = filepath.EvalSymlinks(self); self == installBin {
|
||||
return fmt.Errorf("run update from the new binary, e.g.: sudo /tmp/%s update", appName)
|
||||
}
|
||||
|
||||
// 1. Is this an update?
|
||||
oldVersion := installedVersion(installBin)
|
||||
if fileHash(self) == fileHash(installBin) && !*force {
|
||||
fmt.Printf("Already up to date (%s %s).\n", appName, oldVersion)
|
||||
return nil
|
||||
}
|
||||
if c, ok := compareVersions(version, oldVersion); ok && c < 0 && !*force {
|
||||
return fmt.Errorf("this is %s, older than the installed %s; use -force to downgrade", version, oldVersion)
|
||||
}
|
||||
fmt.Printf("Updating %s %s → %s\n", appName, oldVersion, version)
|
||||
|
||||
// 2. Can this version read the current config?
|
||||
if c, err := loadConfigFile(configFile); err != nil {
|
||||
return fmt.Errorf("this version cannot read %s, nothing changed: %w", configFile, err)
|
||||
} else if err := c.validate(); err != nil {
|
||||
return fmt.Errorf("this version rejects %s, nothing changed: %w", configFile, err)
|
||||
}
|
||||
|
||||
// 3. Back up the config, and keep the old binary until the new one runs.
|
||||
uid, gid, err := serviceUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup := configFile + ".bak-" + oldVersion
|
||||
step("Backing up config to %s", backup)
|
||||
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyFile(installBin, oldBin, 0o755, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(oldBin)
|
||||
|
||||
// 4. Install.
|
||||
step("Installing the new binary")
|
||||
if err := copyFile(self, installBin, 0o755, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writeSystemFiles(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. Restart. If the new version does not stay up, put the old one back
|
||||
// so the web interface stays reachable.
|
||||
step("Restarting %s", serviceName)
|
||||
if err := restartAndVerify(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " The new version failed to start:", err)
|
||||
step("Restoring %s %s", appName, oldVersion)
|
||||
if rErr := copyFile(oldBin, installBin, 0o755, uid, gid); rErr != nil {
|
||||
return fmt.Errorf("update failed and restoring the old binary failed too: %v (original error: %w)", rErr, err)
|
||||
}
|
||||
if rErr := restartAndVerify(); rErr != nil {
|
||||
return fmt.Errorf("update failed and the old version does not start either: %v (original error: %w)", rErr, err)
|
||||
}
|
||||
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
|
||||
}
|
||||
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- uninstall ----------
|
||||
|
||||
func cmdUninstall(args []string) error {
|
||||
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
||||
purge := fs.Bool("purge", false, "also delete "+installDir+" (config, keys, logs) and the user "+serviceName)
|
||||
yes := fs.Bool("y", false, "do not ask for confirmation")
|
||||
_ = fs.Parse(args)
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
if *purge && !*yes {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return errors.New("-purge deletes all peers and keys; add -y to confirm")
|
||||
}
|
||||
fmt.Printf("This deletes %s with all peers, keys and logs. Type \"delete\" to continue: ", installDir)
|
||||
var answer string
|
||||
_, _ = fmt.Scanln(&answer)
|
||||
if answer != "delete" {
|
||||
return errors.New("cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
step("Stopping %s", serviceName)
|
||||
_ = sh("systemctl", "disable", "--now", serviceName)
|
||||
|
||||
step("Removing the WireGuard interface and firewall table")
|
||||
c, err := loadConfigFile(configFile)
|
||||
if err != nil {
|
||||
c = &Config{}
|
||||
c.applyDefaults()
|
||||
}
|
||||
if k, err := newKernel(); err == nil {
|
||||
if err := k.Down(c); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " warning:", err)
|
||||
}
|
||||
k.Close()
|
||||
}
|
||||
|
||||
step("Removing system files")
|
||||
for _, p := range []string{unitPath, sysctlPath, modulesPath} {
|
||||
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = sh("systemctl", "daemon-reload")
|
||||
|
||||
if *purge {
|
||||
step("Deleting %s and the user %s", installDir, serviceName)
|
||||
if err := os.RemoveAll(installDir); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = sh("userdel", serviceName)
|
||||
fmt.Println("\nRemoved everything.")
|
||||
} else {
|
||||
fmt.Printf("\nRemoved. Config, keys and logs are still in %s (uninstall -purge deletes them).\n", installDir)
|
||||
}
|
||||
fmt.Println("IP forwarding stays on until reboot; run 'sysctl -w net.ipv4.ip_forward=0' if nothing else needs it.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- passwd ----------
|
||||
|
||||
func cmdPasswd(args []string) error {
|
||||
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
|
||||
path := fs.String("config", configFile, "config.json to change")
|
||||
_ = fs.Parse(args)
|
||||
if os.Geteuid() != 0 && runtime.GOOS == "linux" {
|
||||
return errors.New("run as root, e.g. with sudo")
|
||||
}
|
||||
if err := setPassword(*path); err != nil {
|
||||
return err
|
||||
}
|
||||
if *path == configFile && shOut("systemctl", "is-active", serviceName) == "active" {
|
||||
if err := sh("systemctl", "reload", serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("The running service uses the new password now.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Traffic is counted as deltas between samples of the kernel counters, so a
|
||||
// counter reset (interface re-created, peer re-enabled, new key) never makes
|
||||
// numbers jump backwards. Counters are from the server's view: Rx is what the
|
||||
// server received = the peer's upload, Tx is the peer's download.
|
||||
|
||||
const (
|
||||
sampleInterval = 30 * time.Second
|
||||
saveInterval = 5 * time.Minute
|
||||
keepHours = 48
|
||||
keepDays = 400
|
||||
onlineWindow = 3 * time.Minute
|
||||
)
|
||||
|
||||
type bucket struct {
|
||||
T int64 `json:"t"` // unix start of the hour or local day
|
||||
Rx int64 `json:"rx"`
|
||||
Tx int64 `json:"tx"`
|
||||
}
|
||||
|
||||
type peerStats struct {
|
||||
LastRx int64 `json:"lastRx"` // last raw counter values
|
||||
LastTx int64 `json:"lastTx"`
|
||||
TotalRx int64 `json:"totalRx"`
|
||||
TotalTx int64 `json:"totalTx"`
|
||||
LastHandshake time.Time `json:"lastHandshake"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Hourly []bucket `json:"hourly"`
|
||||
Daily []bucket `json:"daily"`
|
||||
}
|
||||
|
||||
type statsFile struct {
|
||||
Version int `json:"version"`
|
||||
Peers map[string]*peerStats `json:"peers"` // by peer ID
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
data statsFile
|
||||
dirty bool
|
||||
store *Store
|
||||
kernel Kernel
|
||||
}
|
||||
|
||||
func openStats(path string, store *Store, k Kernel) (*Stats, error) {
|
||||
s := &Stats{path: path, store: store, kernel: k, data: statsFile{Version: 1, Peers: map[string]*peerStats{}}}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &s.data); err != nil {
|
||||
slog.Error("stats file is damaged, starting fresh", "path", path, "err", err)
|
||||
s.data = statsFile{Version: 1, Peers: map[string]*peerStats{}}
|
||||
}
|
||||
if s.data.Peers == nil {
|
||||
s.data.Peers = map[string]*peerStats{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func hourStart(t time.Time) int64 { return t.Truncate(time.Hour).Unix() }
|
||||
|
||||
func dayStart(t time.Time) int64 {
|
||||
y, m, d := t.Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, t.Location()).Unix()
|
||||
}
|
||||
|
||||
func addTo(list []bucket, start, rx, tx int64, keep int) []bucket {
|
||||
if n := len(list); n > 0 && list[n-1].T == start {
|
||||
list[n-1].Rx += rx
|
||||
list[n-1].Tx += tx
|
||||
return list
|
||||
}
|
||||
list = append(list, bucket{T: start, Rx: rx, Tx: tx})
|
||||
if len(list) > keep {
|
||||
list = list[len(list)-keep:]
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (s *Stats) sample() {
|
||||
cfg := s.store.Get()
|
||||
samples, err := s.kernel.Sample(cfg.Server.Interface)
|
||||
if err != nil {
|
||||
slog.Debug("stats sample failed", "err", err)
|
||||
return
|
||||
}
|
||||
idByKey := map[string]string{}
|
||||
exists := map[string]bool{}
|
||||
for _, p := range cfg.Peers {
|
||||
idByKey[p.PublicKey] = p.ID
|
||||
exists[p.ID] = true
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, smp := range samples {
|
||||
id := idByKey[smp.PublicKey]
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
ps := s.data.Peers[id]
|
||||
if ps == nil {
|
||||
ps = &peerStats{}
|
||||
s.data.Peers[id] = ps
|
||||
}
|
||||
dRx, dTx := smp.RxBytes-ps.LastRx, smp.TxBytes-ps.LastTx
|
||||
if dRx < 0 || dTx < 0 { // counters were reset
|
||||
dRx, dTx = smp.RxBytes, smp.TxBytes
|
||||
}
|
||||
ps.LastRx, ps.LastTx = smp.RxBytes, smp.TxBytes
|
||||
if dRx > 0 || dTx > 0 {
|
||||
ps.TotalRx += dRx
|
||||
ps.TotalTx += dTx
|
||||
ps.Hourly = addTo(ps.Hourly, hourStart(now), dRx, dTx, keepHours)
|
||||
ps.Daily = addTo(ps.Daily, dayStart(now), dRx, dTx, keepDays)
|
||||
}
|
||||
if !smp.LastHandshake.IsZero() {
|
||||
ps.LastHandshake = smp.LastHandshake
|
||||
}
|
||||
if smp.Endpoint != "" {
|
||||
ps.Endpoint = smp.Endpoint
|
||||
}
|
||||
s.dirty = true
|
||||
}
|
||||
for id := range s.data.Peers {
|
||||
if !exists[id] {
|
||||
delete(s.data.Peers, id)
|
||||
s.dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stats) save() {
|
||||
s.mu.Lock()
|
||||
if !s.dirty {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(s.data)
|
||||
s.dirty = false
|
||||
s.mu.Unlock()
|
||||
var raw json.RawMessage = b
|
||||
if err := writeFileAtomic(s.path, raw, 0o600); err != nil {
|
||||
slog.Error("saving stats failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stats) Run(stop <-chan struct{}) {
|
||||
s.sample()
|
||||
st := time.NewTicker(sampleInterval)
|
||||
sv := time.NewTicker(saveInterval)
|
||||
defer st.Stop()
|
||||
defer sv.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
s.sample()
|
||||
s.save()
|
||||
return
|
||||
case <-st.C:
|
||||
s.sample()
|
||||
case <-sv.C:
|
||||
s.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Point is one bar of a traffic chart, from the peer's point of view.
|
||||
type Point struct {
|
||||
T int64 `json:"t"`
|
||||
Down int64 `json:"down"`
|
||||
Up int64 `json:"up"`
|
||||
}
|
||||
|
||||
// series returns n points ending with the current hour or day, filling gaps
|
||||
// with zeros. ids selects peers; nil means all peers.
|
||||
func (s *Stats) series(ids []string, rng string) []Point {
|
||||
now := time.Now()
|
||||
var starts []int64
|
||||
daily := rng != "24h"
|
||||
n := map[string]int{"24h": 24, "7d": 7, "30d": 30, "90d": 90}[rng]
|
||||
if n == 0 {
|
||||
n, daily = 24, false
|
||||
}
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if daily {
|
||||
y, m, d := now.Date()
|
||||
starts = append(starts, time.Date(y, m, d-i, 0, 0, 0, 0, now.Location()).Unix())
|
||||
} else {
|
||||
starts = append(starts, now.Truncate(time.Hour).Add(-time.Duration(i)*time.Hour).Unix())
|
||||
}
|
||||
}
|
||||
idx := map[int64]int{}
|
||||
pts := make([]Point, n)
|
||||
for i, t := range starts {
|
||||
pts[i].T = t
|
||||
idx[t] = i
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
add := func(ps *peerStats) {
|
||||
list := ps.Hourly
|
||||
if daily {
|
||||
list = ps.Daily
|
||||
}
|
||||
for _, b := range list {
|
||||
if i, ok := idx[b.T]; ok {
|
||||
pts[i].Down += b.Tx
|
||||
pts[i].Up += b.Rx
|
||||
}
|
||||
}
|
||||
}
|
||||
if ids == nil {
|
||||
for _, ps := range s.data.Peers {
|
||||
add(ps)
|
||||
}
|
||||
} else {
|
||||
for _, id := range ids {
|
||||
if ps := s.data.Peers[id]; ps != nil {
|
||||
add(ps)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// PeerSummary is the live state shown in peer lists.
|
||||
type PeerSummary struct {
|
||||
Online bool `json:"online"`
|
||||
LastHandshake *time.Time `json:"lastHandshake"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Down24h int64 `json:"down24h"`
|
||||
Up24h int64 `json:"up24h"`
|
||||
Down30d int64 `json:"down30d"`
|
||||
Up30d int64 `json:"up30d"`
|
||||
DownTotal int64 `json:"downTotal"`
|
||||
UpTotal int64 `json:"upTotal"`
|
||||
}
|
||||
|
||||
func sumPoints(pts []Point) (down, up int64) {
|
||||
for _, p := range pts {
|
||||
down += p.Down
|
||||
up += p.Up
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Stats) Summary(id string) PeerSummary {
|
||||
var out PeerSummary
|
||||
out.Down24h, out.Up24h = sumPoints(s.series([]string{id}, "24h"))
|
||||
out.Down30d, out.Up30d = sumPoints(s.series([]string{id}, "30d"))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if ps := s.data.Peers[id]; ps != nil {
|
||||
out.DownTotal, out.UpTotal = ps.TotalTx, ps.TotalRx
|
||||
out.Endpoint = ps.Endpoint
|
||||
if !ps.LastHandshake.IsZero() {
|
||||
t := ps.LastHandshake
|
||||
out.LastHandshake = &t
|
||||
out.Online = time.Since(t) < onlineWindow
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Forget drops a peer's live counters, e.g. after its key changed.
|
||||
func (s *Stats) Forget(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if ps := s.data.Peers[id]; ps != nil {
|
||||
ps.LastRx, ps.LastTx = 0, 0
|
||||
ps.Endpoint = ""
|
||||
s.dirty = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
const letsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
|
||||
// webTLS is what the HTTP server needs for the configured TLS mode.
|
||||
type webTLS struct {
|
||||
Config *tls.Config // nil when mode is "off"
|
||||
ACME *autocert.Manager // set in mode "acme"
|
||||
leaf func() *x509.Certificate
|
||||
}
|
||||
|
||||
// Fingerprint is the SHA-256 of the served certificate, for pinning in the
|
||||
// iOS app. Empty for Let's Encrypt, whose certificate is trusted anyway.
|
||||
func (w *webTLS) Fingerprint() string {
|
||||
if w == nil || w.leaf == nil {
|
||||
return ""
|
||||
}
|
||||
c := w.leaf()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(c.Raw)
|
||||
parts := make([]string, len(sum))
|
||||
for i, b := range sum {
|
||||
parts[i] = fmt.Sprintf("%02X", b)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func setupTLS(c *Config, dataDir string) (*webTLS, error) {
|
||||
t := c.Web.TLS
|
||||
switch t.Mode {
|
||||
case "off":
|
||||
return &webTLS{}, nil
|
||||
|
||||
case "acme":
|
||||
m := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(dataDir, "acme")),
|
||||
HostPolicy: autocert.HostWhitelist(t.Domain),
|
||||
Email: t.Email,
|
||||
}
|
||||
if t.Staging {
|
||||
m.Client = &acme.Client{DirectoryURL: letsEncryptStaging}
|
||||
}
|
||||
cfg := m.TLSConfig() // answers tls-alpn-01 challenges on :443
|
||||
cfg.MinVersion = tls.VersionTLS12
|
||||
return &webTLS{Config: cfg, ACME: m}, nil
|
||||
|
||||
case "files":
|
||||
r := &certReloader{certFile: t.CertFile, keyFile: t.KeyFile}
|
||||
if _, err := r.get(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &webTLS{
|
||||
Config: &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return r.get() }},
|
||||
leaf: r.leaf,
|
||||
}, nil
|
||||
|
||||
case "selfsigned":
|
||||
cert, err := loadOrCreateSelfSigned(filepath.Join(dataDir, "tls"), c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &webTLS{
|
||||
Config: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}},
|
||||
leaf: func() *x509.Certificate { return cert.Leaf },
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown tls mode %q", t.Mode)
|
||||
}
|
||||
|
||||
// certReloader re-reads certificate files when they change on disk, so an
|
||||
// external renewal (e.g. certbot) is picked up without a restart.
|
||||
type certReloader struct {
|
||||
certFile, keyFile string
|
||||
mu sync.Mutex
|
||||
cert *tls.Certificate
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func (r *certReloader) get() (*tls.Certificate, error) {
|
||||
st, err := os.Stat(r.certFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.cert != nil && st.ModTime().Equal(r.mtime) {
|
||||
return r.cert, nil
|
||||
}
|
||||
c, err := tls.LoadX509KeyPair(r.certFile, r.keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.cert, r.mtime = &c, st.ModTime()
|
||||
return r.cert, nil
|
||||
}
|
||||
|
||||
func (r *certReloader) leaf() *x509.Certificate {
|
||||
c, err := r.get()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c.Leaf
|
||||
}
|
||||
|
||||
func loadOrCreateSelfSigned(dir string, c *Config) (tls.Certificate, error) {
|
||||
certPath, keyPath := filepath.Join(dir, "selfsigned.crt"), filepath.Join(dir, "selfsigned.key")
|
||||
if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
|
||||
return cert, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
|
||||
name := c.Server.Endpoint
|
||||
if name == "" {
|
||||
name, _ = os.Hostname()
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: name, Organization: []string{appName}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||
}
|
||||
for _, n := range []string{c.Server.Endpoint, c.Web.TLS.Domain} {
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(n); ip != nil {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
|
||||
} else {
|
||||
tmpl.DNSNames = append(tmpl.DNSNames, n)
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.LoadX509KeyPair(certPath, keyPath)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The web UI is three static files built into the binary. It talks only to
|
||||
// /api/v1, the same API the iOS app uses.
|
||||
//
|
||||
//go:embed index.html app.js app.css
|
||||
var webFiles embed.FS
|
||||
|
||||
func webHandler() http.Handler {
|
||||
files := http.FileServerFS(webFiles)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/", "/app.js", "/app.css":
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
files.ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user