Files
GHOSTWIRE/main.go
T
Daniel Redetzke 4793e8dfba GHOSTWIRE 0.1.2: WireGuard server manager with web UI and API
Single Go binary that manages a WireGuard server based on pivpn's defaults:
- config.json as the single source of truth, reconciled to the kernel via
  netlink, wgctrl and its own nftables table (NAT, forward, input)
- web interface (dashboard, peers, peer detail, add peer, server, settings)
  and a JSON API for the future iOS app, with session and API-token auth
- client private keys are never stored; configs and QR codes shown once
- per-peer traffic statistics in stats.json, logs in GHOSTWIRE.jsonl
- HTTPS via Let's Encrypt, self-signed, certificate files or off
- self-managing: install, update (restores the old binary on failure),
  uninstall and passwd subcommands; systemd unit generated by the binary

Tested end to end on Ubuntu 26.04 (kernel 7.0) at dev.redetzke.aero.

Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
2026-10-03 16:54:37 +03:00

321 lines
8.2 KiB
Go

// Command GHOSTWIRE is a small WireGuard server manager with a web interface
// and a JSON API. It keeps its whole state in config.json, applies it to the
// kernel with netlink, wgctrl and nftables, and logs to <appName>.jsonl.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/term"
)
// appName names the binary, the log file (GHOSTWIRE.jsonl), the session
// cookie and the nftables table. It is fixed on purpose.
const appName = "GHOSTWIRE"
// serviceName is the systemd unit, system user and folder under /opt.
const serviceName = "ghostwire"
var version = "0.1.0" // set with -ldflags "-X main.version=..."
func defaultConfigPath() string {
exe, err := os.Executable()
if err != nil {
return "config.json"
}
return filepath.Join(filepath.Dir(exe), "config.json")
}
func main() {
// Subcommands (install, update, ...) manage the installation; without
// one, the binary runs the service.
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
if err := runCommand(os.Args[1], os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, appName+":", err)
os.Exit(1)
}
return
}
flag.Usage = usage
configPath := flag.String("config", defaultConfigPath(), "path to config.json; logs and stats are kept next to it")
passwd := flag.Bool("passwd", false, "set the admin password and exit")
down := flag.Bool("down", false, "remove the WireGuard interface and firewall rules and exit")
check := flag.Bool("check", false, "check that config.json is valid for this version and exit")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println(appName, version)
return
}
var err error
switch {
case *passwd:
if err = setPassword(*configPath); err == nil {
fmt.Fprintf(os.Stderr, "If the service is running: systemctl reload %s\n", serviceName)
}
case *down:
err = teardown(*configPath)
case *check:
err = checkConfig(*configPath)
default:
err = run(*configPath)
}
if err != nil {
fmt.Fprintln(os.Stderr, appName+":", err)
os.Exit(1)
}
}
// checkConfig loads and validates config.json without changing it.
func checkConfig(path string) error {
c, err := loadConfigFile(path)
if err != nil {
return err
}
if err := c.validate(); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
fmt.Println("config OK")
return nil
}
func readSecret(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return string(b), err
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
return strings.TrimRight(line, "\r\n"), err
}
func setPassword(path string) error {
store, err := openStore(path)
if err != nil {
return err
}
pw, err := readSecret("New admin password: ")
if err != nil {
return err
}
if err := validatePassword(pw); err != nil {
return err
}
if term.IsTerminal(int(os.Stdin.Fd())) {
again, err := readSecret("Repeat password: ")
if err != nil {
return err
}
if again != pw {
return errors.New("passwords do not match")
}
}
hash, err := hashPassword(pw)
if err != nil {
return err
}
if err := store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; return nil }); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Password for %q saved.\n", store.Get().Admin.Username)
return nil
}
func teardown(path string) error {
c, err := loadConfigFile(path)
if err != nil {
return err
}
k, err := newKernel()
if err != nil {
return err
}
defer k.Close()
return k.Down(c)
}
func run(configPath string) error {
configPath, _ = filepath.Abs(configPath)
dataDir := filepath.Dir(configPath)
store, err := openStore(configPath)
if err != nil {
return err
}
cfg := store.Get()
logPath := filepath.Join(dataDir, appName+".jsonl")
logw, err := setupLogging(logPath, cfg.Log)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
defer logw.Close()
slog.Info("starting", "version", version, "config", configPath)
if cfg.Admin.PasswordHash == "" {
slog.Warn("no admin password set; run: sudo " + installBin + " passwd")
fmt.Fprintf(os.Stderr, "No admin password set. Run: sudo %s passwd\n", installBin)
}
kernel, err := newKernel()
if err != nil {
slog.Error("kernel access failed", "err", err)
return err
}
defer kernel.Close()
recon := newReconciler(kernel, store)
_ = recon.ApplyNow() // errors are shown in the UI; the UI must stay reachable
stats, err := openStats(filepath.Join(dataDir, "stats.json"), store, kernel)
if err != nil {
return fmt.Errorf("open stats: %w", err)
}
webTLS, err := setupTLS(cfg, dataDir)
if err != nil {
slog.Error("tls setup failed", "err", err)
return fmt.Errorf("tls: %w", err)
}
stop := make(chan struct{})
var stopOnce sync.Once
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
auth := newAuth(store)
app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
logPath: logPath, started: time.Now(), shutdown: shutdown,
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); recon.Run(stop) }()
go func() { defer wg.Done(); stats.Run(stop) }()
go func() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
auth.sweep()
}
}
}()
srv := &http.Server{
Addr: cfg.Web.Listen,
Handler: app.routes(),
TLSConfig: webTLS.Config,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
}
servers := []*http.Server{srv}
errc := make(chan error, 2)
ln, err := net.Listen("tcp", cfg.Web.Listen)
if err != nil {
slog.Error("listen failed", "addr", cfg.Web.Listen, "err", err)
return fmt.Errorf("listen on %s: %w", cfg.Web.Listen, err)
}
go func() {
if webTLS.Config != nil {
errc <- srv.ServeTLS(ln, "", "")
} else {
errc <- srv.Serve(ln)
}
}()
slog.Info("web interface listening", "addr", cfg.Web.Listen, "tls", cfg.Web.TLS.Mode)
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
var h http.Handler = http.HandlerFunc(redirectToHTTPS(cfg.Web.Listen))
if webTLS.ACME != nil {
h = webTLS.ACME.HTTPHandler(h) // also answers http-01 challenges
}
hs := &http.Server{Addr: cfg.Web.HTTPListen, Handler: h, ReadHeaderTimeout: 10 * time.Second}
servers = append(servers, hs)
go func() {
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("http listener failed", "addr", cfg.Web.HTTPListen, "err", err)
}
}()
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
for running := true; running; {
select {
case s := <-sigs:
if s == syscall.SIGHUP {
if err := store.Reload(); err != nil {
slog.Error("reload failed", "err", err)
} else {
logLevel.Set(parseLevel(store.Get().Log.Level))
recon.Kick()
slog.Info("config reloaded")
}
continue
}
slog.Info("stopping", "signal", s.String())
running = false
case <-stop:
running = false
case err := <-errc:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("web server failed", "err", err)
shutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, s := range servers {
_ = s.Shutdown(ctx)
}
wg.Wait()
return err
}
}
}
shutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, s := range servers {
_ = s.Shutdown(ctx)
}
wg.Wait()
slog.Info("stopped")
return nil
}
func redirectToHTTPS(listen string) http.HandlerFunc {
_, port, _ := net.SplitHostPort(listen)
return func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if port != "" && port != "443" {
host = net.JoinHostPort(host, port)
}
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently)
}
}