WireGuard server with an embedded admin console
Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
// Package auth holds the primitives behind the admin login: argon2id password
|
||||
// hashing, opaque session tokens, RFC 6238 one-time passwords, recovery codes
|
||||
// and a login rate limiter. None of it knows about HTTP.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2id parameters: 64 MiB, 3 passes, 4 lanes. Roughly 100 ms on a
|
||||
// modest server, which is the point.
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
// MinPasswordLength is the shortest password accepted.
|
||||
const MinPasswordLength = 12
|
||||
|
||||
// ValidatePassword enforces the password policy: length only. Composition
|
||||
// rules produce worse passwords, not better ones.
|
||||
func ValidatePassword(pw string) error {
|
||||
if utf8.RuneCountInString(pw) < MinPasswordLength {
|
||||
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
||||
}
|
||||
if len(pw) > 1024 {
|
||||
return errors.New("password is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashPassword returns a PHC-format argon2id string.
|
||||
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)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a password against a hash from HashPassword.
|
||||
func VerifyPassword(hash, pw string) bool {
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false
|
||||
}
|
||||
var m, t uint32
|
||||
var p uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// dummyHash is verified against when the user does not exist, so a login
|
||||
// for an unknown name takes as long as one for a known name.
|
||||
var dummyHash, _ = HashPassword("wgx-timing-equaliser-password")
|
||||
|
||||
// EqualiseTiming burns the cost of one hash verification.
|
||||
func EqualiseTiming() { VerifyPassword(dummyHash, "not-the-password") }
|
||||
|
||||
// NewToken returns a random URL-safe token and its storage hash.
|
||||
func NewToken() (token, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(b)
|
||||
return token, HashToken(token), nil
|
||||
}
|
||||
|
||||
// HashToken is how tokens are stored: a leaked database is not a leaked login.
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// NewID returns a short random identifier for peers.
|
||||
func NewID() (string, error) {
|
||||
b := make([]byte, 10)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
// --- TOTP -----------------------------------------------------------------
|
||||
|
||||
// NewTOTPSecret returns a base32 secret for an authenticator app.
|
||||
func NewTOTPSecret() (string, error) {
|
||||
b := make([]byte, 20)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// TOTPURI builds the otpauth:// URI an authenticator app scans.
|
||||
func TOTPURI(issuer, account, secret string) string {
|
||||
v := url.Values{}
|
||||
v.Set("secret", secret)
|
||||
v.Set("issuer", issuer)
|
||||
v.Set("algorithm", "SHA1")
|
||||
v.Set("digits", "6")
|
||||
v.Set("period", "30")
|
||||
return "otpauth://totp/" + url.PathEscape(issuer+":"+account) + "?" + v.Encode()
|
||||
}
|
||||
|
||||
func totpCode(secret string, counter uint64) (string, error) {
|
||||
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.ReplaceAll(secret, " ", "")))
|
||||
if err != nil {
|
||||
return "", errors.New("bad secret")
|
||||
}
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], counter)
|
||||
mac := hmac.New(sha1.New, key)
|
||||
mac.Write(msg[:])
|
||||
sum := mac.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
code := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1_000_000
|
||||
return fmt.Sprintf("%06d", code), nil
|
||||
}
|
||||
|
||||
// TOTPNow returns the current code, for the tests and the setup flow.
|
||||
func TOTPNow(secret string, at time.Time) (string, error) {
|
||||
return totpCode(secret, uint64(at.Unix()/30))
|
||||
}
|
||||
|
||||
// VerifyTOTP accepts the current code and one step either side.
|
||||
func VerifyTOTP(secret, code string, at time.Time) bool {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != 6 {
|
||||
return false
|
||||
}
|
||||
if _, err := strconv.Atoi(code); err != nil {
|
||||
return false
|
||||
}
|
||||
counter := uint64(at.Unix() / 30)
|
||||
ok := false
|
||||
for _, c := range []uint64{counter - 1, counter, counter + 1} {
|
||||
want, err := totpCode(secret, c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// NewRecoveryCodes returns n codes in the form xxxx-xxxx-xxxx and their hashes.
|
||||
func NewRecoveryCodes(n int) (codes, hashes []string, err error) {
|
||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
for i := 0; i < n; i++ {
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var sb strings.Builder
|
||||
for j, x := range b {
|
||||
if j > 0 && j%4 == 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
sb.WriteByte(alphabet[int(x)%len(alphabet)])
|
||||
}
|
||||
codes = append(codes, sb.String())
|
||||
hashes = append(hashes, HashToken(NormaliseRecoveryCode(sb.String())))
|
||||
}
|
||||
return codes, hashes, nil
|
||||
}
|
||||
|
||||
// NormaliseRecoveryCode strips separators and case so typed codes match.
|
||||
func NormaliseRecoveryCode(c string) string {
|
||||
return strings.ToLower(strings.NewReplacer("-", "", " ", "").Replace(c))
|
||||
}
|
||||
|
||||
// --- Rate limiting --------------------------------------------------------
|
||||
|
||||
// Limiter is a fixed-window failure counter keyed by string (an IP, a
|
||||
// username). After max failures in the window the key is locked out until
|
||||
// the window passes.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
window time.Duration
|
||||
hits map[string]*bucket
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
count int
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// NewLimiter allows max failures per window.
|
||||
func NewLimiter(max int, window time.Duration) *Limiter {
|
||||
return &Limiter{max: max, window: window, hits: map[string]*bucket{}}
|
||||
}
|
||||
|
||||
// Allowed reports whether the key may attempt again and how long to wait.
|
||||
func (l *Limiter) Allowed(key string) (bool, time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
b, ok := l.hits[key]
|
||||
if !ok {
|
||||
return true, 0
|
||||
}
|
||||
if time.Since(b.start) > l.window {
|
||||
delete(l.hits, key)
|
||||
return true, 0
|
||||
}
|
||||
if b.count >= l.max {
|
||||
return false, l.window - time.Since(b.start)
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// Fail records a failure.
|
||||
func (l *Limiter) Fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
b, ok := l.hits[key]
|
||||
if !ok || time.Since(b.start) > l.window {
|
||||
l.hits[key] = &bucket{count: 1, start: time.Now()}
|
||||
return
|
||||
}
|
||||
b.count++
|
||||
}
|
||||
|
||||
// Reset clears a key after success.
|
||||
func (l *Limiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
// Sweep drops stale keys; call it now and then.
|
||||
func (l *Limiter) Sweep() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for k, b := range l.hits {
|
||||
if time.Since(b.start) > l.window {
|
||||
delete(l.hits, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPasswordRoundTrip(t *testing.T) {
|
||||
h, err := HashPassword("correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyPassword(h, "correct horse battery staple") {
|
||||
t.Fatal("right password rejected")
|
||||
}
|
||||
if VerifyPassword(h, "correct horse battery stapl") {
|
||||
t.Fatal("wrong password accepted")
|
||||
}
|
||||
if VerifyPassword("garbage", "x") {
|
||||
t.Fatal("garbage hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
if err := ValidatePassword("short"); err == nil {
|
||||
t.Fatal("short password accepted")
|
||||
}
|
||||
if err := ValidatePassword("twelve chars"); err != nil {
|
||||
t.Fatalf("12-character password rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTP(t *testing.T) {
|
||||
// RFC 6238 test vector: secret "12345678901234567890" (base32
|
||||
// GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ), time 59 -> 287082 with SHA1.
|
||||
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
code, err := TOTPNow(secret, time.Unix(59, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code != "287082" {
|
||||
t.Fatalf("got %s, want 287082", code)
|
||||
}
|
||||
if !VerifyTOTP(secret, "287082", time.Unix(59, 0)) {
|
||||
t.Fatal("valid code rejected")
|
||||
}
|
||||
// One step later still accepted (window of one either side).
|
||||
if !VerifyTOTP(secret, "287082", time.Unix(59+30, 0)) {
|
||||
t.Fatal("previous-step code rejected")
|
||||
}
|
||||
if VerifyTOTP(secret, "287082", time.Unix(59+120, 0)) {
|
||||
t.Fatal("stale code accepted")
|
||||
}
|
||||
if VerifyTOTP(secret, "28708", time.Unix(59, 0)) {
|
||||
t.Fatal("short code accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryCodes(t *testing.T) {
|
||||
codes, hashes, err := NewRecoveryCodes(8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(codes) != 8 || len(hashes) != 8 {
|
||||
t.Fatal("wrong count")
|
||||
}
|
||||
if HashToken(NormaliseRecoveryCode(" "+codes[0]+" ")) != hashes[0] {
|
||||
t.Fatal("normalised code does not hash to stored value")
|
||||
}
|
||||
if len(codes[0]) != 14 {
|
||||
t.Fatalf("unexpected format %q", codes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter(t *testing.T) {
|
||||
l := NewLimiter(2, time.Minute)
|
||||
if ok, _ := l.Allowed("a"); !ok {
|
||||
t.Fatal("fresh key blocked")
|
||||
}
|
||||
l.Fail("a")
|
||||
l.Fail("a")
|
||||
if ok, wait := l.Allowed("a"); ok || wait <= 0 {
|
||||
t.Fatal("key not blocked after max failures")
|
||||
}
|
||||
if ok, _ := l.Allowed("b"); !ok {
|
||||
t.Fatal("unrelated key blocked")
|
||||
}
|
||||
l.Reset("a")
|
||||
if ok, _ := l.Allowed("a"); !ok {
|
||||
t.Fatal("reset key still blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokens(t *testing.T) {
|
||||
tok, hash, err := NewToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if HashToken(tok) != hash {
|
||||
t.Fatal("hash mismatch")
|
||||
}
|
||||
id, err := NewID()
|
||||
if err != nil || len(id) != 16 {
|
||||
t.Fatalf("bad id %q %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Package config reads the environment. Everything here is infrastructure
|
||||
// that has to be known before the database opens; anything an administrator
|
||||
// might change while the server runs lives in the database instead (see
|
||||
// engine.Settings).
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the process configuration.
|
||||
type Config struct {
|
||||
DataDir string
|
||||
DBPath string
|
||||
Backend string // auto | kernel | userspace | mock
|
||||
Iface string
|
||||
// Listen is the UDP port WireGuard listens on.
|
||||
ListenPort int
|
||||
// Subnet4 / Subnet6 are the tunnel networks. The server takes the first
|
||||
// usable address of each.
|
||||
Subnet4 netip.Prefix
|
||||
Subnet6 netip.Prefix // may be invalid (unset)
|
||||
// Egress is the interface to masquerade on; empty means auto-detect.
|
||||
Egress string
|
||||
// HTTP is the admin listener address.
|
||||
HTTP string
|
||||
// TLSCert/TLSKey enable HTTPS from files; TLSSelfSigned generates and
|
||||
// persists a certificate in the data directory.
|
||||
TLSCert, TLSKey string
|
||||
TLSSelfSigned bool
|
||||
// SecureCookies forces the Secure flag on when TLS terminates elsewhere.
|
||||
SecureCookies bool
|
||||
// TrustedProxies are CIDRs whose X-Forwarded-For / X-Real-IP is believed.
|
||||
TrustedProxies []netip.Prefix
|
||||
// MetricsToken protects /metrics; empty disables the endpoint.
|
||||
MetricsToken string
|
||||
// SessionIdle / SessionMax bound admin sessions.
|
||||
SessionIdle time.Duration
|
||||
SessionMax time.Duration
|
||||
// TrafficRetention bounds the usage history.
|
||||
TrafficRetention time.Duration
|
||||
// PollInterval is how often the data plane is read.
|
||||
PollInterval time.Duration
|
||||
// LogLevel is debug, info, warn or error.
|
||||
LogLevel string
|
||||
// LogJSON switches the log format.
|
||||
LogJSON bool
|
||||
// Initial* seed the settings on first run only.
|
||||
InitialEndpoint string
|
||||
InitialDNS string
|
||||
// ManageFirewall may be turned off when the host owns the NAT rules.
|
||||
ManageFirewall bool
|
||||
// ManageSysctl may be turned off when the host has already tuned itself.
|
||||
ManageSysctl bool
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) (int, error) {
|
||||
v := env(key, "")
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %q is not a number", key, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) (bool, error) {
|
||||
v := strings.ToLower(env(key, ""))
|
||||
switch v {
|
||||
case "":
|
||||
return def, nil
|
||||
case "1", "true", "yes", "on":
|
||||
return true, nil
|
||||
case "0", "false", "no", "off":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("%s: %q is not a boolean", key, v)
|
||||
}
|
||||
|
||||
func envDuration(key string, def time.Duration) (time.Duration, error) {
|
||||
v := env(key, "")
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %q is not a duration (try 12h, 30m)", key, v)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// FromEnv builds the configuration from WGX_* variables.
|
||||
func FromEnv() (*Config, error) {
|
||||
var errs []error
|
||||
c := &Config{}
|
||||
c.DataDir = env("WGX_DATA_DIR", "/data")
|
||||
c.DBPath = env("WGX_DB", c.DataDir+"/wgx.db")
|
||||
c.Backend = strings.ToLower(env("WGX_BACKEND", "auto"))
|
||||
switch c.Backend {
|
||||
case "auto", "kernel", "userspace", "mock":
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("WGX_BACKEND: %q is not auto, kernel, userspace or mock", c.Backend))
|
||||
}
|
||||
c.Iface = env("WGX_INTERFACE", "wg0")
|
||||
if len(c.Iface) == 0 || len(c.Iface) > 15 || strings.ContainsAny(c.Iface, " /\t\n") {
|
||||
errs = append(errs, errors.New("WGX_INTERFACE: must be 1-15 characters with no spaces or slashes"))
|
||||
}
|
||||
var err error
|
||||
if c.ListenPort, err = envInt("WGX_PORT", 51820); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else if c.ListenPort < 1 || c.ListenPort > 65535 {
|
||||
errs = append(errs, errors.New("WGX_PORT: must be 1-65535"))
|
||||
}
|
||||
if c.Subnet4, err = netip.ParsePrefix(env("WGX_SUBNET", "10.8.0.0/24")); err != nil || !c.Subnet4.Addr().Is4() {
|
||||
errs = append(errs, errors.New("WGX_SUBNET: must be an IPv4 CIDR such as 10.8.0.0/24"))
|
||||
} else if c.Subnet4.Bits() > 30 {
|
||||
errs = append(errs, errors.New("WGX_SUBNET: needs room for at least two hosts (/30 or larger)"))
|
||||
}
|
||||
if v := env("WGX_SUBNET6", ""); v != "" {
|
||||
if c.Subnet6, err = netip.ParsePrefix(v); err != nil || !c.Subnet6.Addr().Is6() {
|
||||
errs = append(errs, errors.New("WGX_SUBNET6: must be an IPv6 CIDR such as fd42:42:42::/64"))
|
||||
}
|
||||
}
|
||||
c.Egress = env("WGX_EGRESS_INTERFACE", "")
|
||||
c.HTTP = env("WGX_HTTP_LISTEN", ":51821")
|
||||
c.TLSCert = env("WGX_TLS_CERT", "")
|
||||
c.TLSKey = env("WGX_TLS_KEY", "")
|
||||
if (c.TLSCert == "") != (c.TLSKey == "") {
|
||||
errs = append(errs, errors.New("WGX_TLS_CERT and WGX_TLS_KEY must be set together"))
|
||||
}
|
||||
if c.TLSSelfSigned, err = envBool("WGX_TLS_SELF_SIGNED", false); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.SecureCookies, err = envBool("WGX_SECURE_COOKIES", false); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
for _, p := range strings.Split(env("WGX_TRUSTED_PROXIES", ""), ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(p)
|
||||
if err != nil {
|
||||
if a, err2 := netip.ParseAddr(p); err2 == nil {
|
||||
pfx = netip.PrefixFrom(a, a.BitLen())
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("WGX_TRUSTED_PROXIES: %q is not an address or CIDR", p))
|
||||
continue
|
||||
}
|
||||
}
|
||||
c.TrustedProxies = append(c.TrustedProxies, pfx)
|
||||
}
|
||||
c.MetricsToken = env("WGX_METRICS_TOKEN", "")
|
||||
if c.SessionIdle, err = envDuration("WGX_SESSION_IDLE", 12*time.Hour); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.SessionMax, err = envDuration("WGX_SESSION_MAX", 7*24*time.Hour); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.TrafficRetention, err = envDuration("WGX_TRAFFIC_RETENTION", 90*24*time.Hour); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.PollInterval, err = envDuration("WGX_POLL_INTERVAL", 2*time.Second); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else if c.PollInterval < 500*time.Millisecond {
|
||||
errs = append(errs, errors.New("WGX_POLL_INTERVAL: must be at least 500ms"))
|
||||
}
|
||||
c.LogLevel = strings.ToLower(env("WGX_LOG_LEVEL", "info"))
|
||||
if c.LogJSON, err = envBool("WGX_LOG_JSON", false); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
c.InitialEndpoint = env("WGX_ENDPOINT", "")
|
||||
c.InitialDNS = env("WGX_DNS", "1.1.1.1, 1.0.0.1")
|
||||
if c.ManageFirewall, err = envBool("WGX_MANAGE_FIREWALL", true); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.ManageSysctl, err = envBool("WGX_MANAGE_SYSCTL", true); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TLSEnabled reports whether the admin listener speaks HTTPS itself.
|
||||
func (c *Config) TLSEnabled() bool { return c.TLSCert != "" || c.TLSSelfSigned }
|
||||
@@ -0,0 +1,56 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// allocate returns the lowest free host address in the subnet, skipping the
|
||||
// network address, the server's own (first host) and, for IPv4, the
|
||||
// broadcast address. `used` holds addresses already handed out.
|
||||
func allocate(subnet netip.Prefix, used map[string]bool) (netip.Addr, error) {
|
||||
subnet = subnet.Masked()
|
||||
base := subnet.Addr()
|
||||
server := base.Next()
|
||||
var last netip.Addr
|
||||
if base.Is4() {
|
||||
// Broadcast: all host bits set.
|
||||
a := base.As4()
|
||||
hostBits := 32 - subnet.Bits()
|
||||
var n uint32 = uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])
|
||||
n |= (1 << hostBits) - 1
|
||||
last = netip.AddrFrom4([4]byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)})
|
||||
}
|
||||
// Cap the scan: a /64 is not walked to the end, and nobody has 65k peers.
|
||||
const maxScan = 65536
|
||||
addr := server.Next()
|
||||
for i := 0; i < maxScan && subnet.Contains(addr); i++ {
|
||||
if base.Is4() && addr == last {
|
||||
break
|
||||
}
|
||||
if !used[addr.String()] {
|
||||
return addr, nil
|
||||
}
|
||||
addr = addr.Next()
|
||||
}
|
||||
return netip.Addr{}, errors.New("no free addresses left in " + subnet.String())
|
||||
}
|
||||
|
||||
// checkAddress validates an operator-chosen address for a peer.
|
||||
func checkAddress(subnet netip.Prefix, s string, used map[string]bool) (netip.Addr, error) {
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("%q is not an IP address", s)
|
||||
}
|
||||
if !subnet.Contains(a) {
|
||||
return netip.Addr{}, fmt.Errorf("%s is outside %s", a, subnet.Masked())
|
||||
}
|
||||
if a == subnet.Masked().Addr() || a == subnet.Masked().Addr().Next() {
|
||||
return netip.Addr{}, fmt.Errorf("%s is reserved for the server", a)
|
||||
}
|
||||
if used[a.String()] {
|
||||
return netip.Addr{}, fmt.Errorf("%s is already assigned", a)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
)
|
||||
|
||||
// Live is what the data plane currently says about one peer, merged with
|
||||
// the totals persisted across restarts.
|
||||
type Live struct {
|
||||
PeerID string `json:"id"`
|
||||
Connected bool `json:"connected"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
LastHandshake time.Time `json:"lastHandshake,omitempty"`
|
||||
Rx int64 `json:"rx"`
|
||||
Tx int64 `json:"tx"`
|
||||
RxRate float64 `json:"rxRate"`
|
||||
TxRate float64 `json:"txRate"`
|
||||
ConnectedSince time.Time `json:"connectedSince,omitempty"`
|
||||
}
|
||||
|
||||
// Totals summarises the whole interface.
|
||||
type Totals struct {
|
||||
Peers int `json:"peers"`
|
||||
Active int `json:"active"`
|
||||
Connected int `json:"connected"`
|
||||
Rx int64 `json:"rx"`
|
||||
Tx int64 `json:"tx"`
|
||||
RxRate float64 `json:"rxRate"`
|
||||
TxRate float64 `json:"txRate"`
|
||||
}
|
||||
|
||||
// Snapshot is the payload pushed to dashboards on every poll.
|
||||
type Snapshot struct {
|
||||
At time.Time `json:"at"`
|
||||
Totals Totals `json:"totals"`
|
||||
Peers map[string]Live `json:"peers"`
|
||||
}
|
||||
|
||||
type counterMemo struct {
|
||||
rx, tx int64 // raw device counters at the last poll
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// collector polls the backend, turns raw counters into deltas and rates,
|
||||
// and batches what needs writing.
|
||||
type collector struct {
|
||||
e *Engine
|
||||
mu sync.Mutex
|
||||
live map[string]*Live // by peer id
|
||||
memo map[string]counterMemo // by public key
|
||||
// base is the persisted total per peer at the moment it was loaded, so
|
||||
// that live totals = base + everything seen since.
|
||||
pendingBuckets map[string]map[int64]*[2]int64 // peer id -> bucket -> [rx, tx]
|
||||
dirty map[string]bool
|
||||
last Snapshot
|
||||
}
|
||||
|
||||
func newCollector(e *Engine) *collector {
|
||||
return &collector{e: e, live: map[string]*Live{}, memo: map[string]counterMemo{}, pendingBuckets: map[string]map[int64]*[2]int64{}, dirty: map[string]bool{}}
|
||||
}
|
||||
|
||||
// Snapshot returns the last computed snapshot.
|
||||
func (c *collector) Snapshot() Snapshot {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
// LiveFor returns a copy of a peer's live state, if any.
|
||||
func (c *collector) LiveFor(id string) (Live, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
l, ok := c.live[id]
|
||||
if !ok {
|
||||
return Live{}, false
|
||||
}
|
||||
return *l, true
|
||||
}
|
||||
|
||||
func (c *collector) run(ctx context.Context) {
|
||||
t := time.NewTicker(c.e.cfg.PollInterval)
|
||||
defer t.Stop()
|
||||
c.poll(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
c.poll(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forget drops a peer's live state after it is deleted.
|
||||
func (c *collector) forget(id, pubKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.live, id)
|
||||
delete(c.memo, pubKey)
|
||||
delete(c.pendingBuckets, id)
|
||||
delete(c.dirty, id)
|
||||
}
|
||||
|
||||
// rekey moves the memo when a peer's key changes so the next poll does not
|
||||
// count the new key's zeroed counters as a reset.
|
||||
func (c *collector) rekey(oldKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.memo, oldKey)
|
||||
}
|
||||
|
||||
func (c *collector) poll(ctx context.Context) {
|
||||
dev, err := c.e.be.Device(ctx)
|
||||
if err != nil {
|
||||
c.e.log.Warn("poll", "error", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
window := time.Duration(c.e.Settings().ConnectedWindow) * time.Second
|
||||
|
||||
c.e.mu.RLock()
|
||||
peers := make([]*store.Peer, 0, len(c.e.peers))
|
||||
for _, p := range c.e.peers {
|
||||
peers = append(peers, p)
|
||||
}
|
||||
c.e.mu.RUnlock()
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for _, ps := range dev.Peers {
|
||||
seen[ps.PublicKey.String()] = struct{}{}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
snap := Snapshot{At: now, Peers: make(map[string]Live, len(peers))}
|
||||
snap.Totals.Peers = len(peers)
|
||||
byKey := map[string]int{}
|
||||
for i, ps := range dev.Peers {
|
||||
byKey[ps.PublicKey.String()] = i
|
||||
}
|
||||
for _, p := range peers {
|
||||
l, ok := c.live[p.ID]
|
||||
if !ok {
|
||||
l = &Live{PeerID: p.ID, Rx: p.RxTotal, Tx: p.TxTotal, LastHandshake: p.LastHandshake, Endpoint: p.LastEndpoint}
|
||||
c.live[p.ID] = l
|
||||
}
|
||||
l.RxRate, l.TxRate = 0, 0
|
||||
if active(p, now) {
|
||||
snap.Totals.Active++
|
||||
}
|
||||
if i, ok := byKey[p.PublicKey]; ok {
|
||||
ps := dev.Peers[i]
|
||||
m, had := c.memo[p.PublicKey]
|
||||
var drx, dtx int64
|
||||
if had {
|
||||
drx, dtx = ps.ReceiveBytes-m.rx, ps.TransmitBytes-m.tx
|
||||
// A counter smaller than last time means the peer was
|
||||
// removed and re-added: the new value is all new traffic.
|
||||
if drx < 0 {
|
||||
drx = ps.ReceiveBytes
|
||||
}
|
||||
if dtx < 0 {
|
||||
dtx = ps.TransmitBytes
|
||||
}
|
||||
dt := now.Sub(m.at).Seconds()
|
||||
if dt > 0 {
|
||||
l.RxRate = float64(drx) / dt
|
||||
l.TxRate = float64(dtx) / dt
|
||||
}
|
||||
} else {
|
||||
// First sight of this key since start: whatever the device
|
||||
// already counted happened before we were watching, unless
|
||||
// the peer was just created, in which case it is zero anyway.
|
||||
// Either way it must not be added to the persisted total, so
|
||||
// only the memo is set.
|
||||
drx, dtx = 0, 0
|
||||
}
|
||||
c.memo[p.PublicKey] = counterMemo{rx: ps.ReceiveBytes, tx: ps.TransmitBytes, at: now}
|
||||
if drx > 0 || dtx > 0 {
|
||||
l.Rx += drx
|
||||
l.Tx += dtx
|
||||
c.dirty[p.ID] = true
|
||||
bucket := now.Truncate(bucketSize).Unix()
|
||||
pb, ok := c.pendingBuckets[p.ID]
|
||||
if !ok {
|
||||
pb = map[int64]*[2]int64{}
|
||||
c.pendingBuckets[p.ID] = pb
|
||||
}
|
||||
b, ok := pb[bucket]
|
||||
if !ok {
|
||||
b = &[2]int64{}
|
||||
pb[bucket] = b
|
||||
}
|
||||
b[0] += drx
|
||||
b[1] += dtx
|
||||
}
|
||||
if !ps.LastHandshake.IsZero() && ps.LastHandshake.After(l.LastHandshake) {
|
||||
l.LastHandshake = ps.LastHandshake
|
||||
c.dirty[p.ID] = true
|
||||
}
|
||||
if ps.Endpoint != nil {
|
||||
ep := ps.Endpoint.String()
|
||||
if ep != l.Endpoint {
|
||||
l.Endpoint = ep
|
||||
c.dirty[p.ID] = true
|
||||
}
|
||||
}
|
||||
// Connected means the *interface* has a recent handshake, not
|
||||
// the remembered one: after a restart, or after a peer is
|
||||
// disabled and re-enabled, everyone is disconnected until the
|
||||
// client handshakes again, which is the truth.
|
||||
connected := !ps.LastHandshake.IsZero() && now.Sub(ps.LastHandshake) < window
|
||||
if connected && !l.Connected {
|
||||
l.ConnectedSince = l.LastHandshake
|
||||
}
|
||||
if !connected {
|
||||
l.ConnectedSince = time.Time{}
|
||||
}
|
||||
l.Connected = connected
|
||||
} else {
|
||||
// Not on the interface: disabled, expired or removed by hand.
|
||||
l.Connected = false
|
||||
l.ConnectedSince = time.Time{}
|
||||
delete(c.memo, p.PublicKey)
|
||||
}
|
||||
if l.Connected {
|
||||
snap.Totals.Connected++
|
||||
}
|
||||
snap.Totals.Rx += l.Rx
|
||||
snap.Totals.Tx += l.Tx
|
||||
snap.Totals.RxRate += l.RxRate
|
||||
snap.Totals.TxRate += l.TxRate
|
||||
snap.Peers[p.ID] = *l
|
||||
}
|
||||
c.last = snap
|
||||
c.e.hub.Publish("status", snap)
|
||||
}
|
||||
|
||||
// flush writes dirty totals and pending buckets to the database.
|
||||
func (c *collector) flush(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
var counters []store.PeerCounters
|
||||
var samples []store.TrafficSample
|
||||
for id := range c.dirty {
|
||||
l := c.live[id]
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
counters = append(counters, store.PeerCounters{ID: id, RxTotal: l.Rx, TxTotal: l.Tx, LastHandshake: l.LastHandshake, LastEndpoint: l.Endpoint})
|
||||
}
|
||||
for id, pb := range c.pendingBuckets {
|
||||
for bucket, v := range pb {
|
||||
samples = append(samples, store.TrafficSample{PeerID: id, Bucket: time.Unix(bucket, 0), Rx: v[0], Tx: v[1]})
|
||||
}
|
||||
}
|
||||
c.dirty = map[string]bool{}
|
||||
c.pendingBuckets = map[string]map[int64]*[2]int64{}
|
||||
c.mu.Unlock()
|
||||
if err := c.e.st.FlushCounters(ctx, counters, samples); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the in-memory peer records' totals current so a later UpdatePeer
|
||||
// does not carry stale numbers around (they are not written by it, but
|
||||
// the API reads them).
|
||||
c.e.mu.Lock()
|
||||
for _, k := range counters {
|
||||
if p, ok := c.e.peers[k.ID]; ok {
|
||||
p.RxTotal, p.TxTotal, p.LastHandshake, p.LastEndpoint = k.RxTotal, k.TxTotal, k.LastHandshake, k.LastEndpoint
|
||||
}
|
||||
}
|
||||
c.e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Package engine ties the pieces together: it owns the server key, brings the
|
||||
// interface up, keeps the data plane in step with the database, reads
|
||||
// counters, and answers the questions the API asks.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/netcfg"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
)
|
||||
|
||||
const (
|
||||
serverKeySetting = "server_private_key"
|
||||
bucketSize = 5 * time.Minute
|
||||
flushEvery = 30 * time.Second
|
||||
houseEvery = 30 * time.Second
|
||||
)
|
||||
|
||||
// Engine is the long-running core.
|
||||
type Engine struct {
|
||||
cfg *config.Config
|
||||
st *store.Store
|
||||
be wg.Backend
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
settings Settings
|
||||
serverKey wg.Key
|
||||
startedAt time.Time
|
||||
sysctls []netcfg.Result
|
||||
egress string
|
||||
fwErr string
|
||||
peers map[string]*store.Peer // by id
|
||||
byKey map[string]*store.Peer // by public key
|
||||
|
||||
col *collector
|
||||
hub *Hub
|
||||
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New wires an engine up without starting anything.
|
||||
func New(cfg *config.Config, st *store.Store, be wg.Backend, log *slog.Logger) *Engine {
|
||||
e := &Engine{cfg: cfg, st: st, be: be, log: log, peers: map[string]*store.Peer{}, byKey: map[string]*store.Peer{}, hub: NewHub()}
|
||||
e.col = newCollector(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// Store exposes the database to the HTTP layer for users, sessions and audit.
|
||||
func (e *Engine) Store() *store.Store { return e.st }
|
||||
|
||||
// Config exposes the process configuration.
|
||||
func (e *Engine) Config() *config.Config { return e.cfg }
|
||||
|
||||
// Hub is the live-update fan-out.
|
||||
func (e *Engine) Hub() *Hub { return e.hub }
|
||||
|
||||
// Backend names the data plane in use.
|
||||
func (e *Engine) Backend() string { return e.be.Kind() }
|
||||
|
||||
// ServerPublicKey is what clients put in their [Peer] section.
|
||||
func (e *Engine) ServerPublicKey() string {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.serverKey.PublicKey().String()
|
||||
}
|
||||
|
||||
// Settings returns a copy of the current settings.
|
||||
func (e *Engine) Settings() Settings {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.settings
|
||||
}
|
||||
|
||||
// ServerAddresses are the interface's own tunnel addresses.
|
||||
func (e *Engine) ServerAddresses() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
out = append(out, netip.PrefixFrom(e.cfg.Subnet4.Masked().Addr().Next(), e.cfg.Subnet4.Bits()))
|
||||
if e.cfg.Subnet6.IsValid() {
|
||||
out = append(out, netip.PrefixFrom(e.cfg.Subnet6.Masked().Addr().Next(), e.cfg.Subnet6.Bits()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) tunnelSubnets() []netip.Prefix {
|
||||
out := []netip.Prefix{e.cfg.Subnet4.Masked()}
|
||||
if e.cfg.Subnet6.IsValid() {
|
||||
out = append(out, e.cfg.Subnet6.Masked())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start loads state, brings the interface up and starts the background
|
||||
// loops. It is safe to call Stop after a failed Start.
|
||||
func (e *Engine) Start(ctx context.Context) error {
|
||||
if err := e.loadSettings(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.loadServerKey(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.loadPeers(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.cfg.ManageSysctl && e.be.Kind() != "mock" {
|
||||
results, err := netcfg.ApplyAll(netcfg.Wanted(e.cfg.Subnet6.IsValid()))
|
||||
e.mu.Lock()
|
||||
e.sysctls = results
|
||||
e.mu.Unlock()
|
||||
for _, r := range results {
|
||||
if !r.Applied {
|
||||
e.log.Warn("sysctl not applied", "key", r.Key, "wanted", r.Value, "current", r.Current, "error", r.Err, "why", r.Why)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
settings := e.Settings()
|
||||
dev := wg.DeviceConfig{PrivateKey: e.serverKey, ListenPort: e.cfg.ListenPort}
|
||||
if err := e.be.Up(ctx, dev, e.ServerAddresses(), settings.MTU); err != nil {
|
||||
return err
|
||||
}
|
||||
e.log.Info("interface up", "iface", e.cfg.Iface, "backend", e.be.Kind(), "port", e.cfg.ListenPort, "addresses", e.ServerAddresses(), "mtu", settings.MTU)
|
||||
if err := e.applyFirewall(ctx); err != nil {
|
||||
// Not fatal: the operator may run their own NAT. It is reported in
|
||||
// the UI and the log so nobody wonders why peers cannot reach out.
|
||||
e.log.Error("firewall rules not applied", "error", err)
|
||||
e.mu.Lock()
|
||||
e.fwErr = err.Error()
|
||||
e.mu.Unlock()
|
||||
}
|
||||
if err := e.reconcile(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.startedAt = time.Now()
|
||||
e.mu.Unlock()
|
||||
|
||||
loopCtx, cancel := context.WithCancel(context.Background())
|
||||
e.cancel = cancel
|
||||
e.wg.Add(2)
|
||||
go func() { defer e.wg.Done(); e.col.run(loopCtx) }()
|
||||
go func() { defer e.wg.Done(); e.housekeeping(loopCtx) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop halts the loops, flushes counters and tears the interface down.
|
||||
func (e *Engine) Stop(ctx context.Context) error {
|
||||
if e.cancel != nil {
|
||||
e.cancel()
|
||||
e.wg.Wait()
|
||||
}
|
||||
var errs []error
|
||||
if err := e.col.flush(ctx); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if e.cfg.ManageFirewall && e.be.Kind() != "mock" {
|
||||
if err := netcfg.Remove(ctx, ""); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if err := e.be.Down(ctx); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (e *Engine) loadSettings(ctx context.Context) error {
|
||||
s, ok, err := loadSettings(ctx, e.st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
def := DefaultSettings(e.cfg.InitialEndpoint, e.cfg.InitialDNS, e.cfg.ListenPort)
|
||||
s = &def
|
||||
if err := saveSettings(ctx, e.st, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.settings = *s
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) loadServerKey(ctx context.Context) error {
|
||||
raw, err := e.st.GetSetting(ctx, serverKeySetting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var key wg.Key
|
||||
if raw == "" {
|
||||
key, err = wg.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.st.SetSetting(ctx, serverKeySetting, key.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
e.log.Info("generated server key", "publicKey", key.PublicKey().String())
|
||||
} else {
|
||||
key, err = wg.ParseKey(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stored server key is invalid: %w", err)
|
||||
}
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.serverKey = key
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) loadPeers(ctx context.Context) error {
|
||||
peers, err := e.st.ListPeers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.peers = make(map[string]*store.Peer, len(peers))
|
||||
e.byKey = make(map[string]*store.Peer, len(peers))
|
||||
for _, p := range peers {
|
||||
e.peers[p.ID] = p
|
||||
e.byKey[p.PublicKey] = p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) applyFirewall(ctx context.Context) error {
|
||||
if !e.cfg.ManageFirewall || e.be.Kind() == "mock" {
|
||||
return nil
|
||||
}
|
||||
egress := e.cfg.Egress
|
||||
if egress == "" {
|
||||
if d, err := netcfg.DefaultEgress(); err == nil {
|
||||
egress = d
|
||||
} else {
|
||||
e.log.Warn("could not detect the egress interface; masquerading on every non-tunnel interface", "error", err)
|
||||
}
|
||||
}
|
||||
s := e.Settings()
|
||||
rules := netcfg.Rules{
|
||||
Iface: e.cfg.Iface,
|
||||
Egress: egress,
|
||||
ListenPort: e.cfg.ListenPort,
|
||||
Subnets: e.tunnelSubnets(),
|
||||
PeerIsolation: s.PeerIsolation,
|
||||
ClampMSS: s.ClampMSS,
|
||||
}
|
||||
if err := netcfg.Apply(ctx, rules); err != nil {
|
||||
return err
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.egress = egress
|
||||
e.fwErr = ""
|
||||
e.mu.Unlock()
|
||||
e.log.Info("firewall rules applied", "egress", egress, "peerIsolation", s.PeerIsolation, "clampMSS", s.ClampMSS)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSettings validates, persists and applies new settings.
|
||||
func (e *Engine) UpdateSettings(ctx context.Context, s Settings) error {
|
||||
if err := s.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
old := e.Settings()
|
||||
if err := saveSettings(ctx, e.st, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.settings = s
|
||||
e.mu.Unlock()
|
||||
if s.MTU != old.MTU {
|
||||
if err := e.be.SetMTU(ctx, s.MTU); err != nil {
|
||||
e.log.Warn("could not change interface MTU", "error", err)
|
||||
}
|
||||
}
|
||||
if s.PeerIsolation != old.PeerIsolation || s.ClampMSS != old.ClampMSS {
|
||||
if err := e.applyFirewall(ctx); err != nil {
|
||||
e.mu.Lock()
|
||||
e.fwErr = err.Error()
|
||||
e.mu.Unlock()
|
||||
return fmt.Errorf("settings saved but firewall rules failed: %w", err)
|
||||
}
|
||||
}
|
||||
e.hub.Publish("settings", s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// active reports whether a peer should currently be on the interface.
|
||||
func active(p *store.Peer, now time.Time) bool {
|
||||
if !p.Enabled {
|
||||
return false
|
||||
}
|
||||
if !p.ExpiresAt.IsZero() && now.After(p.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *Engine) peerConfig(p *store.Peer) (wg.PeerConfig, error) {
|
||||
pub, err := wg.ParseKey(p.PublicKey)
|
||||
if err != nil {
|
||||
return wg.PeerConfig{}, err
|
||||
}
|
||||
pc := wg.PeerConfig{PublicKey: pub}
|
||||
if p.PresharedKey != "" {
|
||||
psk, err := wg.ParseKey(p.PresharedKey)
|
||||
if err != nil {
|
||||
return wg.PeerConfig{}, err
|
||||
}
|
||||
pc.PresharedKey = &psk
|
||||
}
|
||||
if a, err := netip.ParseAddr(p.IPv4); err == nil {
|
||||
pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 32))
|
||||
}
|
||||
if p.IPv6 != "" {
|
||||
if a, err := netip.ParseAddr(p.IPv6); err == nil {
|
||||
pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 128))
|
||||
}
|
||||
}
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
// reconcile makes the interface's peer set match the database, one peer at
|
||||
// a time. It never uses ReplacePeers on a running interface: that would
|
||||
// reset every counter and drop every session for the sake of one change.
|
||||
func (e *Engine) reconcile(ctx context.Context) error {
|
||||
dev, err := e.be.Device(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
e.mu.RLock()
|
||||
want := make(map[string]wg.PeerConfig, len(e.peers))
|
||||
for _, p := range e.peers {
|
||||
if active(p, now) {
|
||||
pc, err := e.peerConfig(p)
|
||||
if err != nil {
|
||||
e.log.Warn("skipping peer with bad key", "peer", p.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
want[p.PublicKey] = pc
|
||||
}
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
have := make(map[string]wg.PeerState, len(dev.Peers))
|
||||
for _, p := range dev.Peers {
|
||||
have[p.PublicKey.String()] = p
|
||||
}
|
||||
var errs []error
|
||||
for key, pc := range want {
|
||||
cur, ok := have[key]
|
||||
if ok && samePeer(cur, pc) {
|
||||
continue
|
||||
}
|
||||
if err := e.be.SetPeer(ctx, pc); err != nil {
|
||||
errs = append(errs, fmt.Errorf("add peer %s: %w", key, err))
|
||||
}
|
||||
}
|
||||
for key, cur := range have {
|
||||
if _, ok := want[key]; !ok {
|
||||
if err := e.be.RemovePeer(ctx, cur.PublicKey); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove peer %s: %w", key, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func samePeer(cur wg.PeerState, want wg.PeerConfig) bool {
|
||||
if len(cur.AllowedIPs) != len(want.AllowedIPs) {
|
||||
return false
|
||||
}
|
||||
set := map[netip.Prefix]bool{}
|
||||
for _, a := range cur.AllowedIPs {
|
||||
set[a] = true
|
||||
}
|
||||
for _, a := range want.AllowedIPs {
|
||||
if !set[a] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return cur.PersistentKeepalive == want.PersistentKeepalive
|
||||
}
|
||||
|
||||
func (e *Engine) housekeeping(ctx context.Context) {
|
||||
t := time.NewTicker(houseEvery)
|
||||
defer t.Stop()
|
||||
prune := time.NewTicker(time.Hour)
|
||||
defer prune.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := e.reconcile(ctx); err != nil {
|
||||
e.log.Warn("reconcile", "error", err)
|
||||
}
|
||||
if err := e.col.flush(ctx); err != nil {
|
||||
e.log.Warn("flush counters", "error", err)
|
||||
}
|
||||
_ = e.st.PruneSessions(ctx)
|
||||
case <-prune.C:
|
||||
_ = e.st.PruneTraffic(ctx, time.Now().Add(-e.cfg.TrafficRetention))
|
||||
_ = e.st.PruneAudit(ctx, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
)
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
DBPath: ":memory:",
|
||||
Backend: "mock",
|
||||
Iface: "wg0",
|
||||
ListenPort: 51820,
|
||||
Subnet4: netip.MustParsePrefix("10.8.0.0/29"),
|
||||
Subnet6: netip.MustParsePrefix("fd42::/64"),
|
||||
HTTP: "127.0.0.1:0",
|
||||
SessionIdle: time.Hour,
|
||||
SessionMax: 24 * time.Hour,
|
||||
TrafficRetention: 24 * time.Hour,
|
||||
PollInterval: time.Hour, // tests drive polls by hand
|
||||
InitialEndpoint: "vpn.example.com",
|
||||
InitialDNS: "1.1.1.1",
|
||||
ManageFirewall: true,
|
||||
ManageSysctl: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEngine(t *testing.T) (*Engine, *wg.Mock) {
|
||||
t.Helper()
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
mock := wg.NewMock("wg0", false)
|
||||
e := New(testConfig(), st, mock, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err := e.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = e.Stop(context.Background()) })
|
||||
return e, mock
|
||||
}
|
||||
|
||||
func TestAllocate(t *testing.T) {
|
||||
subnet := netip.MustParsePrefix("10.8.0.0/29") // .0 net, .1 server, .2-.6 usable, .7 broadcast
|
||||
used := map[string]bool{}
|
||||
var got []string
|
||||
for {
|
||||
a, err := allocate(subnet, used)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
used[a.String()] = true
|
||||
got = append(got, a.String())
|
||||
}
|
||||
want := "10.8.0.2 10.8.0.3 10.8.0.4 10.8.0.5 10.8.0.6"
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Fatalf("got %v, want %s", got, want)
|
||||
}
|
||||
a6, err := allocate(netip.MustParsePrefix("fd42::/64"), map[string]bool{"fd42::2": true})
|
||||
if err != nil || a6.String() != "fd42::3" {
|
||||
t.Fatalf("v6 allocation %v %v", a6, err)
|
||||
}
|
||||
if _, err := checkAddress(subnet, "10.8.0.1", used); err == nil {
|
||||
t.Fatal("server address accepted")
|
||||
}
|
||||
if _, err := checkAddress(subnet, "10.9.0.1", used); err == nil {
|
||||
t.Fatal("outside address accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerAndConfig(t *testing.T) {
|
||||
e, mock := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
p, err := e.CreatePeer(ctx, PeerInput{Name: "Laptop"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.IPv4 != "10.8.0.2" || p.IPv6 != "fd42::2" {
|
||||
t.Fatalf("addresses %s %s", p.IPv4, p.IPv6)
|
||||
}
|
||||
if p.PrivateKey == "" || p.PresharedKey == "" {
|
||||
t.Fatal("server-managed peer should have private and preshared keys")
|
||||
}
|
||||
cfg := e.ClientConfig(p)
|
||||
for _, want := range []string{
|
||||
"PrivateKey = " + p.PrivateKey,
|
||||
"Address = 10.8.0.2/29, fd42::2/64",
|
||||
"DNS = 1.1.1.1",
|
||||
"MTU = 1420",
|
||||
"PublicKey = " + e.ServerPublicKey(),
|
||||
"PresharedKey = " + p.PresharedKey,
|
||||
"AllowedIPs = 0.0.0.0/0, ::/0",
|
||||
"Endpoint = vpn.example.com:51820",
|
||||
"PersistentKeepalive = 25",
|
||||
} {
|
||||
if !strings.Contains(cfg, want) {
|
||||
t.Errorf("config missing %q:\n%s", want, cfg)
|
||||
}
|
||||
}
|
||||
dev, _ := mock.Device(ctx)
|
||||
if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != p.PublicKey {
|
||||
t.Fatal("peer not applied to the interface")
|
||||
}
|
||||
if len(dev.Peers[0].AllowedIPs) != 2 {
|
||||
t.Fatalf("allowed ips %v", dev.Peers[0].AllowedIPs)
|
||||
}
|
||||
png, err := e.QRCode(p, 256)
|
||||
if err != nil || len(png) < 100 {
|
||||
t.Fatalf("qr: %v", err)
|
||||
}
|
||||
|
||||
// A client-keyed peer: no private key, no QR code.
|
||||
priv, _ := wg.GeneratePrivateKey()
|
||||
c, err := e.CreatePeer(ctx, PeerInput{Name: "Router", PublicKey: priv.PublicKey().String(), ClientRoutes: "10.8.0.0/29"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.PrivateKey != "" || !strings.Contains(e.ClientConfig(c), "<your private key>") {
|
||||
t.Fatal("client-keyed peer leaked or lacked placeholder")
|
||||
}
|
||||
if _, err := e.QRCode(c, 256); err == nil {
|
||||
t.Fatal("QR for client-keyed peer should fail")
|
||||
}
|
||||
if _, err := e.CreatePeer(ctx, PeerInput{Name: "Dup", PublicKey: priv.PublicKey().String()}); err == nil {
|
||||
t.Fatal("duplicate public key accepted")
|
||||
}
|
||||
if _, err := e.CreatePeer(ctx, PeerInput{Name: ""}); err == nil {
|
||||
t.Fatal("empty name accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableResetRotateDelete(t *testing.T) {
|
||||
e, mock := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
p, _ := e.CreatePeer(ctx, PeerInput{Name: "Phone"})
|
||||
pub, _ := wg.ParseKey(p.PublicKey)
|
||||
mock.Touch(pub, 1000, 2000, "203.0.113.5:1234")
|
||||
e.col.poll(ctx)
|
||||
l, ok := e.Live(p.ID)
|
||||
if !ok || !l.Connected || l.Endpoint != "203.0.113.5:1234" {
|
||||
t.Fatalf("live state %+v", l)
|
||||
}
|
||||
// First sight sets the memo only; the next delta counts.
|
||||
mock.Touch(pub, 500, 700, "")
|
||||
e.col.poll(ctx)
|
||||
l, _ = e.Live(p.ID)
|
||||
if l.Rx != 500 || l.Tx != 700 {
|
||||
t.Fatalf("totals %d/%d, want 500/700", l.Rx, l.Tx)
|
||||
}
|
||||
|
||||
if _, err := e.SetEnabled(ctx, p.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev, _ := mock.Device(ctx)
|
||||
if len(dev.Peers) != 0 {
|
||||
t.Fatal("disabled peer still on interface")
|
||||
}
|
||||
e.col.poll(ctx)
|
||||
if l, _ := e.Live(p.ID); l.Connected {
|
||||
t.Fatal("disabled peer reported connected")
|
||||
}
|
||||
if _, err := e.SetEnabled(ctx, p.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dev, _ = mock.Device(ctx); len(dev.Peers) != 1 {
|
||||
t.Fatal("enabled peer not back on interface")
|
||||
}
|
||||
// Counters restart at zero after re-add; the total must not go backwards.
|
||||
mock.Touch(pub, 100, 100, "")
|
||||
e.col.poll(ctx)
|
||||
mock.Touch(pub, 100, 100, "")
|
||||
e.col.poll(ctx)
|
||||
l, _ = e.Live(p.ID)
|
||||
if l.Rx != 600 || l.Tx != 800 {
|
||||
t.Fatalf("totals after re-add %d/%d, want 600/800", l.Rx, l.Tx)
|
||||
}
|
||||
|
||||
if err := e.ResetSession(ctx, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := e.RotateKeys(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.PublicKey == p.PublicKey {
|
||||
t.Fatal("key did not change")
|
||||
}
|
||||
dev, _ = mock.Device(ctx)
|
||||
if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != r.PublicKey {
|
||||
t.Fatal("rotated key not applied")
|
||||
}
|
||||
if err := e.col.flush(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.DeletePeer(ctx, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.Peer(p.ID); err != ErrNotFound {
|
||||
t.Fatal("deleted peer still present")
|
||||
}
|
||||
if dev, _ = mock.Device(ctx); len(dev.Peers) != 0 {
|
||||
t.Fatal("deleted peer still on interface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryAndReconcile(t *testing.T) {
|
||||
e, mock := newTestEngine(t)
|
||||
ctx := context.Background()
|
||||
past := time.Now().Add(-time.Minute)
|
||||
p, err := e.CreatePeer(ctx, PeerInput{Name: "Temp", ExpiresAt: &past})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dev, _ := mock.Device(ctx); len(dev.Peers) != 0 {
|
||||
t.Fatal("expired peer applied")
|
||||
}
|
||||
future := time.Now().Add(time.Hour)
|
||||
if _, err := e.UpdatePeer(ctx, p.ID, PeerInput{Name: "Temp", ExpiresAt: &future}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
|
||||
t.Fatal("un-expired peer not applied")
|
||||
}
|
||||
// Someone removes the peer by hand; reconcile puts it back.
|
||||
pub, _ := wg.ParseKey(p.PublicKey)
|
||||
_ = mock.RemovePeer(ctx, pub)
|
||||
if err := e.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
|
||||
t.Fatal("reconcile did not restore peer")
|
||||
}
|
||||
// A stranger appears on the interface; reconcile removes it.
|
||||
stray, _ := wg.GeneratePrivateKey()
|
||||
_ = mock.SetPeer(ctx, wg.PeerConfig{PublicKey: stray.PublicKey()})
|
||||
if err := e.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
|
||||
t.Fatal("reconcile did not remove stray peer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsValidation(t *testing.T) {
|
||||
e, _ := newTestEngine(t)
|
||||
s := e.Settings()
|
||||
s.EndpointHost = ""
|
||||
if err := e.UpdateSettings(context.Background(), s); err == nil {
|
||||
t.Fatal("empty endpoint accepted")
|
||||
}
|
||||
s = e.Settings()
|
||||
s.MTU = 100
|
||||
if err := e.UpdateSettings(context.Background(), s); err == nil {
|
||||
t.Fatal("tiny MTU accepted")
|
||||
}
|
||||
s = e.Settings()
|
||||
s.DNS = "1.1.1.1, example.com"
|
||||
s.MTU = 1380
|
||||
if err := e.UpdateSettings(context.Background(), s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Settings().MTU != 1380 {
|
||||
t.Fatal("settings not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceAcrossRestart(t *testing.T) {
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
ctx := context.Background()
|
||||
e1 := New(testConfig(), st, wg.NewMock("wg0", false), log)
|
||||
if err := e1.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := e1.CreatePeer(ctx, PeerInput{Name: "Keep"})
|
||||
key := e1.ServerPublicKey()
|
||||
_ = e1.Stop(ctx)
|
||||
|
||||
e2 := New(testConfig(), st, wg.NewMock("wg0", false), log)
|
||||
if err := e2.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e2.Stop(ctx)
|
||||
if e2.ServerPublicKey() != key {
|
||||
t.Fatal("server key changed across restart")
|
||||
}
|
||||
got, err := e2.Peer(p.ID)
|
||||
if err != nil || got.PublicKey != p.PublicKey {
|
||||
t.Fatal("peer lost across restart")
|
||||
}
|
||||
if dev, _ := e2.be.Device(ctx); len(dev.Peers) != 1 {
|
||||
t.Fatal("peer not re-applied after restart")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event is one server-sent event.
|
||||
type Event struct {
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Hub fans events out to every open dashboard. A subscriber that cannot keep
|
||||
// up loses events rather than stalling the collector; the next status
|
||||
// snapshot carries the full picture anyway.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
subs map[chan Event]struct{}
|
||||
}
|
||||
|
||||
// NewHub returns an empty hub.
|
||||
func NewHub() *Hub { return &Hub{subs: map[chan Event]struct{}{}} }
|
||||
|
||||
// Subscribe returns a channel of events and a function to leave.
|
||||
func (h *Hub) Subscribe() (<-chan Event, func()) {
|
||||
ch := make(chan Event, 16)
|
||||
h.mu.Lock()
|
||||
h.subs[ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
delete(h.subs, ch)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Publish encodes v as JSON and sends it to every subscriber.
|
||||
func (h *Hub) Publish(name string, v any) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ev := Event{Name: name, Data: data}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.subs {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribers is how many dashboards are listening.
|
||||
func (h *Hub) Subscribers() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.subs)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
)
|
||||
|
||||
// PeerInput is what the API accepts when creating or editing a peer.
|
||||
type PeerInput struct {
|
||||
Name string `json:"name"`
|
||||
// PublicKey, when set on create, means the client generated its own key
|
||||
// pair and the server never sees the private key.
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
// IPv4 / IPv6 may pin addresses; empty means allocate.
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
ClientRoutes string `json:"clientRoutes"`
|
||||
DNS string `json:"dns"`
|
||||
Keepalive *int `json:"keepalive"`
|
||||
MTU *int `json:"mtu"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// ErrValidation marks user errors so the API can answer 400.
|
||||
type ErrValidation struct{ Msg string }
|
||||
|
||||
func (e ErrValidation) Error() string { return e.Msg }
|
||||
|
||||
func invalid(format string, a ...any) error { return ErrValidation{Msg: fmt.Sprintf(format, a...)} }
|
||||
|
||||
// ErrNotFound is returned for unknown peer ids.
|
||||
var ErrNotFound = store.ErrNotFound
|
||||
|
||||
// Peer returns a copy of one peer.
|
||||
func (e *Engine) Peer(id string) (*store.Peer, error) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
p, ok := e.peers[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
cp := *p
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// Peers returns copies of every peer, newest first.
|
||||
func (e *Engine) Peers() []*store.Peer {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
out := make([]*store.Peer, 0, len(e.peers))
|
||||
for _, p := range e.peers {
|
||||
cp := *p
|
||||
out = append(out, &cp)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Live returns the live state for a peer.
|
||||
func (e *Engine) Live(id string) (Live, bool) { return e.col.LiveFor(id) }
|
||||
|
||||
// Snapshot returns the last poll.
|
||||
func (e *Engine) Snapshot() Snapshot { return e.col.Snapshot() }
|
||||
|
||||
func (e *Engine) usedAddresses() (v4, v6 map[string]bool) {
|
||||
v4, v6 = map[string]bool{}, map[string]bool{}
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
for _, p := range e.peers {
|
||||
v4[p.IPv4] = true
|
||||
if p.IPv6 != "" {
|
||||
v6[p.IPv6] = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func validateName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", invalid("name is required")
|
||||
}
|
||||
if len(name) > 64 {
|
||||
return "", invalid("name must be 64 characters or fewer")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// CreatePeer allocates addresses, generates keys and adds the peer to the
|
||||
// interface. The returned peer includes the private key when the server
|
||||
// generated it.
|
||||
func (e *Engine) CreatePeer(ctx context.Context, in PeerInput) (*store.Peer, error) {
|
||||
name, err := validateName(in.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings := e.Settings()
|
||||
p := &store.Peer{Name: name, Enabled: true, Notes: strings.TrimSpace(in.Notes)}
|
||||
if p.ID, err = auth.NewID(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.PublicKey != "" {
|
||||
pub, err := wg.ParseKey(strings.TrimSpace(in.PublicKey))
|
||||
if err != nil {
|
||||
return nil, invalid("public key: %v", err)
|
||||
}
|
||||
p.PublicKey = pub.String()
|
||||
} else {
|
||||
priv, err := wg.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.PrivateKey = priv.String()
|
||||
p.PublicKey = priv.PublicKey().String()
|
||||
}
|
||||
if p.PublicKey == e.ServerPublicKey() {
|
||||
return nil, invalid("that is the server's own public key")
|
||||
}
|
||||
e.mu.RLock()
|
||||
_, dup := e.byKey[p.PublicKey]
|
||||
e.mu.RUnlock()
|
||||
if dup {
|
||||
return nil, invalid("a peer with that public key already exists")
|
||||
}
|
||||
if settings.PresharedKeys {
|
||||
psk, err := wg.GeneratePresharedKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.PresharedKey = psk.String()
|
||||
}
|
||||
used4, used6 := e.usedAddresses()
|
||||
var a4 netip.Addr
|
||||
if in.IPv4 != "" {
|
||||
if a4, err = checkAddress(e.cfg.Subnet4, in.IPv4, used4); err != nil {
|
||||
return nil, invalid("IPv4: %v", err)
|
||||
}
|
||||
} else if a4, err = allocate(e.cfg.Subnet4, used4); err != nil {
|
||||
return nil, invalid("%v", err)
|
||||
}
|
||||
p.IPv4 = a4.String()
|
||||
if e.cfg.Subnet6.IsValid() {
|
||||
var a6 netip.Addr
|
||||
if in.IPv6 != "" {
|
||||
if a6, err = checkAddress(e.cfg.Subnet6, in.IPv6, used6); err != nil {
|
||||
return nil, invalid("IPv6: %v", err)
|
||||
}
|
||||
} else if a6, err = allocate(e.cfg.Subnet6, used6); err != nil {
|
||||
return nil, invalid("%v", err)
|
||||
}
|
||||
p.IPv6 = a6.String()
|
||||
} else if in.IPv6 != "" {
|
||||
return nil, invalid("IPv6 is not enabled on this server (set WGX_SUBNET6)")
|
||||
}
|
||||
if err := applyEditable(p, in, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.st.CreatePeer(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.peers[p.ID] = p
|
||||
e.byKey[p.PublicKey] = p
|
||||
e.mu.Unlock()
|
||||
if err := e.applyPeer(ctx, p); err != nil {
|
||||
e.log.Error("apply new peer", "peer", p.ID, "error", err)
|
||||
}
|
||||
e.hub.Publish("peers", "changed")
|
||||
cp := *p
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// applyEditable copies the fields that may change after creation.
|
||||
func applyEditable(p *store.Peer, in PeerInput, settings Settings) error {
|
||||
routes := strings.TrimSpace(in.ClientRoutes)
|
||||
if routes == "" {
|
||||
routes = settings.ClientRoutes
|
||||
}
|
||||
ps, err := ParsePrefixes(routes)
|
||||
if err != nil {
|
||||
return invalid("client routes: %v", err)
|
||||
}
|
||||
p.ClientRoutes = JoinPrefixes(ps)
|
||||
if _, err := ParseDNS(in.DNS); err != nil {
|
||||
return invalid("%v", err)
|
||||
}
|
||||
p.DNS = strings.TrimSpace(in.DNS)
|
||||
if in.Keepalive != nil {
|
||||
if *in.Keepalive < 0 || *in.Keepalive > 65535 {
|
||||
return invalid("keepalive must be 0-65535 seconds")
|
||||
}
|
||||
p.Keepalive = *in.Keepalive
|
||||
}
|
||||
if in.MTU != nil {
|
||||
if *in.MTU != 0 && (*in.MTU < 1280 || *in.MTU > 9000) {
|
||||
return invalid("MTU must be 0 (server default) or 1280-9000")
|
||||
}
|
||||
p.MTU = *in.MTU
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
p.Enabled = *in.Enabled
|
||||
}
|
||||
if in.ExpiresAt != nil {
|
||||
p.ExpiresAt = in.ExpiresAt.UTC()
|
||||
if p.ExpiresAt.Unix() <= 0 {
|
||||
p.ExpiresAt = time.Time{}
|
||||
}
|
||||
}
|
||||
p.Notes = strings.TrimSpace(in.Notes)
|
||||
if len(p.Notes) > 2000 {
|
||||
return invalid("notes must be 2000 characters or fewer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePeer edits a peer. Keys and addresses do not change here.
|
||||
func (e *Engine) UpdatePeer(ctx context.Context, id string, in PeerInput) (*store.Peer, error) {
|
||||
e.mu.RLock()
|
||||
cur, ok := e.peers[id]
|
||||
e.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
p := *cur
|
||||
name, err := validateName(in.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Name = name
|
||||
if err := applyEditable(&p, in, e.Settings()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.st.UpdatePeer(ctx, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
*cur = p
|
||||
e.mu.Unlock()
|
||||
if err := e.applyPeer(ctx, cur); err != nil {
|
||||
e.log.Error("apply peer", "peer", id, "error", err)
|
||||
}
|
||||
e.hub.Publish("peers", "changed")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// SetEnabled turns a peer on or off. Off removes it from the interface at
|
||||
// once, which drops any session it has: this is "disconnect".
|
||||
func (e *Engine) SetEnabled(ctx context.Context, id string, enabled bool) (*store.Peer, error) {
|
||||
e.mu.RLock()
|
||||
cur, ok := e.peers[id]
|
||||
e.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
p := *cur
|
||||
p.Enabled = enabled
|
||||
if err := e.st.UpdatePeer(ctx, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
*cur = p
|
||||
e.mu.Unlock()
|
||||
if err := e.applyPeer(ctx, cur); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.hub.Publish("peers", "changed")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ResetSession drops a peer's current session without disabling it. The
|
||||
// client will handshake again on its next packet.
|
||||
func (e *Engine) ResetSession(ctx context.Context, id string) error {
|
||||
e.mu.RLock()
|
||||
cur, ok := e.peers[id]
|
||||
e.mu.RUnlock()
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
pub, err := wg.ParseKey(cur.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.be.RemovePeer(ctx, pub); err != nil {
|
||||
return err
|
||||
}
|
||||
e.col.rekey(cur.PublicKey)
|
||||
return e.applyPeer(ctx, cur)
|
||||
}
|
||||
|
||||
// RotateKeys gives a server-managed peer a new key pair (and preshared key).
|
||||
// The old configuration stops working immediately.
|
||||
func (e *Engine) RotateKeys(ctx context.Context, id string) (*store.Peer, error) {
|
||||
e.mu.RLock()
|
||||
cur, ok := e.peers[id]
|
||||
e.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if cur.PrivateKey == "" {
|
||||
return nil, invalid("this peer's keys are managed by the client; create a new peer instead")
|
||||
}
|
||||
priv, err := wg.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := *cur
|
||||
oldKey := p.PublicKey
|
||||
p.PrivateKey = priv.String()
|
||||
p.PublicKey = priv.PublicKey().String()
|
||||
if e.Settings().PresharedKeys {
|
||||
psk, err := wg.GeneratePresharedKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.PresharedKey = psk.String()
|
||||
} else {
|
||||
p.PresharedKey = ""
|
||||
}
|
||||
if err := e.st.UpdatePeer(ctx, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if old, err := wg.ParseKey(oldKey); err == nil {
|
||||
_ = e.be.RemovePeer(ctx, old)
|
||||
}
|
||||
e.col.rekey(oldKey)
|
||||
e.mu.Lock()
|
||||
delete(e.byKey, oldKey)
|
||||
*cur = p
|
||||
e.byKey[p.PublicKey] = cur
|
||||
e.mu.Unlock()
|
||||
if err := e.applyPeer(ctx, cur); err != nil {
|
||||
e.log.Error("apply rotated peer", "peer", id, "error", err)
|
||||
}
|
||||
e.hub.Publish("peers", "changed")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// DeletePeer removes a peer for good.
|
||||
func (e *Engine) DeletePeer(ctx context.Context, id string) error {
|
||||
e.mu.RLock()
|
||||
cur, ok := e.peers[id]
|
||||
e.mu.RUnlock()
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if pub, err := wg.ParseKey(cur.PublicKey); err == nil {
|
||||
if err := e.be.RemovePeer(ctx, pub); err != nil {
|
||||
e.log.Warn("remove peer from interface", "peer", id, "error", err)
|
||||
}
|
||||
}
|
||||
if err := e.st.DeletePeer(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
e.mu.Lock()
|
||||
delete(e.peers, id)
|
||||
delete(e.byKey, cur.PublicKey)
|
||||
e.mu.Unlock()
|
||||
e.col.forget(id, cur.PublicKey)
|
||||
e.hub.Publish("peers", "changed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPeer adds or removes one peer on the interface according to whether
|
||||
// it should be active.
|
||||
func (e *Engine) applyPeer(ctx context.Context, p *store.Peer) error {
|
||||
pc, err := e.peerConfig(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active(p, time.Now()) {
|
||||
return e.be.SetPeer(ctx, pc)
|
||||
}
|
||||
return e.be.RemovePeer(ctx, pc.PublicKey)
|
||||
}
|
||||
|
||||
// ClientConfig renders the WireGuard configuration file for a peer. When the
|
||||
// client holds its own private key the placeholder is left for them.
|
||||
func (e *Engine) ClientConfig(p *store.Peer) string {
|
||||
s := e.Settings()
|
||||
var b strings.Builder
|
||||
b.WriteString("[Interface]\n")
|
||||
if p.PrivateKey != "" {
|
||||
fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey)
|
||||
} else {
|
||||
b.WriteString("PrivateKey = <your private key>\n")
|
||||
}
|
||||
addrs := []string{fmt.Sprintf("%s/%d", p.IPv4, e.cfg.Subnet4.Bits())}
|
||||
if p.IPv6 != "" && e.cfg.Subnet6.IsValid() {
|
||||
addrs = append(addrs, fmt.Sprintf("%s/%d", p.IPv6, e.cfg.Subnet6.Bits()))
|
||||
}
|
||||
fmt.Fprintf(&b, "Address = %s\n", strings.Join(addrs, ", "))
|
||||
dns := p.DNS
|
||||
if dns == "" {
|
||||
dns = s.DNS
|
||||
}
|
||||
if d, _ := ParseDNS(dns); len(d) > 0 {
|
||||
fmt.Fprintf(&b, "DNS = %s\n", strings.Join(d, ", "))
|
||||
}
|
||||
mtu := p.MTU
|
||||
if mtu == 0 {
|
||||
mtu = s.MTU
|
||||
}
|
||||
fmt.Fprintf(&b, "MTU = %d\n", mtu)
|
||||
b.WriteString("\n[Peer]\n")
|
||||
fmt.Fprintf(&b, "PublicKey = %s\n", e.ServerPublicKey())
|
||||
if p.PresharedKey != "" {
|
||||
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
|
||||
}
|
||||
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.ClientRoutes)
|
||||
fmt.Fprintf(&b, "Endpoint = %s\n", net.JoinHostPort(s.EndpointHost, strconv.Itoa(s.EndpointPort)))
|
||||
ka := p.Keepalive
|
||||
if ka == 0 {
|
||||
ka = s.Keepalive
|
||||
}
|
||||
if ka > 0 {
|
||||
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", ka)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// QRCode renders the client configuration as a PNG.
|
||||
func (e *Engine) QRCode(p *store.Peer, size int) ([]byte, error) {
|
||||
if p.PrivateKey == "" {
|
||||
return nil, errors.New("no QR code: the private key is held by the client")
|
||||
}
|
||||
if size < 128 || size > 1024 {
|
||||
size = 384
|
||||
}
|
||||
return qrcode.Encode(e.ClientConfig(p), qrcode.Medium, size)
|
||||
}
|
||||
|
||||
// Usage returns a peer's traffic series since a time.
|
||||
func (e *Engine) Usage(ctx context.Context, peerID string, since time.Time) ([]store.TrafficPoint, error) {
|
||||
if peerID != "" {
|
||||
if _, err := e.Peer(peerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Pending buckets are flushed first so the last few minutes show.
|
||||
if err := e.col.flush(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pts, err := e.st.TrafficSeries(ctx, peerID, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pts == nil {
|
||||
pts = []store.TrafficPoint{}
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
// UsageByPeer sums traffic per peer since a time.
|
||||
func (e *Engine) UsageByPeer(ctx context.Context, since time.Time) ([]store.PeerUsage, error) {
|
||||
if err := e.col.flush(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := e.st.UsageSince(ctx, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
u = []store.PeerUsage{}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
)
|
||||
|
||||
// Settings are the administrator-editable server options. They persist in the
|
||||
// database and can be changed from the UI without restarting the container.
|
||||
type Settings struct {
|
||||
// EndpointHost is the public name or address clients connect to.
|
||||
EndpointHost string `json:"endpointHost"`
|
||||
// EndpointPort is what clients dial; usually the listen port, but
|
||||
// different when the container's UDP port is remapped.
|
||||
EndpointPort int `json:"endpointPort"`
|
||||
// DNS handed to clients, comma separated. Empty means none.
|
||||
DNS string `json:"dns"`
|
||||
// ClientRoutes is the default AllowedIPs written into client configs.
|
||||
ClientRoutes string `json:"clientRoutes"`
|
||||
// MTU for the server interface and, by default, client configs.
|
||||
MTU int `json:"mtu"`
|
||||
// Keepalive is the default PersistentKeepalive for clients, in seconds.
|
||||
Keepalive int `json:"keepalive"`
|
||||
// PeerIsolation stops peers reaching one another.
|
||||
PeerIsolation bool `json:"peerIsolation"`
|
||||
// ClampMSS rewrites TCP MSS on forwarded SYNs to fit the tunnel MTU.
|
||||
ClampMSS bool `json:"clampMSS"`
|
||||
// PresharedKeys adds a per-peer preshared key to every new peer.
|
||||
PresharedKeys bool `json:"presharedKeys"`
|
||||
// ConnectedWindow is how many seconds since the last handshake still
|
||||
// counts as connected. WireGuard rejects sessions after 180 s.
|
||||
ConnectedWindow int `json:"connectedWindow"`
|
||||
}
|
||||
|
||||
// DefaultSettings returns what a fresh install starts with.
|
||||
func DefaultSettings(endpointHost, dns string, port int) Settings {
|
||||
return Settings{
|
||||
EndpointHost: endpointHost,
|
||||
EndpointPort: port,
|
||||
DNS: dns,
|
||||
ClientRoutes: "0.0.0.0/0, ::/0",
|
||||
MTU: 1420,
|
||||
Keepalive: 25,
|
||||
PeerIsolation: false,
|
||||
ClampMSS: true,
|
||||
PresharedKeys: true,
|
||||
ConnectedWindow: 180,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks settings coming in from the API.
|
||||
func (s *Settings) Validate() error {
|
||||
var errs []error
|
||||
s.EndpointHost = strings.TrimSpace(s.EndpointHost)
|
||||
if s.EndpointHost == "" {
|
||||
errs = append(errs, errors.New("endpoint host is required"))
|
||||
} else if strings.ContainsAny(s.EndpointHost, " /\\:") && !strings.HasPrefix(s.EndpointHost, "[") {
|
||||
if _, err := netip.ParseAddr(s.EndpointHost); err != nil {
|
||||
errs = append(errs, errors.New("endpoint host must be a hostname or IP address without a port"))
|
||||
}
|
||||
}
|
||||
if s.EndpointPort < 1 || s.EndpointPort > 65535 {
|
||||
errs = append(errs, errors.New("endpoint port must be 1-65535"))
|
||||
}
|
||||
if _, err := ParseDNS(s.DNS); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if _, err := ParsePrefixes(s.ClientRoutes); err != nil {
|
||||
errs = append(errs, fmt.Errorf("client routes: %w", err))
|
||||
}
|
||||
if s.MTU < 1280 || s.MTU > 9000 {
|
||||
errs = append(errs, errors.New("MTU must be between 1280 and 9000"))
|
||||
}
|
||||
if s.Keepalive < 0 || s.Keepalive > 65535 {
|
||||
errs = append(errs, errors.New("keepalive must be 0-65535 seconds"))
|
||||
}
|
||||
if s.ConnectedWindow < 30 || s.ConnectedWindow > 3600 {
|
||||
errs = append(errs, errors.New("connected window must be 30-3600 seconds"))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ParseDNS validates a comma-separated list of resolvers (addresses, or a
|
||||
// search domain which WireGuard clients also accept in the DNS field).
|
||||
func ParseDNS(s string) ([]string, error) {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := netip.ParseAddr(part); err != nil {
|
||||
// Allow search domains: letters, digits, dots and dashes only.
|
||||
for _, r := range part {
|
||||
if !(r == '.' || r == '-' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return nil, fmt.Errorf("DNS entry %q is neither an address nor a domain", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParsePrefixes parses a comma-separated CIDR list; bare addresses become
|
||||
// host prefixes.
|
||||
func ParsePrefixes(s string) ([]netip.Prefix, error) {
|
||||
var out []netip.Prefix
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
p, err := netip.ParsePrefix(part)
|
||||
if err != nil {
|
||||
a, err2 := netip.ParseAddr(part)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("%q is not a CIDR", part)
|
||||
}
|
||||
p = netip.PrefixFrom(a, a.BitLen())
|
||||
}
|
||||
out = append(out, p.Masked())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("at least one route is required")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// JoinPrefixes renders prefixes the way a WireGuard config expects.
|
||||
func JoinPrefixes(ps []netip.Prefix) string {
|
||||
parts := make([]string, len(ps))
|
||||
for i, p := range ps {
|
||||
parts[i] = p.String()
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
const settingsKey = "server"
|
||||
|
||||
func loadSettings(ctx context.Context, st *store.Store) (*Settings, bool, error) {
|
||||
raw, err := st.GetSetting(ctx, settingsKey)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
var s Settings
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
return nil, false, fmt.Errorf("settings are corrupt: %w", err)
|
||||
}
|
||||
return &s, true, nil
|
||||
}
|
||||
|
||||
func saveSettings(ctx context.Context, st *store.Store, s *Settings) error {
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return st.SetSetting(ctx, settingsKey, string(raw))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/netcfg"
|
||||
)
|
||||
|
||||
// SysctlStatus is one sysctl as reported to the UI.
|
||||
type SysctlStatus struct {
|
||||
Key string `json:"key"`
|
||||
Wanted string `json:"wanted"`
|
||||
Current string `json:"current"`
|
||||
Applied bool `json:"applied"`
|
||||
Required bool `json:"required"`
|
||||
Why string `json:"why"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Status is the server overview.
|
||||
type Status struct {
|
||||
Version string `json:"version"`
|
||||
Backend string `json:"backend"`
|
||||
Interface string `json:"interface"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
Addresses []string `json:"addresses"`
|
||||
Subnet4 string `json:"subnet4"`
|
||||
Subnet6 string `json:"subnet6,omitempty"`
|
||||
Egress string `json:"egress,omitempty"`
|
||||
FirewallError string `json:"firewallError,omitempty"`
|
||||
FirewallManaged bool `json:"firewallManaged"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Sysctls []SysctlStatus `json:"sysctls"`
|
||||
Totals Totals `json:"totals"`
|
||||
Settings Settings `json:"settings"`
|
||||
}
|
||||
|
||||
// Version is stamped at build time.
|
||||
var Version = "dev"
|
||||
|
||||
// Status assembles the overview.
|
||||
func (e *Engine) Status() Status {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
st := Status{
|
||||
Version: Version,
|
||||
Backend: e.be.Kind(),
|
||||
Interface: e.cfg.Iface,
|
||||
PublicKey: e.serverKey.PublicKey().String(),
|
||||
ListenPort: e.cfg.ListenPort,
|
||||
Subnet4: e.cfg.Subnet4.Masked().String(),
|
||||
Egress: e.egress,
|
||||
FirewallError: e.fwErr,
|
||||
FirewallManaged: e.cfg.ManageFirewall && e.be.Kind() != "mock",
|
||||
StartedAt: e.startedAt,
|
||||
Settings: e.settings,
|
||||
Sysctls: []SysctlStatus{},
|
||||
}
|
||||
if e.cfg.Subnet6.IsValid() {
|
||||
st.Subnet6 = e.cfg.Subnet6.Masked().String()
|
||||
}
|
||||
for _, a := range e.ServerAddresses() {
|
||||
st.Addresses = append(st.Addresses, a.String())
|
||||
}
|
||||
for _, r := range e.sysctls {
|
||||
st.Sysctls = append(st.Sysctls, sysctlStatus(r))
|
||||
}
|
||||
st.Totals = e.col.Snapshot().Totals
|
||||
return st
|
||||
}
|
||||
|
||||
func sysctlStatus(r netcfg.Result) SysctlStatus {
|
||||
return SysctlStatus{Key: r.Key, Wanted: r.Value, Current: r.Current, Applied: r.Applied, Required: r.Required, Why: r.Why, Error: r.Err}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build linux
|
||||
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// DefaultEgress returns the interface the default IPv4 route leaves through.
|
||||
// Masquerading on exactly that interface (rather than "anything that is not
|
||||
// wg0") keeps the NAT rule from touching docker-internal traffic.
|
||||
func DefaultEgress() (string, error) {
|
||||
routes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, r := range routes {
|
||||
if r.Dst == nil || (r.Dst.IP.Equal(net.IPv4zero) && isZeroMask(r.Dst.Mask)) {
|
||||
if r.LinkIndex == 0 {
|
||||
continue
|
||||
}
|
||||
link, err := netlink.LinkByIndex(r.LinkIndex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return link.Attrs().Name, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no default route")
|
||||
}
|
||||
|
||||
func isZeroMask(m net.IPMask) bool {
|
||||
for _, b := range m {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package netcfg
|
||||
|
||||
import "errors"
|
||||
|
||||
// DefaultEgress is only implemented on Linux.
|
||||
func DefaultEgress() (string, error) { return "", errors.New("not supported on this platform") }
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package netcfg owns everything around the WireGuard interface that is not
|
||||
// WireGuard itself: IP forwarding, the nftables ruleset that NATs peers to the
|
||||
// outside world, and the sysctls that keep throughput up.
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rules describes the firewall WGX wants.
|
||||
type Rules struct {
|
||||
// Iface is the WireGuard interface name, e.g. wg0.
|
||||
Iface string
|
||||
// Egress is the interface peers reach the outside world through. Empty
|
||||
// means "any interface that is not Iface", which is what most single-NIC
|
||||
// containers want.
|
||||
Egress string
|
||||
// ListenPort is the UDP port to accept WireGuard traffic on.
|
||||
ListenPort int
|
||||
// Subnets are the tunnel networks to masquerade (v4 and/or v6).
|
||||
Subnets []netip.Prefix
|
||||
// PeerIsolation drops traffic between peers when true.
|
||||
PeerIsolation bool
|
||||
// ClampMSS rewrites the MSS of forwarded SYNs to fit the path MTU. It
|
||||
// costs almost nothing and removes the single most common cause of
|
||||
// "the VPN connects but websites hang".
|
||||
ClampMSS bool
|
||||
// Table names the nftables table, so a host with its own rules never
|
||||
// collides with ours. Defaults to "wgx".
|
||||
Table string
|
||||
}
|
||||
|
||||
// Ruleset renders the nftables script for the given rules. It is a pure
|
||||
// function so tests can check the output without a kernel.
|
||||
func Ruleset(r Rules) string {
|
||||
table := r.Table
|
||||
if table == "" {
|
||||
table = "wgx"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "table inet %s\n", table)
|
||||
fmt.Fprintf(&b, "delete table inet %s\n", table)
|
||||
fmt.Fprintf(&b, "table inet %s {\n", table)
|
||||
|
||||
fmt.Fprintf(&b, " chain input {\n")
|
||||
fmt.Fprintf(&b, " type filter hook input priority filter; policy accept;\n")
|
||||
fmt.Fprintf(&b, " udp dport %d accept comment \"wireguard\"\n", r.ListenPort)
|
||||
fmt.Fprintf(&b, " }\n")
|
||||
|
||||
fmt.Fprintf(&b, " chain forward {\n")
|
||||
fmt.Fprintf(&b, " type filter hook forward priority filter; policy accept;\n")
|
||||
if r.PeerIsolation {
|
||||
fmt.Fprintf(&b, " iifname %q oifname %q drop comment \"peer isolation\"\n", r.Iface, r.Iface)
|
||||
}
|
||||
if r.ClampMSS {
|
||||
fmt.Fprintf(&b, " iifname %q tcp flags syn tcp option maxseg size set rt mtu comment \"clamp mss\"\n", r.Iface)
|
||||
fmt.Fprintf(&b, " oifname %q tcp flags syn tcp option maxseg size set rt mtu comment \"clamp mss\"\n", r.Iface)
|
||||
}
|
||||
fmt.Fprintf(&b, " iifname %q accept\n", r.Iface)
|
||||
fmt.Fprintf(&b, " oifname %q ct state related,established accept\n", r.Iface)
|
||||
fmt.Fprintf(&b, " }\n")
|
||||
|
||||
fmt.Fprintf(&b, " chain postrouting {\n")
|
||||
fmt.Fprintf(&b, " type nat hook postrouting priority srcnat; policy accept;\n")
|
||||
for _, s := range r.Subnets {
|
||||
fam := "ip"
|
||||
if s.Addr().Is6() {
|
||||
fam = "ip6"
|
||||
}
|
||||
if r.Egress != "" {
|
||||
fmt.Fprintf(&b, " %s saddr %s oifname %q masquerade\n", fam, s.Masked(), r.Egress)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " %s saddr %s oifname != %q masquerade\n", fam, s.Masked(), r.Iface)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " }\n")
|
||||
fmt.Fprintf(&b, "}\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Apply loads the ruleset with nft(8).
|
||||
func Apply(ctx context.Context, r Rules) error {
|
||||
return runNFT(ctx, Ruleset(r))
|
||||
}
|
||||
|
||||
// Remove deletes the WGX table, ignoring the case where it is already gone.
|
||||
func Remove(ctx context.Context, table string) error {
|
||||
if table == "" {
|
||||
table = "wgx"
|
||||
}
|
||||
script := fmt.Sprintf("table inet %s\ndelete table inet %s\n", table, table)
|
||||
return runNFT(ctx, script)
|
||||
}
|
||||
|
||||
func runNFT(ctx context.Context, script string) error {
|
||||
nft, err := exec.LookPath("nft")
|
||||
if err != nil {
|
||||
return fmt.Errorf("nft is not installed: %w", err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, nft, "-f", "-")
|
||||
cmd.Stdin = strings.NewReader(script)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("nft: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuleset(t *testing.T) {
|
||||
r := Rules{
|
||||
Iface: "wg0",
|
||||
Egress: "eth0",
|
||||
ListenPort: 51820,
|
||||
Subnets: []netip.Prefix{netip.MustParsePrefix("10.8.0.0/24"), netip.MustParsePrefix("fd42::/64")},
|
||||
PeerIsolation: true,
|
||||
ClampMSS: true,
|
||||
}
|
||||
out := Ruleset(r)
|
||||
for _, want := range []string{
|
||||
"table inet wgx {",
|
||||
"udp dport 51820 accept",
|
||||
`iifname "wg0" oifname "wg0" drop`,
|
||||
`tcp option maxseg size set rt mtu`,
|
||||
`ip saddr 10.8.0.0/24 oifname "eth0" masquerade`,
|
||||
`ip6 saddr fd42::/64 oifname "eth0" masquerade`,
|
||||
`oifname "wg0" ct state related,established accept`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("ruleset missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Without an egress, masquerade on anything that is not the tunnel.
|
||||
r.Egress = ""
|
||||
r.PeerIsolation = false
|
||||
out = Ruleset(r)
|
||||
if !strings.Contains(out, `oifname != "wg0" masquerade`) {
|
||||
t.Errorf("expected wildcard masquerade:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "peer isolation") {
|
||||
t.Error("isolation rule present when off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWanted(t *testing.T) {
|
||||
v4 := Wanted(false)
|
||||
v6 := Wanted(true)
|
||||
if len(v6) != len(v4)+1 {
|
||||
t.Fatal("ipv6 forwarding not added")
|
||||
}
|
||||
if v4[0].Key != "net.ipv4.ip_forward" || !v4[0].Required {
|
||||
t.Fatal("ip_forward must be first and required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sysctl is one kernel parameter and the value WGX wants for it.
|
||||
type Sysctl struct {
|
||||
Key string
|
||||
Value string
|
||||
// Required marks the ones the VPN cannot work without (forwarding). The
|
||||
// others are throughput tuning: nice to have, and often refused inside a
|
||||
// container because they are not network-namespaced.
|
||||
Required bool
|
||||
// Why is shown in the log and the UI when a value could not be set.
|
||||
Why string
|
||||
}
|
||||
|
||||
// Result records what happened to one sysctl.
|
||||
type Result struct {
|
||||
Sysctl
|
||||
Applied bool
|
||||
Current string
|
||||
Err string
|
||||
}
|
||||
|
||||
// Wanted returns the sysctls WGX applies at startup, in order.
|
||||
func Wanted(ipv6 bool) []Sysctl {
|
||||
s := []Sysctl{
|
||||
{Key: "net.ipv4.ip_forward", Value: "1", Required: true, Why: "peers cannot reach anything beyond the server without forwarding"},
|
||||
// Strict reverse-path filtering drops replies that arrive on the
|
||||
// tunnel for a source the kernel would route elsewhere. Loose is
|
||||
// what every VPN gateway runs.
|
||||
{Key: "net.ipv4.conf.all.rp_filter", Value: "2", Why: "strict rp_filter drops legitimate tunnel replies"},
|
||||
{Key: "net.ipv4.conf.default.rp_filter", Value: "2", Why: "strict rp_filter drops legitimate tunnel replies"},
|
||||
// The remaining ones are throughput. They are global (not
|
||||
// namespaced), so inside a container they usually fail and must be
|
||||
// set on the host instead -- see docs/performance.md.
|
||||
{Key: "net.core.rmem_max", Value: "26214400", Why: "larger UDP receive buffers stop bursts being dropped before WireGuard reads them"},
|
||||
{Key: "net.core.wmem_max", Value: "26214400", Why: "larger UDP send buffers keep the encrypt path from stalling"},
|
||||
{Key: "net.core.rmem_default", Value: "1048576", Why: "default socket receive buffer"},
|
||||
{Key: "net.core.wmem_default", Value: "1048576", Why: "default socket send buffer"},
|
||||
{Key: "net.core.netdev_max_backlog", Value: "16384", Why: "deeper per-CPU input queue for 10GbE bursts"},
|
||||
{Key: "net.ipv4.udp_rmem_min", Value: "16384", Why: "minimum UDP receive buffer under memory pressure"},
|
||||
{Key: "net.ipv4.udp_wmem_min", Value: "16384", Why: "minimum UDP send buffer under memory pressure"},
|
||||
}
|
||||
if ipv6 {
|
||||
s = append(s, Sysctl{Key: "net.ipv6.conf.all.forwarding", Value: "1", Required: true, Why: "IPv6 peers cannot reach anything beyond the server without forwarding"})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ApplyAll writes each sysctl through /proc/sys and reports what happened.
|
||||
// A value that is already right counts as applied. A required value that
|
||||
// cannot be set is returned as an error along with the full report so the
|
||||
// caller can decide whether to keep going.
|
||||
func ApplyAll(want []Sysctl) ([]Result, error) {
|
||||
var results []Result
|
||||
var fatal []string
|
||||
for _, s := range want {
|
||||
r := Result{Sysctl: s}
|
||||
path := filepath.Join("/proc/sys", strings.ReplaceAll(s.Key, ".", "/"))
|
||||
cur, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
r.Current = strings.TrimSpace(string(cur))
|
||||
}
|
||||
if r.Current == s.Value {
|
||||
r.Applied = true
|
||||
results = append(results, r)
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(s.Value), 0o644); err != nil {
|
||||
r.Err = err.Error()
|
||||
if s.Required && !forwardingSatisfied(r.Current, s.Value) {
|
||||
fatal = append(fatal, fmt.Sprintf("%s=%s (%s)", s.Key, s.Value, r.Err))
|
||||
}
|
||||
} else {
|
||||
r.Applied = true
|
||||
r.Current = s.Value
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
if len(fatal) > 0 {
|
||||
return results, fmt.Errorf("required sysctls could not be set: %s -- pass them with `--sysctl` or the compose `sysctls:` list", strings.Join(fatal, ", "))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// forwardingSatisfied treats "1" as satisfied for forwarding keys even when
|
||||
// the file was read-only, which is what a compose `sysctls:` entry produces.
|
||||
func forwardingSatisfied(current, want string) bool { return current == want }
|
||||
@@ -0,0 +1,402 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/engine"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
)
|
||||
|
||||
type healthBody struct {
|
||||
OK bool `json:"ok"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, healthBody{OK: true, Version: engine.Version})
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.eng.Status())
|
||||
}
|
||||
|
||||
// peerBody is a peer as the API presents it: never the private or preshared
|
||||
// key (those only leave through the config and QR endpoints).
|
||||
type peerBody struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
ServerKeys bool `json:"serverKeys"`
|
||||
PresharedKey bool `json:"presharedKey"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
ClientRoutes string `json:"clientRoutes"`
|
||||
DNS string `json:"dns"`
|
||||
Keepalive int `json:"keepalive"`
|
||||
MTU int `json:"mtu"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Expired bool `json:"expired"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Live engine.Live `json:"live"`
|
||||
}
|
||||
|
||||
func (s *Server) toPeerBody(p *store.Peer) peerBody {
|
||||
b := peerBody{
|
||||
ID: p.ID, Name: p.Name, PublicKey: p.PublicKey, ServerKeys: p.PrivateKey != "", PresharedKey: p.PresharedKey != "",
|
||||
IPv4: p.IPv4, IPv6: p.IPv6, ClientRoutes: p.ClientRoutes, DNS: p.DNS, Keepalive: p.Keepalive, MTU: p.MTU,
|
||||
Enabled: p.Enabled, Notes: p.Notes, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if !p.ExpiresAt.IsZero() {
|
||||
t := p.ExpiresAt
|
||||
b.ExpiresAt = &t
|
||||
b.Expired = time.Now().After(t)
|
||||
}
|
||||
if l, ok := s.eng.Live(p.ID); ok {
|
||||
b.Live = l
|
||||
} else {
|
||||
b.Live = engine.Live{PeerID: p.ID, Rx: p.RxTotal, Tx: p.TxTotal, LastHandshake: p.LastHandshake, Endpoint: p.LastEndpoint}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *Server) handlePeers(w http.ResponseWriter, r *http.Request) {
|
||||
peers := s.eng.Peers()
|
||||
out := make([]peerBody, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
out = append(out, s.toPeerBody(p))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handlePeer(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.Peer(r.PathValue("id"))
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.toPeerBody(p))
|
||||
}
|
||||
|
||||
type createPeerResponse struct {
|
||||
peerBody
|
||||
Config string `json:"config"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreatePeer(w http.ResponseWriter, r *http.Request) {
|
||||
var in engine.PeerInput
|
||||
if !readJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
p, err := s.eng.CreatePeer(r.Context(), in)
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "peer.created", p.Name, fmt.Sprintf("%s %s", p.ID, p.IPv4))
|
||||
writeJSON(w, http.StatusCreated, createPeerResponse{peerBody: s.toPeerBody(p), Config: s.eng.ClientConfig(p)})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePeer(w http.ResponseWriter, r *http.Request) {
|
||||
var in engine.PeerInput
|
||||
if !readJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
p, err := s.eng.UpdatePeer(r.Context(), r.PathValue("id"), in)
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "peer.updated", p.Name, p.ID)
|
||||
writeJSON(w, http.StatusOK, s.toPeerBody(p))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := s.eng.Peer(id)
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.eng.DeletePeer(r.Context(), id); err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "peer.deleted", p.Name, id)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleEnablePeer(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.SetEnabled(r.Context(), r.PathValue("id"), true)
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "peer.enabled", p.Name, p.ID)
|
||||
writeJSON(w, http.StatusOK, s.toPeerBody(p))
|
||||
}
|
||||
|
||||
// handleDisablePeer is "disconnect": the peer leaves the interface and its
|
||||
// session dies with it.
|
||||
func (s *Server) handleDisablePeer(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.SetEnabled(r.Context(), r.PathValue("id"), false)
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "peer.disabled", p.Name, p.ID)
|
||||
writeJSON(w, http.StatusOK, s.toPeerBody(p))
|
||||
}
|
||||
|
||||
func (s *Server) handleResetPeer(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := s.eng.Peer(id)
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.eng.ResetSession(r.Context(), id); err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "peer.session_reset", p.Name, id)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleRotatePeer(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.RotateKeys(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, "peer.keys_rotated", p.Name, p.ID)
|
||||
writeJSON(w, http.StatusOK, createPeerResponse{peerBody: s.toPeerBody(p), Config: s.eng.ClientConfig(p)})
|
||||
}
|
||||
|
||||
func safeFilename(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '.':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
out = "wgx"
|
||||
}
|
||||
if len(out) > 15 {
|
||||
// wg-quick derives the interface name from the file name and caps it
|
||||
// at 15 characters.
|
||||
out = out[:15]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handlePeerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.Peer(r.PathValue("id"))
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
cfg := s.eng.ClientConfig(p)
|
||||
if r.URL.Query().Get("download") != "" {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.conf"`, safeFilename(p.Name)))
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.audit(r, "peer.config_viewed", p.Name, p.ID)
|
||||
_, _ = w.Write([]byte(cfg))
|
||||
}
|
||||
|
||||
func (s *Server) handlePeerQR(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.Peer(r.PathValue("id"))
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
size, _ := strconv.Atoi(r.URL.Query().Get("size"))
|
||||
png, err := s.eng.QRCode(p, size)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(png)
|
||||
}
|
||||
|
||||
func sinceParam(r *http.Request) time.Time {
|
||||
switch r.URL.Query().Get("range") {
|
||||
case "1h":
|
||||
return time.Now().Add(-time.Hour)
|
||||
case "7d":
|
||||
return time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "30d":
|
||||
return time.Now().Add(-30 * 24 * time.Hour)
|
||||
case "90d":
|
||||
return time.Now().Add(-90 * 24 * time.Hour)
|
||||
default:
|
||||
return time.Now().Add(-24 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePeerUsage(w http.ResponseWriter, r *http.Request) {
|
||||
pts, err := s.eng.Usage(r.Context(), r.PathValue("id"), sinceParam(r))
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pts)
|
||||
}
|
||||
|
||||
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
|
||||
pts, err := s.eng.Usage(r.Context(), "", sinceParam(r))
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pts)
|
||||
}
|
||||
|
||||
func (s *Server) handleUsageByPeer(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := s.eng.UsageByPeer(r.Context(), sinceParam(r))
|
||||
if err != nil {
|
||||
engineError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.eng.Settings())
|
||||
}
|
||||
|
||||
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var in engine.Settings
|
||||
if !readJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if err := s.eng.UpdateSettings(r.Context(), in); err != nil {
|
||||
if strings.Contains(err.Error(), "firewall") {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "settings.updated", "", "")
|
||||
writeJSON(w, http.StatusOK, s.eng.Settings())
|
||||
}
|
||||
|
||||
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
entries, err := s.eng.Store().ListAudit(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// handleEvents streams status snapshots and change notices as SSE.
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Send the current picture at once rather than waiting for the next poll.
|
||||
snap := s.eng.Snapshot()
|
||||
if !snap.At.IsZero() {
|
||||
s.eng.Hub().Publish("status", snap)
|
||||
}
|
||||
ch, leave := s.eng.Hub().Subscribe()
|
||||
defer leave()
|
||||
fmt.Fprintf(w, "retry: 3000\n\n")
|
||||
flusher.Flush()
|
||||
keep := time.NewTicker(25 * time.Second)
|
||||
defer keep.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-keep.C:
|
||||
fmt.Fprintf(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
case ev := <-ch:
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Name, ev.Data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMetrics exposes Prometheus metrics, protected by a bearer token or
|
||||
// an admin session.
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
allowed := false
|
||||
if s.cfg.MetricsToken != "" {
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if subtle.ConstantTimeCompare([]byte(tok), []byte(s.cfg.MetricsToken)) == 1 {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
if u, sess := s.loadSession(r); u != nil && !sess.TOTPPending {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusUnauthorized, "metrics require a bearer token or a session")
|
||||
return
|
||||
}
|
||||
snap := s.eng.Snapshot()
|
||||
peers := s.eng.Peers()
|
||||
names := map[string]string{}
|
||||
for _, p := range peers {
|
||||
names[p.ID] = p.Name
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# HELP wgx_peers Number of configured peers.\n# TYPE wgx_peers gauge\nwgx_peers %d\n", snap.Totals.Peers)
|
||||
fmt.Fprintf(&b, "# HELP wgx_peers_connected Peers with a recent handshake.\n# TYPE wgx_peers_connected gauge\nwgx_peers_connected %d\n", snap.Totals.Connected)
|
||||
fmt.Fprintf(&b, "# HELP wgx_receive_bytes_total Bytes received from peers.\n# TYPE wgx_receive_bytes_total counter\n")
|
||||
for id, l := range snap.Peers {
|
||||
fmt.Fprintf(&b, "wgx_receive_bytes_total{peer=%q,name=%q} %d\n", id, names[id], l.Rx)
|
||||
}
|
||||
fmt.Fprintf(&b, "# HELP wgx_transmit_bytes_total Bytes sent to peers.\n# TYPE wgx_transmit_bytes_total counter\n")
|
||||
for id, l := range snap.Peers {
|
||||
fmt.Fprintf(&b, "wgx_transmit_bytes_total{peer=%q,name=%q} %d\n", id, names[id], l.Tx)
|
||||
}
|
||||
fmt.Fprintf(&b, "# HELP wgx_peer_connected Whether the peer has a recent handshake.\n# TYPE wgx_peer_connected gauge\n")
|
||||
for id, l := range snap.Peers {
|
||||
v := 0
|
||||
if l.Connected {
|
||||
v = 1
|
||||
}
|
||||
fmt.Fprintf(&b, "wgx_peer_connected{peer=%q,name=%q} %d\n", id, names[id], v)
|
||||
}
|
||||
fmt.Fprintf(&b, "# HELP wgx_peer_last_handshake_seconds Unix time of the last handshake.\n# TYPE wgx_peer_last_handshake_seconds gauge\n")
|
||||
for id, l := range snap.Peers {
|
||||
if !l.LastHandshake.IsZero() {
|
||||
fmt.Fprintf(&b, "wgx_peer_last_handshake_seconds{peer=%q,name=%q} %d\n", id, names[id], l.LastHandshake.Unix())
|
||||
}
|
||||
}
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
)
|
||||
|
||||
const cookieName = "wgx_session"
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
)
|
||||
|
||||
// userOf returns the authenticated user for a request.
|
||||
func userOf(r *http.Request) *store.User {
|
||||
u, _ := r.Context().Value(ctxUser).(*store.User)
|
||||
return u
|
||||
}
|
||||
|
||||
func sessionOf(r *http.Request) *store.Session {
|
||||
s, _ := r.Context().Value(ctxSession).(*store.Session)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) setCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
}
|
||||
|
||||
// loadSession resolves the cookie to a user, if the session is valid.
|
||||
func (s *Server) loadSession(r *http.Request) (*store.User, *store.Session) {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess, err := s.eng.Store().SessionByHash(ctx, auth.HashToken(c.Value))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(sess.ExpiresAt) || now.Sub(sess.CreatedAt) > s.cfg.SessionMax {
|
||||
_ = s.eng.Store().DeleteSession(ctx, sess.TokenHash)
|
||||
return nil, nil
|
||||
}
|
||||
u, err := s.eng.Store().UserByID(ctx, sess.UserID)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Slide the idle expiry, but not on every request: once a minute is
|
||||
// plenty and keeps the write load off SQLite.
|
||||
if now.Sub(sess.LastSeenAt) > time.Minute {
|
||||
_ = s.eng.Store().TouchSession(ctx, sess.TokenHash, now.Add(s.cfg.SessionIdle))
|
||||
}
|
||||
return u, sess
|
||||
}
|
||||
|
||||
// authed requires a fully authenticated session.
|
||||
func (s *Server) authed(next http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
u, sess := s.loadSession(r)
|
||||
if u == nil || sess.TOTPPending {
|
||||
writeError(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxUser, u)
|
||||
ctx = context.WithValue(ctx, ctxSession, sess)
|
||||
next(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// admin additionally requires the admin role.
|
||||
func (s *Server) admin(next http.HandlerFunc) http.Handler {
|
||||
return s.authed(func(w http.ResponseWriter, r *http.Request) {
|
||||
if userOf(r).Role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "administrator role required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) audit(r *http.Request, action, target, detail string) {
|
||||
actor := "-"
|
||||
if u := userOf(r); u != nil {
|
||||
actor = u.Username
|
||||
}
|
||||
if err := s.eng.Store().Audit(r.Context(), store.AuditEntry{Actor: actor, Action: action, Target: target, Detail: detail, IP: s.clientIP(r)}); err != nil {
|
||||
s.log.Warn("audit write failed", "error", err)
|
||||
}
|
||||
s.log.Info("audit", "actor", actor, "action", action, "target", target, "detail", detail, "ip", s.clientIP(r))
|
||||
}
|
||||
|
||||
// --- setup -----------------------------------------------------------------
|
||||
|
||||
type setupStatus struct {
|
||||
NeedsSetup bool `json:"needsSetup"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.eng.Store().CountUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, setupStatus{NeedsSetup: n == 0})
|
||||
}
|
||||
|
||||
type setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
EndpointHost string `json:"endpointHost"`
|
||||
}
|
||||
|
||||
func validUsername(u string) bool {
|
||||
if len(u) < 2 || len(u) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range u {
|
||||
if !(r == '.' || r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleSetup creates the first administrator. It only works while there
|
||||
// are no users at all, so a running server cannot be taken over by it.
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
n, err := s.eng.Store().CountUsers(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
writeError(w, http.StatusConflict, "setup has already been completed")
|
||||
return
|
||||
}
|
||||
var req setupRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if !validUsername(req.Username) {
|
||||
writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
settings := s.eng.Settings()
|
||||
if host := strings.TrimSpace(req.EndpointHost); host != "" {
|
||||
settings.EndpointHost = host
|
||||
}
|
||||
if err := settings.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.eng.Store().CreateUser(ctx, req.Username, hash, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.UpdateSettings(ctx, settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, u, false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
|
||||
s.audit(r, "setup", u.Username, "first administrator created")
|
||||
writeJSON(w, http.StatusCreated, s.meBody(ctx, u))
|
||||
}
|
||||
|
||||
// --- login -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
TOTPRequired bool `json:"totpRequired"`
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User, totpPending bool) error {
|
||||
token, hash, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
expires := now.Add(s.cfg.SessionIdle)
|
||||
if totpPending {
|
||||
expires = now.Add(10 * time.Minute)
|
||||
}
|
||||
ua := r.UserAgent()
|
||||
if len(ua) > 200 {
|
||||
ua = ua[:200]
|
||||
}
|
||||
if err := s.eng.Store().CreateSession(r.Context(), store.Session{
|
||||
TokenHash: hash, UserID: u.ID, CreatedAt: now, LastSeenAt: now, ExpiresAt: expires,
|
||||
IP: s.clientIP(r), UserAgent: ua, TOTPPending: totpPending,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.setCookie(w, token, now.Add(s.cfg.SessionMax))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
if ok, wait := s.ipLimit.Allowed(ip); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
||||
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
|
||||
return
|
||||
}
|
||||
var req loginRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if ok, wait := s.usrLimit.Allowed(strings.ToLower(req.Username)); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
||||
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
u, err := s.eng.Store().UserByName(ctx, req.Username)
|
||||
if err != nil {
|
||||
auth.EqualiseTiming()
|
||||
s.ipLimit.Fail(ip)
|
||||
s.usrLimit.Fail(strings.ToLower(req.Username))
|
||||
s.log.Warn("login failed", "user", req.Username, "ip", ip)
|
||||
writeError(w, http.StatusUnauthorized, "wrong username or password")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(u.PasswordHash, req.Password) {
|
||||
s.ipLimit.Fail(ip)
|
||||
s.usrLimit.Fail(strings.ToLower(req.Username))
|
||||
s.log.Warn("login failed", "user", req.Username, "ip", ip)
|
||||
_ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.failed", IP: ip})
|
||||
writeError(w, http.StatusUnauthorized, "wrong username or password")
|
||||
return
|
||||
}
|
||||
s.ipLimit.Reset(ip)
|
||||
s.usrLimit.Reset(strings.ToLower(req.Username))
|
||||
if err := s.startSession(w, r, u, u.TOTPEnabled); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if u.TOTPEnabled {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().TouchLogin(ctx, u.ID)
|
||||
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
|
||||
s.audit(r, "login", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, loginResponse{})
|
||||
}
|
||||
|
||||
type totpRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// handleLoginTOTP completes a login that is waiting on a second factor.
|
||||
// A recovery code is accepted in place of a TOTP code.
|
||||
func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
writeError(w, http.StatusForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
u, sess := s.loadSession(r)
|
||||
if u == nil || !sess.TOTPPending {
|
||||
writeError(w, http.StatusUnauthorized, "no login in progress")
|
||||
return
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
if ok, wait := s.ipLimit.Allowed(ip); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
|
||||
writeError(w, http.StatusTooManyRequests, "too many attempts; try again later")
|
||||
return
|
||||
}
|
||||
var req totpRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ok := auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now())
|
||||
usedRecovery := false
|
||||
if !ok {
|
||||
used, err := s.eng.Store().UseRecoveryCode(ctx, u.ID, auth.HashToken(auth.NormaliseRecoveryCode(req.Code)))
|
||||
if err == nil && used {
|
||||
ok, usedRecovery = true, true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
s.ipLimit.Fail(ip)
|
||||
_ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.totp_failed", IP: ip})
|
||||
writeError(w, http.StatusUnauthorized, "wrong code")
|
||||
return
|
||||
}
|
||||
s.ipLimit.Reset(ip)
|
||||
if err := s.eng.Store().ClearTOTPPending(ctx, sess.TokenHash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().TouchSession(ctx, sess.TokenHash, time.Now().Add(s.cfg.SessionIdle))
|
||||
_ = s.eng.Store().TouchLogin(ctx, u.ID)
|
||||
r = r.WithContext(context.WithValue(ctx, ctxUser, u))
|
||||
detail := ""
|
||||
if usedRecovery {
|
||||
detail = "recovery code used"
|
||||
}
|
||||
s.audit(r, "login", u.Username, detail)
|
||||
writeJSON(w, http.StatusOK, loginResponse{})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if sess := sessionOf(r); sess != nil {
|
||||
_ = s.eng.Store().DeleteSession(r.Context(), sess.TokenHash)
|
||||
}
|
||||
s.clearCookie(w)
|
||||
s.audit(r, "logout", userOf(r).Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// --- account ---------------------------------------------------------------
|
||||
|
||||
type meBody struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TOTPEnabled bool `json:"totpEnabled"`
|
||||
RecoveryCodes int `json:"recoveryCodesLeft"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt time.Time `json:"lastLoginAt,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) meBody(ctx context.Context, u *store.User) meBody {
|
||||
left, _ := s.eng.Store().RecoveryCodesLeft(ctx, u.ID)
|
||||
return meBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, RecoveryCodes: left, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.meBody(r.Context(), userOf(r)))
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
Current string `json:"current"`
|
||||
New string `json:"new"`
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req changePasswordRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u := userOf(r)
|
||||
if !auth.VerifyPassword(u.PasswordHash, req.Current) {
|
||||
writeError(w, http.StatusForbidden, "current password is wrong")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.New); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.New)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetPassword(r.Context(), u.ID, hash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Every other browser is signed out; this one keeps its session.
|
||||
sess := sessionOf(r)
|
||||
_ = s.eng.Store().DeleteUserSessions(r.Context(), u.ID)
|
||||
if err := s.startSession(w, r, u, false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = sess
|
||||
s.audit(r, "password.changed", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
type totpSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// handleTOTPSetup issues a pending secret; it becomes active on confirm.
|
||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
if u.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, "two-factor authentication is already on")
|
||||
return
|
||||
}
|
||||
secret, err := auth.NewTOTPSecret()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetTOTP(r.Context(), u.ID, secret, false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: auth.TOTPURI("WGX", u.Username, secret)})
|
||||
}
|
||||
|
||||
// handleTOTPQR renders the pending secret's otpauth URI as a QR code. Only a
|
||||
// secret that is not yet active is shown: an enabled one must never leave
|
||||
// the server again.
|
||||
func (s *Server) handleTOTPQR(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
if u.TOTPEnabled || u.TOTPSecret == "" {
|
||||
writeError(w, http.StatusNotFound, "no two-factor setup in progress")
|
||||
return
|
||||
}
|
||||
png, err := qrcode.Encode(auth.TOTPURI("WGX", u.Username, u.TOTPSecret), qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(png)
|
||||
}
|
||||
|
||||
type totpConfirmResponse struct {
|
||||
RecoveryCodes []string `json:"recoveryCodes"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u := userOf(r)
|
||||
if u.TOTPEnabled || u.TOTPSecret == "" {
|
||||
writeError(w, http.StatusConflict, "start two-factor setup first")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now()) {
|
||||
writeError(w, http.StatusBadRequest, "wrong code; check the time on your device")
|
||||
return
|
||||
}
|
||||
codes, hashes, err := auth.NewRecoveryCodes(8)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := s.eng.Store().SetTOTP(ctx, u.ID, u.TOTPSecret, true); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, hashes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "totp.enabled", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||
}
|
||||
|
||||
type totpDisableRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpDisableRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u := userOf(r)
|
||||
if !auth.VerifyPassword(u.PasswordHash, req.Password) {
|
||||
writeError(w, http.StatusForbidden, "password is wrong")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := s.eng.Store().SetTOTP(ctx, u.ID, "", false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, nil)
|
||||
s.audit(r, "totp.disabled", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
type sessionBody struct {
|
||||
Current bool `json:"current"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
cur := sessionOf(r)
|
||||
rows, err := s.eng.Store().DB().QueryContext(r.Context(), `SELECT token_hash, created_at, last_seen_at, ip, user_agent FROM sessions WHERE user_id = ? AND totp_pending = 0 ORDER BY last_seen_at DESC`, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []sessionBody{}
|
||||
for rows.Next() {
|
||||
var hash, ip, ua string
|
||||
var created, seen int64
|
||||
if err := rows.Scan(&hash, &created, &seen, &ip, &ua); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out = append(out, sessionBody{Current: hash == cur.TokenHash, CreatedAt: time.Unix(created, 0), LastSeenAt: time.Unix(seen, 0), IP: ip, UserAgent: ua})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleRevokeSessions signs the user out everywhere but here.
|
||||
func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) {
|
||||
u := userOf(r)
|
||||
cur := sessionOf(r)
|
||||
if _, err := s.eng.Store().DB().ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, u.ID, cur.TokenHash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "sessions.revoked", u.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// --- users -----------------------------------------------------------------
|
||||
|
||||
type userBody struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
TOTPEnabled bool `json:"totpEnabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt time.Time `json:"lastLoginAt,omitempty"`
|
||||
}
|
||||
|
||||
func toUserBody(u *store.User) userBody {
|
||||
return userBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
|
||||
}
|
||||
|
||||
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.eng.Store().ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]userBody, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, toUserBody(u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type userRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func validRole(role string) bool { return role == "admin" || role == "viewer" }
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req userRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if !validUsername(req.Username) {
|
||||
writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore")
|
||||
return
|
||||
}
|
||||
if !validRole(req.Role) {
|
||||
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.eng.Store().CreateUser(r.Context(), req.Username, hash, req.Role)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
writeError(w, http.StatusConflict, "that username is taken")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "user.created", u.Username, "role "+u.Role)
|
||||
writeJSON(w, http.StatusCreated, toUserBody(u))
|
||||
}
|
||||
|
||||
type userUpdateRequest struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ResetTOTP bool `json:"resetTotp,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad user id")
|
||||
return
|
||||
}
|
||||
var req userUpdateRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
target, err := s.eng.Store().UserByID(ctx, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "no such user")
|
||||
return
|
||||
}
|
||||
var changes []string
|
||||
if req.Role != "" && req.Role != target.Role {
|
||||
if !validRole(req.Role) {
|
||||
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
|
||||
return
|
||||
}
|
||||
if target.ID == userOf(r).ID {
|
||||
writeError(w, http.StatusBadRequest, "you cannot change your own role")
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetRole(ctx, id, req.Role); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
changes = append(changes, "role "+req.Role)
|
||||
}
|
||||
if req.Password != "" {
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().SetPassword(ctx, id, hash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().DeleteUserSessions(ctx, id)
|
||||
changes = append(changes, "password reset")
|
||||
}
|
||||
if req.ResetTOTP && target.TOTPEnabled {
|
||||
if err := s.eng.Store().SetTOTP(ctx, id, "", false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.eng.Store().ReplaceRecoveryCodes(ctx, id, nil)
|
||||
changes = append(changes, "two-factor reset")
|
||||
}
|
||||
if len(changes) > 0 {
|
||||
s.audit(r, "user.updated", target.Username, strings.Join(changes, ", "))
|
||||
}
|
||||
u, _ := s.eng.Store().UserByID(ctx, id)
|
||||
writeJSON(w, http.StatusOK, toUserBody(u))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad user id")
|
||||
return
|
||||
}
|
||||
if id == userOf(r).ID {
|
||||
writeError(w, http.StatusBadRequest, "you cannot delete yourself")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
target, err := s.eng.Store().UserByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "no such user")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.eng.Store().DeleteUser(ctx, id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.audit(r, "user.deleted", target.Username, "")
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Package server is the admin HTTP API and the host for the embedded UI.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/engine"
|
||||
"github.com/Coffey-Labs/WGX/internal/server/static"
|
||||
)
|
||||
|
||||
// Server serves the API and UI.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
eng *engine.Engine
|
||||
log *slog.Logger
|
||||
mux *http.ServeMux
|
||||
ipLimit *auth.Limiter
|
||||
usrLimit *auth.Limiter
|
||||
http *http.Server
|
||||
}
|
||||
|
||||
// New builds the router.
|
||||
func New(cfg *config.Config, eng *engine.Engine, log *slog.Logger) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
eng: eng,
|
||||
log: log,
|
||||
mux: http.NewServeMux(),
|
||||
ipLimit: auth.NewLimiter(20, 15*time.Minute),
|
||||
usrLimit: auth.NewLimiter(8, 15*time.Minute),
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the full middleware chain, for tests and for ListenAndServe.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.securityHeaders(s.mux))
|
||||
}
|
||||
|
||||
// ListenAndServe runs until ctx is cancelled.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
s.http = &http.Server{
|
||||
Addr: s.cfg.HTTP,
|
||||
Handler: s.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
// No WriteTimeout: the SSE stream is long-lived. Handlers that
|
||||
// matter bound themselves.
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 64 << 10,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if s.cfg.TLSEnabled() {
|
||||
cert, err := s.loadCertificate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tlsCfg = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
||||
}
|
||||
s.http.TLSConfig = tlsCfg
|
||||
}
|
||||
ln, err := net.Listen("tcp", s.cfg.HTTP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", s.cfg.HTTP, err)
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if tlsCfg != nil {
|
||||
errCh <- s.http.ServeTLS(ln, "", "")
|
||||
} else {
|
||||
errCh <- s.http.Serve(ln)
|
||||
}
|
||||
}()
|
||||
s.log.Info("admin UI listening", "addr", ln.Addr().String(), "tls", tlsCfg != nil)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return s.http.Shutdown(shutdown)
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
// Unauthenticated.
|
||||
m.HandleFunc("GET /api/health", s.handleHealth)
|
||||
m.HandleFunc("GET /api/setup", s.handleSetupStatus)
|
||||
m.HandleFunc("POST /api/setup", s.handleSetup)
|
||||
m.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||
m.HandleFunc("POST /api/auth/totp", s.handleLoginTOTP)
|
||||
m.HandleFunc("GET /metrics", s.handleMetrics)
|
||||
|
||||
// Session required.
|
||||
m.Handle("GET /api/auth/me", s.authed(s.handleMe))
|
||||
m.Handle("POST /api/auth/logout", s.authed(s.handleLogout))
|
||||
m.Handle("POST /api/auth/password", s.authed(s.handleChangePassword))
|
||||
m.Handle("POST /api/auth/totp/setup", s.authed(s.handleTOTPSetup))
|
||||
m.Handle("GET /api/auth/totp/qr.png", s.authed(s.handleTOTPQR))
|
||||
m.Handle("POST /api/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
|
||||
m.Handle("POST /api/auth/totp/disable", s.authed(s.handleTOTPDisable))
|
||||
m.Handle("GET /api/auth/sessions", s.authed(s.handleSessions))
|
||||
m.Handle("POST /api/auth/sessions/revoke", s.authed(s.handleRevokeSessions))
|
||||
|
||||
m.Handle("GET /api/status", s.authed(s.handleStatus))
|
||||
m.Handle("GET /api/events", s.authed(s.handleEvents))
|
||||
m.Handle("GET /api/peers", s.authed(s.handlePeers))
|
||||
m.Handle("GET /api/peers/{id}", s.authed(s.handlePeer))
|
||||
m.Handle("GET /api/peers/{id}/config", s.authed(s.handlePeerConfig))
|
||||
m.Handle("GET /api/peers/{id}/qr.png", s.authed(s.handlePeerQR))
|
||||
m.Handle("GET /api/peers/{id}/usage", s.authed(s.handlePeerUsage))
|
||||
m.Handle("GET /api/usage", s.authed(s.handleUsage))
|
||||
m.Handle("GET /api/usage/peers", s.authed(s.handleUsageByPeer))
|
||||
m.Handle("GET /api/settings", s.authed(s.handleGetSettings))
|
||||
m.Handle("GET /api/audit", s.authed(s.handleAudit))
|
||||
m.Handle("GET /api/users", s.authed(s.handleUsers))
|
||||
|
||||
// Admin role required.
|
||||
m.Handle("POST /api/peers", s.admin(s.handleCreatePeer))
|
||||
m.Handle("PUT /api/peers/{id}", s.admin(s.handleUpdatePeer))
|
||||
m.Handle("DELETE /api/peers/{id}", s.admin(s.handleDeletePeer))
|
||||
m.Handle("POST /api/peers/{id}/enable", s.admin(s.handleEnablePeer))
|
||||
m.Handle("POST /api/peers/{id}/disable", s.admin(s.handleDisablePeer))
|
||||
m.Handle("POST /api/peers/{id}/reset", s.admin(s.handleResetPeer))
|
||||
m.Handle("POST /api/peers/{id}/rotate", s.admin(s.handleRotatePeer))
|
||||
m.Handle("PUT /api/settings", s.admin(s.handlePutSettings))
|
||||
m.Handle("POST /api/users", s.admin(s.handleCreateUser))
|
||||
m.Handle("PUT /api/users/{id}", s.admin(s.handleUpdateUser))
|
||||
m.Handle("DELETE /api/users/{id}", s.admin(s.handleDeleteUser))
|
||||
|
||||
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "no such endpoint")
|
||||
})
|
||||
m.Handle("/", s.spa())
|
||||
}
|
||||
|
||||
// spa serves the embedded UI, falling back to index.html for client routes.
|
||||
func (s *Server) spa() http.Handler {
|
||||
files := static.FS()
|
||||
fileServer := http.FileServerFS(files)
|
||||
index, _ := fs.ReadFile(files, "index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
p := path.Clean(r.URL.Path)
|
||||
if p != "/" {
|
||||
if f, err := files.Open(strings.TrimPrefix(p, "/")); err == nil {
|
||||
f.Close()
|
||||
if strings.HasPrefix(p, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if index == nil {
|
||||
http.Error(w, "the admin UI has not been built; run `npm run build` in web/", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Write(index)
|
||||
})
|
||||
}
|
||||
|
||||
// --- middleware ------------------------------------------------------------
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
if rec == http.ErrAbortHandler {
|
||||
panic(rec)
|
||||
}
|
||||
s.log.Error("panic", "path", r.URL.Path, "error", rec)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) 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' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if s.cfg.TLSEnabled() || s.cfg.SecureCookies {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
h.Set("Cache-Control", "no-store")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// sameOrigin rejects cross-site state changes. Cookies are SameSite=Strict
|
||||
// already; this is the belt to that brace, for browsers that send
|
||||
// Sec-Fetch-Site or Origin.
|
||||
func (s *Server) sameOrigin(r *http.Request) bool {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
|
||||
return site == "same-origin" || site == "none"
|
||||
}
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
host := r.Host
|
||||
return strings.EqualFold(strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"), host)
|
||||
}
|
||||
// Neither header: not a modern browser. A non-browser client cannot be
|
||||
// tricked by a third-party page, so allow it.
|
||||
return true
|
||||
}
|
||||
|
||||
// clientIP returns the caller's address, honouring proxy headers only from
|
||||
// trusted proxies.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
if !s.trusted(addr) {
|
||||
return addr.String()
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
// Walk from the right, skipping trusted hops, to the first address
|
||||
// that is not one of our proxies.
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !s.trusted(a) {
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
|
||||
if a, err := netip.ParseAddr(real); err == nil {
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
func (s *Server) trusted(a netip.Addr) bool {
|
||||
for _, p := range s.cfg.TrustedProxies {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, errorBody{Error: msg})
|
||||
}
|
||||
|
||||
// readJSON decodes a small JSON body strictly.
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "application/json") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "expected application/json")
|
||||
return false
|
||||
}
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad JSON: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// engineError maps engine errors to status codes.
|
||||
func engineError(w http.ResponseWriter, err error) {
|
||||
var ve engine.ErrValidation
|
||||
switch {
|
||||
case errors.As(err, &ve):
|
||||
writeError(w, http.StatusBadRequest, ve.Msg)
|
||||
case errors.Is(err, engine.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/engine"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
c *http.Client
|
||||
}
|
||||
|
||||
func newClient(t *testing.T) (*client, *engine.Engine) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{
|
||||
DBPath: ":memory:", Backend: "mock", Iface: "wg0", ListenPort: 51820,
|
||||
Subnet4: netip.MustParsePrefix("10.8.0.0/24"), HTTP: "127.0.0.1:0",
|
||||
SessionIdle: time.Hour, SessionMax: 24 * time.Hour, TrafficRetention: time.Hour, PollInterval: time.Hour,
|
||||
InitialEndpoint: "vpn.example.com", InitialDNS: "1.1.1.1", MetricsToken: "metrics-secret",
|
||||
}
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
eng := engine.New(cfg, st, wg.NewMock("wg0", false), log)
|
||||
if err := eng.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = eng.Stop(context.Background()) })
|
||||
s := New(cfg, eng, log)
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &client{t: t, srv: srv, c: &http.Client{Jar: jar}}, eng
|
||||
}
|
||||
|
||||
func (c *client) do(method, path string, body any, headers ...string) (*http.Response, []byte) {
|
||||
c.t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, c.srv.URL+path, rd)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
res, err := c.c.Do(req)
|
||||
if err != nil {
|
||||
c.t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
out, _ := io.ReadAll(res.Body)
|
||||
return res, out
|
||||
}
|
||||
|
||||
func (c *client) expect(method, path string, body any, status int) []byte {
|
||||
c.t.Helper()
|
||||
res, out := c.do(method, path, body)
|
||||
if res.StatusCode != status {
|
||||
c.t.Fatalf("%s %s: got %d, want %d: %s", method, path, res.StatusCode, status, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSetupLoginAndPeers(t *testing.T) {
|
||||
c, _ := newClient(t)
|
||||
|
||||
// Before setup: nothing works, setup is announced.
|
||||
out := c.expect("GET", "/api/setup", nil, 200)
|
||||
if !strings.Contains(string(out), `"needsSetup":true`) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
c.expect("GET", "/api/peers", nil, 401)
|
||||
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "short"}, 400)
|
||||
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
|
||||
c.expect("POST", "/api/setup", map[string]string{"username": "x", "password": "a-long-enough-password"}, 409)
|
||||
|
||||
// Setup signed us in.
|
||||
out = c.expect("GET", "/api/auth/me", nil, 200)
|
||||
if !strings.Contains(string(out), `"username":"admin"`) || !strings.Contains(string(out), `"role":"admin"`) {
|
||||
t.Fatal(string(out))
|
||||
}
|
||||
c.expect("POST", "/api/auth/logout", nil, 200)
|
||||
c.expect("GET", "/api/auth/me", nil, 401)
|
||||
|
||||
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "wrong-password-here"}, 401)
|
||||
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
|
||||
|
||||
// Cross-site state change refused.
|
||||
res, _ := c.do("POST", "/api/peers", map[string]string{"name": "x"}, "Sec-Fetch-Site", "cross-site")
|
||||
if res.StatusCode != 403 {
|
||||
t.Fatalf("cross-site request got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
out = c.expect("POST", "/api/peers", map[string]any{"name": "Laptop", "clientRoutes": "", "dns": "", "keepalive": nil, "mtu": nil, "expiresAt": nil, "notes": ""}, 201)
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
Config string `json:"config"`
|
||||
}
|
||||
_ = json.Unmarshal(out, &created)
|
||||
if created.IPv4 != "10.8.0.2" || !strings.Contains(created.Config, "Endpoint = vpn.test:51820") {
|
||||
t.Fatalf("%+v", created)
|
||||
}
|
||||
if strings.Contains(string(out), `"privateKey"`) {
|
||||
t.Fatal("private key leaked in peer body")
|
||||
}
|
||||
out = c.expect("GET", "/api/peers", nil, 200)
|
||||
if !strings.Contains(string(out), `"name":"Laptop"`) {
|
||||
t.Fatal(string(out))
|
||||
}
|
||||
res, out = c.do("GET", "/api/peers/"+created.ID+"/config?download=1", nil)
|
||||
if res.StatusCode != 200 || !strings.Contains(res.Header.Get("Content-Disposition"), `Laptop.conf`) || !strings.Contains(string(out), "[Interface]") {
|
||||
t.Fatalf("config download: %d %s", res.StatusCode, res.Header)
|
||||
}
|
||||
res, out = c.do("GET", "/api/peers/"+created.ID+"/qr.png", nil)
|
||||
if res.StatusCode != 200 || res.Header.Get("Content-Type") != "image/png" || !bytes.HasPrefix(out, []byte("\x89PNG")) {
|
||||
t.Fatalf("qr: %d %s", res.StatusCode, res.Header.Get("Content-Type"))
|
||||
}
|
||||
c.expect("POST", "/api/peers/"+created.ID+"/disable", nil, 200)
|
||||
out = c.expect("GET", "/api/peers/"+created.ID, nil, 200)
|
||||
if !strings.Contains(string(out), `"enabled":false`) {
|
||||
t.Fatal(string(out))
|
||||
}
|
||||
c.expect("POST", "/api/peers/"+created.ID+"/enable", nil, 200)
|
||||
c.expect("POST", "/api/peers/"+created.ID+"/reset", nil, 200)
|
||||
c.expect("POST", "/api/peers/"+created.ID+"/rotate", nil, 200)
|
||||
c.expect("PUT", "/api/peers/"+created.ID, map[string]any{"name": "Laptop 2", "clientRoutes": "10.8.0.0/24", "dns": "9.9.9.9", "keepalive": 15, "mtu": 1380, "expiresAt": nil, "notes": "n"}, 200)
|
||||
c.expect("GET", "/api/peers/"+created.ID+"/usage?range=24h", nil, 200)
|
||||
c.expect("GET", "/api/usage?range=7d", nil, 200)
|
||||
c.expect("GET", "/api/status", nil, 200)
|
||||
c.expect("GET", "/api/audit", nil, 200)
|
||||
c.expect("DELETE", "/api/peers/"+created.ID, nil, 200)
|
||||
c.expect("GET", "/api/peers/"+created.ID, nil, 404)
|
||||
|
||||
// Settings round trip.
|
||||
out = c.expect("GET", "/api/settings", nil, 200)
|
||||
var s engine.Settings
|
||||
_ = json.Unmarshal(out, &s)
|
||||
s.MTU = 1400
|
||||
c.expect("PUT", "/api/settings", s, 200)
|
||||
s.MTU = 10
|
||||
c.expect("PUT", "/api/settings", s, 400)
|
||||
|
||||
// Metrics: token or session.
|
||||
res, _ = c.do("GET", "/metrics", nil)
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("metrics with session: %d", res.StatusCode)
|
||||
}
|
||||
anon := &http.Client{}
|
||||
req, _ := http.NewRequest("GET", c.srv.URL+"/metrics", nil)
|
||||
if r, _ := anon.Do(req); r.StatusCode != 401 {
|
||||
t.Fatalf("anonymous metrics: %d", r.StatusCode)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer metrics-secret")
|
||||
r, _ := anon.Do(req)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if r.StatusCode != 200 || !strings.Contains(string(b), "wgx_peers ") {
|
||||
t.Fatalf("token metrics: %d %s", r.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerRoleAndUsers(t *testing.T) {
|
||||
c, _ := newClient(t)
|
||||
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
|
||||
c.expect("POST", "/api/users", map[string]string{"username": "eve", "password": "another-long-password", "role": "viewer"}, 201)
|
||||
c.expect("POST", "/api/users", map[string]string{"username": "eve", "password": "another-long-password", "role": "viewer"}, 409)
|
||||
c.expect("POST", "/api/auth/logout", nil, 200)
|
||||
c.expect("POST", "/api/auth/login", map[string]string{"username": "eve", "password": "another-long-password"}, 200)
|
||||
c.expect("GET", "/api/peers", nil, 200)
|
||||
c.expect("POST", "/api/peers", map[string]any{"name": "x", "clientRoutes": "", "dns": "", "keepalive": nil, "mtu": nil, "expiresAt": nil, "notes": ""}, 403)
|
||||
c.expect("GET", "/api/users", nil, 200)
|
||||
c.expect("DELETE", "/api/users/1", nil, 403)
|
||||
}
|
||||
|
||||
func TestTOTPFlow(t *testing.T) {
|
||||
c, _ := newClient(t)
|
||||
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
|
||||
out := c.expect("POST", "/api/auth/totp/setup", nil, 200)
|
||||
var setup struct{ Secret string }
|
||||
_ = json.Unmarshal(out, &setup)
|
||||
c.expect("GET", "/api/auth/totp/qr.png", nil, 200)
|
||||
c.expect("POST", "/api/auth/totp/confirm", map[string]string{"code": "000000"}, 400)
|
||||
code, _ := auth.TOTPNow(setup.Secret, time.Now())
|
||||
out = c.expect("POST", "/api/auth/totp/confirm", map[string]string{"code": code}, 200)
|
||||
var conf struct{ RecoveryCodes []string }
|
||||
_ = json.Unmarshal(out, &conf)
|
||||
if len(conf.RecoveryCodes) != 8 {
|
||||
t.Fatal("no recovery codes")
|
||||
}
|
||||
c.expect("POST", "/api/auth/logout", nil, 200)
|
||||
|
||||
// Login now stops half way.
|
||||
out = c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
|
||||
if !strings.Contains(string(out), `"totpRequired":true`) {
|
||||
t.Fatal(string(out))
|
||||
}
|
||||
c.expect("GET", "/api/peers", nil, 401)
|
||||
c.expect("POST", "/api/auth/totp", map[string]string{"code": "123456"}, 401)
|
||||
code, _ = auth.TOTPNow(setup.Secret, time.Now())
|
||||
c.expect("POST", "/api/auth/totp", map[string]string{"code": code}, 200)
|
||||
c.expect("GET", "/api/peers", nil, 200)
|
||||
|
||||
// A recovery code works once.
|
||||
c.expect("POST", "/api/auth/logout", nil, 200)
|
||||
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
|
||||
c.expect("POST", "/api/auth/totp", map[string]string{"code": conf.RecoveryCodes[0]}, 200)
|
||||
c.expect("POST", "/api/auth/logout", nil, 200)
|
||||
c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200)
|
||||
c.expect("POST", "/api/auth/totp", map[string]string{"code": conf.RecoveryCodes[0]}, 401)
|
||||
}
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
c, _ := newClient(t)
|
||||
c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201)
|
||||
c.expect("POST", "/api/auth/logout", nil, 200)
|
||||
var last int
|
||||
for i := 0; i < 10; i++ {
|
||||
res, _ := c.do("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "wrong-password-here"})
|
||||
last = res.StatusCode
|
||||
}
|
||||
if last != 429 {
|
||||
t.Fatalf("expected 429 after repeated failures, got %d", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAndSPA(t *testing.T) {
|
||||
c, _ := newClient(t)
|
||||
res, _ := c.do("GET", "/api/health", nil)
|
||||
for _, h := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy"} {
|
||||
if res.Header.Get(h) == "" {
|
||||
t.Errorf("missing %s", h)
|
||||
}
|
||||
}
|
||||
if res.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Error("api responses must be no-store")
|
||||
}
|
||||
res, _ = c.do("GET", "/api/nope", nil)
|
||||
if res.StatusCode != 404 {
|
||||
t.Errorf("unknown api path: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package static carries the built admin UI inside the binary. `npm run
|
||||
// build` in web/ writes into dist/; the Go build embeds whatever is there.
|
||||
package static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// FS returns the built UI rooted at dist/.
|
||||
func FS() fs.FS {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loadCertificate returns the configured certificate, generating and
|
||||
// persisting a self-signed one when asked to.
|
||||
func (s *Server) loadCertificate() (tls.Certificate, error) {
|
||||
if s.cfg.TLSCert != "" {
|
||||
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("load TLS certificate: %w", err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
certPath := filepath.Join(s.cfg.DataDir, "tls.crt")
|
||||
keyPath := filepath.Join(s.cfg.DataDir, "tls.key")
|
||||
if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
|
||||
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil && time.Now().Before(leaf.NotAfter.Add(-30*24*time.Hour)) {
|
||||
return cert, nil
|
||||
}
|
||||
}
|
||||
s.log.Info("generating a self-signed TLS certificate", "path", certPath)
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
host := s.eng.Settings().EndpointHost
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "WGX", Organization: []string{"WGX"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(3 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
}
|
||||
if host != "" {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
|
||||
} else {
|
||||
tmpl.DNSNames = append(tmpl.DNSNames, host)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.X509KeyPair(certPEM, keyPEM)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Peer is a client device.
|
||||
type Peer struct {
|
||||
ID string
|
||||
Name string
|
||||
PublicKey string
|
||||
PrivateKey string // empty when the client generated its own key pair
|
||||
PresharedKey string
|
||||
IPv4 string // tunnel address without prefix, e.g. 10.8.0.2
|
||||
IPv6 string // may be empty
|
||||
ClientRoutes string // AllowedIPs the *client* routes into the tunnel
|
||||
DNS string // override; empty means the server default
|
||||
Keepalive int // seconds; 0 means the server default
|
||||
MTU int // 0 means the server default
|
||||
Enabled bool
|
||||
ExpiresAt time.Time
|
||||
Notes string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RxTotal int64
|
||||
TxTotal int64
|
||||
LastHandshake time.Time
|
||||
LastEndpoint string
|
||||
}
|
||||
|
||||
const peerCols = `id, name, public_key, private_key, preshared_key, ipv4, ipv6, client_routes, dns, keepalive, mtu, enabled, expires_at, notes, created_at, updated_at, rx_total, tx_total, last_handshake, last_endpoint`
|
||||
|
||||
func scanPeer(row interface{ Scan(...any) error }) (*Peer, error) {
|
||||
var p Peer
|
||||
var priv, psk, v6 sql.NullString
|
||||
var enabled int
|
||||
var exp, created, updated int64
|
||||
var expN, hsN sql.NullInt64
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.PublicKey, &priv, &psk, &p.IPv4, &v6, &p.ClientRoutes, &p.DNS, &p.Keepalive, &p.MTU, &enabled, &expN, &p.Notes, &created, &updated, &p.RxTotal, &p.TxTotal, &hsN, &p.LastEndpoint); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_ = exp
|
||||
p.PrivateKey = priv.String
|
||||
p.PresharedKey = psk.String
|
||||
p.IPv6 = v6.String
|
||||
p.Enabled = enabled == 1
|
||||
if expN.Valid {
|
||||
p.ExpiresAt = time.Unix(expN.Int64, 0)
|
||||
}
|
||||
p.CreatedAt = time.Unix(created, 0)
|
||||
p.UpdatedAt = time.Unix(updated, 0)
|
||||
if hsN.Valid && hsN.Int64 > 0 {
|
||||
p.LastHandshake = time.Unix(hsN.Int64, 0)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullTime(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CreatePeer inserts a peer.
|
||||
func (s *Store) CreatePeer(ctx context.Context, p *Peer) error {
|
||||
now := time.Now()
|
||||
p.CreatedAt, p.UpdatedAt = now, now
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO peers(`+peerCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.Name, p.PublicKey, nullStr(p.PrivateKey), nullStr(p.PresharedKey), p.IPv4, nullStr(p.IPv6), p.ClientRoutes, p.DNS, p.Keepalive, p.MTU, boolInt(p.Enabled), nullTime(p.ExpiresAt), p.Notes, now.Unix(), now.Unix(), p.RxTotal, p.TxTotal, nullTime(p.LastHandshake), p.LastEndpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdatePeer writes every editable column of a peer.
|
||||
func (s *Store) UpdatePeer(ctx context.Context, p *Peer) error {
|
||||
p.UpdatedAt = time.Now()
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE peers SET name=?, public_key=?, private_key=?, preshared_key=?, ipv4=?, ipv6=?, client_routes=?, dns=?, keepalive=?, mtu=?, enabled=?, expires_at=?, notes=?, updated_at=? WHERE id=?`,
|
||||
p.Name, p.PublicKey, nullStr(p.PrivateKey), nullStr(p.PresharedKey), p.IPv4, nullStr(p.IPv6), p.ClientRoutes, p.DNS, p.Keepalive, p.MTU, boolInt(p.Enabled), nullTime(p.ExpiresAt), p.Notes, p.UpdatedAt.Unix(), p.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PeerByID loads one peer.
|
||||
func (s *Store) PeerByID(ctx context.Context, id string) (*Peer, error) {
|
||||
return scanPeer(s.db.QueryRowContext(ctx, `SELECT `+peerCols+` FROM peers WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
// ListPeers returns every peer, newest first.
|
||||
func (s *Store) ListPeers(ctx context.Context) ([]*Peer, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+peerCols+` FROM peers ORDER BY created_at DESC, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Peer
|
||||
for rows.Next() {
|
||||
p, err := scanPeer(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePeer removes a peer and its traffic history.
|
||||
func (s *Store) DeletePeer(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM peers WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsedAddresses returns every tunnel address in use, for allocation.
|
||||
func (s *Store) UsedAddresses(ctx context.Context) (v4, v6 []string, err error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT ipv4, ipv6 FROM peers`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var a string
|
||||
var b sql.NullString
|
||||
if err := rows.Scan(&a, &b); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
v4 = append(v4, a)
|
||||
if b.Valid {
|
||||
v6 = append(v6, b.String)
|
||||
}
|
||||
}
|
||||
return v4, v6, rows.Err()
|
||||
}
|
||||
|
||||
// PeerCounters is the running total the collector flushes.
|
||||
type PeerCounters struct {
|
||||
ID string
|
||||
RxTotal int64
|
||||
TxTotal int64
|
||||
LastHandshake time.Time
|
||||
LastEndpoint string
|
||||
}
|
||||
|
||||
// TrafficSample is one bucket increment.
|
||||
type TrafficSample struct {
|
||||
PeerID string
|
||||
Bucket time.Time
|
||||
Rx, Tx int64
|
||||
}
|
||||
|
||||
// FlushCounters writes peer totals and traffic buckets in one transaction.
|
||||
func (s *Store) FlushCounters(ctx context.Context, counters []PeerCounters, samples []TrafficSample) error {
|
||||
if len(counters) == 0 && len(samples) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, c := range counters {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE peers SET rx_total=?, tx_total=?, last_handshake=?, last_endpoint=? WHERE id=?`, c.RxTotal, c.TxTotal, nullTime(c.LastHandshake), c.LastEndpoint, c.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, t := range samples {
|
||||
if t.Rx == 0 && t.Tx == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO traffic(peer_id, bucket_start, rx, tx) VALUES(?,?,?,?) ON CONFLICT(peer_id, bucket_start) DO UPDATE SET rx = rx + excluded.rx, tx = tx + excluded.tx`, t.PeerID, t.Bucket.Unix(), t.Rx, t.Tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// TrafficPoint is one row of a usage series.
|
||||
type TrafficPoint struct {
|
||||
Bucket time.Time `json:"t"`
|
||||
Rx int64 `json:"rx"`
|
||||
Tx int64 `json:"tx"`
|
||||
}
|
||||
|
||||
// TrafficSeries returns a peer's buckets since a time; peerID "" means all
|
||||
// peers summed.
|
||||
func (s *Store) TrafficSeries(ctx context.Context, peerID string, since time.Time) ([]TrafficPoint, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if peerID == "" {
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT bucket_start, SUM(rx), SUM(tx) FROM traffic WHERE bucket_start >= ? GROUP BY bucket_start ORDER BY bucket_start`, since.Unix())
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT bucket_start, rx, tx FROM traffic WHERE peer_id = ? AND bucket_start >= ? ORDER BY bucket_start`, peerID, since.Unix())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TrafficPoint
|
||||
for rows.Next() {
|
||||
var b, rx, tx int64
|
||||
if err := rows.Scan(&b, &rx, &tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, TrafficPoint{Bucket: time.Unix(b, 0), Rx: rx, Tx: tx})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PeerUsage is a per-peer total over a window.
|
||||
type PeerUsage struct {
|
||||
PeerID string `json:"peerId"`
|
||||
Rx int64 `json:"rx"`
|
||||
Tx int64 `json:"tx"`
|
||||
}
|
||||
|
||||
// UsageSince sums traffic per peer since a time.
|
||||
func (s *Store) UsageSince(ctx context.Context, since time.Time) ([]PeerUsage, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT peer_id, SUM(rx), SUM(tx) FROM traffic WHERE bucket_start >= ? GROUP BY peer_id`, since.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PeerUsage
|
||||
for rows.Next() {
|
||||
var u PeerUsage
|
||||
if err := rows.Scan(&u.PeerID, &u.Rx, &u.Tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PruneTraffic deletes buckets older than the cutoff.
|
||||
func (s *Store) PruneTraffic(ctx context.Context, before time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM traffic WHERE bucket_start < ?`, before.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// AuditEntry is one administrative action.
|
||||
type AuditEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Detail string `json:"detail"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// Audit appends an entry.
|
||||
func (s *Store) Audit(ctx context.Context, e AuditEntry) error {
|
||||
if e.At.IsZero() {
|
||||
e.At = time.Now()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO audit(at, actor, action, target, detail, ip) VALUES(?,?,?,?,?,?)`, e.At.Unix(), e.Actor, e.Action, e.Target, e.Detail, e.IP)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAudit returns the newest entries.
|
||||
func (s *Store) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, at, actor, action, target, detail, ip FROM audit ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []AuditEntry{}
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
var at int64
|
||||
if err := rows.Scan(&e.ID, &at, &e.Actor, &e.Action, &e.Target, &e.Detail, &e.IP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.At = time.Unix(at, 0)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PruneAudit keeps the newest n entries.
|
||||
func (s *Store) PruneAudit(ctx context.Context, keep int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT ?)`, keep)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package store is the SQLite persistence layer. Everything WGX remembers --
|
||||
// peers and their keys, admin users, sessions, traffic history and the audit
|
||||
// log -- lives in one file under the data directory.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store wraps the database handle.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the database at path and applies the schema.
|
||||
func Open(path string) (*Store, error) {
|
||||
if path != ":memory:" {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
}
|
||||
dsn := path
|
||||
if path != ":memory:" {
|
||||
// The file holds private keys, so it is created unreadable to anyone
|
||||
// but the owner. `_pragma` options ride along in the DSN.
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
dsn = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)"
|
||||
} else {
|
||||
dsn = "file::memory:?cache=shared&_pragma=foreign_keys(ON)"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// One connection: SQLite serialises writers anyway and a single handle
|
||||
// avoids "database is locked" surprises under WAL with the pure-Go driver.
|
||||
db.SetMaxOpenConns(1)
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// DB exposes the handle for the rare caller that needs raw SQL (tests).
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recovery_codes (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS recovery_codes_user ON recovery_codes(user_id);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
totp_pending INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id);
|
||||
CREATE TABLE IF NOT EXISTS peers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL UNIQUE,
|
||||
private_key TEXT,
|
||||
preshared_key TEXT,
|
||||
ipv4 TEXT NOT NULL UNIQUE,
|
||||
ipv6 TEXT UNIQUE,
|
||||
client_routes TEXT NOT NULL,
|
||||
dns TEXT NOT NULL DEFAULT '',
|
||||
keepalive INTEGER NOT NULL DEFAULT 0,
|
||||
mtu INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
expires_at INTEGER,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
rx_total INTEGER NOT NULL DEFAULT 0,
|
||||
tx_total INTEGER NOT NULL DEFAULT 0,
|
||||
last_handshake INTEGER,
|
||||
last_endpoint TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS traffic (
|
||||
peer_id TEXT NOT NULL REFERENCES peers(id) ON DELETE CASCADE,
|
||||
bucket_start INTEGER NOT NULL,
|
||||
rx INTEGER NOT NULL DEFAULT 0,
|
||||
tx INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (peer_id, bucket_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS traffic_bucket ON traffic(bucket_start);
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS audit_at ON audit(at);
|
||||
`
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
if _, err := s.db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSetting returns the raw value of a key, or "" when unset.
|
||||
func (s *Store) GetSetting(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetSetting writes a key.
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a row does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// User is an administrator account.
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
TOTPSecret string
|
||||
TOTPEnabled bool
|
||||
CreatedAt time.Time
|
||||
LastLoginAt time.Time
|
||||
}
|
||||
|
||||
func scanUser(row interface{ Scan(...any) error }) (*User, error) {
|
||||
var u User
|
||||
var secret sql.NullString
|
||||
var totp int
|
||||
var created int64
|
||||
var last sql.NullInt64
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &secret, &totp, &created, &last); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.TOTPSecret = secret.String
|
||||
u.TOTPEnabled = totp == 1
|
||||
u.CreatedAt = time.Unix(created, 0)
|
||||
if last.Valid {
|
||||
u.LastLoginAt = time.Unix(last.Int64, 0)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
const userCols = `id, username, password_hash, role, totp_secret, totp_enabled, created_at, last_login_at`
|
||||
|
||||
// CountUsers returns how many users exist; zero means first-run setup is due.
|
||||
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateUser inserts a user and returns it.
|
||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash, role string) (*User, error) {
|
||||
now := time.Now().Unix()
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users(username, password_hash, role, created_at) VALUES(?, ?, ?, ?)`, username, passwordHash, role, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.UserByID(ctx, id)
|
||||
}
|
||||
|
||||
// UserByID looks a user up by id.
|
||||
func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
|
||||
return scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
// UserByName looks a user up by username (case-insensitive).
|
||||
func (s *Store) UserByName(ctx context.Context, name string) (*User, error) {
|
||||
return scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name))
|
||||
}
|
||||
|
||||
// ListUsers returns every user ordered by username.
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]*User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetPassword replaces a user's password hash.
|
||||
func (s *Store) SetPassword(ctx context.Context, id int64, hash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetRole changes a user's role.
|
||||
func (s *Store) SetRole(ctx context.Context, id int64, role string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET role = ? WHERE id = ?`, role, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetTOTP stores a secret and whether it is active. An empty secret clears it.
|
||||
func (s *Store) SetTOTP(ctx context.Context, id int64, secret string, enabled bool) error {
|
||||
var sec any
|
||||
if secret != "" {
|
||||
sec = secret
|
||||
}
|
||||
en := 0
|
||||
if enabled {
|
||||
en = 1
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, sec, en, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchLogin records a successful login.
|
||||
func (s *Store) TouchLogin(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUser removes a user and, through cascades, their sessions and codes.
|
||||
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceRecoveryCodes replaces a user's recovery codes with the given hashes.
|
||||
func (s *Store) ReplaceRecoveryCodes(ctx context.Context, id int64, hashes []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range hashes {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes(user_id, code_hash) VALUES(?, ?)`, id, h); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UseRecoveryCode marks a code used if it exists and is unused; it reports
|
||||
// whether it did.
|
||||
func (s *Store) UseRecoveryCode(ctx context.Context, id int64, hash string) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, time.Now().Unix(), id, hash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// RecoveryCodesLeft counts a user's unused recovery codes.
|
||||
func (s *Store) RecoveryCodesLeft(ctx context.Context, id int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, id).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Session is one logged-in browser.
|
||||
type Session struct {
|
||||
TokenHash string
|
||||
UserID int64
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
IP string
|
||||
UserAgent string
|
||||
TOTPPending bool
|
||||
}
|
||||
|
||||
// CreateSession stores a session.
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session) error {
|
||||
pending := 0
|
||||
if sess.TOTPPending {
|
||||
pending = 1
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(token_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent, totp_pending) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sess.TokenHash, sess.UserID, sess.CreatedAt.Unix(), sess.LastSeenAt.Unix(), sess.ExpiresAt.Unix(), sess.IP, sess.UserAgent, pending)
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionByHash loads a session.
|
||||
func (s *Store) SessionByHash(ctx context.Context, hash string) (*Session, error) {
|
||||
var sess Session
|
||||
var created, seen, exp int64
|
||||
var pending int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT token_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent, totp_pending FROM sessions WHERE token_hash = ?`, hash).
|
||||
Scan(&sess.TokenHash, &sess.UserID, &created, &seen, &exp, &sess.IP, &sess.UserAgent, &pending)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.CreatedAt = time.Unix(created, 0)
|
||||
sess.LastSeenAt = time.Unix(seen, 0)
|
||||
sess.ExpiresAt = time.Unix(exp, 0)
|
||||
sess.TOTPPending = pending == 1
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// TouchSession bumps last_seen and the sliding expiry.
|
||||
func (s *Store) TouchSession(ctx context.Context, hash string, expires time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE token_hash = ?`, time.Now().Unix(), expires.Unix(), hash)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearTOTPPending marks a session fully authenticated.
|
||||
func (s *Store) ClearTOTPPending(ctx context.Context, hash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_pending = 0 WHERE token_hash = ?`, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSession logs one browser out.
|
||||
func (s *Store) DeleteSession(ctx context.Context, hash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserSessions logs a user out everywhere.
|
||||
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PruneSessions drops expired sessions.
|
||||
func (s *Store) PruneSessions(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package wg abstracts the WireGuard data plane behind a small interface so the
|
||||
// rest of WGX does not care whether peers live in the kernel module, in a
|
||||
// userspace wireguard-go process, or in an in-memory mock used by tests and
|
||||
// UI development.
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Key is a 32-byte WireGuard key (public, private or preshared).
|
||||
type Key [32]byte
|
||||
|
||||
// PeerState is one peer as the data plane currently sees it.
|
||||
type PeerState struct {
|
||||
PublicKey Key
|
||||
Endpoint *net.UDPAddr
|
||||
LastHandshake time.Time // zero when the peer has never completed a handshake
|
||||
ReceiveBytes int64
|
||||
TransmitBytes int64
|
||||
AllowedIPs []netip.Prefix
|
||||
PersistentKeepalive time.Duration
|
||||
}
|
||||
|
||||
// PeerConfig is what WGX wants a peer to look like on the interface.
|
||||
type PeerConfig struct {
|
||||
PublicKey Key
|
||||
PresharedKey *Key
|
||||
AllowedIPs []netip.Prefix
|
||||
PersistentKeepalive time.Duration
|
||||
}
|
||||
|
||||
// DeviceConfig is the interface-level configuration.
|
||||
type DeviceConfig struct {
|
||||
PrivateKey Key
|
||||
ListenPort int
|
||||
// FirewallMark is applied to every packet the interface sends; zero means
|
||||
// none. Left at zero by WGX, but exposed for completeness.
|
||||
FirewallMark int
|
||||
}
|
||||
|
||||
// DeviceState is a snapshot of the interface.
|
||||
type DeviceState struct {
|
||||
Name string
|
||||
PublicKey Key
|
||||
ListenPort int
|
||||
Peers []PeerState
|
||||
}
|
||||
|
||||
// Backend is the data plane WGX drives.
|
||||
type Backend interface {
|
||||
// Kind names the implementation: "kernel", "userspace" or "mock".
|
||||
Kind() string
|
||||
// Up creates the interface (if needed), applies the device configuration
|
||||
// and brings the link up with the given addresses and MTU.
|
||||
Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error
|
||||
// Down tears the interface down and releases every resource Up acquired.
|
||||
Down(ctx context.Context) error
|
||||
// Device returns the current state of the interface and all of its peers.
|
||||
Device(ctx context.Context) (*DeviceState, error)
|
||||
// SetPeer adds or replaces a peer; AllowedIPs replace what was there.
|
||||
SetPeer(ctx context.Context, p PeerConfig) error
|
||||
// RemovePeer removes a peer. Removing a peer that is absent is not an error.
|
||||
RemovePeer(ctx context.Context, pub Key) error
|
||||
// ReplacePeers makes the interface's peer set exactly the given list.
|
||||
ReplacePeers(ctx context.Context, peers []PeerConfig) error
|
||||
// SetMTU changes the interface MTU without disturbing peers.
|
||||
SetMTU(ctx context.Context, mtu int) error
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package wg
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
// GeneratePrivateKey returns a fresh Curve25519 private key, clamped the way
|
||||
// WireGuard expects.
|
||||
func GeneratePrivateKey() (Key, error) {
|
||||
var k Key
|
||||
if _, err := rand.Read(k[:]); err != nil {
|
||||
return Key{}, fmt.Errorf("generate private key: %w", err)
|
||||
}
|
||||
k[0] &= 248
|
||||
k[31] &= 127
|
||||
k[31] |= 64
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// GeneratePresharedKey returns 32 random bytes for use as a preshared key.
|
||||
func GeneratePresharedKey() (Key, error) {
|
||||
var k Key
|
||||
if _, err := rand.Read(k[:]); err != nil {
|
||||
return Key{}, fmt.Errorf("generate preshared key: %w", err)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// PublicKey derives the public key of a private key.
|
||||
func (k Key) PublicKey() Key {
|
||||
var pub Key
|
||||
priv := k
|
||||
curve25519.ScalarBaseMult((*[32]byte)(&pub), (*[32]byte)(&priv))
|
||||
return pub
|
||||
}
|
||||
|
||||
// String renders the key the way wg(8) does: standard base64.
|
||||
func (k Key) String() string { return base64.StdEncoding.EncodeToString(k[:]) }
|
||||
|
||||
// IsZero reports whether the key is all zeros.
|
||||
func (k Key) IsZero() bool { return k == Key{} }
|
||||
|
||||
// ParseKey parses a base64 key as produced by wg genkey / wg pubkey.
|
||||
func ParseKey(s string) (Key, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return Key{}, errors.New("key is not valid base64")
|
||||
}
|
||||
if len(b) != 32 {
|
||||
return Key{}, errors.New("key must decode to 32 bytes")
|
||||
}
|
||||
var k Key
|
||||
copy(k[:], b)
|
||||
return k, nil
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//go:build linux
|
||||
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// linuxBackend drives a real WireGuard interface. In kernel mode the link is a
|
||||
// native `wireguard` netlink link and every packet is handled by the module;
|
||||
// in userspace mode a wireguard-go process owns a TUN device with the same
|
||||
// name and WGX talks to it over its UAPI socket. Both are configured through
|
||||
// wgctrl, which picks the transport on its own.
|
||||
type linuxBackend struct {
|
||||
name string
|
||||
userspace bool
|
||||
client *wgctrl.Client
|
||||
proc *exec.Cmd
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// KernelAvailable reports whether the running kernel can create a WireGuard
|
||||
// link. It tries to add and immediately delete a probe interface rather than
|
||||
// trusting /sys/module, because a module that is loadable but not yet loaded
|
||||
// is only discovered by asking for it.
|
||||
func KernelAvailable() bool {
|
||||
const probe = "wgxprobe0"
|
||||
link := &netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: probe}}
|
||||
if err := netlink.LinkAdd(link); err != nil {
|
||||
return false
|
||||
}
|
||||
_ = netlink.LinkDel(link)
|
||||
return true
|
||||
}
|
||||
|
||||
// NewKernel returns a backend that uses the kernel module.
|
||||
func NewKernel(name string, log *slog.Logger) (Backend, error) {
|
||||
return newLinux(name, false, log)
|
||||
}
|
||||
|
||||
// NewUserspace returns a backend that runs wireguard-go for the data plane.
|
||||
func NewUserspace(name string, log *slog.Logger) (Backend, error) {
|
||||
if _, err := exec.LookPath("wireguard-go"); err != nil {
|
||||
return nil, errors.New("wireguard-go is not installed and the kernel has no WireGuard support")
|
||||
}
|
||||
return newLinux(name, true, log)
|
||||
}
|
||||
|
||||
func newLinux(name string, userspace bool, log *slog.Logger) (Backend, error) {
|
||||
c, err := wgctrl.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open wgctrl: %w", err)
|
||||
}
|
||||
return &linuxBackend{name: name, userspace: userspace, client: c, log: log}, nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Kind() string {
|
||||
if b.userspace {
|
||||
return "userspace"
|
||||
}
|
||||
return "kernel"
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error {
|
||||
// A previous run that died without Down leaves the link behind. Start
|
||||
// clean rather than inheriting peers and addresses nobody remembers.
|
||||
if err := b.deleteLink(); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.userspace {
|
||||
if err := b.startUserspace(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: b.name}}); err != nil {
|
||||
return fmt.Errorf("create %s: %w (is the container running with NET_ADMIN?)", b.name, err)
|
||||
}
|
||||
}
|
||||
link, err := b.link()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
priv := wgtypes.Key(cfg.PrivateKey)
|
||||
port := cfg.ListenPort
|
||||
wcfg := wgtypes.Config{PrivateKey: &priv, ListenPort: &port, ReplacePeers: true}
|
||||
if cfg.FirewallMark != 0 {
|
||||
fw := cfg.FirewallMark
|
||||
wcfg.FirewallMark = &fw
|
||||
}
|
||||
if err := b.client.ConfigureDevice(b.name, wcfg); err != nil {
|
||||
return fmt.Errorf("configure %s: %w", b.name, err)
|
||||
}
|
||||
for _, p := range addrs {
|
||||
// Not prefixToIPNet: that masks the host bits, and an interface
|
||||
// address must keep them (10.8.0.1/24, not 10.8.0.0/24).
|
||||
ipn := addrToIPNet(p)
|
||||
if err := netlink.AddrAdd(link, &netlink.Addr{IPNet: &ipn}); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("add address %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
if mtu > 0 {
|
||||
if err := netlink.LinkSetMTU(link, mtu); err != nil {
|
||||
return fmt.Errorf("set mtu %d: %w", mtu, err)
|
||||
}
|
||||
}
|
||||
if err := netlink.LinkSetUp(link); err != nil {
|
||||
return fmt.Errorf("bring up %s: %w", b.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) startUserspace(ctx context.Context) error {
|
||||
_ = os.MkdirAll("/var/run/wireguard", 0o700)
|
||||
cmd := exec.Command("wireguard-go", "-f", b.name)
|
||||
cmd.Env = append(os.Environ(), "WG_PROCESS_FOREGROUND=1", "LOG_LEVEL=error")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start wireguard-go: %w", err)
|
||||
}
|
||||
b.proc = cmd
|
||||
sock := filepath.Join("/var/run/wireguard", b.name+".sock")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(sock); err == nil {
|
||||
if _, err := netlink.LinkByName(b.name); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return errors.New("wireguard-go did not create its UAPI socket in time")
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Down(ctx context.Context) error {
|
||||
var errs []error
|
||||
if err := b.deleteLink(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if b.proc != nil && b.proc.Process != nil {
|
||||
_ = b.proc.Process.Kill()
|
||||
_ = b.proc.Wait()
|
||||
b.proc = nil
|
||||
}
|
||||
if err := b.client.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (b *linuxBackend) deleteLink() error {
|
||||
link, err := netlink.LinkByName(b.name)
|
||||
if err != nil {
|
||||
var nf netlink.LinkNotFoundError
|
||||
if errors.As(err, &nf) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("look up %s: %w", b.name, err)
|
||||
}
|
||||
if err := netlink.LinkDel(link); err != nil {
|
||||
return fmt.Errorf("delete stale %s: %w", b.name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) link() (netlink.Link, error) {
|
||||
link, err := netlink.LinkByName(b.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("look up %s: %w", b.name, err)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) Device(ctx context.Context) (*DeviceState, error) {
|
||||
d, err := b.client.Device(b.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", b.name, err)
|
||||
}
|
||||
st := &DeviceState{Name: d.Name, PublicKey: Key(d.PublicKey), ListenPort: d.ListenPort}
|
||||
st.Peers = make([]PeerState, 0, len(d.Peers))
|
||||
for _, p := range d.Peers {
|
||||
ps := PeerState{
|
||||
PublicKey: Key(p.PublicKey),
|
||||
Endpoint: p.Endpoint,
|
||||
LastHandshake: p.LastHandshakeTime,
|
||||
ReceiveBytes: p.ReceiveBytes,
|
||||
TransmitBytes: p.TransmitBytes,
|
||||
PersistentKeepalive: p.PersistentKeepaliveInterval,
|
||||
}
|
||||
for _, a := range p.AllowedIPs {
|
||||
if pfx, ok := ipNetToPrefix(a); ok {
|
||||
ps.AllowedIPs = append(ps.AllowedIPs, pfx)
|
||||
}
|
||||
}
|
||||
st.Peers = append(st.Peers, ps)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (b *linuxBackend) SetPeer(ctx context.Context, p PeerConfig) error {
|
||||
return b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{toPeerConfig(p)}})
|
||||
}
|
||||
|
||||
func (b *linuxBackend) RemovePeer(ctx context.Context, pub Key) error {
|
||||
err := b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{{PublicKey: wgtypes.Key(pub), Remove: true}}})
|
||||
if err != nil && strings.Contains(err.Error(), "no such") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *linuxBackend) ReplacePeers(ctx context.Context, peers []PeerConfig) error {
|
||||
cfg := wgtypes.Config{ReplacePeers: true}
|
||||
for _, p := range peers {
|
||||
cfg.Peers = append(cfg.Peers, toPeerConfig(p))
|
||||
}
|
||||
return b.client.ConfigureDevice(b.name, cfg)
|
||||
}
|
||||
|
||||
func (b *linuxBackend) SetMTU(ctx context.Context, mtu int) error {
|
||||
link, err := b.link()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return netlink.LinkSetMTU(link, mtu)
|
||||
}
|
||||
|
||||
func toPeerConfig(p PeerConfig) wgtypes.PeerConfig {
|
||||
pc := wgtypes.PeerConfig{PublicKey: wgtypes.Key(p.PublicKey), ReplaceAllowedIPs: true}
|
||||
if p.PresharedKey != nil {
|
||||
psk := wgtypes.Key(*p.PresharedKey)
|
||||
pc.PresharedKey = &psk
|
||||
}
|
||||
if p.PersistentKeepalive > 0 {
|
||||
ka := p.PersistentKeepalive
|
||||
pc.PersistentKeepaliveInterval = &ka
|
||||
}
|
||||
for _, a := range p.AllowedIPs {
|
||||
pc.AllowedIPs = append(pc.AllowedIPs, prefixToIPNet(a))
|
||||
}
|
||||
return pc
|
||||
}
|
||||
|
||||
// prefixToIPNet converts a route prefix; host bits are cleared.
|
||||
func prefixToIPNet(p netip.Prefix) net.IPNet {
|
||||
return addrToIPNet(p.Masked())
|
||||
}
|
||||
|
||||
// addrToIPNet converts an interface address with its prefix length, keeping
|
||||
// the host bits.
|
||||
func addrToIPNet(p netip.Prefix) net.IPNet {
|
||||
ip := p.Addr()
|
||||
if ip.Is4() {
|
||||
a := ip.As4()
|
||||
return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 32)}
|
||||
}
|
||||
a := ip.As16()
|
||||
return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 128)}
|
||||
}
|
||||
|
||||
func ipNetToPrefix(n net.IPNet) (netip.Prefix, bool) {
|
||||
addr, ok := netip.AddrFromSlice(n.IP)
|
||||
if !ok {
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
ones, _ := n.Mask.Size()
|
||||
return netip.PrefixFrom(addr, ones), true
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build linux
|
||||
|
||||
package wg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIPNetConversions(t *testing.T) {
|
||||
// An interface address keeps its host bits; a route prefix loses them.
|
||||
// Getting this wrong once put 10.8.0.0/24 on the interface, and the
|
||||
// server answered nothing on 10.8.0.1.
|
||||
addr := addrToIPNet(netip.MustParsePrefix("10.8.0.1/24"))
|
||||
if addr.IP.String() != "10.8.0.1" {
|
||||
t.Fatalf("address lost host bits: %s", addr.IP)
|
||||
}
|
||||
if ones, _ := addr.Mask.Size(); ones != 24 {
|
||||
t.Fatalf("mask %d", ones)
|
||||
}
|
||||
route := prefixToIPNet(netip.MustParsePrefix("10.8.0.7/24"))
|
||||
if route.IP.String() != "10.8.0.0" {
|
||||
t.Fatalf("route kept host bits: %s", route.IP)
|
||||
}
|
||||
v6 := addrToIPNet(netip.MustParsePrefix("fd42::1/64"))
|
||||
if v6.IP.String() != "fd42::1" || len(v6.IP) != 16 {
|
||||
t.Fatalf("v6 %s", v6.IP)
|
||||
}
|
||||
back, ok := ipNetToPrefix(route)
|
||||
if !ok || back.String() != "10.8.0.0/24" {
|
||||
t.Fatalf("round trip %v %v", back, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeys(t *testing.T) {
|
||||
priv, err := GeneratePrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if priv[0]&7 != 0 || priv[31]&128 != 0 || priv[31]&64 == 0 {
|
||||
t.Fatal("private key not clamped")
|
||||
}
|
||||
pub := priv.PublicKey()
|
||||
parsed, err := ParseKey(pub.String())
|
||||
if err != nil || parsed != pub {
|
||||
t.Fatal("public key does not round-trip through base64")
|
||||
}
|
||||
if _, err := ParseKey("not base64!"); err == nil {
|
||||
t.Fatal("bad key accepted")
|
||||
}
|
||||
if _, err := ParseKey("AAAA"); err == nil {
|
||||
t.Fatal("short key accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package wg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Mock is an in-memory data plane. It needs no privileges, so it is what the
|
||||
// tests use and what `WGX_BACKEND=mock` gives a developer working on the UI.
|
||||
// With Simulate on, peers randomly handshake, move traffic and go quiet so
|
||||
// the dashboard has something to show.
|
||||
type Mock struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
cfg DeviceConfig
|
||||
peers map[Key]*mockPeer
|
||||
up bool
|
||||
Simulate bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
type mockPeer struct {
|
||||
cfg PeerConfig
|
||||
endpoint *net.UDPAddr
|
||||
handshake time.Time
|
||||
rx, tx int64
|
||||
active bool
|
||||
}
|
||||
|
||||
// NewMock returns an empty mock backend for the named interface.
|
||||
func NewMock(name string, simulate bool) *Mock {
|
||||
return &Mock{name: name, peers: map[Key]*mockPeer{}, Simulate: simulate}
|
||||
}
|
||||
|
||||
func (m *Mock) Kind() string { return "mock" }
|
||||
|
||||
func (m *Mock) Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cfg = cfg
|
||||
m.up = true
|
||||
if m.Simulate && m.stop == nil {
|
||||
m.stop = make(chan struct{})
|
||||
go m.simulate(m.stop)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) Down(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.up = false
|
||||
if m.stop != nil {
|
||||
close(m.stop)
|
||||
m.stop = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) Device(ctx context.Context) (*DeviceState, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
st := &DeviceState{Name: m.name, PublicKey: m.cfg.PrivateKey.PublicKey(), ListenPort: m.cfg.ListenPort}
|
||||
keys := make([]Key, 0, len(m.peers))
|
||||
for k := range m.peers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() })
|
||||
for _, k := range keys {
|
||||
p := m.peers[k]
|
||||
st.Peers = append(st.Peers, PeerState{
|
||||
PublicKey: k,
|
||||
Endpoint: p.endpoint,
|
||||
LastHandshake: p.handshake,
|
||||
ReceiveBytes: p.rx,
|
||||
TransmitBytes: p.tx,
|
||||
AllowedIPs: append([]netip.Prefix(nil), p.cfg.AllowedIPs...),
|
||||
PersistentKeepalive: p.cfg.PersistentKeepalive,
|
||||
})
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (m *Mock) SetPeer(ctx context.Context, p PeerConfig) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if existing, ok := m.peers[p.PublicKey]; ok {
|
||||
existing.cfg = p
|
||||
return nil
|
||||
}
|
||||
// Half of new peers start out busy so a fresh mock install has
|
||||
// something moving on the dashboard straight away.
|
||||
m.peers[p.PublicKey] = &mockPeer{cfg: p, active: m.Simulate && rand.IntN(2) == 0}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) RemovePeer(ctx context.Context, pub Key) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.peers, pub)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) ReplacePeers(ctx context.Context, peers []PeerConfig) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
next := map[Key]*mockPeer{}
|
||||
for _, p := range peers {
|
||||
if existing, ok := m.peers[p.PublicKey]; ok {
|
||||
existing.cfg = p
|
||||
next[p.PublicKey] = existing
|
||||
} else {
|
||||
next[p.PublicKey] = &mockPeer{cfg: p}
|
||||
}
|
||||
}
|
||||
m.peers = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mock) SetMTU(ctx context.Context, mtu int) error { return nil }
|
||||
|
||||
// Touch fakes a handshake and some traffic for a peer. Tests use it to make
|
||||
// a peer look connected without waiting on the simulator.
|
||||
func (m *Mock) Touch(pub Key, rx, tx int64, endpoint string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.peers[pub]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.handshake = time.Now()
|
||||
p.rx += rx
|
||||
p.tx += tx
|
||||
if endpoint != "" {
|
||||
if ap, err := netip.ParseAddrPort(endpoint); err == nil {
|
||||
p.endpoint = net.UDPAddrFromAddrPort(ap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mock) simulate(stop chan struct{}) {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
m.mu.Lock()
|
||||
for _, p := range m.peers {
|
||||
// Peers flip between active and idle a few times an hour.
|
||||
if rand.IntN(60) == 0 {
|
||||
p.active = !p.active
|
||||
}
|
||||
if p.active {
|
||||
if p.endpoint == nil {
|
||||
p.endpoint = &net.UDPAddr{IP: net.IPv4(203, 0, 113, byte(1+rand.IntN(250))), Port: 30000 + rand.IntN(30000)}
|
||||
}
|
||||
if time.Since(p.handshake) > time.Duration(90+rand.IntN(40))*time.Second {
|
||||
p.handshake = time.Now()
|
||||
}
|
||||
p.rx += int64(rand.IntN(400_000))
|
||||
p.tx += int64(rand.IntN(3_000_000))
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !linux
|
||||
|
||||
package wg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var errLinuxOnly = errors.New("real WireGuard interfaces are only supported on Linux; use WGX_BACKEND=mock for development")
|
||||
|
||||
// KernelAvailable is always false off Linux.
|
||||
func KernelAvailable() bool { return false }
|
||||
|
||||
// NewKernel is unavailable off Linux.
|
||||
func NewKernel(name string, log *slog.Logger) (Backend, error) { return nil, errLinuxOnly }
|
||||
|
||||
// NewUserspace is unavailable off Linux.
|
||||
func NewUserspace(name string, log *slog.Logger) (Backend, error) { return nil, errLinuxOnly }
|
||||
Reference in New Issue
Block a user