mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-17 08:54:22 +00:00
247 lines
9.0 KiB
Go
247 lines
9.0 KiB
Go
// Package config loads MeshTender runtime configuration from the environment.
|
|
package config
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// Config holds all runtime configuration for the server.
|
|
type Config struct {
|
|
// Addr is the TCP address the HTTP server listens on (e.g. ":8080").
|
|
Addr string
|
|
// DatabaseURL is the Postgres connection string (pgx-compatible DSN).
|
|
DatabaseURL string
|
|
|
|
// MasterKey is a 32-byte AES key used to encrypt the server identity
|
|
// seed at rest. Supplied as 64 hex chars via MESHTENDER_MASTER_KEY.
|
|
MasterKey [32]byte
|
|
|
|
// WebAuthn relying-party settings. RPID is the effective domain (no
|
|
// scheme/port) — set it to the root registrable domain (e.g.
|
|
// "meshtender.com") so credentials are valid across every subdomain.
|
|
// RPOrigins are the full origins browsers will send a ceremony from;
|
|
// supply a comma-separated list to allow both the auth and app hosts
|
|
// (e.g. "https://auth.meshtender.com,https://app.meshtender.com").
|
|
RPID string
|
|
RPDisplayName string
|
|
RPOrigins []string
|
|
|
|
// PrimaryHost is the canonical app hostname (no scheme/port) — the host
|
|
// that serves the product. Requests arriving on a different, verified org
|
|
// custom domain serve that org's public page; all other paths there
|
|
// redirect back to PrimaryHost. Defaults to RPID.
|
|
PrimaryHost string
|
|
|
|
// AuthHost is the dedicated hostname that serves the login/signup UI and
|
|
// runs WebAuthn ceremonies (e.g. "auth.meshtender.com"). A successful
|
|
// sign-in there hands off to PrimaryHost via a single-use code. Required.
|
|
AuthHost string
|
|
|
|
// RootHost is the public marketing + organization-discovery hostname (the
|
|
// bare apex, e.g. "meshtender.com" / dev "localhost"). It carries no
|
|
// session (cookies are host-only), so it serves only public content.
|
|
// Required.
|
|
RootHost string
|
|
|
|
// WWWHost redirects to RootHost (e.g. "www.meshtender.com"). Defaults to
|
|
// "www." + RootHost when RootHost is set.
|
|
WWWHost string
|
|
|
|
// Secure reports whether the deployment is HTTPS (derived from RPOrigins).
|
|
// Drives cookie Secure/__Host- prefixing and the scheme of cross-host URLs.
|
|
Secure bool
|
|
|
|
// TLSCert and TLSKey, when both set, make the server terminate TLS itself
|
|
// (https). Used for local HTTPS dev (e.g. an mkcert cert for *.example.dev),
|
|
// where HSTS-preloaded TLDs like .dev force the browser onto https. In
|
|
// production TLS usually terminates at a proxy and these stay empty.
|
|
TLSCert string
|
|
TLSKey string
|
|
|
|
// Outbound mail, used only for account recovery (verify an address, reset a
|
|
// password). ResendAPIKey empty means no provider is configured: the server
|
|
// logs messages instead of sending them and MailEnabled is false, so the UI
|
|
// hides recovery-by-email rather than promising mail it can't deliver.
|
|
// MailFrom must be an address on a domain verified with Resend (SPF + DKIM
|
|
// published) or every send is rejected. MailReplyTo is optional.
|
|
ResendAPIKey string
|
|
MailFrom string
|
|
MailReplyTo string
|
|
MailEnabled bool
|
|
|
|
// TrustedProxies are CIDR ranges whose X-Forwarded-For / X-Real-IP headers are
|
|
// trusted when resolving a request's client IP. Loopback is always trusted (a
|
|
// same-host reverse proxy). The client IP is the rightmost X-Forwarded-For
|
|
// entry that is NOT a trusted proxy; if the connecting peer itself isn't
|
|
// trusted, forwarding headers are ignored entirely (so they can't be spoofed).
|
|
// Configured via MESHTENDER_TRUSTED_PROXIES — a comma-separated list of CIDRs
|
|
// or bare IPs, plus the shorthand token "private" (adds the RFC1918, link-local
|
|
// and ULA ranges — handy when a home router/LAN sits in front).
|
|
TrustedProxies []*net.IPNet
|
|
}
|
|
|
|
// RadioDefaults is a set of LoRa parameters, used to match a repeater's stored
|
|
// radio config against the region presets offered in the UI.
|
|
type RadioDefaults struct {
|
|
FreqHz uint32
|
|
BwHz uint32
|
|
SF uint8
|
|
CR uint8
|
|
}
|
|
|
|
// Load reads configuration from the environment, applying defaults and
|
|
// validating required fields.
|
|
func Load() (*Config, error) {
|
|
trustedProxies, err := parseTrustedProxies(os.Getenv("MESHTENDER_TRUSTED_PROXIES"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("MESHTENDER_TRUSTED_PROXIES: %w", err)
|
|
}
|
|
c := &Config{
|
|
Addr: envOr("MESHTENDER_ADDR", ":8080"),
|
|
DatabaseURL: os.Getenv("MESHTENDER_DATABASE_URL"),
|
|
RPID: envOr("MESHTENDER_RP_ID", "localhost"),
|
|
RPDisplayName: envOr("MESHTENDER_RP_NAME", "MeshTender"),
|
|
RPOrigins: splitOrigins(envOr("MESHTENDER_RP_ORIGIN", "http://localhost:8080")),
|
|
PrimaryHost: envOr("MESHTENDER_PRIMARY_HOST", envOr("MESHTENDER_RP_ID", "localhost")),
|
|
AuthHost: os.Getenv("MESHTENDER_AUTH_HOST"),
|
|
RootHost: os.Getenv("MESHTENDER_ROOT_HOST"),
|
|
WWWHost: os.Getenv("MESHTENDER_WWW_HOST"),
|
|
TLSCert: os.Getenv("MESHTENDER_TLS_CERT"),
|
|
TLSKey: os.Getenv("MESHTENDER_TLS_KEY"),
|
|
ResendAPIKey: os.Getenv("MESHTENDER_RESEND_API_KEY"),
|
|
MailFrom: os.Getenv("MESHTENDER_MAIL_FROM"),
|
|
MailReplyTo: os.Getenv("MESHTENDER_MAIL_REPLY_TO"),
|
|
TrustedProxies: trustedProxies,
|
|
}
|
|
|
|
// A configured API key with no From address can never deliver anything, and the
|
|
// failure would only show up as recovery mail silently not arriving. Fail closed
|
|
// at startup instead, the same treatment a malformed proxy range gets.
|
|
if c.ResendAPIKey != "" && c.MailFrom == "" {
|
|
return nil, fmt.Errorf("MESHTENDER_MAIL_FROM is required when MESHTENDER_RESEND_API_KEY is set")
|
|
}
|
|
// MailFrom is the switch for the FEATURE; the API key is the switch for real
|
|
// DELIVERY. Keeping them separate is what makes the flow walkable in dev: set only
|
|
// MailFrom and the recovery UI is live while messages go to the log. Gating the
|
|
// feature on the API key instead would hide every email control locally, which
|
|
// would make the logging sender useless for the case it exists to serve.
|
|
c.MailEnabled = c.MailFrom != ""
|
|
|
|
// MeshTender runs across three hosts (auth + app + root). Require the two that
|
|
// have no sane default (PrimaryHost falls back to RPID above).
|
|
if c.AuthHost == "" {
|
|
return nil, fmt.Errorf("MESHTENDER_AUTH_HOST is required")
|
|
}
|
|
if c.RootHost == "" {
|
|
return nil, fmt.Errorf("MESHTENDER_ROOT_HOST is required")
|
|
}
|
|
if c.WWWHost == "" {
|
|
c.WWWHost = "www." + c.RootHost
|
|
}
|
|
// HTTPS deployments advertise https:// origins; this drives Secure cookies
|
|
// and the scheme used when building absolute cross-host URLs.
|
|
for _, o := range c.RPOrigins {
|
|
if strings.HasPrefix(o, "https://") {
|
|
c.Secure = true
|
|
}
|
|
}
|
|
|
|
if c.DatabaseURL == "" {
|
|
return nil, fmt.Errorf("MESHTENDER_DATABASE_URL is required")
|
|
}
|
|
|
|
rawKey := os.Getenv("MESHTENDER_MASTER_KEY")
|
|
if rawKey == "" {
|
|
return nil, fmt.Errorf("MESHTENDER_MASTER_KEY is required (64 hex chars)")
|
|
}
|
|
keyBytes, err := hex.DecodeString(rawKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("MESHTENDER_MASTER_KEY must be hex: %w", err)
|
|
}
|
|
if len(keyBytes) != 32 {
|
|
return nil, fmt.Errorf("MESHTENDER_MASTER_KEY must decode to 32 bytes, got %d", len(keyBytes))
|
|
}
|
|
copy(c.MasterKey[:], keyBytes)
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// privateRanges are added by the "private" shorthand token.
|
|
var privateRanges = []string{
|
|
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16",
|
|
"fc00::/7", "fe80::/10",
|
|
}
|
|
|
|
// parseTrustedProxies parses MESHTENDER_TRUSTED_PROXIES into CIDR ranges. Loopback
|
|
// is always included (a same-host reverse proxy). Each entry may be a CIDR, a bare
|
|
// IP (treated as a /32 or /128), or the token "private"/"loopback". A malformed
|
|
// entry is a hard error: silently dropping it would leave the real proxy untrusted
|
|
// so its X-Forwarded-For is ignored, collapsing every client to the proxy's IP
|
|
// (spoof exposure and one shared rate-limit bucket).
|
|
func parseTrustedProxies(s string) ([]*net.IPNet, error) {
|
|
nets := []*net.IPNet{mustCIDR("127.0.0.0/8"), mustCIDR("::1/128")}
|
|
for _, tok := range strings.Split(s, ",") {
|
|
tok = strings.TrimSpace(tok)
|
|
if tok == "" {
|
|
continue
|
|
}
|
|
switch strings.ToLower(tok) {
|
|
case "loopback":
|
|
continue // already included
|
|
case "private":
|
|
for _, c := range privateRanges {
|
|
nets = append(nets, mustCIDR(c))
|
|
}
|
|
continue
|
|
}
|
|
if strings.Contains(tok, "/") {
|
|
_, n, err := net.ParseCIDR(tok)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid CIDR %q: %w", tok, err)
|
|
}
|
|
nets = append(nets, n)
|
|
continue
|
|
}
|
|
ip := net.ParseIP(tok)
|
|
if ip == nil {
|
|
return nil, fmt.Errorf("invalid IP %q", tok)
|
|
}
|
|
bits := 32
|
|
if ip.To4() == nil {
|
|
bits = 128
|
|
}
|
|
mask := net.CIDRMask(bits, bits)
|
|
nets = append(nets, &net.IPNet{IP: ip.Mask(mask), Mask: mask})
|
|
}
|
|
return nets, nil
|
|
}
|
|
|
|
func mustCIDR(s string) *net.IPNet {
|
|
_, n, _ := net.ParseCIDR(s)
|
|
return n
|
|
}
|
|
|
|
func envOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// splitOrigins parses a comma-separated list of WebAuthn origins, trimming
|
|
// surrounding whitespace and dropping empty entries.
|
|
func splitOrigins(s string) []string {
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|