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

203 lines
6.7 KiB
Go

// Package config reads the environment. Everything here is infrastructure
// that has to be known before the database opens; anything an administrator
// might change while the server runs lives in the database instead (see
// engine.Settings).
package config
import (
"errors"
"fmt"
"net/netip"
"os"
"strconv"
"strings"
"time"
)
// Config is the process configuration.
type Config struct {
DataDir string
DBPath string
Backend string // auto | kernel | userspace | mock
Iface string
// Listen is the UDP port WireGuard listens on.
ListenPort int
// Subnet4 / Subnet6 are the tunnel networks. The server takes the first
// usable address of each.
Subnet4 netip.Prefix
Subnet6 netip.Prefix // may be invalid (unset)
// Egress is the interface to masquerade on; empty means auto-detect.
Egress string
// HTTP is the admin listener address.
HTTP string
// TLSCert/TLSKey enable HTTPS from files; TLSSelfSigned generates and
// persists a certificate in the data directory.
TLSCert, TLSKey string
TLSSelfSigned bool
// SecureCookies forces the Secure flag on when TLS terminates elsewhere.
SecureCookies bool
// TrustedProxies are CIDRs whose X-Forwarded-For / X-Real-IP is believed.
TrustedProxies []netip.Prefix
// MetricsToken protects /metrics; empty disables the endpoint.
MetricsToken string
// SessionIdle / SessionMax bound admin sessions.
SessionIdle time.Duration
SessionMax time.Duration
// TrafficRetention bounds the usage history.
TrafficRetention time.Duration
// PollInterval is how often the data plane is read.
PollInterval time.Duration
// LogLevel is debug, info, warn or error.
LogLevel string
// LogJSON switches the log format.
LogJSON bool
// Initial* seed the settings on first run only.
InitialEndpoint string
InitialDNS string
// ManageFirewall may be turned off when the host owns the NAT rules.
ManageFirewall bool
// ManageSysctl may be turned off when the host has already tuned itself.
ManageSysctl bool
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return strings.TrimSpace(v)
}
return def
}
func envInt(key string, def int) (int, error) {
v := env(key, "")
if v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %q is not a number", key, v)
}
return n, nil
}
func envBool(key string, def bool) (bool, error) {
v := strings.ToLower(env(key, ""))
switch v {
case "":
return def, nil
case "1", "true", "yes", "on":
return true, nil
case "0", "false", "no", "off":
return false, nil
}
return false, fmt.Errorf("%s: %q is not a boolean", key, v)
}
func envDuration(key string, def time.Duration) (time.Duration, error) {
v := env(key, "")
if v == "" {
return def, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("%s: %q is not a duration (try 12h, 30m)", key, v)
}
return d, nil
}
// FromEnv builds the configuration from IHASVPN_* variables.
func FromEnv() (*Config, error) {
var errs []error
c := &Config{}
c.DataDir = env("IHASVPN_DATA_DIR", "/data")
c.DBPath = env("IHASVPN_DB", c.DataDir+"/ihasvpn.db")
c.Backend = strings.ToLower(env("IHASVPN_BACKEND", "auto"))
switch c.Backend {
case "auto", "kernel", "userspace", "mock":
default:
errs = append(errs, fmt.Errorf("IHASVPN_BACKEND: %q is not auto, kernel, userspace or mock", c.Backend))
}
c.Iface = env("IHASVPN_INTERFACE", "wg0")
if len(c.Iface) == 0 || len(c.Iface) > 15 || strings.ContainsAny(c.Iface, " /\t\n") {
errs = append(errs, errors.New("IHASVPN_INTERFACE: must be 1-15 characters with no spaces or slashes"))
}
var err error
if c.ListenPort, err = envInt("IHASVPN_PORT", 51820); err != nil {
errs = append(errs, err)
} else if c.ListenPort < 1 || c.ListenPort > 65535 {
errs = append(errs, errors.New("IHASVPN_PORT: must be 1-65535"))
}
if c.Subnet4, err = netip.ParsePrefix(env("IHASVPN_SUBNET", "10.8.0.0/24")); err != nil || !c.Subnet4.Addr().Is4() {
errs = append(errs, errors.New("IHASVPN_SUBNET: must be an IPv4 CIDR such as 10.8.0.0/24"))
} else if c.Subnet4.Bits() > 30 {
errs = append(errs, errors.New("IHASVPN_SUBNET: needs room for at least two hosts (/30 or larger)"))
}
if v := env("IHASVPN_SUBNET6", ""); v != "" {
if c.Subnet6, err = netip.ParsePrefix(v); err != nil || !c.Subnet6.Addr().Is6() {
errs = append(errs, errors.New("IHASVPN_SUBNET6: must be an IPv6 CIDR such as fd42:42:42::/64"))
}
}
c.Egress = env("IHASVPN_EGRESS_INTERFACE", "")
c.HTTP = env("IHASVPN_HTTP_LISTEN", ":51821")
c.TLSCert = env("IHASVPN_TLS_CERT", "")
c.TLSKey = env("IHASVPN_TLS_KEY", "")
if (c.TLSCert == "") != (c.TLSKey == "") {
errs = append(errs, errors.New("IHASVPN_TLS_CERT and IHASVPN_TLS_KEY must be set together"))
}
if c.TLSSelfSigned, err = envBool("IHASVPN_TLS_SELF_SIGNED", false); err != nil {
errs = append(errs, err)
}
if c.SecureCookies, err = envBool("IHASVPN_SECURE_COOKIES", false); err != nil {
errs = append(errs, err)
}
for _, p := range strings.Split(env("IHASVPN_TRUSTED_PROXIES", ""), ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
pfx, err := netip.ParsePrefix(p)
if err != nil {
if a, err2 := netip.ParseAddr(p); err2 == nil {
pfx = netip.PrefixFrom(a, a.BitLen())
} else {
errs = append(errs, fmt.Errorf("IHASVPN_TRUSTED_PROXIES: %q is not an address or CIDR", p))
continue
}
}
c.TrustedProxies = append(c.TrustedProxies, pfx)
}
c.MetricsToken = env("IHASVPN_METRICS_TOKEN", "")
if c.SessionIdle, err = envDuration("IHASVPN_SESSION_IDLE", 12*time.Hour); err != nil {
errs = append(errs, err)
}
if c.SessionMax, err = envDuration("IHASVPN_SESSION_MAX", 7*24*time.Hour); err != nil {
errs = append(errs, err)
}
if c.TrafficRetention, err = envDuration("IHASVPN_TRAFFIC_RETENTION", 90*24*time.Hour); err != nil {
errs = append(errs, err)
}
if c.PollInterval, err = envDuration("IHASVPN_POLL_INTERVAL", 2*time.Second); err != nil {
errs = append(errs, err)
} else if c.PollInterval < 500*time.Millisecond {
errs = append(errs, errors.New("IHASVPN_POLL_INTERVAL: must be at least 500ms"))
}
c.LogLevel = strings.ToLower(env("IHASVPN_LOG_LEVEL", "info"))
if c.LogJSON, err = envBool("IHASVPN_LOG_JSON", false); err != nil {
errs = append(errs, err)
}
c.InitialEndpoint = env("IHASVPN_ENDPOINT", "")
c.InitialDNS = env("IHASVPN_DNS", "1.1.1.1, 1.0.0.1")
if c.ManageFirewall, err = envBool("IHASVPN_MANAGE_FIREWALL", true); err != nil {
errs = append(errs, err)
}
if c.ManageSysctl, err = envBool("IHASVPN_MANAGE_SYSCTL", true); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
return c, nil
}
// TLSEnabled reports whether the admin listener speaks HTTPS itself.
func (c *Config) TLSEnabled() bool { return c.TLSCert != "" || c.TLSSelfSigned }