config.mx raw

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