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:
jcoffey
2026-09-12 19:56:08 -07:00
commit 6c006e1d4d
72 changed files with 11675 additions and 0 deletions
+277
View File
@@ -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)
}
}
}