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.
169 lines
5.2 KiB
Go
169 lines
5.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"strings"
|
|
|
|
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
|
)
|
|
|
|
// Settings are the administrator-editable server options. They persist in the
|
|
// database and can be changed from the UI without restarting the container.
|
|
type Settings struct {
|
|
// EndpointHost is the public name or address clients connect to.
|
|
EndpointHost string `json:"endpointHost"`
|
|
// EndpointPort is what clients dial; usually the listen port, but
|
|
// different when the container's UDP port is remapped.
|
|
EndpointPort int `json:"endpointPort"`
|
|
// DNS handed to clients, comma separated. Empty means none.
|
|
DNS string `json:"dns"`
|
|
// ClientRoutes is the default AllowedIPs written into client configs.
|
|
ClientRoutes string `json:"clientRoutes"`
|
|
// MTU for the server interface and, by default, client configs.
|
|
MTU int `json:"mtu"`
|
|
// Keepalive is the default PersistentKeepalive for clients, in seconds.
|
|
Keepalive int `json:"keepalive"`
|
|
// PeerIsolation stops peers reaching one another.
|
|
PeerIsolation bool `json:"peerIsolation"`
|
|
// ClampMSS rewrites TCP MSS on forwarded SYNs to fit the tunnel MTU.
|
|
ClampMSS bool `json:"clampMSS"`
|
|
// PresharedKeys adds a per-peer preshared key to every new peer.
|
|
PresharedKeys bool `json:"presharedKeys"`
|
|
// ConnectedWindow is how many seconds since the last handshake still
|
|
// counts as connected. WireGuard rejects sessions after 180 s.
|
|
ConnectedWindow int `json:"connectedWindow"`
|
|
}
|
|
|
|
// DefaultSettings returns what a fresh install starts with.
|
|
func DefaultSettings(endpointHost, dns string, port int) Settings {
|
|
return Settings{
|
|
EndpointHost: endpointHost,
|
|
EndpointPort: port,
|
|
DNS: dns,
|
|
ClientRoutes: "0.0.0.0/0, ::/0",
|
|
MTU: 1420,
|
|
Keepalive: 25,
|
|
PeerIsolation: false,
|
|
ClampMSS: true,
|
|
PresharedKeys: true,
|
|
ConnectedWindow: 180,
|
|
}
|
|
}
|
|
|
|
// Validate checks settings coming in from the API.
|
|
func (s *Settings) Validate() error {
|
|
var errs []error
|
|
s.EndpointHost = strings.TrimSpace(s.EndpointHost)
|
|
if s.EndpointHost == "" {
|
|
errs = append(errs, errors.New("endpoint host is required"))
|
|
} else if strings.ContainsAny(s.EndpointHost, " /\\:") && !strings.HasPrefix(s.EndpointHost, "[") {
|
|
if _, err := netip.ParseAddr(s.EndpointHost); err != nil {
|
|
errs = append(errs, errors.New("endpoint host must be a hostname or IP address without a port"))
|
|
}
|
|
}
|
|
if s.EndpointPort < 1 || s.EndpointPort > 65535 {
|
|
errs = append(errs, errors.New("endpoint port must be 1-65535"))
|
|
}
|
|
if _, err := ParseDNS(s.DNS); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
if _, err := ParsePrefixes(s.ClientRoutes); err != nil {
|
|
errs = append(errs, fmt.Errorf("client routes: %w", err))
|
|
}
|
|
if s.MTU < 1280 || s.MTU > 9000 {
|
|
errs = append(errs, errors.New("MTU must be between 1280 and 9000"))
|
|
}
|
|
if s.Keepalive < 0 || s.Keepalive > 65535 {
|
|
errs = append(errs, errors.New("keepalive must be 0-65535 seconds"))
|
|
}
|
|
if s.ConnectedWindow < 30 || s.ConnectedWindow > 3600 {
|
|
errs = append(errs, errors.New("connected window must be 30-3600 seconds"))
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// ParseDNS validates a comma-separated list of resolvers (addresses, or a
|
|
// search domain which WireGuard clients also accept in the DNS field).
|
|
func ParseDNS(s string) ([]string, error) {
|
|
var out []string
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if _, err := netip.ParseAddr(part); err != nil {
|
|
// Allow search domains: letters, digits, dots and dashes only.
|
|
for _, r := range part {
|
|
if !(r == '.' || r == '-' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
|
return nil, fmt.Errorf("DNS entry %q is neither an address nor a domain", part)
|
|
}
|
|
}
|
|
}
|
|
out = append(out, part)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ParsePrefixes parses a comma-separated CIDR list; bare addresses become
|
|
// host prefixes.
|
|
func ParsePrefixes(s string) ([]netip.Prefix, error) {
|
|
var out []netip.Prefix
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
p, err := netip.ParsePrefix(part)
|
|
if err != nil {
|
|
a, err2 := netip.ParseAddr(part)
|
|
if err2 != nil {
|
|
return nil, fmt.Errorf("%q is not a CIDR", part)
|
|
}
|
|
p = netip.PrefixFrom(a, a.BitLen())
|
|
}
|
|
out = append(out, p.Masked())
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, errors.New("at least one route is required")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// JoinPrefixes renders prefixes the way a WireGuard config expects.
|
|
func JoinPrefixes(ps []netip.Prefix) string {
|
|
parts := make([]string, len(ps))
|
|
for i, p := range ps {
|
|
parts[i] = p.String()
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
const settingsKey = "server"
|
|
|
|
func loadSettings(ctx context.Context, st *store.Store) (*Settings, bool, error) {
|
|
raw, err := st.GetSetting(ctx, settingsKey)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if raw == "" {
|
|
return nil, false, nil
|
|
}
|
|
var s Settings
|
|
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
|
return nil, false, fmt.Errorf("settings are corrupt: %w", err)
|
|
}
|
|
return &s, true, nil
|
|
}
|
|
|
|
func saveSettings(ctx context.Context, st *store.Store, s *Settings) error {
|
|
raw, err := json.Marshal(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return st.SetSetting(ctx, settingsKey, string(raw))
|
|
}
|