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
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user