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.
196 lines
4.8 KiB
Go
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
|
|
}
|