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.
487 lines
12 KiB
Go
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
|
|
}
|