WireGuard server with an embedded admin console
Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
//go:build linux
|
||||
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// DefaultEgress returns the interface the default IPv4 route leaves through.
|
||||
// Masquerading on exactly that interface (rather than "anything that is not
|
||||
// wg0") keeps the NAT rule from touching docker-internal traffic.
|
||||
func DefaultEgress() (string, error) {
|
||||
routes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, r := range routes {
|
||||
if r.Dst == nil || (r.Dst.IP.Equal(net.IPv4zero) && isZeroMask(r.Dst.Mask)) {
|
||||
if r.LinkIndex == 0 {
|
||||
continue
|
||||
}
|
||||
link, err := netlink.LinkByIndex(r.LinkIndex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return link.Attrs().Name, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no default route")
|
||||
}
|
||||
|
||||
func isZeroMask(m net.IPMask) bool {
|
||||
for _, b := range m {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package netcfg
|
||||
|
||||
import "errors"
|
||||
|
||||
// DefaultEgress is only implemented on Linux.
|
||||
func DefaultEgress() (string, error) { return "", errors.New("not supported on this platform") }
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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 WGX 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 "wgx".
|
||||
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 = "wgx"
|
||||
}
|
||||
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 WGX table, ignoring the case where it is already gone.
|
||||
func Remove(ctx context.Context, table string) error {
|
||||
if table == "" {
|
||||
table = "wgx"
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuleset(t *testing.T) {
|
||||
r := Rules{
|
||||
Iface: "wg0",
|
||||
Egress: "eth0",
|
||||
ListenPort: 51820,
|
||||
Subnets: []netip.Prefix{netip.MustParsePrefix("10.8.0.0/24"), netip.MustParsePrefix("fd42::/64")},
|
||||
PeerIsolation: true,
|
||||
ClampMSS: true,
|
||||
}
|
||||
out := Ruleset(r)
|
||||
for _, want := range []string{
|
||||
"table inet wgx {",
|
||||
"udp dport 51820 accept",
|
||||
`iifname "wg0" oifname "wg0" drop`,
|
||||
`tcp option maxseg size set rt mtu`,
|
||||
`ip saddr 10.8.0.0/24 oifname "eth0" masquerade`,
|
||||
`ip6 saddr fd42::/64 oifname "eth0" masquerade`,
|
||||
`oifname "wg0" ct state related,established accept`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("ruleset missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Without an egress, masquerade on anything that is not the tunnel.
|
||||
r.Egress = ""
|
||||
r.PeerIsolation = false
|
||||
out = Ruleset(r)
|
||||
if !strings.Contains(out, `oifname != "wg0" masquerade`) {
|
||||
t.Errorf("expected wildcard masquerade:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "peer isolation") {
|
||||
t.Error("isolation rule present when off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWanted(t *testing.T) {
|
||||
v4 := Wanted(false)
|
||||
v6 := Wanted(true)
|
||||
if len(v6) != len(v4)+1 {
|
||||
t.Fatal("ipv6 forwarding not added")
|
||||
}
|
||||
if v4[0].Key != "net.ipv4.ip_forward" || !v4[0].Required {
|
||||
t.Fatal("ip_forward must be first and required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sysctl is one kernel parameter and the value WGX wants for it.
|
||||
type Sysctl struct {
|
||||
Key string
|
||||
Value string
|
||||
// Required marks the ones the VPN cannot work without (forwarding). The
|
||||
// others are throughput tuning: nice to have, and often refused inside a
|
||||
// container because they are not network-namespaced.
|
||||
Required bool
|
||||
// Why is shown in the log and the UI when a value could not be set.
|
||||
Why string
|
||||
}
|
||||
|
||||
// Result records what happened to one sysctl.
|
||||
type Result struct {
|
||||
Sysctl
|
||||
Applied bool
|
||||
Current string
|
||||
Err string
|
||||
}
|
||||
|
||||
// Wanted returns the sysctls WGX applies at startup, in order.
|
||||
func Wanted(ipv6 bool) []Sysctl {
|
||||
s := []Sysctl{
|
||||
{Key: "net.ipv4.ip_forward", Value: "1", Required: true, Why: "peers cannot reach anything beyond the server without forwarding"},
|
||||
// Strict reverse-path filtering drops replies that arrive on the
|
||||
// tunnel for a source the kernel would route elsewhere. Loose is
|
||||
// what every VPN gateway runs.
|
||||
{Key: "net.ipv4.conf.all.rp_filter", Value: "2", Why: "strict rp_filter drops legitimate tunnel replies"},
|
||||
{Key: "net.ipv4.conf.default.rp_filter", Value: "2", Why: "strict rp_filter drops legitimate tunnel replies"},
|
||||
// The remaining ones are throughput. They are global (not
|
||||
// namespaced), so inside a container they usually fail and must be
|
||||
// set on the host instead -- see docs/performance.md.
|
||||
{Key: "net.core.rmem_max", Value: "26214400", Why: "larger UDP receive buffers stop bursts being dropped before WireGuard reads them"},
|
||||
{Key: "net.core.wmem_max", Value: "26214400", Why: "larger UDP send buffers keep the encrypt path from stalling"},
|
||||
{Key: "net.core.rmem_default", Value: "1048576", Why: "default socket receive buffer"},
|
||||
{Key: "net.core.wmem_default", Value: "1048576", Why: "default socket send buffer"},
|
||||
{Key: "net.core.netdev_max_backlog", Value: "16384", Why: "deeper per-CPU input queue for 10GbE bursts"},
|
||||
{Key: "net.ipv4.udp_rmem_min", Value: "16384", Why: "minimum UDP receive buffer under memory pressure"},
|
||||
{Key: "net.ipv4.udp_wmem_min", Value: "16384", Why: "minimum UDP send buffer under memory pressure"},
|
||||
}
|
||||
if ipv6 {
|
||||
s = append(s, Sysctl{Key: "net.ipv6.conf.all.forwarding", Value: "1", Required: true, Why: "IPv6 peers cannot reach anything beyond the server without forwarding"})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ApplyAll writes each sysctl through /proc/sys and reports what happened.
|
||||
// A value that is already right counts as applied. A required value that
|
||||
// cannot be set is returned as an error along with the full report so the
|
||||
// caller can decide whether to keep going.
|
||||
func ApplyAll(want []Sysctl) ([]Result, error) {
|
||||
var results []Result
|
||||
var fatal []string
|
||||
for _, s := range want {
|
||||
r := Result{Sysctl: s}
|
||||
path := filepath.Join("/proc/sys", strings.ReplaceAll(s.Key, ".", "/"))
|
||||
cur, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
r.Current = strings.TrimSpace(string(cur))
|
||||
}
|
||||
if r.Current == s.Value {
|
||||
r.Applied = true
|
||||
results = append(results, r)
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(s.Value), 0o644); err != nil {
|
||||
r.Err = err.Error()
|
||||
if s.Required && !forwardingSatisfied(r.Current, s.Value) {
|
||||
fatal = append(fatal, fmt.Sprintf("%s=%s (%s)", s.Key, s.Value, r.Err))
|
||||
}
|
||||
} else {
|
||||
r.Applied = true
|
||||
r.Current = s.Value
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
if len(fatal) > 0 {
|
||||
return results, fmt.Errorf("required sysctls could not be set: %s -- pass them with `--sysctl` or the compose `sysctls:` list", strings.Join(fatal, ", "))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// forwardingSatisfied treats "1" as satisfied for forwarding keys even when
|
||||
// the file was read-only, which is what a compose `sysctls:` entry produces.
|
||||
func forwardingSatisfied(current, want string) bool { return current == want }
|
||||
Reference in New Issue
Block a user