Files
ihasvpn/internal/engine/peers.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

487 lines
12 KiB
Go

package engine
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"sort"
"strconv"
"strings"
"time"
"github.com/skip2/go-qrcode"
"github.com/Coffey-Labs/ihasvpn/internal/auth"
"github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/ihasvpn/internal/wg"
)
// PeerInput is what the API accepts when creating or editing a peer.
type PeerInput struct {
Name string `json:"name"`
// PublicKey, when set on create, means the client generated its own key
// pair and the server never sees the private key.
PublicKey string `json:"publicKey,omitempty"`
// IPv4 / IPv6 may pin addresses; empty means allocate.
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
ClientRoutes string `json:"clientRoutes"`
DNS string `json:"dns"`
Keepalive *int `json:"keepalive"`
MTU *int `json:"mtu"`
Enabled *bool `json:"enabled"`
ExpiresAt *time.Time `json:"expiresAt"`
Notes string `json:"notes"`
}
// ErrValidation marks user errors so the API can answer 400.
type ErrValidation struct{ Msg string }
func (e ErrValidation) Error() string { return e.Msg }
func invalid(format string, a ...any) error { return ErrValidation{Msg: fmt.Sprintf(format, a...)} }
// ErrNotFound is returned for unknown peer ids.
var ErrNotFound = store.ErrNotFound
// Peer returns a copy of one peer.
func (e *Engine) Peer(id string) (*store.Peer, error) {
e.mu.RLock()
defer e.mu.RUnlock()
p, ok := e.peers[id]
if !ok {
return nil, ErrNotFound
}
cp := *p
return &cp, nil
}
// Peers returns copies of every peer, newest first.
func (e *Engine) Peers() []*store.Peer {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]*store.Peer, 0, len(e.peers))
for _, p := range e.peers {
cp := *p
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool {
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].CreatedAt.After(out[j].CreatedAt)
}
return out[i].ID < out[j].ID
})
return out
}
// Live returns the live state for a peer.
func (e *Engine) Live(id string) (Live, bool) { return e.col.LiveFor(id) }
// Snapshot returns the last poll.
func (e *Engine) Snapshot() Snapshot { return e.col.Snapshot() }
func (e *Engine) usedAddresses() (v4, v6 map[string]bool) {
v4, v6 = map[string]bool{}, map[string]bool{}
e.mu.RLock()
defer e.mu.RUnlock()
for _, p := range e.peers {
v4[p.IPv4] = true
if p.IPv6 != "" {
v6[p.IPv6] = true
}
}
return
}
func validateName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", invalid("name is required")
}
if len(name) > 64 {
return "", invalid("name must be 64 characters or fewer")
}
return name, nil
}
// CreatePeer allocates addresses, generates keys and adds the peer to the
// interface. The returned peer includes the private key when the server
// generated it.
func (e *Engine) CreatePeer(ctx context.Context, in PeerInput) (*store.Peer, error) {
name, err := validateName(in.Name)
if err != nil {
return nil, err
}
settings := e.Settings()
p := &store.Peer{Name: name, Enabled: true, Notes: strings.TrimSpace(in.Notes)}
if p.ID, err = auth.NewID(); err != nil {
return nil, err
}
if in.PublicKey != "" {
pub, err := wg.ParseKey(strings.TrimSpace(in.PublicKey))
if err != nil {
return nil, invalid("public key: %v", err)
}
p.PublicKey = pub.String()
} else {
priv, err := wg.GeneratePrivateKey()
if err != nil {
return nil, err
}
p.PrivateKey = priv.String()
p.PublicKey = priv.PublicKey().String()
}
if p.PublicKey == e.ServerPublicKey() {
return nil, invalid("that is the server's own public key")
}
e.mu.RLock()
_, dup := e.byKey[p.PublicKey]
e.mu.RUnlock()
if dup {
return nil, invalid("a peer with that public key already exists")
}
if settings.PresharedKeys {
psk, err := wg.GeneratePresharedKey()
if err != nil {
return nil, err
}
p.PresharedKey = psk.String()
}
used4, used6 := e.usedAddresses()
var a4 netip.Addr
if in.IPv4 != "" {
if a4, err = checkAddress(e.cfg.Subnet4, in.IPv4, used4); err != nil {
return nil, invalid("IPv4: %v", err)
}
} else if a4, err = allocate(e.cfg.Subnet4, used4); err != nil {
return nil, invalid("%v", err)
}
p.IPv4 = a4.String()
if e.cfg.Subnet6.IsValid() {
var a6 netip.Addr
if in.IPv6 != "" {
if a6, err = checkAddress(e.cfg.Subnet6, in.IPv6, used6); err != nil {
return nil, invalid("IPv6: %v", err)
}
} else if a6, err = allocate(e.cfg.Subnet6, used6); err != nil {
return nil, invalid("%v", err)
}
p.IPv6 = a6.String()
} else if in.IPv6 != "" {
return nil, invalid("IPv6 is not enabled on this server (set IHASVPN_SUBNET6)")
}
if err := applyEditable(p, in, settings); err != nil {
return nil, err
}
if err := e.st.CreatePeer(ctx, p); err != nil {
return nil, err
}
e.mu.Lock()
e.peers[p.ID] = p
e.byKey[p.PublicKey] = p
e.mu.Unlock()
if err := e.applyPeer(ctx, p); err != nil {
e.log.Error("apply new peer", "peer", p.ID, "error", err)
}
e.hub.Publish("peers", "changed")
cp := *p
return &cp, nil
}
// applyEditable copies the fields that may change after creation.
func applyEditable(p *store.Peer, in PeerInput, settings Settings) error {
routes := strings.TrimSpace(in.ClientRoutes)
if routes == "" {
routes = settings.ClientRoutes
}
ps, err := ParsePrefixes(routes)
if err != nil {
return invalid("client routes: %v", err)
}
p.ClientRoutes = JoinPrefixes(ps)
if _, err := ParseDNS(in.DNS); err != nil {
return invalid("%v", err)
}
p.DNS = strings.TrimSpace(in.DNS)
if in.Keepalive != nil {
if *in.Keepalive < 0 || *in.Keepalive > 65535 {
return invalid("keepalive must be 0-65535 seconds")
}
p.Keepalive = *in.Keepalive
}
if in.MTU != nil {
if *in.MTU != 0 && (*in.MTU < 1280 || *in.MTU > 9000) {
return invalid("MTU must be 0 (server default) or 1280-9000")
}
p.MTU = *in.MTU
}
if in.Enabled != nil {
p.Enabled = *in.Enabled
}
if in.ExpiresAt != nil {
p.ExpiresAt = in.ExpiresAt.UTC()
if p.ExpiresAt.Unix() <= 0 {
p.ExpiresAt = time.Time{}
}
}
p.Notes = strings.TrimSpace(in.Notes)
if len(p.Notes) > 2000 {
return invalid("notes must be 2000 characters or fewer")
}
return nil
}
// UpdatePeer edits a peer. Keys and addresses do not change here.
func (e *Engine) UpdatePeer(ctx context.Context, id string, in PeerInput) (*store.Peer, error) {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return nil, ErrNotFound
}
p := *cur
name, err := validateName(in.Name)
if err != nil {
return nil, err
}
p.Name = name
if err := applyEditable(&p, in, e.Settings()); err != nil {
return nil, err
}
if err := e.st.UpdatePeer(ctx, &p); err != nil {
return nil, err
}
e.mu.Lock()
*cur = p
e.mu.Unlock()
if err := e.applyPeer(ctx, cur); err != nil {
e.log.Error("apply peer", "peer", id, "error", err)
}
e.hub.Publish("peers", "changed")
return &p, nil
}
// SetEnabled turns a peer on or off. Off removes it from the interface at
// once, which drops any session it has: this is "disconnect".
func (e *Engine) SetEnabled(ctx context.Context, id string, enabled bool) (*store.Peer, error) {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return nil, ErrNotFound
}
p := *cur
p.Enabled = enabled
if err := e.st.UpdatePeer(ctx, &p); err != nil {
return nil, err
}
e.mu.Lock()
*cur = p
e.mu.Unlock()
if err := e.applyPeer(ctx, cur); err != nil {
return nil, err
}
e.hub.Publish("peers", "changed")
return &p, nil
}
// ResetSession drops a peer's current session without disabling it. The
// client will handshake again on its next packet.
func (e *Engine) ResetSession(ctx context.Context, id string) error {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return ErrNotFound
}
pub, err := wg.ParseKey(cur.PublicKey)
if err != nil {
return err
}
if err := e.be.RemovePeer(ctx, pub); err != nil {
return err
}
e.col.rekey(cur.PublicKey)
return e.applyPeer(ctx, cur)
}
// RotateKeys gives a server-managed peer a new key pair (and preshared key).
// The old configuration stops working immediately.
func (e *Engine) RotateKeys(ctx context.Context, id string) (*store.Peer, error) {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return nil, ErrNotFound
}
if cur.PrivateKey == "" {
return nil, invalid("this peer's keys are managed by the client; create a new peer instead")
}
priv, err := wg.GeneratePrivateKey()
if err != nil {
return nil, err
}
p := *cur
oldKey := p.PublicKey
p.PrivateKey = priv.String()
p.PublicKey = priv.PublicKey().String()
if e.Settings().PresharedKeys {
psk, err := wg.GeneratePresharedKey()
if err != nil {
return nil, err
}
p.PresharedKey = psk.String()
} else {
p.PresharedKey = ""
}
if err := e.st.UpdatePeer(ctx, &p); err != nil {
return nil, err
}
if old, err := wg.ParseKey(oldKey); err == nil {
_ = e.be.RemovePeer(ctx, old)
}
e.col.rekey(oldKey)
e.mu.Lock()
delete(e.byKey, oldKey)
*cur = p
e.byKey[p.PublicKey] = cur
e.mu.Unlock()
if err := e.applyPeer(ctx, cur); err != nil {
e.log.Error("apply rotated peer", "peer", id, "error", err)
}
e.hub.Publish("peers", "changed")
return &p, nil
}
// DeletePeer removes a peer for good.
func (e *Engine) DeletePeer(ctx context.Context, id string) error {
e.mu.RLock()
cur, ok := e.peers[id]
e.mu.RUnlock()
if !ok {
return ErrNotFound
}
if pub, err := wg.ParseKey(cur.PublicKey); err == nil {
if err := e.be.RemovePeer(ctx, pub); err != nil {
e.log.Warn("remove peer from interface", "peer", id, "error", err)
}
}
if err := e.st.DeletePeer(ctx, id); err != nil {
return err
}
e.mu.Lock()
delete(e.peers, id)
delete(e.byKey, cur.PublicKey)
e.mu.Unlock()
e.col.forget(id, cur.PublicKey)
e.hub.Publish("peers", "changed")
return nil
}
// applyPeer adds or removes one peer on the interface according to whether
// it should be active.
func (e *Engine) applyPeer(ctx context.Context, p *store.Peer) error {
pc, err := e.peerConfig(p)
if err != nil {
return err
}
if active(p, time.Now()) {
return e.be.SetPeer(ctx, pc)
}
return e.be.RemovePeer(ctx, pc.PublicKey)
}
// ClientConfig renders the WireGuard configuration file for a peer. When the
// client holds its own private key the placeholder is left for them.
func (e *Engine) ClientConfig(p *store.Peer) string {
s := e.Settings()
var b strings.Builder
b.WriteString("[Interface]\n")
if p.PrivateKey != "" {
fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey)
} else {
b.WriteString("PrivateKey = <your private key>\n")
}
addrs := []string{fmt.Sprintf("%s/%d", p.IPv4, e.cfg.Subnet4.Bits())}
if p.IPv6 != "" && e.cfg.Subnet6.IsValid() {
addrs = append(addrs, fmt.Sprintf("%s/%d", p.IPv6, e.cfg.Subnet6.Bits()))
}
fmt.Fprintf(&b, "Address = %s\n", strings.Join(addrs, ", "))
dns := p.DNS
if dns == "" {
dns = s.DNS
}
if d, _ := ParseDNS(dns); len(d) > 0 {
fmt.Fprintf(&b, "DNS = %s\n", strings.Join(d, ", "))
}
mtu := p.MTU
if mtu == 0 {
mtu = s.MTU
}
fmt.Fprintf(&b, "MTU = %d\n", mtu)
b.WriteString("\n[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", e.ServerPublicKey())
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.ClientRoutes)
fmt.Fprintf(&b, "Endpoint = %s\n", net.JoinHostPort(s.EndpointHost, strconv.Itoa(s.EndpointPort)))
ka := p.Keepalive
if ka == 0 {
ka = s.Keepalive
}
if ka > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", ka)
}
return b.String()
}
// QRCode renders the client configuration as a PNG.
func (e *Engine) QRCode(p *store.Peer, size int) ([]byte, error) {
if p.PrivateKey == "" {
return nil, errors.New("no QR code: the private key is held by the client")
}
if size < 128 || size > 1024 {
size = 384
}
return qrcode.Encode(e.ClientConfig(p), qrcode.Medium, size)
}
// Usage returns a peer's traffic series since a time.
func (e *Engine) Usage(ctx context.Context, peerID string, since time.Time) ([]store.TrafficPoint, error) {
if peerID != "" {
if _, err := e.Peer(peerID); err != nil {
return nil, err
}
}
// Pending buckets are flushed first so the last few minutes show.
if err := e.col.flush(ctx); err != nil {
return nil, err
}
pts, err := e.st.TrafficSeries(ctx, peerID, since)
if err != nil {
return nil, err
}
if pts == nil {
pts = []store.TrafficPoint{}
}
return pts, nil
}
// UsageByPeer sums traffic per peer since a time.
func (e *Engine) UsageByPeer(ctx context.Context, since time.Time) ([]store.PeerUsage, error) {
if err := e.col.flush(ctx); err != nil {
return nil, err
}
u, err := e.st.UsageSince(ctx, since)
if err != nil {
return nil, err
}
if u == nil {
u = []store.PeerUsage{}
}
return u, nil
}