Files
gowebmail/config/config.go

817 lines
22 KiB
Go
Raw Normal View History

2026-03-08 11:48:27 +00:00
// Package config loads and persists GoWebMail configuration from data/gowebmail.conf
2026-03-07 06:20:39 +00:00
package config
import (
"bufio"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
)
// Config holds all application configuration.
type Config struct {
// Server
ListenAddr string // e.g. ":8080" or "0.0.0.0:8080"
ListenPort string // derived from ListenAddr, e.g. "8080"
Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks
BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly
// Security
EncryptionKey []byte // 32 bytes / AES-256
SessionSecret []byte
SecureCookie bool
SessionMaxAge int
TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers
// Notification SMTP (outbound alerts — separate from user mail accounts)
NotifyEnabled bool
NotifySMTPHost string
NotifySMTPPort int
NotifyFrom string
NotifyUser string // optional — leave blank for unauthenticated relay
NotifyPass string // optional
// Brute force protection
BruteEnabled bool
BruteMaxAttempts int
BruteWindowMins int
BruteBanHours int
BruteWhitelist []net.IP // IPs exempt from blocking
GeoBlockCountries []string // 2-letter codes to deny (deny-list mode)
GeoAllowCountries []string // 2-letter codes to allow (allow-list mode, empty=allow all)
2026-03-07 06:20:39 +00:00
// Storage
DBPath string
// Google OAuth2
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string // auto-derived from BaseURL if blank
// Microsoft OAuth2
MicrosoftClientID string
MicrosoftClientSecret string
MicrosoftTenantID string
MicrosoftRedirectURL string // auto-derived from BaseURL if blank
}
const configPath = "./data/gowebmail.conf"
2026-03-07 06:20:39 +00:00
type configField struct {
key string
defVal string
comments []string
}
// allFields is the single source of truth for config keys.
// Adding a field here causes it to automatically appear in gowebmail.conf on next startup.
2026-03-07 06:20:39 +00:00
var allFields = []configField{
{
key: "HOSTNAME",
defVal: "localhost",
comments: []string{
"--- Server ---",
2026-03-08 11:48:27 +00:00
"Public hostname of this GoWebMail instance (no port, no protocol).",
2026-03-07 06:20:39 +00:00
"Examples: localhost | mail.example.com | 192.168.1.10",
"Used to build BASE_URL and OAuth redirect URIs automatically.",
"Also used in security checks to reject requests with unexpected Host headers.",
},
},
{
key: "LISTEN_ADDR",
defVal: ":8080",
comments: []string{
"Address and port to listen on. Format: [host]:port",
" :8080 — all interfaces, port 8080",
" 0.0.0.0:8080 — all interfaces (explicit)",
" 127.0.0.1:8080 — localhost only",
},
},
{
key: "BASE_URL",
defVal: "",
comments: []string{
"Public URL of this instance (no trailing slash). Leave blank to auto-build",
"from HOSTNAME and LISTEN_ADDR port (recommended).",
" Auto-build examples:",
" HOSTNAME=localhost + :8080 → http://localhost:8080",
" HOSTNAME=mail.example.com + :443 → https://mail.example.com",
" HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080",
"Override here only if you need a custom path prefix or your proxy rewrites the URL.",
},
},
{
key: "SECURE_COOKIE",
defVal: "false",
comments: []string{
2026-03-08 11:48:27 +00:00
"Set to true when GoWebMail is served over HTTPS (directly or via proxy).",
2026-03-07 06:20:39 +00:00
"Marks session cookies as Secure so browsers only send them over TLS.",
},
},
{
key: "SESSION_MAX_AGE",
defVal: "604800",
comments: []string{
"How long a login session lasts, in seconds. Default: 604800 (7 days).",
},
},
{
key: "TRUSTED_PROXIES",
defVal: "",
comments: []string{
"Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.",
"Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,",
2026-03-08 11:48:27 +00:00
"which GoWebMail uses to determine the real client IP and whether TLS is in use.",
2026-03-07 06:20:39 +00:00
" Examples:",
" 127.0.0.1 (loopback only — Nginx/Traefik on same host)",
" 10.0.0.0/8,172.16.0.0/12 (private networks)",
" 192.168.1.50,192.168.1.51 (specific IPs)",
" Leave blank to disable proxy trust (requests are taken at face value).",
" NOTE: Do not add untrusted IPs — clients could spoof their source address.",
},
},
{
key: "NOTIFY_ENABLED",
defVal: "true",
comments: []string{
"--- Security Notifications ---",
"Send email alerts to users when their account is targeted by brute-force attacks.",
"Set to false to disable all security notification emails.",
},
},
{
key: "NOTIFY_SMTP_HOST",
defVal: "",
comments: []string{
"SMTP server hostname for sending security notification emails.",
"Example: smtp.example.com",
},
},
{
key: "NOTIFY_SMTP_PORT",
defVal: "587",
comments: []string{
"SMTP server port. Common values: 587 (STARTTLS), 465 (TLS), 25 (relay, no auth).",
},
},
{
key: "NOTIFY_FROM",
defVal: "",
comments: []string{
"Sender address for security notification emails. Example: security@example.com",
},
},
{
key: "NOTIFY_USER",
defVal: "",
comments: []string{
"SMTP username for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "NOTIFY_PASS",
defVal: "",
comments: []string{
"SMTP password for authenticated relay. Leave blank for unauthenticated relay.",
},
},
{
key: "BRUTE_ENABLED",
defVal: "true",
comments: []string{
"--- Brute Force Protection ---",
"Enable automatic IP blocking after repeated failed logins.",
"Set to false to disable entirely.",
},
},
{
key: "BRUTE_MAX_ATTEMPTS",
defVal: "5",
comments: []string{
"Number of failed login attempts within BRUTE_WINDOW_MINUTES that triggers a ban.",
},
},
{
key: "BRUTE_WINDOW_MINUTES",
defVal: "30",
comments: []string{
"Time window in minutes for counting failed login attempts.",
},
},
{
key: "BRUTE_BAN_HOURS",
defVal: "12",
comments: []string{
"How many hours to ban an offending IP. Set to 0 for permanent ban (admin must unban manually).",
},
},
{
key: "BRUTE_WHITELIST_IPS",
defVal: "",
comments: []string{
"Comma-separated IPv4/IPv6 addresses that are never blocked by brute force protection.",
"Example: 192.168.1.1,10.0.0.1",
},
},
{
key: "GEO_BLOCK_COUNTRIES",
defVal: "",
comments: []string{
"--- Geo Blocking (uses ip-api.com, requires internet access) ---",
"Comma-separated 2-letter ISO country codes to DENY access from.",
"Example: CN,RU,KP",
"Leave blank to disable deny-list. Takes precedence over GEO_ALLOW_COUNTRIES.",
},
},
{
key: "GEO_ALLOW_COUNTRIES",
defVal: "",
comments: []string{
"Comma-separated 2-letter ISO country codes to ALLOW (all others are denied).",
"Example: SK,CZ,DE",
"Leave blank to allow all countries. Only active if GEO_BLOCK_COUNTRIES is also blank.",
},
},
2026-03-07 06:20:39 +00:00
{
key: "DB_PATH",
defVal: "./data/gowebmail.db",
2026-03-07 06:20:39 +00:00
comments: []string{
"--- Storage ---",
"Path to the SQLite database file.",
},
},
{
key: "ENCRYPTION_KEY",
defVal: "",
comments: []string{
"AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).",
"Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.",
"NOTE: Back this up. Losing it makes the entire database permanently unreadable.",
},
},
{
key: "SESSION_SECRET",
defVal: "",
comments: []string{
"Secret used to sign session cookies. Auto-generated on first run.",
"Changing this invalidates all active sessions (everyone gets logged out).",
},
},
{
key: "GOOGLE_CLIENT_ID",
defVal: "",
comments: []string{
"--- Gmail / Google OAuth2 ---",
"Create at: https://console.cloud.google.com/apis/credentials",
" Application type : Web application",
" Required scope : https://mail.google.com/",
" Redirect URI : <BASE_URL>/auth/gmail/callback",
},
},
{
key: "GOOGLE_CLIENT_SECRET",
defVal: "",
comments: []string{},
},
{
key: "GOOGLE_REDIRECT_URL",
defVal: "",
comments: []string{
"Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.",
"Must exactly match what is registered in Google Cloud Console.",
},
},
{
key: "MICROSOFT_CLIENT_ID",
defVal: "",
comments: []string{
"--- Outlook / Microsoft 365 OAuth2 ---",
"Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps",
" Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email",
" Redirect URI : <BASE_URL>/auth/outlook/callback",
},
},
{
key: "MICROSOFT_CLIENT_SECRET",
defVal: "",
comments: []string{},
},
{
key: "MICROSOFT_TENANT_ID",
defVal: "common",
comments: []string{
"Use 'common' to allow any Microsoft account,",
"or your Azure tenant ID to restrict to one organisation.",
},
},
{
key: "MICROSOFT_REDIRECT_URL",
defVal: "",
comments: []string{
"Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.",
"Must exactly match what is registered in Azure.",
},
},
}
// Load reads/creates data/gowebmail.conf, fills in missing keys, then returns Config.
2026-03-07 06:20:39 +00:00
// Environment variables override file values when set.
func Load() (*Config, error) {
if err := os.MkdirAll("./data", 0700); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
existing, err := readConfigFile(configPath)
if err != nil {
return nil, err
}
// Auto-generate secrets if missing
if existing["ENCRYPTION_KEY"] == "" {
existing["ENCRYPTION_KEY"] = mustHex(32)
fmt.Println("WARNING: Generated new ENCRYPTION_KEY — it is saved in data/gowebmail.conf — back it up!")
2026-03-07 06:20:39 +00:00
}
if existing["SESSION_SECRET"] == "" {
existing["SESSION_SECRET"] = mustHex(32)
}
// Write back (preserves existing, adds any new fields from allFields)
if err := writeConfigFile(configPath, existing); err != nil {
return nil, fmt.Errorf("write config: %w", err)
}
// get returns env var if set, else file value, else ""
get := func(key string) string {
2026-03-08 11:48:27 +00:00
// Only check env vars that are explicitly GoWebMail-namespaced or well-known.
2026-03-07 06:20:39 +00:00
// We deliberately do NOT fall back to generic vars like PORT to avoid
// picking up cloud-platform env vars unintentionally.
if v := os.Getenv("GOMAIL_" + key); v != "" {
return v
}
if v := os.Getenv(key); v != "" {
return v
}
return existing[key]
}
// ---- Resolve listen address ----
listenAddr := get("LISTEN_ADDR")
if listenAddr == "" {
listenAddr = ":8080"
}
// Ensure it has a port
if !strings.Contains(listenAddr, ":") {
listenAddr = ":" + listenAddr
}
_, listenPort, err := net.SplitHostPort(listenAddr)
if err != nil {
return nil, fmt.Errorf("invalid LISTEN_ADDR %q: %w", listenAddr, err)
}
// ---- Resolve hostname ----
hostname := get("HOSTNAME")
if hostname == "" {
hostname = "localhost"
}
// Strip any accidental protocol or port from hostname
hostname = strings.TrimPrefix(hostname, "http://")
hostname = strings.TrimPrefix(hostname, "https://")
hostname = strings.Split(hostname, ":")[0]
hostname = strings.TrimRight(hostname, "/")
// ---- Build BASE_URL ----
baseURL := get("BASE_URL")
if baseURL == "" {
baseURL = buildBaseURL(hostname, listenPort)
}
// Strip trailing slash
baseURL = strings.TrimRight(baseURL, "/")
// ---- OAuth redirect URLs (auto-derive if blank) ----
googleRedirect := get("GOOGLE_REDIRECT_URL")
if googleRedirect == "" {
googleRedirect = baseURL + "/auth/gmail/callback"
}
outlookRedirect := get("MICROSOFT_REDIRECT_URL")
if outlookRedirect == "" {
outlookRedirect = baseURL + "/auth/outlook/callback"
}
// ---- Decode secrets ----
encHex := get("ENCRYPTION_KEY")
encKey, err := hex.DecodeString(encHex)
if err != nil || len(encKey) != 32 {
return nil, fmt.Errorf("ENCRYPTION_KEY must be 64 hex chars (32 bytes), got %d chars", len(encHex))
}
sessSecret := get("SESSION_SECRET")
if sessSecret == "" {
return nil, fmt.Errorf("SESSION_SECRET is empty — this should not happen")
}
// ---- Trusted proxies ----
trustedProxies, err := parseCIDRList(get("TRUSTED_PROXIES"))
if err != nil {
return nil, fmt.Errorf("invalid TRUSTED_PROXIES: %w", err)
}
cfg := &Config{
ListenAddr: listenAddr,
ListenPort: listenPort,
Hostname: hostname,
BaseURL: baseURL,
DBPath: get("DB_PATH"),
EncryptionKey: encKey,
SessionSecret: []byte(sessSecret),
SecureCookie: atobool(get("SECURE_COOKIE"), false),
SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800),
TrustedProxies: trustedProxies,
BruteEnabled: atobool(get("BRUTE_ENABLED"), true),
BruteMaxAttempts: atoi(get("BRUTE_MAX_ATTEMPTS"), 5),
BruteWindowMins: atoi(get("BRUTE_WINDOW_MINUTES"), 30),
BruteBanHours: atoi(get("BRUTE_BAN_HOURS"), 12),
BruteWhitelist: parseIPList(get("BRUTE_WHITELIST_IPS")),
GeoBlockCountries: parseCountryList(get("GEO_BLOCK_COUNTRIES")),
GeoAllowCountries: parseCountryList(get("GEO_ALLOW_COUNTRIES")),
NotifyEnabled: atobool(get("NOTIFY_ENABLED"), true),
NotifySMTPHost: get("NOTIFY_SMTP_HOST"),
NotifySMTPPort: atoi(get("NOTIFY_SMTP_PORT"), 587),
NotifyFrom: get("NOTIFY_FROM"),
NotifyUser: get("NOTIFY_USER"),
NotifyPass: get("NOTIFY_PASS"),
2026-03-07 06:20:39 +00:00
GoogleClientID: get("GOOGLE_CLIENT_ID"),
GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"),
GoogleRedirectURL: googleRedirect,
MicrosoftClientID: get("MICROSOFT_CLIENT_ID"),
MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"),
MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"),
MicrosoftRedirectURL: outlookRedirect,
}
// Derive SECURE_COOKIE automatically if BASE_URL uses https
if strings.HasPrefix(baseURL, "https://") && !cfg.SecureCookie {
cfg.SecureCookie = true
}
logStartupInfo(cfg)
return cfg, nil
}
// buildBaseURL constructs the public URL from hostname and port.
// Port 443 → https://<hostname>, port 80 → http://<hostname>,
// anything else → http://<hostname>:<port>
func buildBaseURL(hostname, port string) string {
switch port {
case "443":
return "https://" + hostname
case "80":
return "http://" + hostname
default:
return "http://" + hostname + ":" + port
}
}
// IsIPWhitelisted returns true if the IP is in the brute force whitelist.
func (c *Config) IsIPWhitelisted(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, w := range c.BruteWhitelist {
if w.Equal(ip) {
return true
}
}
return false
}
// IsCountryAllowed returns true if traffic from the given 2-letter country code is permitted.
// Logic: deny-list takes precedence; then allow-list if non-empty; otherwise allow all.
func (c *Config) IsCountryAllowed(code string) bool {
code = strings.ToUpper(code)
if len(c.GeoBlockCountries) > 0 {
for _, bc := range c.GeoBlockCountries {
if bc == code {
return false
}
}
}
if len(c.GeoAllowCountries) > 0 {
for _, ac := range c.GeoAllowCountries {
if ac == code {
return true
}
}
return false
}
return true
}
2026-03-07 06:20:39 +00:00
// IsAllowedHost returns true if the request Host header matches our expected hostname.
// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode).
func (c *Config) IsAllowedHost(requestHost string) bool {
if c.Hostname == "localhost" {
return true // dev mode — permissive
}
// Strip port from request Host header
h := requestHost
if host, _, err := net.SplitHostPort(requestHost); err == nil {
h = host
}
return strings.EqualFold(h, c.Hostname)
}
// RealIP extracts the genuine client IP from the request, honouring X-Forwarded-For
// only when the request comes from a trusted proxy.
func (c *Config) RealIP(remoteAddr string, xForwardedFor string) string {
// Parse remote addr
remoteIP, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
remoteIP = remoteAddr
}
if xForwardedFor == "" || !c.isTrustedProxy(remoteIP) {
return remoteIP
}
// Take the left-most (client) IP from X-Forwarded-For
parts := strings.Split(xForwardedFor, ",")
if len(parts) > 0 {
ip := strings.TrimSpace(parts[0])
if net.ParseIP(ip) != nil {
return ip
}
}
return remoteIP
}
// IsHTTPS returns true if the request arrived over TLS, either directly
// or as indicated by X-Forwarded-Proto from a trusted proxy.
func (c *Config) IsHTTPS(remoteAddr string, xForwardedProto string) bool {
if xForwardedProto != "" {
remoteIP, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
remoteIP = remoteAddr
}
if c.isTrustedProxy(remoteIP) {
return strings.EqualFold(xForwardedProto, "https")
}
}
return strings.HasPrefix(c.BaseURL, "https://")
}
func (c *Config) isTrustedProxy(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
for _, cidr := range c.TrustedProxies {
if cidr.Contains(ip) {
return true
}
}
return false
}
// ---- Config file I/O ----
func readConfigFile(path string) (map[string]string, error) {
values := make(map[string]string)
f, err := os.Open(path)
if os.IsNotExist(err) {
return values, nil
}
if err != nil {
return nil, fmt.Errorf("open config: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
values[key] = val
}
return values, scanner.Err()
}
func writeConfigFile(path string, values map[string]string) error {
var sb strings.Builder
2026-03-08 11:48:27 +00:00
sb.WriteString("# GoWebMail Configuration\n")
2026-03-07 06:20:39 +00:00
sb.WriteString("# =====================\n")
sb.WriteString("# Auto-generated and updated on each startup.\n")
sb.WriteString("# Edit freely — your values are always preserved.\n")
sb.WriteString("# Environment variables (or GOMAIL_<KEY>) override values here.\n")
sb.WriteString("#\n\n")
for _, field := range allFields {
for _, c := range field.comments {
if c == "" {
sb.WriteString("#\n")
} else {
sb.WriteString("# " + c + "\n")
}
}
val := values[field.key]
if val == "" {
val = field.defVal
}
sb.WriteString(field.key + " = " + val + "\n\n")
}
return os.WriteFile(path, []byte(sb.String()), 0600)
}
// ---- Admin settings API ----
// EditableKeys lists config keys that may be changed via the admin UI.
// SESSION_SECRET and ENCRYPTION_KEY are intentionally excluded.
var EditableKeys = func() map[string]bool {
excluded := map[string]bool{
"SESSION_SECRET": true,
"ENCRYPTION_KEY": true,
}
m := map[string]bool{}
for _, f := range allFields {
if !excluded[f.key] {
m[f.key] = true
}
}
return m
}()
// GetSettings returns the current raw config file values for all editable keys.
func GetSettings() (map[string]string, error) {
raw, err := readConfigFile(configPath)
if err != nil {
return nil, err
}
result := make(map[string]string, len(allFields))
for _, f := range allFields {
if EditableKeys[f.key] {
if v, ok := raw[f.key]; ok {
result[f.key] = v
} else {
result[f.key] = f.defVal
}
}
}
return result, nil
}
// SetSettings merges the provided map into the config file.
// Only EditableKeys are accepted; unknown or protected keys are silently ignored.
// Returns the list of keys that were actually changed.
func SetSettings(updates map[string]string) ([]string, error) {
raw, err := readConfigFile(configPath)
if err != nil {
return nil, err
}
var changed []string
for k, v := range updates {
if !EditableKeys[k] {
continue
}
if raw[k] != v {
raw[k] = v
changed = append(changed, k)
}
}
if len(changed) == 0 {
return nil, nil
}
return changed, writeConfigFile(configPath, raw)
}
// ---- Host validation middleware helper ----
// HostCheck returns an HTTP middleware that rejects requests with unexpected Host headers.
// Skipped in dev mode (hostname == "localhost").
func (c *Config) HostCheckMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !c.IsAllowedHost(r.Host) {
http.Error(w, "Invalid host header", http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
}
// ---- Helpers ----
func parseCIDRList(s string) ([]net.IPNet, error) {
var nets []net.IPNet
if s == "" {
return nets, nil
}
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
// Allow bare IPs (treat as /32 or /128)
if !strings.Contains(raw, "/") {
ip := net.ParseIP(raw)
if ip == nil {
return nil, fmt.Errorf("invalid IP %q", raw)
}
bits := 32
if ip.To4() == nil {
bits = 128
}
raw = fmt.Sprintf("%s/%d", ip.String(), bits)
}
_, ipNet, err := net.ParseCIDR(raw)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", raw, err)
}
nets = append(nets, *ipNet)
}
return nets, nil
}
func logStartupInfo(cfg *Config) {
2026-03-08 11:48:27 +00:00
fmt.Printf("GoWebMail starting:\n")
2026-03-07 06:20:39 +00:00
fmt.Printf(" Listen : %s\n", cfg.ListenAddr)
fmt.Printf(" Base URL: %s\n", cfg.BaseURL)
fmt.Printf(" Hostname: %s\n", cfg.Hostname)
if len(cfg.TrustedProxies) > 0 {
cidrs := make([]string, len(cfg.TrustedProxies))
for i, n := range cfg.TrustedProxies {
cidrs[i] = n.String()
}
fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", "))
}
}
func parseIPList(s string) []net.IP {
var ips []net.IP
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if ip := net.ParseIP(raw); ip != nil {
ips = append(ips, ip)
}
}
return ips
}
func parseCountryList(s string) []string {
var codes []string
for _, raw := range strings.Split(s, ",") {
raw = strings.TrimSpace(strings.ToUpper(raw))
if len(raw) == 2 {
codes = append(codes, raw)
}
}
return codes
}
2026-03-07 06:20:39 +00:00
func mustHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
return hex.EncodeToString(b)
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func atoi(s string, fallback int) int {
if v, err := strconv.Atoi(s); err == nil {
return v
}
return fallback
}
func atobool(s string, fallback bool) bool {
if v, err := strconv.ParseBool(s); err == nil {
return v
}
return fallback
}
// Needed for HostCheckMiddleware