Files
ihasvpn/cmd/ihasvpn/main.go
T
jcoffey-dev 02e7993c87 Rename the project to ihasvpn
WGX shares its name with several other WireGuard tools, so the project
becomes ihasvpn, alongside ihasmail.

- Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image
  ghcr.io/coffey-labs/ihasvpn.
- Environment variables move from WGX_* to IHASVPN_*. The default database
  is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and
  the session cookie and theme key are renamed, so existing sessions end.
- The mark is the ihasmail cat peeking over the edge of a shield, drawn as
  a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks,
  social card, favicons and app icons from that one drawing.
- The console takes ihasmail's palette: the ihasmail.org teal-navy for dark,
  its contrast-checked light tiers with the site's light accent, received
  traffic in the cat's orange and sent in teal. The wordmark weight and
  font stack follow ihasmail.org.
- Detail values wrap at spaces before breaking inside an address, so an
  IPv6 tunnel address no longer splits mid-number.
- The README history note about the earlier WGX installer is gone with the
  name it explained. Screenshots retaken.
2026-09-12 23:48:36 -07:00

196 lines
4.9 KiB
Go

// Command ihasvpn runs the WireGuard server and its admin UI.
//
// ihasvpn run the server (the container's default)
// ihasvpn reset-password U set a new password for admin user U and drop
// their sessions and second factor; for lockouts
// ihasvpn 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/ihasvpn/internal/auth"
"github.com/Coffey-Labs/ihasvpn/internal/config"
"github.com/Coffey-Labs/ihasvpn/internal/engine"
"github.com/Coffey-Labs/ihasvpn/internal/server"
"github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/ihasvpn/internal/wg"
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version", "-v":
fmt.Println("ihasvpn", 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, "ihasvpn:", err)
os.Exit(1)
}
}
const usage = `usage: ihasvpn [serve | reset-password <user> | version]
Configuration is read from IHASVPN_* 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 ihasvpn", "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: ihasvpn 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("IHASVPN_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
}