Files
ihasvpn/cmd/wgx/main.go
T
jcoffey 6c006e1d4d 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.
2026-09-12 19:56:08 -07:00

196 lines
4.8 KiB
Go

// Command wgx runs the WireGuard server and its admin UI.
//
// wgx run the server (the container's default)
// wgx reset-password U set a new password for admin user U and drop
// their sessions and second factor; for lockouts
// wgx version print the version
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"golang.org/x/term"
"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"
"github.com/Coffey-Labs/WGX/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg"
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version", "-v":
fmt.Println("wgx", engine.Version)
return
case "reset-password":
if err := resetPassword(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
return
case "serve", "run":
case "help", "--help", "-h":
fmt.Print(usage)
return
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n%s", os.Args[1], usage)
os.Exit(2)
}
}
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "wgx:", err)
os.Exit(1)
}
}
const usage = `usage: wgx [serve | reset-password <user> | version]
Configuration is read from WGX_* environment variables; see the README.
`
func newLogger(cfg *config.Config) *slog.Logger {
var level slog.Level
switch cfg.LogLevel {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: level}
if cfg.LogJSON {
return slog.New(slog.NewJSONHandler(os.Stderr, opts))
}
return slog.New(slog.NewTextHandler(os.Stderr, opts))
}
func openBackend(cfg *config.Config, log *slog.Logger) (wg.Backend, error) {
switch cfg.Backend {
case "mock":
log.Warn("using the mock data plane: no real tunnel will be created")
return wg.NewMock(cfg.Iface, true), nil
case "kernel":
return wg.NewKernel(cfg.Iface, log)
case "userspace":
return wg.NewUserspace(cfg.Iface, log)
}
if wg.KernelAvailable() {
return wg.NewKernel(cfg.Iface, log)
}
log.Warn("the kernel has no WireGuard support; falling back to wireguard-go, which is slower. Load the wireguard module on the host for full speed.")
return wg.NewUserspace(cfg.Iface, log)
}
func run() error {
cfg, err := config.FromEnv()
if err != nil {
return err
}
log := newLogger(cfg)
log.Info("starting wgx", "version", engine.Version)
st, err := store.Open(cfg.DBPath)
if err != nil {
return err
}
defer st.Close()
be, err := openBackend(cfg, log)
if err != nil {
return err
}
eng := engine.New(cfg, st, be, log)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := eng.Start(ctx); err != nil {
_ = eng.Stop(context.Background())
return err
}
srv := server.New(cfg, eng, log)
serveErr := make(chan error, 1)
go func() { serveErr <- srv.ListenAndServe(ctx) }()
select {
case <-ctx.Done():
log.Info("shutting down")
case err := <-serveErr:
if err != nil {
log.Error("admin server failed", "error", err)
}
}
stop()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := eng.Stop(shutdown); err != nil {
log.Warn("shutdown", "error", err)
}
return nil
}
// resetPassword is the way back in when every administrator is locked out.
// It runs inside the container against the same database.
func resetPassword(args []string) error {
if len(args) != 1 {
return errors.New("usage: wgx reset-password <username>")
}
cfg, err := config.FromEnv()
if err != nil {
return err
}
st, err := store.Open(cfg.DBPath)
if err != nil {
return err
}
defer st.Close()
ctx := context.Background()
u, err := st.UserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("no user named %q", args[0])
}
var pw string
if v := os.Getenv("WGX_NEW_PASSWORD"); v != "" {
pw = v
} else {
fmt.Fprint(os.Stderr, "New password: ")
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
pw = strings.TrimSpace(string(b))
}
if err := auth.ValidatePassword(pw); err != nil {
return err
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
if err := st.SetPassword(ctx, u.ID, hash); err != nil {
return err
}
if err := st.SetTOTP(ctx, u.ID, "", false); err != nil {
return err
}
_ = st.ReplaceRecoveryCodes(ctx, u.ID, nil)
_ = st.DeleteUserSessions(ctx, u.ID)
_ = st.Audit(ctx, store.AuditEntry{Actor: "cli", Action: "password.reset", Target: u.Username, Detail: "two-factor cleared, sessions dropped"})
fmt.Fprintf(os.Stderr, "password for %s reset; two-factor cleared and sessions dropped\n", u.Username)
return nil
}