config.mx raw

   1  package config
   2  
   3  import (
   4  	"git.smesh.lol/moxie/pkg/mxutil"
   5  	"fmt"
   6  	"os"
   7  )
   8  
   9  var dotenv map[string]string
  10  
  11  // C holds all relay configuration, loaded from ORLY_* environment variables.
  12  // Domain sub-structs are embedded anonymously so existing cfg.Field access
  13  // still works, while cfg.AuthCfg, cfg.LimitsCfg, etc. can be passed to
  14  // constructors that only need their specific slice.
  15  type C struct {
  16  	// Core identity (not in a sub-struct - every domain needs these)
  17  	AppName       string
  18  	DataDir       string
  19  	Listen        string
  20  	Port          int32
  21  	HealthPort    int32
  22  	LogLevel      string
  23  	LogToStdout   bool
  24  	LogBufferSize int32
  25  
  26  	// Domain config slices - embedded for backward-compatible field promotion.
  27  	AuthCfg
  28  	LimitsCfg
  29  	WorkersCfg
  30  	StorageCfg
  31  	BlossomCfg
  32  	WebCfg
  33  	IdentityCfg
  34  
  35  	// Bunker
  36  	BunkerEnabled bool
  37  	BunkerPort    int32
  38  	// Payment
  39  	NWCUri              string
  40  	SubscriptionEnabled bool
  41  	MonthlyPriceSats    int64
  42  	// NIP-43
  43  	NIP43Enabled        bool
  44  	NIP43PublishEvents  bool
  45  	NIP43PublishMembers bool
  46  	NIP43InviteExpSec   int32
  47  	// Policy
  48  	PolicyEnabled bool
  49  	PolicyPath    string
  50  	// Bridge (Marmot)
  51  	BridgeEnabled          bool
  52  	BridgeDomain           string
  53  	BridgeNSEC             string
  54  	BridgeRelayURL         string
  55  	BridgePublicRelayURL   string
  56  	BridgeSMTPPort         int32
  57  	BridgeSMTPHost         string
  58  	BridgeDataDir          string
  59  	BridgeDKIMKeyPath      string
  60  	BridgeDKIMSelector     string
  61  	BridgeNWCURI           string
  62  	BridgeMonthlyPriceSats int64
  63  	BridgeComposeURL       string
  64  	BridgeSMTPRelayHost    string
  65  	BridgeSMTPRelayPort    int32
  66  	BridgeSMTPMXPort       int32
  67  	BridgeSMTPRelayUser    string
  68  	BridgeSMTPRelayPass    string
  69  	BridgeAliasPriceSats   int64
  70  	BridgeProfile          string
  71  	// Smesh client
  72  	SmeshEnabled  bool
  73  	SmeshPort     int32
  74  	Smesh2Enabled bool
  75  	Smesh2Port    int32
  76  	Smesh3Enabled bool
  77  	Smesh3Port    int32
  78  	Smesh3Dir     string
  79  	DeployPubkey  string
  80  	// Misc
  81  	SprocketEnabled bool
  82  	EnableShutdown  bool
  83  }
  84  
  85  // Load reads configuration from ORLY_* env vars with optional .env file.
  86  // Load reads the environment and the dotenv file once at startup and returns
  87  // the config. The stores it makes are package globals, which the rule only
  88  // allows from an init-named function, so the body lives in initLoad.
  89  func Load() (c *C) {
  90  	return initLoad()
  91  }
  92  
  93  func initLoad() (c *C) {
  94  	appName := os.Getenv("ORLY_APP_NAME")
  95  	if appName == "" {
  96  		appName = "ORLY"
  97  	}
  98  	home := os.Getenv("HOME")
  99  	if home != "" {
 100  		dotenv = loadEnvFile(home | "/.config/" | appName | "/.env")
 101  	}
 102  	c := &C{}
 103  	c.AppName = estr("ORLY_APP_NAME", "ORLY")
 104  	c.DataDir = expandHome(estr("ORLY_DATA_DIR", "~/.local/share/ORLY"))
 105  	c.Listen = estr("ORLY_LISTEN", "0.0.0.0")
 106  	c.Port = eint("ORLY_PORT", 3334)
 107  	c.HealthPort = eint("ORLY_HEALTH_PORT", 0)
 108  	c.LogLevel = estr("ORLY_LOG_LEVEL", "info")
 109  	c.LogToStdout = ebool("ORLY_LOG_TO_STDOUT", false)
 110  	c.LogBufferSize = eint("ORLY_LOG_BUFFER_SIZE", 10000)
 111  	c.RelayURL = estr("ORLY_RELAY_URL", "")
 112  	c.RelayAddresses = elist("ORLY_RELAY_ADDRESSES")
 113  	c.RelayPeers = elist("ORLY_RELAY_PEERS")
 114  	c.SyncPubkey = estr("ORLY_SYNC_PUBKEY", "")
 115  	c.ClientTag = estr("ORLY_CLIENT_TAG", "git.smesh.lol/morly")
 116  	c.AuthRequired = ebool("ORLY_AUTH_REQUIRED", false)
 117  	c.AuthToWrite = ebool("ORLY_AUTH_TO_WRITE", false)
 118  	c.PrivilegedOpen = ebool("ORLY_PRIVILEGED_OPEN", false)
 119  	c.NIP70Enforce = ebool("ORLY_NIP70_ENFORCE", true)
 120  	c.MarmotOpen = ebool("ORLY_MARMOT_OPEN", false)
 121  	c.NIP46BypassAuth = ebool("ORLY_NIP46_BYPASS_AUTH", false)
 122  	c.ACLMode = estr("ORLY_ACL_MODE", "none")
 123  	c.MuteBlacklist = estr("ORLY_MUTE_BLACKLIST", "")
 124  	c.Admins = elist("ORLY_ADMINS")
 125  	c.Owners = elist("ORLY_OWNERS")
 126  	c.FreeWriteLimit = eint("ORLY_FREE_WRITE_LIMIT", 25)
 127  	c.FreeWriteWindow = eint("ORLY_FREE_WRITE_WINDOW", 300)
 128  	c.MaxConnPerIP = eint("ORLY_MAX_CONN_PER_IP", 100)
 129  	c.IngestWorkers = eint("ORLY_INGEST_WORKERS", 0)
 130  	c.MediaProxyWorkers = eint("ORLY_MEDIA_PROXY_WORKERS", 8)
 131  	c.BlossomWorkers = eint("ORLY_BLOSSOM_WORKERS", 4)
 132  	c.MaxGlobalConns = eint("ORLY_MAX_GLOBAL_CONNECTIONS", 500)
 133  	c.MaxSubscriptions = eint("ORLY_MAX_SUBSCRIPTIONS", 10000)
 134  	c.ConnDelayMaxMs = eint("ORLY_CONN_DELAY_MAX_MS", 2000)
 135  	c.IPWhitelist = elist("ORLY_IP_WHITELIST")
 136  	c.IPBlacklist = elist("ORLY_IP_BLACKLIST")
 137  	c.HTTPGuardBotBlock = ebool("ORLY_HTTP_GUARD_BOT_BLOCK", true)
 138  	c.RateLimitEnabled = ebool("ORLY_RATE_LIMIT_ENABLED", true)
 139  	c.RateLimitTargetMB = eint("ORLY_RATE_LIMIT_TARGET_MB", 0)
 140  	c.RateLimitWriteKp = efloat("ORLY_RATE_LIMIT_WRITE_KP", 0.5)
 141  	c.RateLimitWriteKi = efloat("ORLY_RATE_LIMIT_WRITE_KI", 0.1)
 142  	c.RateLimitWriteKd = efloat("ORLY_RATE_LIMIT_WRITE_KD", 0.05)
 143  	c.RateLimitReadKp = efloat("ORLY_RATE_LIMIT_READ_KP", 0.3)
 144  	c.RateLimitReadKi = efloat("ORLY_RATE_LIMIT_READ_KI", 0.05)
 145  	c.RateLimitReadKd = efloat("ORLY_RATE_LIMIT_READ_KD", 0.02)
 146  	c.RateLimitMaxWriteMs = eint("ORLY_RATE_LIMIT_MAX_WRITE_MS", 1000)
 147  	c.RateLimitMaxReadMs = eint("ORLY_RATE_LIMIT_MAX_READ_MS", 500)
 148  	c.RateLimitWriteTarget = efloat("ORLY_RATE_LIMIT_WRITE_TARGET", 0.85)
 149  	c.RateLimitReadTarget = efloat("ORLY_RATE_LIMIT_READ_TARGET", 0.90)
 150  	c.EmergencyThreshold = efloat("ORLY_RATE_LIMIT_EMERGENCY_THRESHOLD", 1.167)
 151  	c.RecoveryThreshold = efloat("ORLY_RATE_LIMIT_RECOVERY_THRESHOLD", 0.833)
 152  	c.EmergencyMaxMs = eint("ORLY_RATE_LIMIT_EMERGENCY_MAX_MS", 5000)
 153  	c.QueryResultLimit = eint("ORLY_QUERY_RESULT_LIMIT", 256)
 154  	c.FollowListFreqSec = edursec("ORLY_FOLLOW_LIST_FREQUENCY", 3600)
 155  	c.FollowsThrottle = ebool("ORLY_FOLLOWS_THROTTLE", false)
 156  	c.FollowsThrottleIncrMs = edurms("ORLY_FOLLOWS_THROTTLE_INCREMENT", 25)
 157  	c.FollowsThrottleMaxMs = edurms("ORLY_FOLLOWS_THROTTLE_MAX", 60000)
 158  	c.SocialThrottleD2IncrMs = edurms("ORLY_SOCIAL_THROTTLE_D2_INCREMENT", 50)
 159  	c.SocialThrottleD2MaxMs = edurms("ORLY_SOCIAL_THROTTLE_D2_MAX", 30000)
 160  	c.SocialThrottleD3IncrMs = edurms("ORLY_SOCIAL_THROTTLE_D3_INCREMENT", 200)
 161  	c.SocialThrottleD3MaxMs = edurms("ORLY_SOCIAL_THROTTLE_D3_MAX", 60000)
 162  	c.SocialThrottleOutsiderIncrMs = edurms("ORLY_SOCIAL_THROTTLE_OUTSIDER_INCREMENT", 500)
 163  	c.SocialThrottleOutsiderMaxMs = edurms("ORLY_SOCIAL_THROTTLE_OUTSIDER_MAX", 120000)
 164  	c.SocialWoTMaxDepth = eint("ORLY_SOCIAL_WOT_DEPTH", 3)
 165  	c.SocialWoTRefreshSec = edursec("ORLY_SOCIAL_WOT_REFRESH", 3600)
 166  	c.GrapeVineEnabled = ebool("ORLY_GRAPEVINE_ENABLED", false)
 167  	c.GrapeVineMaxDepth = eint("ORLY_GRAPEVINE_MAX_DEPTH", 6)
 168  	c.GrapeVineMaxCycles = eint("ORLY_GRAPEVINE_MAX_CYCLES", 20)
 169  	c.GrapeVineAttenuation = efloat("ORLY_GRAPEVINE_ATTENUATION", 0.8)
 170  	c.GrapeVineRigor = efloat("ORLY_GRAPEVINE_RIGOR", 0.25)
 171  	c.GrapeVineFollowConf = efloat("ORLY_GRAPEVINE_FOLLOW_CONFIDENCE", 0.05)
 172  	c.GrapeVineObservers = elist("ORLY_GRAPEVINE_OBSERVERS")
 173  	c.GrapeVineRefreshSec = edursec("ORLY_GRAPEVINE_REFRESH", 21600)
 174  	c.GrapeVineAutoWhitelist = ebool("ORLY_GRAPEVINE_AUTO_WHITELIST", false)
 175  	c.GrapeVineWhitelistThresh = efloat("ORLY_GRAPEVINE_WHITELIST_THRESHOLD", 0.5)
 176  	c.GrapeVineWhitelistRefSec = edursec("ORLY_GRAPEVINE_WHITELIST_REFRESH", 21600)
 177  	c.GraphQueriesEnabled = ebool("ORLY_GRAPH_QUERIES_ENABLED", true)
 178  	c.GraphMaxDepth = eint("ORLY_GRAPH_MAX_DEPTH", 16)
 179  	c.GraphMaxResults = eint("ORLY_GRAPH_MAX_RESULTS", 10000)
 180  	c.GraphRateLimitRPM = eint("ORLY_GRAPH_RATE_LIMIT_RPM", 60)
 181  	c.ProxyEnabled = ebool("ORLY_PROXY_ENABLED", true)
 182  	c.ProxyMaxRelays = eint("ORLY_PROXY_MAX_RELAYS", 16)
 183  	c.ProxyTimeoutSec = eint("ORLY_PROXY_TIMEOUT_SEC", 15)
 184  	c.ArchiveEnabled = ebool("ORLY_ARCHIVE_ENABLED", false)
 185  	c.ArchiveRelays = elist("ORLY_ARCHIVE_RELAYS")
 186  	if len(c.ArchiveRelays) == 0 {
 187  		c.ArchiveRelays = []string{"wss://archive.orly.dev/"}
 188  	}
 189  	c.ArchiveTimeoutSec = eint("ORLY_ARCHIVE_TIMEOUT_SEC", 30)
 190  	c.ArchiveCacheTTLHrs = eint("ORLY_ARCHIVE_CACHE_TTL_HRS", 24)
 191  	c.BlossomEnabled = ebool("ORLY_BLOSSOM_ENABLED", true)
 192  	c.BlossomDir = estr("ORLY_BLOSSOM_DIR", c.DataDir|"/blossom")
 193  	c.BlossomUpstream = estr("ORLY_BLOSSOM_UPSTREAM", "")
 194  	c.BlossomServiceLevels = estr("ORLY_BLOSSOM_SERVICE_LEVELS", "")
 195  	c.BlossomRateLimit = ebool("ORLY_BLOSSOM_RATE_LIMIT", false)
 196  	c.BlossomDailyLimitMB = eint64("ORLY_BLOSSOM_DAILY_LIMIT_MB", 10)
 197  	c.BlossomBurstLimitMB = eint64("ORLY_BLOSSOM_BURST_LIMIT_MB", 50)
 198  	c.BlossomDeleteRequireServerTag = ebool("ORLY_BLOSSOM_DELETE_REQUIRE_SERVER_TAG", false)
 199  	c.BootstrapRelays = elist("ORLY_BOOTSTRAP_RELAYS")
 200  	c.SpiderMode = estr("ORLY_SPIDER_MODE", "none")
 201  	c.DirectorySpider = ebool("ORLY_DIRECTORY_SPIDER", false)
 202  	c.DirectorySpiderIntSec = edursec("ORLY_DIRECTORY_SPIDER_INTERVAL", 86400)
 203  	c.DirectorySpiderMaxHops = eint("ORLY_DIRECTORY_SPIDER_HOPS", 3)
 204  	c.CrawlerEnabled = ebool("ORLY_CRAWLER_ENABLED", false)
 205  	c.CrawlerDiscoveryIntSec = edursec("ORLY_CRAWLER_DISCOVERY_INTERVAL", 14400)
 206  	c.CrawlerSyncIntSec = edursec("ORLY_CRAWLER_SYNC_INTERVAL", 1800)
 207  	c.CrawlerMaxHops = eint("ORLY_CRAWLER_MAX_HOPS", 5)
 208  	c.CrawlerConcurrency = eint("ORLY_CRAWLER_CONCURRENCY", 3)
 209  	c.NegentropyEnabled = ebool("ORLY_NEGENTROPY_ENABLED", false)
 210  	c.NegentropyFullSyncPubs = estr("ORLY_NEGENTROPY_FULL_SYNC_PUBKEYS", "")
 211  	c.TLSDomains = elist("ORLY_TLS_DOMAINS")
 212  	c.Certs = elist("ORLY_CERTS")
 213  	c.TorEnabled = ebool("ORLY_TOR_ENABLED", true)
 214  	c.TorPort = eint("ORLY_TOR_PORT", 3336)
 215  	c.TorDataDir = estr("ORLY_TOR_DATA_DIR", "")
 216  	c.TorBinary = estr("ORLY_TOR_BINARY", "tor")
 217  	c.TorSOCKS = eint("ORLY_TOR_SOCKS", 0)
 218  	c.NRCEnabled = ebool("ORLY_NRC_ENABLED", true)
 219  	c.NRCRendezvousURL = estr("ORLY_NRC_RENDEZVOUS_URL", "")
 220  	c.NRCAuthorizedKeys = estr("ORLY_NRC_AUTHORIZED_KEYS", "")
 221  	c.NRCSessionTimeSec = edursec("ORLY_NRC_SESSION_TIMEOUT", 1800)
 222  	c.WGEnabled = ebool("ORLY_WG_ENABLED", false)
 223  	c.WGPort = eint("ORLY_WG_PORT", 51820)
 224  	c.WGEndpoint = estr("ORLY_WG_ENDPOINT", "")
 225  	c.WGNetwork = estr("ORLY_WG_NETWORK", "10.73.0.0/16")
 226  	c.BunkerEnabled = ebool("ORLY_BUNKER_ENABLED", false)
 227  	c.BunkerPort = eint("ORLY_BUNKER_PORT", 3335)
 228  	c.NWCUri = estr("ORLY_NWC_URI", "")
 229  	c.SubscriptionEnabled = ebool("ORLY_SUBSCRIPTION_ENABLED", false)
 230  	c.MonthlyPriceSats = eint64("ORLY_MONTHLY_PRICE_SATS", 6000)
 231  	c.NIP43Enabled = ebool("ORLY_NIP43_ENABLED", false)
 232  	c.NIP43PublishEvents = ebool("ORLY_NIP43_PUBLISH_EVENTS", true)
 233  	c.NIP43PublishMembers = ebool("ORLY_NIP43_PUBLISH_MEMBER_LIST", true)
 234  	c.NIP43InviteExpSec = edursec("ORLY_NIP43_INVITE_EXPIRY", 86400)
 235  	c.PolicyEnabled = ebool("ORLY_POLICY_ENABLED", false)
 236  	c.PolicyPath = estr("ORLY_POLICY_PATH", "")
 237  	c.MaxStorageBytes = eint64("ORLY_MAX_STORAGE_BYTES", 0)
 238  	c.GCEnabled = ebool("ORLY_GC_ENABLED", false)
 239  	c.GCIntervalSec = eint("ORLY_GC_INTERVAL_SEC", 60)
 240  	c.GCBatchSize = eint("ORLY_GC_BATCH_SIZE", 1000)
 241  	c.BridgeEnabled = ebool("ORLY_BRIDGE_ENABLED", false)
 242  	c.BridgeDomain = estr("ORLY_BRIDGE_DOMAIN", "")
 243  	c.BridgeNSEC = estr("ORLY_BRIDGE_NSEC", "")
 244  	c.BridgeRelayURL = estr("ORLY_BRIDGE_RELAY_URL", "")
 245  	c.BridgePublicRelayURL = estr("ORLY_BRIDGE_PUBLIC_RELAY_URL", "")
 246  	c.BridgeSMTPPort = eint("ORLY_BRIDGE_SMTP_PORT", 2525)
 247  	c.BridgeSMTPHost = estr("ORLY_BRIDGE_SMTP_HOST", "0.0.0.0")
 248  	c.BridgeDataDir = estr("ORLY_BRIDGE_DATA_DIR", "")
 249  	c.BridgeDKIMKeyPath = estr("ORLY_BRIDGE_DKIM_KEY", "")
 250  	c.BridgeDKIMSelector = estr("ORLY_BRIDGE_DKIM_SELECTOR", "marmot")
 251  	c.BridgeNWCURI = estr("ORLY_BRIDGE_NWC_URI", "")
 252  	c.BridgeMonthlyPriceSats = eint64("ORLY_BRIDGE_MONTHLY_PRICE_SATS", 2100)
 253  	c.BridgeComposeURL = estr("ORLY_BRIDGE_COMPOSE_URL", "")
 254  	c.BridgeSMTPRelayHost = estr("ORLY_BRIDGE_SMTP_RELAY_HOST", "")
 255  	c.BridgeSMTPRelayPort = eint("ORLY_BRIDGE_SMTP_RELAY_PORT", 587)
 256  	c.BridgeSMTPMXPort = eint("ORLY_BRIDGE_SMTP_MX_PORT", 0)
 257  	c.BridgeSMTPRelayUser = estr("ORLY_BRIDGE_SMTP_RELAY_USERNAME", "")
 258  	c.BridgeSMTPRelayPass = estr("ORLY_BRIDGE_SMTP_RELAY_PASSWORD", "")
 259  	c.BridgeAliasPriceSats = eint64("ORLY_BRIDGE_ALIAS_PRICE_SATS", 4200)
 260  	c.BridgeProfile = estr("ORLY_BRIDGE_PROFILE", "")
 261  	c.ClusterAdmins = elist("ORLY_CLUSTER_ADMINS")
 262  	c.RelayGroupAdmins = elist("ORLY_RELAY_GROUP_ADMINS")
 263  	c.ClusterPropPrivileged = ebool("ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS", true)
 264  	c.SmeshEnabled = ebool("ORLY_SMESH_ENABLED", true)
 265  	c.SmeshPort = eint("ORLY_SMESH_PORT", 8088)
 266  	c.Smesh2Enabled = ebool("ORLY_SMESH2_ENABLED", true)
 267  	c.Smesh2Port = eint("ORLY_SMESH2_PORT", 8089)
 268  	c.Smesh3Enabled = ebool("ORLY_SMESH3_ENABLED", true)
 269  	c.Smesh3Port = eint("ORLY_SMESH3_PORT", 8090)
 270  	c.Smesh3Dir = estr("ORLY_SMESH3_DIR", "")
 271  	c.DeployPubkey = estr("ORLY_DEPLOY_PUBKEY", "")
 272  	c.StaticDir = estr("ORLY_STATIC_DIR", "web/static")
 273  	c.WebDisable = ebool("ORLY_WEB_DISABLE", false)
 274  	c.WebDevProxyURL = estr("ORLY_WEB_DEV_PROXY_URL", "")
 275  	c.BrandingDir = estr("ORLY_BRANDING_DIR", "")
 276  	c.BrandingEnabled = ebool("ORLY_BRANDING_ENABLED", true)
 277  	c.Theme = estr("ORLY_THEME", "auto")
 278  	c.CORSEnabled = ebool("ORLY_CORS_ENABLED", false)
 279  	c.CORSOrigins = elist("ORLY_CORS_ORIGINS")
 280  	c.SprocketEnabled = ebool("ORLY_SPROCKET_ENABLED", false)
 281  	c.EnableShutdown = ebool("ORLY_ENABLE_SHUTDOWN", false)
 282  	return c
 283  }
 284  
 285  // Addr returns "listen:port".
 286  func (c *C) Addr() (s string) {
 287  	for i := 0; i < len(c.Listen); i++ {
 288  		if c.Listen[i] == ':' {
 289  			return c.Listen
 290  		}
 291  	}
 292  	return c.Listen | ":" | itoa(c.Port)
 293  }
 294  
 295  // PrintHelp writes all env var documentation.
 296  func PrintHelp() {
 297  	w := os.Stderr
 298  	fmt.Fprintln(w, "ORLY - Nostr relay")
 299  	fmt.Fprintln(w, "")
 300  	fmt.Fprintln(w, "Usage: musiquay [relay|sync|crawl|env|help|version]")
 301  	fmt.Fprintln(w, "")
 302  	fmt.Fprintln(w, "Environment variables:")
 303  	s := func(title string) { fmt.Fprintf(w, "\n  %s\n", title) }
 304  	p := func(name, def, desc string) { fmt.Fprintf(w, "    %-48s %s (default: %s)\n", name, desc, def) }
 305  	s("Core")
 306  	p("ORLY_APP_NAME", "ORLY", "relay display name")
 307  	p("ORLY_DATA_DIR", "~/.local/share/ORLY", "event store location")
 308  	p("ORLY_LISTEN", "0.0.0.0", "listen address")
 309  	p("ORLY_PORT", "3334", "listen port")
 310  	p("ORLY_HEALTH_PORT", "0", "health check port (0=disabled)")
 311  	p("ORLY_LOG_LEVEL", "info", "log level: fatal error warn info debug trace")
 312  	p("ORLY_LOG_TO_STDOUT", "false", "log to stdout instead of stderr")
 313  	p("ORLY_LOG_BUFFER_SIZE", "10000", "log entries kept for web UI")
 314  	s("Relay Identity")
 315  	p("ORLY_RELAY_URL", "", "base URL (e.g. https://relay.example.com)")
 316  	p("ORLY_RELAY_ADDRESSES", "", "websocket addresses (comma-separated)")
 317  	p("ORLY_RELAY_PEERS", "", "peer relay URLs (comma-separated)")
 318  	p("ORLY_CLIENT_TAG", "git.smesh.lol/morly", "client tag for published events")
 319  	s("Auth & Access")
 320  	p("ORLY_AUTH_REQUIRED", "false", "require auth for all requests")
 321  	p("ORLY_AUTH_TO_WRITE", "false", "require auth for writes only")
 322  	p("ORLY_PRIVILEGED_OPEN", "false", "disable privileged-kind auth checks")
 323  	p("ORLY_NIP70_ENFORCE", "true", "enforce NIP-70 protected tag broadcast filter (false = relay all)")
 324  	p("ORLY_MARMOT_OPEN", "false", "exempt MLS kinds from auth (443,444,445)")
 325  	p("ORLY_NIP46_BYPASS_AUTH", "false", "allow NIP-46 (kind 24133) without auth")
 326  	p("ORLY_ACL_MODE", "none", "ACL mode: follows, managed, curating, none")
 327  	p("ORLY_MUTE_BLACKLIST", "", "hex pubkey whose mute list (kind 10000) bans authors")
 328  	p("ORLY_ADMINS", "", "admin npubs (comma-separated)")
 329  	p("ORLY_OWNERS", "", "owner npubs (comma-separated)")
 330  	p("ORLY_FREE_WRITE_LIMIT", "25", "max unauthenticated writes per IP per window (0=disable)")
 331  	p("ORLY_FREE_WRITE_WINDOW", "300", "free write window in seconds")
 332  	s("Connection Limits")
 333  	p("ORLY_MAX_CONN_PER_IP", "10", "max WebSocket connections per IP")
 334  	p("ORLY_MAX_GLOBAL_CONNECTIONS", "500", "max total WebSocket connections")
 335  	p("ORLY_MAX_SUBSCRIPTIONS", "10000", "max total active subscriptions")
 336  	p("ORLY_INGEST_WORKERS", "0", "Stage-A sig-verify worker count (0=sync fallback)")
 337  	p("ORLY_MEDIA_PROXY_WORKERS", "4", "media proxy worker count (503 when all busy)")
 338  	p("ORLY_BLOSSOM_WORKERS", "4", "blossom file I/O worker count")
 339  	p("ORLY_STATIC_WORKERS", "1", "static file worker count")
 340  	p("ORLY_CONN_DELAY_MAX_MS", "2000", "max delay for new connections under load")
 341  	s("IP Control")
 342  	p("ORLY_IP_WHITELIST", "", "allowed IPs (comma-separated, prefix match)")
 343  	p("ORLY_IP_BLACKLIST", "", "blocked IPs (comma-separated, prefix match)")
 344  	s("HTTP Guard")
 345  	p("ORLY_HTTP_GUARD_BOT_BLOCK", "true", "block known bot User-Agents")
 346  	s("Rate Limiting (PID)")
 347  	p("ORLY_RATE_LIMIT_ENABLED", "true", "enable adaptive PID rate limiting")
 348  	p("ORLY_RATE_LIMIT_TARGET_MB", "0", "target memory limit (0=auto)")
 349  	p("ORLY_RATE_LIMIT_WRITE_KP", "0.5", "PID proportional gain for writes")
 350  	p("ORLY_RATE_LIMIT_WRITE_KI", "0.1", "PID integral gain for writes")
 351  	p("ORLY_RATE_LIMIT_WRITE_KD", "0.05", "PID derivative gain for writes")
 352  	p("ORLY_RATE_LIMIT_READ_KP", "0.3", "PID proportional gain for reads")
 353  	p("ORLY_RATE_LIMIT_READ_KI", "0.05", "PID integral gain for reads")
 354  	p("ORLY_RATE_LIMIT_READ_KD", "0.02", "PID derivative gain for reads")
 355  	p("ORLY_RATE_LIMIT_MAX_WRITE_MS", "1000", "max write delay (ms)")
 356  	p("ORLY_RATE_LIMIT_MAX_READ_MS", "500", "max read delay (ms)")
 357  	p("ORLY_RATE_LIMIT_WRITE_TARGET", "0.85", "write throttle setpoint")
 358  	p("ORLY_RATE_LIMIT_READ_TARGET", "0.90", "read throttle setpoint")
 359  	p("ORLY_RATE_LIMIT_EMERGENCY_THRESHOLD", "1.167", "emergency mode trigger ratio")
 360  	p("ORLY_RATE_LIMIT_RECOVERY_THRESHOLD", "0.833", "emergency mode exit ratio")
 361  	p("ORLY_RATE_LIMIT_EMERGENCY_MAX_MS", "5000", "max delay in emergency mode")
 362  	s("Query")
 363  	p("ORLY_QUERY_RESULT_LIMIT", "256", "max events per REQ filter")
 364  	s("Follows ACL")
 365  	p("ORLY_FOLLOW_LIST_FREQUENCY", "1h", "admin follow list refresh interval")
 366  	p("ORLY_FOLLOWS_THROTTLE", "false", "enable progressive delay for non-followed")
 367  	p("ORLY_FOLLOWS_THROTTLE_INCREMENT", "25ms", "delay per event for non-followed")
 368  	p("ORLY_FOLLOWS_THROTTLE_MAX", "60s", "max throttle delay")
 369  	s("Social WoT Throttle")
 370  	p("ORLY_SOCIAL_THROTTLE_D2_INCREMENT", "50ms", "delay per event for WoT depth-2")
 371  	p("ORLY_SOCIAL_THROTTLE_D2_MAX", "30s", "max delay for WoT depth-2")
 372  	p("ORLY_SOCIAL_THROTTLE_D3_INCREMENT", "200ms", "delay per event for WoT depth-3")
 373  	p("ORLY_SOCIAL_THROTTLE_D3_MAX", "60s", "max delay for WoT depth-3")
 374  	p("ORLY_SOCIAL_THROTTLE_OUTSIDER_INCREMENT", "500ms", "delay per event for outsiders")
 375  	p("ORLY_SOCIAL_THROTTLE_OUTSIDER_MAX", "120s", "max delay for outsiders")
 376  	p("ORLY_SOCIAL_WOT_DEPTH", "3", "max WoT traversal depth")
 377  	p("ORLY_SOCIAL_WOT_REFRESH", "1h", "WoT depth map recompute interval")
 378  	s("GrapeVine")
 379  	p("ORLY_GRAPEVINE_ENABLED", "false", "enable WoT influence scoring")
 380  	p("ORLY_GRAPEVINE_MAX_DEPTH", "6", "max BFS depth for follow graph")
 381  	p("ORLY_GRAPEVINE_MAX_CYCLES", "20", "max convergence iterations")
 382  	p("ORLY_GRAPEVINE_ATTENUATION", "0.8", "weight decay per hop")
 383  	p("ORLY_GRAPEVINE_RIGOR", "0.25", "certainty curve steepness")
 384  	p("ORLY_GRAPEVINE_FOLLOW_CONFIDENCE", "0.05", "base confidence per follow edge")
 385  	p("ORLY_GRAPEVINE_OBSERVERS", "", "hex pubkeys for auto-scoring")
 386  	p("ORLY_GRAPEVINE_REFRESH", "6h", "recalculation interval")
 387  	p("ORLY_GRAPEVINE_AUTO_WHITELIST", "false", "auto-update ACL from scores")
 388  	p("ORLY_GRAPEVINE_WHITELIST_THRESHOLD", "0.5", "min score for whitelist")
 389  	p("ORLY_GRAPEVINE_WHITELIST_REFRESH", "6h", "whitelist refresh interval")
 390  	s("Graph Queries")
 391  	p("ORLY_GRAPH_QUERIES_ENABLED", "true", "enable _graph filter extension")
 392  	p("ORLY_GRAPH_MAX_DEPTH", "16", "max graph traversal depth")
 393  	p("ORLY_GRAPH_MAX_RESULTS", "10000", "max results per graph query")
 394  	p("ORLY_GRAPH_RATE_LIMIT_RPM", "60", "graph queries per minute per conn")
 395  	s("Proxy")
 396  	p("ORLY_PROXY_ENABLED", "true", "enable _proxy filter extension")
 397  	p("ORLY_PROXY_MAX_RELAYS", "16", "max relay URLs per proxy query")
 398  	p("ORLY_PROXY_TIMEOUT_SEC", "15", "proxy relay query timeout")
 399  	s("Archive")
 400  	p("ORLY_ARCHIVE_ENABLED", "false", "enable archive relay augmentation")
 401  	p("ORLY_ARCHIVE_RELAYS", "wss://archive.orly.dev/", "archive relay URLs")
 402  	p("ORLY_ARCHIVE_TIMEOUT_SEC", "30", "archive relay query timeout")
 403  	p("ORLY_ARCHIVE_CACHE_TTL_HRS", "24", "hours to cache query fingerprints")
 404  	s("Blossom")
 405  	p("ORLY_BLOSSOM_ENABLED", "true", "enable blob storage server")
 406  	p("ORLY_BLOSSOM_DIR", "$DATA_DIR/blossom", "blob storage directory")
 407  	p("ORLY_BLOSSOM_UPSTREAM", "https://smesh.lol", "CORS-proxy fallback origin for missing blobs (empty disables)")
 408  	p("ORLY_BLOSSOM_SERVICE_LEVELS", "", "service levels (name:mb_per_sat)")
 409  	p("ORLY_BLOSSOM_RATE_LIMIT", "false", "rate-limit non-followed uploads")
 410  	p("ORLY_BLOSSOM_DAILY_LIMIT_MB", "10", "daily upload limit for non-followed")
 411  	p("ORLY_BLOSSOM_BURST_LIMIT_MB", "50", "burst upload limit")
 412  	p("ORLY_BLOSSOM_DELETE_REQUIRE_SERVER_TAG", "false", "require server tag in delete auth")
 413  	s("Sync & Discovery")
 414  	p("ORLY_BOOTSTRAP_RELAYS", "", "bootstrap relay URLs (comma-separated)")
 415  	p("ORLY_SPIDER_MODE", "none", "spider mode: none, follows")
 416  	p("ORLY_DIRECTORY_SPIDER", "false", "enable directory metadata sync")
 417  	p("ORLY_DIRECTORY_SPIDER_INTERVAL", "24h", "directory spider interval")
 418  	p("ORLY_DIRECTORY_SPIDER_HOPS", "3", "max relay discovery hops")
 419  	p("ORLY_CRAWLER_ENABLED", "false", "enable corpus crawler")
 420  	p("ORLY_CRAWLER_DISCOVERY_INTERVAL", "4h", "relay discovery interval")
 421  	p("ORLY_CRAWLER_SYNC_INTERVAL", "30m", "relay sync interval")
 422  	p("ORLY_CRAWLER_MAX_HOPS", "5", "max hops for relay discovery")
 423  	p("ORLY_CRAWLER_CONCURRENCY", "3", "concurrent relay syncs")
 424  	p("ORLY_NEGENTROPY_ENABLED", "false", "enable NIP-77 set reconciliation")
 425  	p("ORLY_NEGENTROPY_FULL_SYNC_PUBKEYS", "", "pubkeys allowed full sync")
 426  	s("TLS")
 427  	p("ORLY_TLS_DOMAINS", "", "TLS domain names (comma-separated)")
 428  	p("ORLY_CERTS", "", "cert root paths (comma-separated)")
 429  	s("Tor")
 430  	p("ORLY_TOR_ENABLED", "true", "enable Tor hidden service")
 431  	p("ORLY_TOR_PORT", "3336", "Tor internal port")
 432  	p("ORLY_TOR_DATA_DIR", "", "Tor data directory")
 433  	p("ORLY_TOR_BINARY", "tor", "path to tor binary")
 434  	p("ORLY_TOR_SOCKS", "0", "SOCKS port for outbound Tor")
 435  	s("NRC")
 436  	p("ORLY_NRC_ENABLED", "true", "enable NRC rendezvous bridge")
 437  	p("ORLY_NRC_RENDEZVOUS_URL", "", "rendezvous relay URL")
 438  	p("ORLY_NRC_AUTHORIZED_KEYS", "", "authorized client pubkeys")
 439  	p("ORLY_NRC_SESSION_TIMEOUT", "30m", "NRC session inactivity timeout")
 440  	s("WireGuard")
 441  	p("ORLY_WG_ENABLED", "false", "enable embedded WireGuard VPN")
 442  	p("ORLY_WG_PORT", "51820", "WireGuard UDP port")
 443  	p("ORLY_WG_ENDPOINT", "", "WireGuard public endpoint")
 444  	p("ORLY_WG_NETWORK", "10.73.0.0/16", "WireGuard internal network")
 445  	s("Bunker")
 446  	p("ORLY_BUNKER_ENABLED", "false", "enable NIP-46 bunker service")
 447  	p("ORLY_BUNKER_PORT", "3335", "bunker WebSocket port")
 448  	s("Payment")
 449  	p("ORLY_NWC_URI", "", "NWC connection string")
 450  	p("ORLY_SUBSCRIPTION_ENABLED", "false", "enable subscription access")
 451  	p("ORLY_MONTHLY_PRICE_SATS", "6000", "monthly subscription price")
 452  	s("NIP-43")
 453  	p("ORLY_NIP43_ENABLED", "false", "enable relay access metadata")
 454  	p("ORLY_NIP43_PUBLISH_EVENTS", "true", "publish member add/remove events")
 455  	p("ORLY_NIP43_PUBLISH_MEMBER_LIST", "true", "publish membership list events")
 456  	p("ORLY_NIP43_INVITE_EXPIRY", "24h", "invite code validity period")
 457  	s("Policy")
 458  	p("ORLY_POLICY_ENABLED", "false", "enable policy-based event processing")
 459  	p("ORLY_POLICY_PATH", "", "absolute path to policy JSON file")
 460  	s("Storage & GC")
 461  	p("ORLY_MAX_STORAGE_BYTES", "0", "max storage bytes (0=auto 80%%)")
 462  	p("ORLY_GC_ENABLED", "false", "enable garbage collection")
 463  	p("ORLY_GC_INTERVAL_SEC", "60", "GC run interval")
 464  	p("ORLY_GC_BATCH_SIZE", "1000", "events per GC run")
 465  	s("Bridge (Marmot)")
 466  	p("ORLY_BRIDGE_ENABLED", "false", "enable Nostr-Email bridge")
 467  	p("ORLY_BRIDGE_DOMAIN", "", "email domain for bridge")
 468  	p("ORLY_BRIDGE_NSEC", "", "bridge identity nsec")
 469  	p("ORLY_BRIDGE_RELAY_URL", "", "relay URL for standalone mode")
 470  	p("ORLY_BRIDGE_PUBLIC_RELAY_URL", "", "public relay URL for events")
 471  	p("ORLY_BRIDGE_SMTP_PORT", "2525", "SMTP listen port")
 472  	p("ORLY_BRIDGE_SMTP_HOST", "0.0.0.0", "SMTP listen address")
 473  	p("ORLY_BRIDGE_DATA_DIR", "", "bridge data directory")
 474  	p("ORLY_BRIDGE_DKIM_KEY", "", "DKIM private key path")
 475  	p("ORLY_BRIDGE_DKIM_SELECTOR", "marmot", "DKIM selector")
 476  	p("ORLY_BRIDGE_NWC_URI", "", "NWC URI for bridge payments")
 477  	p("ORLY_BRIDGE_MONTHLY_PRICE_SATS", "2100", "bridge subscription price")
 478  	p("ORLY_BRIDGE_COMPOSE_URL", "", "compose form URL")
 479  	p("ORLY_BRIDGE_SMTP_RELAY_HOST", "", "SMTP smarthost")
 480  	p("ORLY_BRIDGE_SMTP_RELAY_PORT", "587", "SMTP smarthost port")
 481  	p("ORLY_BRIDGE_SMTP_MX_PORT", "0", "direct MX port (0=auto)")
 482  	p("ORLY_BRIDGE_SMTP_RELAY_USERNAME", "", "SMTP smarthost username")
 483  	p("ORLY_BRIDGE_SMTP_RELAY_PASSWORD", "", "SMTP smarthost password")
 484  	p("ORLY_BRIDGE_ALIAS_PRICE_SATS", "4200", "alias email monthly price")
 485  	p("ORLY_BRIDGE_PROFILE", "", "bridge profile template path")
 486  	s("Cluster")
 487  	p("ORLY_CLUSTER_ADMINS", "", "cluster admin npubs")
 488  	p("ORLY_RELAY_GROUP_ADMINS", "", "relay group admin npubs")
 489  	p("ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS", "true", "replicate privileged events")
 490  	s("Smesh Client")
 491  	p("ORLY_SMESH_ENABLED", "true", "enable musiquay web client")
 492  	p("ORLY_SMESH_PORT", "8088", "musiquay client port")
 493  	p("ORLY_SMESH2_ENABLED", "true", "enable musiquay2 client")
 494  	p("ORLY_SMESH2_PORT", "8089", "musiquay2 client port")
 495  	p("ORLY_SMESH3_ENABLED", "true", "enable musiquay3 client")
 496  	p("ORLY_SMESH3_PORT", "8090", "musiquay3 client port")
 497  	p("ORLY_SMESH3_DIR", "", "musiquay3 disk directory (hot-reload)")
 498  	p("ORLY_DEPLOY_PUBKEY", "", "deploy asset bundle pubkey")
 499  	s("Web UI")
 500  	p("ORLY_STATIC_DIR", "web/static", "static file directory")
 501  	p("ORLY_WEB_DISABLE", "false", "disable embedded web UI")
 502  	p("ORLY_WEB_DEV_PROXY_URL", "", "dev proxy URL when UI disabled")
 503  	p("ORLY_BRANDING_DIR", "", "branding assets directory")
 504  	p("ORLY_BRANDING_ENABLED", "true", "enable custom branding")
 505  	p("ORLY_THEME", "auto", "UI theme: auto, light, dark")
 506  	p("ORLY_CORS_ENABLED", "false", "enable CORS headers")
 507  	p("ORLY_CORS_ORIGINS", "", "allowed CORS origins")
 508  	s("Misc")
 509  	p("ORLY_SPROCKET_ENABLED", "false", "enable sprocket plugin system")
 510  	p("ORLY_ENABLE_SHUTDOWN", "false", "expose /shutdown on health port")
 511  	fmt.Fprintln(w, "")
 512  }
 513  
 514  // --- env helpers ---
 515  
 516  func estr(key, fb string) (s string) {
 517  	if v := os.Getenv(key); v != "" {
 518  		return v
 519  	}
 520  	if dotenv != nil {
 521  		if v, ok := dotenv[key]; ok && v != "" {
 522  			return v
 523  		}
 524  	}
 525  	return fb
 526  }
 527  
 528  func eint(key string, fb int32) (n int32) {
 529  	v := estr(key, "")
 530  	if v == "" {
 531  		return fb
 532  	}
 533  	n, ok := parseInt(v)
 534  	if !ok {
 535  		return fb
 536  	}
 537  	return n
 538  }
 539  
 540  func eint64(key string, fb int64) (n int64) {
 541  	v := estr(key, "")
 542  	if v == "" {
 543  		return fb
 544  	}
 545  	n, ok := parseInt64(v)
 546  	if !ok {
 547  		return fb
 548  	}
 549  	return n
 550  }
 551  
 552  func ebool(key string, fb bool) (ok bool) {
 553  	v := estr(key, "")
 554  	if v == "" {
 555  		return fb
 556  	}
 557  	return v == "true" || v == "True" || v == "TRUE" ||
 558  		v == "1" || v == "yes" || v == "Yes" || v == "YES"
 559  }
 560  
 561  func efloat(key string, fb float64) (f float64) {
 562  	v := estr(key, "")
 563  	if v == "" {
 564  		return fb
 565  	}
 566  	f, ok := parseFloat(v)
 567  	if !ok {
 568  		return fb
 569  	}
 570  	return f
 571  }
 572  
 573  func elist(key string) (ss []string) {
 574  	v := estr(key, "")
 575  	if v == "" {
 576  		return nil
 577  	}
 578  	return splitComma(v)
 579  }
 580  
 581  func edursec(key string, fb int32) (n int32) {
 582  	v := estr(key, "")
 583  	if v == "" {
 584  		return fb
 585  	}
 586  	ms := parseDuration(v)
 587  	if ms <= 0 {
 588  		return fb
 589  	}
 590  	return ms / 1000
 591  }
 592  
 593  func edurms(key string, fb int32) (n int32) {
 594  	v := estr(key, "")
 595  	if v == "" {
 596  		return fb
 597  	}
 598  	ms := parseDuration(v)
 599  	if ms <= 0 {
 600  		return fb
 601  	}
 602  	return ms
 603  }
 604  
 605  // --- parsing ---
 606  
 607  func parseInt(s string) (n int32, ok bool) {
 608  	if len(s) == 0 {
 609  		return 0, false
 610  	}
 611  	neg := false
 612  	i := 0
 613  	if s[0] == '-' {
 614  		neg = true
 615  		i = 1
 616  	}
 617  	n := 0
 618  	for ; i < len(s); i++ {
 619  		if s[i] < '0' || s[i] > '9' {
 620  			return 0, false
 621  		}
 622  		n = n*10 + int32(s[i]-'0')
 623  	}
 624  	if neg {
 625  		return -n, true
 626  	}
 627  	return n, true
 628  }
 629  
 630  func parseInt64(s string) (n int64, ok bool) {
 631  	if len(s) == 0 {
 632  		return 0, false
 633  	}
 634  	neg := false
 635  	i := 0
 636  	if s[0] == '-' {
 637  		neg = true
 638  		i = 1
 639  	}
 640  	var n int64
 641  	for ; i < len(s); i++ {
 642  		if s[i] < '0' || s[i] > '9' {
 643  			return 0, false
 644  		}
 645  		n = n*10 + int64(s[i]-'0')
 646  	}
 647  	if neg {
 648  		return -n, true
 649  	}
 650  	return n, true
 651  }
 652  
 653  func parseFloat(s string) (f float64, ok bool) {
 654  	if len(s) == 0 {
 655  		return 0, false
 656  	}
 657  	neg := false
 658  	i := 0
 659  	if s[0] == '-' {
 660  		neg = true
 661  		i = 1
 662  	} else if s[0] == '+' {
 663  		i = 1
 664  	}
 665  	var integer float64
 666  	for ; i < len(s) && s[i] != '.'; i++ {
 667  		if s[i] < '0' || s[i] > '9' {
 668  			return 0, false
 669  		}
 670  		integer = integer*10 + float64(s[i]-'0')
 671  	}
 672  	var frac float64
 673  	if i < len(s) && s[i] == '.' {
 674  		i++
 675  		mul := 0.1
 676  		for ; i < len(s); i++ {
 677  			if s[i] < '0' || s[i] > '9' {
 678  				return 0, false
 679  			}
 680  			frac += float64(s[i]-'0') * mul
 681  			mul *= 0.1
 682  		}
 683  	}
 684  	result := integer + frac
 685  	if neg {
 686  		result = -result
 687  	}
 688  	return result, true
 689  }
 690  
 691  // parseDuration handles "1h", "30m", "60s", "25ms" and returns milliseconds.
 692  func parseDuration(s string) (n int32) {
 693  	if len(s) == 0 {
 694  		return 0
 695  	}
 696  	i := 0
 697  	for i < len(s) && s[i] >= '0' && s[i] <= '9' {
 698  		i++
 699  	}
 700  	if i == 0 {
 701  		return 0
 702  	}
 703  	n, ok := parseInt(s[:i])
 704  	if !ok {
 705  		return 0
 706  	}
 707  	unit := s[i:]
 708  	switch unit {
 709  	case "h":
 710  		return n * 3600000
 711  	case "m":
 712  		return n * 60000
 713  	case "s", "":
 714  		return n * 1000
 715  	case "ms":
 716  		return n
 717  	}
 718  	return n * 1000
 719  }
 720  
 721  // --- .env file ---
 722  
 723  func loadEnvFile(path string) (m map[string]string) {
 724  	data, err := os.ReadFile(path)
 725  	if err != nil {
 726  		return nil
 727  	}
 728  	m := map[string]string{}
 729  	for len(data) > 0 {
 730  		nl := -1
 731  		for i := 0; i < len(data); i++ {
 732  			if data[i] == '\n' {
 733  				nl = i
 734  				break
 735  			}
 736  		}
 737  		var line []byte
 738  		if nl >= 0 {
 739  			line = data[:nl]
 740  			data = data[nl+1:]
 741  		} else {
 742  			line = data
 743  			data = nil
 744  		}
 745  		if len(line) > 0 && line[len(line)-1] == '\r' {
 746  			line = line[:len(line)-1]
 747  		}
 748  		line = trim(line)
 749  		if len(line) == 0 || line[0] == '#' {
 750  			continue
 751  		}
 752  		if len(line) > 7 && string(line[:7]) == "export " {
 753  			line = trim(line[7:])
 754  		}
 755  		eq := -1
 756  		for i := 0; i < len(line); i++ {
 757  			if line[i] == '=' {
 758  				eq = i
 759  				break
 760  			}
 761  		}
 762  		if eq < 0 {
 763  			continue
 764  		}
 765  		key := copyb(trim(line[:eq]))
 766  		val := copyb(trim(line[eq+1:]))
 767  		if len(val) >= 2 {
 768  			if (val[0] == '"' && val[len(val)-1] == '"') ||
 769  				(val[0] == '\'' && val[len(val)-1] == '\'') {
 770  				val = copyb(val[1 : len(val)-1])
 771  			}
 772  		}
 773  		m[string(key)] = string(val)
 774  	}
 775  	return m
 776  }
 777  
 778  // --- utility ---
 779  
 780  func expandHome(path string) (s string) {
 781  	if len(path) >= 2 && path[0] == '~' && path[1] == '/' {
 782  		home := os.Getenv("HOME")
 783  		if home != "" {
 784  			return home | path[1:]
 785  		}
 786  	}
 787  	return path
 788  }
 789  
 790  func splitComma(s string) (ss []string) {
 791  	var result []string
 792  	start := 0
 793  	for i := 0; i <= len(s); i++ {
 794  		if i == len(s) || s[i] == ',' {
 795  			part := trim([]byte(s[start:i]))
 796  			if len(part) > 0 {
 797  				result = mxutil.Ensure(result, 1)
 798  				result = push(result, string(copyb(part)))
 799  			}
 800  			start = i + 1
 801  		}
 802  	}
 803  	return result
 804  }
 805  
 806  func trim(b []byte) (buf []byte) {
 807  	for len(b) > 0 && (b[0] == ' ' || b[0] == '\t') {
 808  		b = b[1:]
 809  	}
 810  	for len(b) > 0 && (b[len(b)-1] == ' ' || b[len(b)-1] == '\t') {
 811  		b = b[:len(b)-1]
 812  	}
 813  	return b
 814  }
 815  
 816  func copyb(b []byte) (buf []byte) {
 817  	c := []byte{:len(b)}
 818  	copy(c, b)
 819  	return c
 820  }
 821  
 822  func itoa(n int32) (s string) {
 823  	if n == 0 {
 824  		return "0"
 825  	}
 826  	neg := false
 827  	if n < 0 {
 828  		neg = true
 829  		n = -n
 830  	}
 831  	var buf [20]byte
 832  	i := 19
 833  	for n > 0 {
 834  		buf[i] = byte('0' + n%10)
 835  		i--
 836  		n /= 10
 837  	}
 838  	if neg {
 839  		buf[i] = '-'
 840  		i--
 841  	}
 842  	return string(buf[i+1:])
 843  }
 844