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

114 lines
3.7 KiB
Go

// Package netcfg owns everything around the WireGuard interface that is not
// WireGuard itself: IP forwarding, the nftables ruleset that NATs peers to the
// outside world, and the sysctls that keep throughput up.
package netcfg
import (
"context"
"bytes"
"fmt"
"net/netip"
"os/exec"
"strings"
)
// Rules describes the firewall ihasvpn wants.
type Rules struct {
// Iface is the WireGuard interface name, e.g. wg0.
Iface string
// Egress is the interface peers reach the outside world through. Empty
// means "any interface that is not Iface", which is what most single-NIC
// containers want.
Egress string
// ListenPort is the UDP port to accept WireGuard traffic on.
ListenPort int
// Subnets are the tunnel networks to masquerade (v4 and/or v6).
Subnets []netip.Prefix
// PeerIsolation drops traffic between peers when true.
PeerIsolation bool
// ClampMSS rewrites the MSS of forwarded SYNs to fit the path MTU. It
// costs almost nothing and removes the single most common cause of
// "the VPN connects but websites hang".
ClampMSS bool
// Table names the nftables table, so a host with its own rules never
// collides with ours. Defaults to "ihasvpn".
Table string
}
// Ruleset renders the nftables script for the given rules. It is a pure
// function so tests can check the output without a kernel.
func Ruleset(r Rules) string {
table := r.Table
if table == "" {
table = "ihasvpn"
}
var b strings.Builder
fmt.Fprintf(&b, "table inet %s\n", table)
fmt.Fprintf(&b, "delete table inet %s\n", table)
fmt.Fprintf(&b, "table inet %s {\n", table)
fmt.Fprintf(&b, " chain input {\n")
fmt.Fprintf(&b, " type filter hook input priority filter; policy accept;\n")
fmt.Fprintf(&b, " udp dport %d accept comment \"wireguard\"\n", r.ListenPort)
fmt.Fprintf(&b, " }\n")
fmt.Fprintf(&b, " chain forward {\n")
fmt.Fprintf(&b, " type filter hook forward priority filter; policy accept;\n")
if r.PeerIsolation {
fmt.Fprintf(&b, " iifname %q oifname %q drop comment \"peer isolation\"\n", r.Iface, r.Iface)
}
if r.ClampMSS {
fmt.Fprintf(&b, " iifname %q tcp flags syn tcp option maxseg size set rt mtu comment \"clamp mss\"\n", r.Iface)
fmt.Fprintf(&b, " oifname %q tcp flags syn tcp option maxseg size set rt mtu comment \"clamp mss\"\n", r.Iface)
}
fmt.Fprintf(&b, " iifname %q accept\n", r.Iface)
fmt.Fprintf(&b, " oifname %q ct state related,established accept\n", r.Iface)
fmt.Fprintf(&b, " }\n")
fmt.Fprintf(&b, " chain postrouting {\n")
fmt.Fprintf(&b, " type nat hook postrouting priority srcnat; policy accept;\n")
for _, s := range r.Subnets {
fam := "ip"
if s.Addr().Is6() {
fam = "ip6"
}
if r.Egress != "" {
fmt.Fprintf(&b, " %s saddr %s oifname %q masquerade\n", fam, s.Masked(), r.Egress)
} else {
fmt.Fprintf(&b, " %s saddr %s oifname != %q masquerade\n", fam, s.Masked(), r.Iface)
}
}
fmt.Fprintf(&b, " }\n")
fmt.Fprintf(&b, "}\n")
return b.String()
}
// Apply loads the ruleset with nft(8).
func Apply(ctx context.Context, r Rules) error {
return runNFT(ctx, Ruleset(r))
}
// Remove deletes the ihasvpn table, ignoring the case where it is already gone.
func Remove(ctx context.Context, table string) error {
if table == "" {
table = "ihasvpn"
}
script := fmt.Sprintf("table inet %s\ndelete table inet %s\n", table, table)
return runNFT(ctx, script)
}
func runNFT(ctx context.Context, script string) error {
nft, err := exec.LookPath("nft")
if err != nil {
return fmt.Errorf("nft is not installed: %w", err)
}
cmd := exec.CommandContext(ctx, nft, "-f", "-")
cmd.Stdin = strings.NewReader(script)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("nft: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}