Files
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

422 lines
11 KiB
Go

// Package engine ties the pieces together: it owns the server key, brings the
// interface up, keeps the data plane in step with the database, reads
// counters, and answers the questions the API asks.
package engine
import (
"context"
"errors"
"fmt"
"log/slog"
"net/netip"
"sync"
"time"
"github.com/Coffey-Labs/ihasvpn/internal/config"
"github.com/Coffey-Labs/ihasvpn/internal/netcfg"
"github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/ihasvpn/internal/wg"
)
const (
serverKeySetting = "server_private_key"
bucketSize = 5 * time.Minute
flushEvery = 30 * time.Second
houseEvery = 30 * time.Second
)
// Engine is the long-running core.
type Engine struct {
cfg *config.Config
st *store.Store
be wg.Backend
log *slog.Logger
mu sync.RWMutex
settings Settings
serverKey wg.Key
startedAt time.Time
sysctls []netcfg.Result
egress string
fwErr string
peers map[string]*store.Peer // by id
byKey map[string]*store.Peer // by public key
col *collector
hub *Hub
cancel context.CancelFunc
wg sync.WaitGroup
}
// New wires an engine up without starting anything.
func New(cfg *config.Config, st *store.Store, be wg.Backend, log *slog.Logger) *Engine {
e := &Engine{cfg: cfg, st: st, be: be, log: log, peers: map[string]*store.Peer{}, byKey: map[string]*store.Peer{}, hub: NewHub()}
e.col = newCollector(e)
return e
}
// Store exposes the database to the HTTP layer for users, sessions and audit.
func (e *Engine) Store() *store.Store { return e.st }
// Config exposes the process configuration.
func (e *Engine) Config() *config.Config { return e.cfg }
// Hub is the live-update fan-out.
func (e *Engine) Hub() *Hub { return e.hub }
// Backend names the data plane in use.
func (e *Engine) Backend() string { return e.be.Kind() }
// ServerPublicKey is what clients put in their [Peer] section.
func (e *Engine) ServerPublicKey() string {
e.mu.RLock()
defer e.mu.RUnlock()
return e.serverKey.PublicKey().String()
}
// Settings returns a copy of the current settings.
func (e *Engine) Settings() Settings {
e.mu.RLock()
defer e.mu.RUnlock()
return e.settings
}
// ServerAddresses are the interface's own tunnel addresses.
func (e *Engine) ServerAddresses() []netip.Prefix {
var out []netip.Prefix
out = append(out, netip.PrefixFrom(e.cfg.Subnet4.Masked().Addr().Next(), e.cfg.Subnet4.Bits()))
if e.cfg.Subnet6.IsValid() {
out = append(out, netip.PrefixFrom(e.cfg.Subnet6.Masked().Addr().Next(), e.cfg.Subnet6.Bits()))
}
return out
}
func (e *Engine) tunnelSubnets() []netip.Prefix {
out := []netip.Prefix{e.cfg.Subnet4.Masked()}
if e.cfg.Subnet6.IsValid() {
out = append(out, e.cfg.Subnet6.Masked())
}
return out
}
// Start loads state, brings the interface up and starts the background
// loops. It is safe to call Stop after a failed Start.
func (e *Engine) Start(ctx context.Context) error {
if err := e.loadSettings(ctx); err != nil {
return err
}
if err := e.loadServerKey(ctx); err != nil {
return err
}
if err := e.loadPeers(ctx); err != nil {
return err
}
if e.cfg.ManageSysctl && e.be.Kind() != "mock" {
results, err := netcfg.ApplyAll(netcfg.Wanted(e.cfg.Subnet6.IsValid()))
e.mu.Lock()
e.sysctls = results
e.mu.Unlock()
for _, r := range results {
if !r.Applied {
e.log.Warn("sysctl not applied", "key", r.Key, "wanted", r.Value, "current", r.Current, "error", r.Err, "why", r.Why)
}
}
if err != nil {
return err
}
}
settings := e.Settings()
dev := wg.DeviceConfig{PrivateKey: e.serverKey, ListenPort: e.cfg.ListenPort}
if err := e.be.Up(ctx, dev, e.ServerAddresses(), settings.MTU); err != nil {
return err
}
e.log.Info("interface up", "iface", e.cfg.Iface, "backend", e.be.Kind(), "port", e.cfg.ListenPort, "addresses", e.ServerAddresses(), "mtu", settings.MTU)
if err := e.applyFirewall(ctx); err != nil {
// Not fatal: the operator may run their own NAT. It is reported in
// the UI and the log so nobody wonders why peers cannot reach out.
e.log.Error("firewall rules not applied", "error", err)
e.mu.Lock()
e.fwErr = err.Error()
e.mu.Unlock()
}
if err := e.reconcile(ctx); err != nil {
return err
}
e.mu.Lock()
e.startedAt = time.Now()
e.mu.Unlock()
loopCtx, cancel := context.WithCancel(context.Background())
e.cancel = cancel
e.wg.Add(2)
go func() { defer e.wg.Done(); e.col.run(loopCtx) }()
go func() { defer e.wg.Done(); e.housekeeping(loopCtx) }()
return nil
}
// Stop halts the loops, flushes counters and tears the interface down.
func (e *Engine) Stop(ctx context.Context) error {
if e.cancel != nil {
e.cancel()
e.wg.Wait()
}
var errs []error
if err := e.col.flush(ctx); err != nil {
errs = append(errs, err)
}
if e.cfg.ManageFirewall && e.be.Kind() != "mock" {
if err := netcfg.Remove(ctx, ""); err != nil {
errs = append(errs, err)
}
}
if err := e.be.Down(ctx); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func (e *Engine) loadSettings(ctx context.Context) error {
s, ok, err := loadSettings(ctx, e.st)
if err != nil {
return err
}
if !ok {
def := DefaultSettings(e.cfg.InitialEndpoint, e.cfg.InitialDNS, e.cfg.ListenPort)
s = &def
if err := saveSettings(ctx, e.st, s); err != nil {
return err
}
}
e.mu.Lock()
e.settings = *s
e.mu.Unlock()
return nil
}
func (e *Engine) loadServerKey(ctx context.Context) error {
raw, err := e.st.GetSetting(ctx, serverKeySetting)
if err != nil {
return err
}
var key wg.Key
if raw == "" {
key, err = wg.GeneratePrivateKey()
if err != nil {
return err
}
if err := e.st.SetSetting(ctx, serverKeySetting, key.String()); err != nil {
return err
}
e.log.Info("generated server key", "publicKey", key.PublicKey().String())
} else {
key, err = wg.ParseKey(raw)
if err != nil {
return fmt.Errorf("stored server key is invalid: %w", err)
}
}
e.mu.Lock()
e.serverKey = key
e.mu.Unlock()
return nil
}
func (e *Engine) loadPeers(ctx context.Context) error {
peers, err := e.st.ListPeers(ctx)
if err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
e.peers = make(map[string]*store.Peer, len(peers))
e.byKey = make(map[string]*store.Peer, len(peers))
for _, p := range peers {
e.peers[p.ID] = p
e.byKey[p.PublicKey] = p
}
return nil
}
func (e *Engine) applyFirewall(ctx context.Context) error {
if !e.cfg.ManageFirewall || e.be.Kind() == "mock" {
return nil
}
egress := e.cfg.Egress
if egress == "" {
if d, err := netcfg.DefaultEgress(); err == nil {
egress = d
} else {
e.log.Warn("could not detect the egress interface; masquerading on every non-tunnel interface", "error", err)
}
}
s := e.Settings()
rules := netcfg.Rules{
Iface: e.cfg.Iface,
Egress: egress,
ListenPort: e.cfg.ListenPort,
Subnets: e.tunnelSubnets(),
PeerIsolation: s.PeerIsolation,
ClampMSS: s.ClampMSS,
}
if err := netcfg.Apply(ctx, rules); err != nil {
return err
}
e.mu.Lock()
e.egress = egress
e.fwErr = ""
e.mu.Unlock()
e.log.Info("firewall rules applied", "egress", egress, "peerIsolation", s.PeerIsolation, "clampMSS", s.ClampMSS)
return nil
}
// UpdateSettings validates, persists and applies new settings.
func (e *Engine) UpdateSettings(ctx context.Context, s Settings) error {
if err := s.Validate(); err != nil {
return err
}
old := e.Settings()
if err := saveSettings(ctx, e.st, &s); err != nil {
return err
}
e.mu.Lock()
e.settings = s
e.mu.Unlock()
if s.MTU != old.MTU {
if err := e.be.SetMTU(ctx, s.MTU); err != nil {
e.log.Warn("could not change interface MTU", "error", err)
}
}
if s.PeerIsolation != old.PeerIsolation || s.ClampMSS != old.ClampMSS {
if err := e.applyFirewall(ctx); err != nil {
e.mu.Lock()
e.fwErr = err.Error()
e.mu.Unlock()
return fmt.Errorf("settings saved but firewall rules failed: %w", err)
}
}
e.hub.Publish("settings", s)
return nil
}
// active reports whether a peer should currently be on the interface.
func active(p *store.Peer, now time.Time) bool {
if !p.Enabled {
return false
}
if !p.ExpiresAt.IsZero() && now.After(p.ExpiresAt) {
return false
}
return true
}
func (e *Engine) peerConfig(p *store.Peer) (wg.PeerConfig, error) {
pub, err := wg.ParseKey(p.PublicKey)
if err != nil {
return wg.PeerConfig{}, err
}
pc := wg.PeerConfig{PublicKey: pub}
if p.PresharedKey != "" {
psk, err := wg.ParseKey(p.PresharedKey)
if err != nil {
return wg.PeerConfig{}, err
}
pc.PresharedKey = &psk
}
if a, err := netip.ParseAddr(p.IPv4); err == nil {
pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 32))
}
if p.IPv6 != "" {
if a, err := netip.ParseAddr(p.IPv6); err == nil {
pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 128))
}
}
return pc, nil
}
// reconcile makes the interface's peer set match the database, one peer at
// a time. It never uses ReplacePeers on a running interface: that would
// reset every counter and drop every session for the sake of one change.
func (e *Engine) reconcile(ctx context.Context) error {
dev, err := e.be.Device(ctx)
if err != nil {
return err
}
now := time.Now()
e.mu.RLock()
want := make(map[string]wg.PeerConfig, len(e.peers))
for _, p := range e.peers {
if active(p, now) {
pc, err := e.peerConfig(p)
if err != nil {
e.log.Warn("skipping peer with bad key", "peer", p.ID, "error", err)
continue
}
want[p.PublicKey] = pc
}
}
e.mu.RUnlock()
have := make(map[string]wg.PeerState, len(dev.Peers))
for _, p := range dev.Peers {
have[p.PublicKey.String()] = p
}
var errs []error
for key, pc := range want {
cur, ok := have[key]
if ok && samePeer(cur, pc) {
continue
}
if err := e.be.SetPeer(ctx, pc); err != nil {
errs = append(errs, fmt.Errorf("add peer %s: %w", key, err))
}
}
for key, cur := range have {
if _, ok := want[key]; !ok {
if err := e.be.RemovePeer(ctx, cur.PublicKey); err != nil {
errs = append(errs, fmt.Errorf("remove peer %s: %w", key, err))
}
}
}
return errors.Join(errs...)
}
func samePeer(cur wg.PeerState, want wg.PeerConfig) bool {
if len(cur.AllowedIPs) != len(want.AllowedIPs) {
return false
}
set := map[netip.Prefix]bool{}
for _, a := range cur.AllowedIPs {
set[a] = true
}
for _, a := range want.AllowedIPs {
if !set[a] {
return false
}
}
return cur.PersistentKeepalive == want.PersistentKeepalive
}
func (e *Engine) housekeeping(ctx context.Context) {
t := time.NewTicker(houseEvery)
defer t.Stop()
prune := time.NewTicker(time.Hour)
defer prune.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := e.reconcile(ctx); err != nil {
e.log.Warn("reconcile", "error", err)
}
if err := e.col.flush(ctx); err != nil {
e.log.Warn("flush counters", "error", err)
}
_ = e.st.PruneSessions(ctx)
case <-prune.C:
_ = e.st.PruneTraffic(ctx, time.Now().Add(-e.cfg.TrafficRetention))
_ = e.st.PruneAudit(ctx, 5000)
}
}
}