config.go raw
1 // Package config provides a go-simpler.org/env configuration table and helpers
2 // for working with the list of key/value lists stored in .env files.
3 //
4 // IMPORTANT: This file is the SINGLE SOURCE OF TRUTH for all environment variables.
5 // All configuration options MUST be defined here with proper `env` struct tags.
6 // Never use os.Getenv() directly in other packages - pass configuration via structs.
7 // This ensures all options appear in `./orly help` output and are documented.
8 //
9 // For database backends, use GetDatabaseConfigValues() to extract database-specific
10 // settings, then construct a database.DatabaseConfig in the caller (e.g., main.go).
11 package config
12
13 import (
14 "bufio"
15 "fmt"
16 "io"
17 "os"
18 "path/filepath"
19 "reflect"
20 "sort"
21 "strings"
22 "time"
23
24 "github.com/adrg/xdg"
25 "go-simpler.org/env"
26 lol "next.orly.dev/pkg/lol"
27 "next.orly.dev/pkg/lol/chk"
28 "next.orly.dev/pkg/lol/log"
29 "next.orly.dev/pkg/logbuffer"
30 "next.orly.dev/pkg/version"
31 )
32
33 // C holds application configuration settings loaded from environment variables
34 // and default values. It defines parameters for app behaviour, storage
35 // locations, logging, and network settings used across the relay service.
36 type C struct {
37 AppName string `env:"ORLY_APP_NAME" usage:"set a name to display on information about the relay" default:"ORLY"`
38 DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/ORLY"`
39 Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
40 Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
41 HealthPort int `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
42 EnableShutdown bool `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
43 LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
44 DBLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
45 DBBlockCacheMB int `env:"ORLY_DB_BLOCK_CACHE_MB" default:"1024" usage:"Badger block cache size in MB (higher improves read hit ratio, increase for large archives)"`
46 DBIndexCacheMB int `env:"ORLY_DB_INDEX_CACHE_MB" default:"512" usage:"Badger index cache size in MB (improves index lookup performance, increase for large archives)"`
47 DBZSTDLevel int `env:"ORLY_DB_ZSTD_LEVEL" default:"3" usage:"Badger ZSTD compression level (1=fast/500MB/s, 3=balanced, 9=best ratio/slower, 0=disable)"`
48 LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
49 LogBufferSize int `env:"ORLY_LOG_BUFFER_SIZE" default:"10000" usage:"number of log entries to keep in memory for web UI viewing (0 disables)"`
50 Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
51 PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
52 PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
53 IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
54 IPBlacklist []string `env:"ORLY_IP_BLACKLIST" usage:"comma-separated list of IP addresses to block; matches on prefixes to allow subnets, e.g. 192.168 = 192.168.0.0/16"`
55 Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
56 Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
57 ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows, managed (nip-86), curating, none" default:"none"`
58 AuthRequired bool `env:"ORLY_AUTH_REQUIRED" usage:"require authentication for all requests (works with managed ACL)" default:"false"`
59 AuthToWrite bool `env:"ORLY_AUTH_TO_WRITE" usage:"require authentication only for write operations (EVENT), allow REQ/COUNT without auth" default:"false"`
60 NIP46BypassAuth bool `env:"ORLY_NIP46_BYPASS_AUTH" usage:"allow NIP-46 bunker events (kind 24133) through without authentication even when auth is required" default:"false"`
61 BootstrapRelays []string `env:"ORLY_BOOTSTRAP_RELAYS" usage:"comma-separated list of bootstrap relay URLs for initial sync"`
62 NWCUri string `env:"ORLY_NWC_URI" usage:"NWC (Nostr Wallet Connect) connection string for Lightning payments"`
63 SubscriptionEnabled bool `env:"ORLY_SUBSCRIPTION_ENABLED" default:"false" usage:"enable subscription-based access control requiring payment for non-directory events"`
64 MonthlyPriceSats int64 `env:"ORLY_MONTHLY_PRICE_SATS" default:"6000" usage:"price in satoshis for one month subscription (default ~$2 USD)"`
65 RelayURL string `env:"ORLY_RELAY_URL" usage:"base URL for the relay dashboard (e.g., https://relay.example.com)"`
66 RelayAddresses []string `env:"ORLY_RELAY_ADDRESSES" usage:"comma-separated list of websocket addresses for this relay (e.g., wss://relay.example.com,wss://backup.example.com)"`
67 RelayPeers []string `env:"ORLY_RELAY_PEERS" usage:"comma-separated list of peer relay URLs for distributed synchronization (e.g., https://peer1.example.com,https://peer2.example.com)"`
68 RelayGroupAdmins []string `env:"ORLY_RELAY_GROUP_ADMINS" usage:"comma-separated list of npubs authorized to publish relay group configuration events"`
69 ClusterAdmins []string `env:"ORLY_CLUSTER_ADMINS" usage:"comma-separated list of npubs authorized to manage cluster membership"`
70 FollowListFrequency time.Duration `env:"ORLY_FOLLOW_LIST_FREQUENCY" usage:"how often to fetch admin follow lists (default: 1h)" default:"1h"`
71
72 // Progressive throttle for follows ACL mode - allows non-followed users to write with increasing delay
73 FollowsThrottleEnabled bool `env:"ORLY_FOLLOWS_THROTTLE" default:"false" usage:"enable progressive delay for non-followed users in follows ACL mode"`
74 FollowsThrottlePerEvent time.Duration `env:"ORLY_FOLLOWS_THROTTLE_INCREMENT" default:"25ms" usage:"delay added per event for non-followed users"`
75 FollowsThrottleMaxDelay time.Duration `env:"ORLY_FOLLOWS_THROTTLE_MAX" default:"60s" usage:"maximum throttle delay cap"`
76
77 // Social ACL mode - WoT graph topology with inbound-trust rate limiting
78 SocialThrottleD2Increment time.Duration `env:"ORLY_SOCIAL_THROTTLE_D2_INCREMENT" default:"50ms" usage:"throttle increment per event for WoT depth-2 users"`
79 SocialThrottleD2Max time.Duration `env:"ORLY_SOCIAL_THROTTLE_D2_MAX" default:"30s" usage:"max throttle delay for WoT depth-2 users"`
80 SocialThrottleD3Increment time.Duration `env:"ORLY_SOCIAL_THROTTLE_D3_INCREMENT" default:"200ms" usage:"throttle increment per event for WoT depth-3 users"`
81 SocialThrottleD3Max time.Duration `env:"ORLY_SOCIAL_THROTTLE_D3_MAX" default:"60s" usage:"max throttle delay for WoT depth-3 users"`
82 SocialThrottleOutsiderIncrement time.Duration `env:"ORLY_SOCIAL_THROTTLE_OUTSIDER_INCREMENT" default:"500ms" usage:"throttle increment per event for outsiders (beyond WoT)"`
83 SocialThrottleOutsiderMax time.Duration `env:"ORLY_SOCIAL_THROTTLE_OUTSIDER_MAX" default:"120s" usage:"max throttle delay for outsiders"`
84 SocialWoTMaxDepth int `env:"ORLY_SOCIAL_WOT_DEPTH" default:"3" usage:"maximum WoT traversal depth (1-16)"`
85 SocialWoTRefreshInterval time.Duration `env:"ORLY_SOCIAL_WOT_REFRESH" default:"1h" usage:"how often to recompute the WoT depth map"`
86
87 // GrapeVine WoT influence scoring API
88 GrapeVineEnabled bool `env:"ORLY_GRAPEVINE_ENABLED" default:"false" usage:"enable GrapeVine WoT influence scoring API (NIP-98 authenticated)"`
89 GrapeVineMaxDepth int `env:"ORLY_GRAPEVINE_MAX_DEPTH" default:"6" usage:"max BFS depth for follow graph traversal"`
90 GrapeVineCycles int `env:"ORLY_GRAPEVINE_CYCLES" default:"5" usage:"convergence iterations for influence scoring"`
91 GrapeVineAttenuation float64 `env:"ORLY_GRAPEVINE_ATTENUATION" default:"0.8" usage:"weight decay factor per hop (0-1)"`
92 GrapeVineRigor float64 `env:"ORLY_GRAPEVINE_RIGOR" default:"0.25" usage:"certainty curve steepness (0-1)"`
93 GrapeVineFollowConf float64 `env:"ORLY_GRAPEVINE_FOLLOW_CONFIDENCE" default:"0.05" usage:"base confidence weight for a follow edge"`
94 GrapeVineObservers []string `env:"ORLY_GRAPEVINE_OBSERVERS" usage:"comma-separated hex pubkeys to auto-calculate scores for"`
95 GrapeVineRefresh time.Duration `env:"ORLY_GRAPEVINE_REFRESH" default:"6h" usage:"recalculation interval for configured observers"`
96
97 // Blossom blob storage service settings
98 BlossomEnabled bool `env:"ORLY_BLOSSOM_ENABLED" default:"true" usage:"enable Blossom blob storage server (only works with Badger backend)"`
99 BlossomServiceLevels string `env:"ORLY_BLOSSOM_SERVICE_LEVELS" usage:"comma-separated list of service levels in format: name:storage_mb_per_sat_per_month (e.g., basic:1,premium:10)"`
100
101 // Blossom upload rate limiting (for non-followed users)
102 BlossomRateLimitEnabled bool `env:"ORLY_BLOSSOM_RATE_LIMIT" default:"false" usage:"enable upload rate limiting for non-followed users"`
103 BlossomDailyLimitMB int64 `env:"ORLY_BLOSSOM_DAILY_LIMIT_MB" default:"10" usage:"daily upload limit in MB for non-followed users (EMA averaged)"`
104 BlossomBurstLimitMB int64 `env:"ORLY_BLOSSOM_BURST_LIMIT_MB" default:"50" usage:"max burst upload in MB (bucket cap)"`
105
106 // Blossom delete replay protection (proposed BUD enhancement)
107 BlossomDeleteRequireServerTag bool `env:"ORLY_BLOSSOM_DELETE_REQUIRE_SERVER_TAG" default:"false" usage:"require server tag in delete auth events to prevent cross-server replay attacks (not yet ratified in spec)"`
108
109 // Web UI and dev mode settings
110 WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
111 WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`
112
113 // Branding/white-label settings
114 BrandingDir string `env:"ORLY_BRANDING_DIR" usage:"directory containing branding assets and configuration (default: ~/.config/ORLY/branding)"`
115 BrandingEnabled bool `env:"ORLY_BRANDING_ENABLED" default:"true" usage:"enable custom branding if branding directory exists"`
116 Theme string `env:"ORLY_THEME" default:"auto" usage:"UI color theme: auto (follow system), light, dark"`
117
118 // CORS settings (for standalone dashboard mode)
119 CORSEnabled bool `env:"ORLY_CORS_ENABLED" default:"false" usage:"enable CORS headers for API endpoints (required for standalone dashboard)"`
120 CORSOrigins []string `env:"ORLY_CORS_ORIGINS" usage:"allowed CORS origins (comma-separated, or * for all origins)"`
121
122 // Sprocket settings
123 SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"`
124
125 // Spider settings
126 SpiderMode string `env:"ORLY_SPIDER_MODE" default:"none" usage:"spider mode for syncing events: none, follows"`
127
128 // Directory Spider settings
129 DirectorySpiderEnabled bool `env:"ORLY_DIRECTORY_SPIDER" default:"false" usage:"enable directory spider for metadata sync (kinds 0, 3, 10000, 10002)"`
130 DirectorySpiderInterval time.Duration `env:"ORLY_DIRECTORY_SPIDER_INTERVAL" default:"24h" usage:"how often to run directory spider"`
131 DirectorySpiderMaxHops int `env:"ORLY_DIRECTORY_SPIDER_HOPS" default:"3" usage:"maximum hops for relay discovery from seed users"`
132
133
134 // Corpus Crawler settings (relay discovery + negentropy full-event sync)
135 CrawlerEnabled bool `env:"ORLY_CRAWLER_ENABLED" default:"false" usage:"enable corpus crawler for relay discovery and full event sync via negentropy"`
136 CrawlerDiscoveryInterval time.Duration `env:"ORLY_CRAWLER_DISCOVERY_INTERVAL" default:"4h" usage:"how often to run relay discovery via kind 10002 hop expansion"`
137 CrawlerSyncInterval time.Duration `env:"ORLY_CRAWLER_SYNC_INTERVAL" default:"30m" usage:"how often to re-sync known relays via negentropy"`
138 CrawlerMaxHops int `env:"ORLY_CRAWLER_MAX_HOPS" default:"5" usage:"maximum hops for relay discovery from seed pubkeys"`
139 CrawlerConcurrency int `env:"ORLY_CRAWLER_CONCURRENCY" default:"3" usage:"number of relays to sync concurrently"`
140
141 PolicyEnabled bool `env:"ORLY_POLICY_ENABLED" default:"false" usage:"enable policy-based event processing (default config: $HOME/.config/ORLY/policy.json)"`
142 PolicyPath string `env:"ORLY_POLICY_PATH" usage:"ABSOLUTE path to policy configuration file (MUST start with /); overrides default location; relative paths are rejected"`
143
144 // NIP-43 Relay Access Metadata and Requests
145 NIP43Enabled bool `env:"ORLY_NIP43_ENABLED" default:"false" usage:"enable NIP-43 relay access metadata and invite system"`
146 NIP43PublishEvents bool `env:"ORLY_NIP43_PUBLISH_EVENTS" default:"true" usage:"publish kind 8000/8001 events when members are added/removed"`
147 NIP43PublishMemberList bool `env:"ORLY_NIP43_PUBLISH_MEMBER_LIST" default:"true" usage:"publish kind 13534 membership list events"`
148 NIP43InviteExpiry time.Duration `env:"ORLY_NIP43_INVITE_EXPIRY" default:"24h" usage:"how long invite codes remain valid"`
149
150 // Database configuration
151 DBType string `env:"ORLY_DB_TYPE" default:"badger" usage:"database backend to use: badger, neo4j, or grpc"`
152 QueryCacheDisabled bool `env:"ORLY_QUERY_CACHE_DISABLED" default:"true" usage:"disable query cache to reduce memory usage (trades memory for query performance)"`
153
154 // gRPC database client settings (only used when ORLY_DB_TYPE=grpc)
155 GRPCServerAddress string `env:"ORLY_GRPC_SERVER" default:"127.0.0.1:50051" usage:"address of remote gRPC database server (only used when ORLY_DB_TYPE=grpc)"`
156 GRPCConnectTimeout time.Duration `env:"ORLY_GRPC_CONNECT_TIMEOUT" default:"10s" usage:"gRPC connection timeout (only used when ORLY_DB_TYPE=grpc)"`
157
158 // gRPC ACL client settings (only used when ORLY_ACL_TYPE=grpc)
159 ACLType string `env:"ORLY_ACL_TYPE" default:"local" usage:"ACL backend: local (in-process) or grpc (remote ACL server)"`
160 GRPCACLServerAddress string `env:"ORLY_GRPC_ACL_SERVER" default:"127.0.0.1:50052" usage:"address of remote gRPC ACL server (only used when ORLY_ACL_TYPE=grpc)"`
161 GRPCACLConnectTimeout time.Duration `env:"ORLY_GRPC_ACL_TIMEOUT" default:"10s" usage:"gRPC ACL connection timeout (only used when ORLY_ACL_TYPE=grpc)"`
162
163 // gRPC Sync client settings (only used when ORLY_SYNC_TYPE=grpc)
164 SyncType string `env:"ORLY_SYNC_TYPE" default:"local" usage:"sync backend: local (in-process) or grpc (remote sync services)"`
165 GRPCSyncDistributedAddress string `env:"ORLY_GRPC_SYNC_DISTRIBUTED" default:"127.0.0.1:50053" usage:"address of gRPC distributed sync server"`
166 GRPCSyncClusterAddress string `env:"ORLY_GRPC_SYNC_CLUSTER" default:"127.0.0.1:50054" usage:"address of gRPC cluster sync server"`
167 GRPCSyncRelayGroupAddress string `env:"ORLY_GRPC_SYNC_RELAYGROUP" default:"127.0.0.1:50055" usage:"address of gRPC relay group server"`
168 GRPCSyncNegentropyAddress string `env:"ORLY_GRPC_SYNC_NEGENTROPY" default:"127.0.0.1:50056" usage:"address of gRPC negentropy server"`
169 GRPCSyncConnectTimeout time.Duration `env:"ORLY_GRPC_SYNC_TIMEOUT" default:"10s" usage:"gRPC sync connection timeout"`
170 NegentropyEnabled bool `env:"ORLY_NEGENTROPY_ENABLED" default:"false" usage:"enable NIP-77 negentropy set reconciliation"`
171 NegentropyFullSyncPubkeys string `env:"ORLY_NEGENTROPY_FULL_SYNC_PUBKEYS" default:"" usage:"comma-separated npubs or hex pubkeys allowed full negentropy sync (others get public only)"`
172
173 QueryCacheSizeMB int `env:"ORLY_QUERY_CACHE_SIZE_MB" default:"512" usage:"query cache size in MB (caches database query results for faster REQ responses)"`
174 QueryCacheMaxAge string `env:"ORLY_QUERY_CACHE_MAX_AGE" default:"5m" usage:"maximum age for cached query results (e.g., 5m, 10m, 1h)"`
175
176 // Neo4j configuration (only used when ORLY_DB_TYPE=neo4j)
177 Neo4jURI string `env:"ORLY_NEO4J_URI" default:"bolt://localhost:7687" usage:"Neo4j bolt URI (only used when ORLY_DB_TYPE=neo4j)"`
178 Neo4jUser string `env:"ORLY_NEO4J_USER" default:"neo4j" usage:"Neo4j authentication username (only used when ORLY_DB_TYPE=neo4j)"`
179 Neo4jPassword string `env:"ORLY_NEO4J_PASSWORD" default:"password" usage:"Neo4j authentication password (only used when ORLY_DB_TYPE=neo4j)"`
180
181 // Neo4j driver tuning (memory and connection management)
182 Neo4jMaxConnPoolSize int `env:"ORLY_NEO4J_MAX_CONN_POOL" default:"25" usage:"max Neo4j connection pool size (driver default: 100, lower reduces memory)"`
183 Neo4jFetchSize int `env:"ORLY_NEO4J_FETCH_SIZE" default:"1000" usage:"max records per fetch batch (prevents memory overflow, -1=fetch all)"`
184 Neo4jMaxTxRetrySeconds int `env:"ORLY_NEO4J_MAX_TX_RETRY_SEC" default:"30" usage:"max seconds for retryable transaction attempts"`
185 Neo4jQueryResultLimit int `env:"ORLY_NEO4J_QUERY_RESULT_LIMIT" default:"10000" usage:"max results returned per query (prevents unbounded memory usage, 0=unlimited)"`
186
187 // Neo4j Cypher query proxy (NIP-98 owner-gated HTTP endpoint)
188 Neo4jCypherEnabled bool `env:"ORLY_NEO4J_CYPHER_ENABLED" default:"false" usage:"enable POST /api/neo4j/cypher endpoint for owner-gated read-only Cypher queries"`
189 Neo4jCypherTimeoutSec int `env:"ORLY_NEO4J_CYPHER_TIMEOUT" default:"30" usage:"default timeout in seconds for Cypher queries (max 120)"`
190 Neo4jCypherMaxRows int `env:"ORLY_NEO4J_CYPHER_MAX_ROWS" default:"10000" usage:"max result rows returned per Cypher query (0=unlimited)"`
191
192 // Advanced database tuning (increase for large archives to reduce cache misses)
193 SerialCachePubkeys int `env:"ORLY_SERIAL_CACHE_PUBKEYS" default:"250000" usage:"max pubkeys to cache for compact event storage (~8MB memory, increase for large archives)"`
194 SerialCacheEventIds int `env:"ORLY_SERIAL_CACHE_EVENT_IDS" default:"1000000" usage:"max event IDs to cache for compact event storage (~32MB memory, increase for large archives)"`
195
196 // Connection concurrency control
197 MaxHandlersPerConnection int `env:"ORLY_MAX_HANDLERS_PER_CONN" default:"100" usage:"max concurrent message handlers per WebSocket connection (limits goroutine growth under load)"`
198 MaxConnectionsPerIP int `env:"ORLY_MAX_CONN_PER_IP" default:"10" usage:"max WebSocket connections per IP address (progressive delay applied as count increases)"`
199
200 // Connection storm mitigation (adaptive, works with PID rate limiter)
201 MaxGlobalConnections int `env:"ORLY_MAX_GLOBAL_CONNECTIONS" default:"500" usage:"maximum total WebSocket connections before refusing new ones"`
202 ConnectionDelayMaxMs int `env:"ORLY_CONN_DELAY_MAX_MS" default:"2000" usage:"maximum delay in ms for new connections under load"`
203 GoroutineWarningCount int `env:"ORLY_GOROUTINE_WARNING" default:"5000" usage:"goroutine count at which connection acceptance slows down"`
204 GoroutineMaxCount int `env:"ORLY_GOROUTINE_MAX" default:"10000" usage:"goroutine count at which new connections are refused"`
205 MaxSubscriptions int `env:"ORLY_MAX_SUBSCRIPTIONS" default:"10000" usage:"maximum total active subscriptions (reduced to 1000 in emergency mode)"`
206
207 // HTTP guard (application-level bot blocking + rate limiting for Cloudron/nginx-less deployments)
208 HTTPGuardEnabled bool `env:"ORLY_HTTP_GUARD_ENABLED" default:"true" usage:"enable HTTP guard (bot blocking + per-IP rate limiting)"`
209 HTTPGuardRPM int `env:"ORLY_HTTP_GUARD_RPM" default:"120" usage:"max HTTP requests per minute per IP"`
210 HTTPGuardWSPerMin int `env:"ORLY_HTTP_GUARD_WS_PER_MIN" default:"10" usage:"max WebSocket upgrade requests per minute per IP"`
211 HTTPGuardBotBlock bool `env:"ORLY_HTTP_GUARD_BOT_BLOCK" default:"true" usage:"block known scraper/bot User-Agents (SemrushBot, AhrefsBot, GPTBot, etc.)"`
212
213 // Query result limits (prevents memory exhaustion from unbounded queries)
214 QueryResultLimit int `env:"ORLY_QUERY_RESULT_LIMIT" default:"256" usage:"max events returned per REQ filter (prevents unbounded memory usage, 0=unlimited)"`
215
216 // Adaptive rate limiting (PID-controlled)
217 RateLimitEnabled bool `env:"ORLY_RATE_LIMIT_ENABLED" default:"true" usage:"enable adaptive PID-controlled rate limiting for database operations"`
218 RateLimitTargetMB int `env:"ORLY_RATE_LIMIT_TARGET_MB" default:"0" usage:"target memory limit in MB (0=auto-detect: 66% of available, min 500MB)"`
219 RateLimitWriteKp float64 `env:"ORLY_RATE_LIMIT_WRITE_KP" default:"0.5" usage:"PID proportional gain for write operations"`
220 RateLimitWriteKi float64 `env:"ORLY_RATE_LIMIT_WRITE_KI" default:"0.1" usage:"PID integral gain for write operations"`
221 RateLimitWriteKd float64 `env:"ORLY_RATE_LIMIT_WRITE_KD" default:"0.05" usage:"PID derivative gain for write operations (filtered)"`
222 RateLimitReadKp float64 `env:"ORLY_RATE_LIMIT_READ_KP" default:"0.3" usage:"PID proportional gain for read operations"`
223 RateLimitReadKi float64 `env:"ORLY_RATE_LIMIT_READ_KI" default:"0.05" usage:"PID integral gain for read operations"`
224 RateLimitReadKd float64 `env:"ORLY_RATE_LIMIT_READ_KD" default:"0.02" usage:"PID derivative gain for read operations (filtered)"`
225 RateLimitMaxWriteMs int `env:"ORLY_RATE_LIMIT_MAX_WRITE_MS" default:"1000" usage:"maximum delay for write operations in milliseconds"`
226 RateLimitMaxReadMs int `env:"ORLY_RATE_LIMIT_MAX_READ_MS" default:"500" usage:"maximum delay for read operations in milliseconds"`
227 RateLimitWriteTarget float64 `env:"ORLY_RATE_LIMIT_WRITE_TARGET" default:"0.85" usage:"PID setpoint for writes (throttle when load exceeds this, 0.0-1.0)"`
228 RateLimitReadTarget float64 `env:"ORLY_RATE_LIMIT_READ_TARGET" default:"0.90" usage:"PID setpoint for reads (throttle when load exceeds this, 0.0-1.0)"`
229 RateLimitEmergencyThreshold float64 `env:"ORLY_RATE_LIMIT_EMERGENCY_THRESHOLD" default:"1.167" usage:"memory pressure ratio (target+1/6) to trigger emergency mode with aggressive throttling"`
230 RateLimitRecoveryThreshold float64 `env:"ORLY_RATE_LIMIT_RECOVERY_THRESHOLD" default:"0.833" usage:"memory pressure ratio (target-1/6) below which emergency mode exits (hysteresis)"`
231 RateLimitEmergencyMaxMs int `env:"ORLY_RATE_LIMIT_EMERGENCY_MAX_MS" default:"5000" usage:"maximum delay for writes in emergency mode (milliseconds)"`
232
233 // TLS configuration
234 TLSDomains []string `env:"ORLY_TLS_DOMAINS" usage:"comma-separated list of domains to respond to for TLS"`
235 Certs []string `env:"ORLY_CERTS" usage:"comma-separated list of paths to certificate root names (e.g., /path/to/cert will load /path/to/cert.pem and /path/to/cert.key)"`
236
237 // WireGuard VPN configuration (for secure bunker access)
238 WGEnabled bool `env:"ORLY_WG_ENABLED" default:"false" usage:"enable embedded WireGuard VPN server for private bunker access"`
239 WGPort int `env:"ORLY_WG_PORT" default:"51820" usage:"UDP port for WireGuard VPN server"`
240 WGEndpoint string `env:"ORLY_WG_ENDPOINT" usage:"public IP/domain for WireGuard endpoint (required if WG enabled)"`
241 WGNetwork string `env:"ORLY_WG_NETWORK" default:"10.73.0.0/16" usage:"WireGuard internal network CIDR"`
242
243 // NIP-46 Bunker configuration (remote signing service)
244 BunkerEnabled bool `env:"ORLY_BUNKER_ENABLED" default:"false" usage:"enable NIP-46 bunker signing service (requires WireGuard)"`
245 BunkerPort int `env:"ORLY_BUNKER_PORT" default:"3335" usage:"internal port for bunker WebSocket (only accessible via WireGuard)"`
246
247 // Tor hidden service configuration (subprocess mode - runs tor binary automatically)
248 TorEnabled bool `env:"ORLY_TOR_ENABLED" default:"true" usage:"enable Tor hidden service (spawns tor subprocess; disable with false if tor not installed)"`
249 TorPort int `env:"ORLY_TOR_PORT" default:"3336" usage:"internal port for Tor hidden service traffic"`
250 TorDataDir string `env:"ORLY_TOR_DATA_DIR" usage:"Tor data directory (default: $ORLY_DATA_DIR/tor)"`
251 TorBinary string `env:"ORLY_TOR_BINARY" default:"tor" usage:"path to tor binary (default: search in PATH)"`
252 TorSOCKS int `env:"ORLY_TOR_SOCKS" default:"0" usage:"SOCKS port for outbound Tor connections (0=disabled)"`
253
254 // Nostr Relay Connect (NRC) configuration - tunnel private relay through public relay
255 NRCEnabled bool `env:"ORLY_NRC_ENABLED" default:"true" usage:"enable NRC bridge to expose this relay through a public rendezvous relay"`
256 NRCRendezvousURL string `env:"ORLY_NRC_RENDEZVOUS_URL" usage:"WebSocket URL of the public relay to use as rendezvous point (e.g., wss://relay.example.com)"`
257 NRCAuthorizedKeys string `env:"ORLY_NRC_AUTHORIZED_KEYS" usage:"comma-separated list of authorized client pubkeys (hex) for secret-based auth"`
258 NRCSessionTimeout string `env:"ORLY_NRC_SESSION_TIMEOUT" default:"30m" usage:"inactivity timeout for NRC sessions"`
259
260 // Cluster replication configuration
261 ClusterPropagatePrivilegedEvents bool `env:"ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS" default:"true" usage:"propagate privileged events (DMs, gift wraps, etc.) to relay peers for replication"`
262
263 // Graph query configuration (NIP-XX)
264 GraphQueriesEnabled bool `env:"ORLY_GRAPH_QUERIES_ENABLED" default:"true" usage:"enable graph traversal queries (_graph filter extension)"`
265 GraphMaxDepth int `env:"ORLY_GRAPH_MAX_DEPTH" default:"16" usage:"maximum depth for graph traversal queries (1-16)"`
266 GraphMaxResults int `env:"ORLY_GRAPH_MAX_RESULTS" default:"10000" usage:"maximum pubkeys/events returned per graph query"`
267 GraphRateLimitRPM int `env:"ORLY_GRAPH_RATE_LIMIT_RPM" default:"60" usage:"graph queries per minute per connection (0=unlimited)"`
268
269 // Archive relay configuration (query augmentation from authoritative archives)
270 ArchiveEnabled bool `env:"ORLY_ARCHIVE_ENABLED" default:"false" usage:"enable archive relay query augmentation (fetch from archives, cache locally)"`
271 ArchiveRelays []string `env:"ORLY_ARCHIVE_RELAYS" default:"wss://archive.orly.dev/" usage:"comma-separated list of archive relay URLs for query augmentation"`
272 ArchiveTimeoutSec int `env:"ORLY_ARCHIVE_TIMEOUT_SEC" default:"30" usage:"timeout in seconds for archive relay queries"`
273 ArchiveCacheTTLHrs int `env:"ORLY_ARCHIVE_CACHE_TTL_HRS" default:"24" usage:"hours to cache query fingerprints to avoid repeated archive requests"`
274
275 // Storage management configuration (access-based garbage collection)
276 // TODO: GC implementation needs batch transaction handling to avoid Badger race conditions
277 // TODO: GC should use smaller batches with delays between transactions on large datasets
278 // TODO: GC deletion should be serialized or use transaction pools to prevent concurrent txn issues
279 MaxStorageBytes int64 `env:"ORLY_MAX_STORAGE_BYTES" default:"0" usage:"maximum storage in bytes (0=auto-detect 80%% of filesystem)"`
280 GCEnabled bool `env:"ORLY_GC_ENABLED" default:"false" usage:"enable continuous garbage collection based on access patterns (EXPERIMENTAL - may cause crashes under load)"`
281 GCIntervalSec int `env:"ORLY_GC_INTERVAL_SEC" default:"60" usage:"seconds between GC runs when storage exceeds limit"`
282 GCBatchSize int `env:"ORLY_GC_BATCH_SIZE" default:"1000" usage:"number of events to consider per GC run"`
283
284 // Email bridge configuration
285 BridgeEnabled bool `env:"ORLY_BRIDGE_ENABLED" default:"false" usage:"enable Nostr-Email bridge (Marmot DM to SMTP)"`
286 BridgeDomain string `env:"ORLY_BRIDGE_DOMAIN" usage:"email domain for the bridge (e.g., relay.example.com)"`
287 BridgeNSEC string `env:"ORLY_BRIDGE_NSEC" usage:"bridge identity nsec (default: use relay identity from database)"`
288 BridgeRelayURL string `env:"ORLY_BRIDGE_RELAY_URL" usage:"WebSocket relay URL for standalone mode (e.g., wss://relay.example.com)"`
289 BridgeSMTPPort int `env:"ORLY_BRIDGE_SMTP_PORT" default:"2525" usage:"SMTP server listen port"`
290 BridgeSMTPHost string `env:"ORLY_BRIDGE_SMTP_HOST" default:"0.0.0.0" usage:"SMTP server listen address"`
291 BridgeDataDir string `env:"ORLY_BRIDGE_DATA_DIR" usage:"bridge data directory (default: $ORLY_DATA_DIR/bridge)"`
292 BridgeDKIMKeyPath string `env:"ORLY_BRIDGE_DKIM_KEY" usage:"path to DKIM private key PEM file"`
293 BridgeDKIMSelector string `env:"ORLY_BRIDGE_DKIM_SELECTOR" default:"marmot" usage:"DKIM selector for DNS TXT record"`
294 BridgeNWCURI string `env:"ORLY_BRIDGE_NWC_URI" usage:"NWC connection string for subscription payments (falls back to ORLY_NWC_URI)"`
295 BridgeMonthlyPriceSats int64 `env:"ORLY_BRIDGE_MONTHLY_PRICE_SATS" default:"2100" usage:"price in sats for one month bridge subscription"`
296 BridgeComposeURL string `env:"ORLY_BRIDGE_COMPOSE_URL" usage:"public URL of the compose form (e.g., https://relay.example.com/compose)"`
297 BridgeSMTPRelayHost string `env:"ORLY_BRIDGE_SMTP_RELAY_HOST" usage:"SMTP smarthost for outbound delivery (e.g., smtp.migadu.com)"`
298 BridgeSMTPRelayPort int `env:"ORLY_BRIDGE_SMTP_RELAY_PORT" default:"587" usage:"SMTP smarthost port (587 for STARTTLS)"`
299 BridgeSMTPRelayUsername string `env:"ORLY_BRIDGE_SMTP_RELAY_USERNAME" usage:"SMTP smarthost AUTH username"`
300 BridgeSMTPRelayPassword string `env:"ORLY_BRIDGE_SMTP_RELAY_PASSWORD" usage:"SMTP smarthost AUTH password"`
301 BridgeACLGRPCServer string `env:"ORLY_BRIDGE_ACL_GRPC_SERVER" usage:"gRPC address of ACL server for paid subscription management"`
302 BridgeAliasPriceSats int64 `env:"ORLY_BRIDGE_ALIAS_PRICE_SATS" default:"4200" usage:"monthly price in sats for alias email (default 2x base price)"`
303 BridgeProfile string `env:"ORLY_BRIDGE_PROFILE" usage:"path to bridge profile template file (default: $BRIDGE_DATA_DIR/profile.txt)"`
304
305 // Smesh embedded web client
306 SmeshEnabled bool `env:"ORLY_SMESH_ENABLED" default:"false" usage:"enable embedded Smesh web client on a dedicated port"`
307 SmeshPort int `env:"ORLY_SMESH_PORT" default:"8088" usage:"port for the embedded Smesh web client"`
308
309 // ServeMode is set programmatically by the 'serve' subcommand to grant full owner
310 // access to all users (no env tag - internal use only)
311 ServeMode bool
312 }
313
314 // New creates and initializes a new configuration object for the relay
315 // application
316 //
317 // # Return Values
318 //
319 // - cfg: A pointer to the initialized configuration struct containing default
320 // or environment-provided values
321 //
322 // - err: An error object that is non-nil if any operation during
323 // initialization fails
324 //
325 // # Expected Behaviour:
326 //
327 // Initializes a new configuration instance by loading environment variables and
328 // checking for a .env file in the default configuration directory. Sets logging
329 // levels based on configuration values and returns the populated configuration
330 // or an error if any step fails
331 func New() (cfg *C, err error) {
332 cfg = &C{}
333 // Load .env file from config directory before parsing env vars.
334 // The app name determines the config dir: ~/.config/<AppName>/.env
335 // Real environment variables take precedence over .env values.
336 appName := os.Getenv("ORLY_APP_NAME")
337 if appName == "" {
338 appName = "ORLY"
339 }
340 loadDotEnv(filepath.Join(xdg.ConfigHome, appName, ".env"))
341 if err = env.Load(cfg, &env.Options{SliceSep: ","}); chk.T(err) {
342 if err != nil {
343 fmt.Fprintf(os.Stderr, "ERROR: %s\n\n", err)
344 }
345 PrintHelp(cfg, os.Stderr)
346 os.Exit(1)
347 }
348 if cfg.DataDir == "" || strings.Contains(cfg.DataDir, "~") {
349 cfg.DataDir = filepath.Join(xdg.DataHome, cfg.AppName)
350 }
351 if GetEnv() {
352 PrintEnv(cfg, os.Stdout)
353 os.Exit(0)
354 }
355 if HelpRequested() {
356 PrintHelp(cfg, os.Stderr)
357 os.Exit(0)
358 }
359 if cfg.LogToStdout {
360 lol.Writer = os.Stdout
361 }
362 // Initialize log buffer for web UI viewing
363 if cfg.LogBufferSize > 0 {
364 logbuffer.Init(cfg.LogBufferSize)
365 logbuffer.SetCurrentLevel(cfg.LogLevel)
366 lol.Writer = logbuffer.NewBufferedWriter(lol.Writer, logbuffer.GlobalBuffer)
367 // Reinitialize the loggers to use the new wrapped Writer
368 // The lol.Main logger is initialized in init() with os.Stderr directly,
369 // so we need to recreate it with the new Writer
370 l, c, e := lol.New(lol.Writer, 2)
371 lol.Main.Log = l
372 lol.Main.Check = c
373 lol.Main.Errorf = e
374 // Also update the log package convenience variables
375 log.F, log.E, log.W, log.I, log.D, log.T = l.F, l.E, l.W, l.I, l.D, l.T
376 }
377 lol.SetLogLevel(cfg.LogLevel)
378 return
379 }
380
381 // loadDotEnv reads a .env file and sets environment variables for any keys
382 // not already present in the real environment. This allows .env files to
383 // provide defaults while real environment variables take precedence.
384 func loadDotEnv(path string) {
385 f, err := os.Open(path)
386 if err != nil {
387 return // file doesn't exist or isn't readable, not an error
388 }
389 defer f.Close()
390
391 scanner := bufio.NewScanner(f)
392 for scanner.Scan() {
393 line := strings.TrimSpace(scanner.Text())
394 if line == "" || line[0] == '#' {
395 continue
396 }
397 k, v, ok := strings.Cut(line, "=")
398 if !ok {
399 continue
400 }
401 k = strings.TrimSpace(k)
402 v = strings.TrimSpace(v)
403 // Don't override real environment variables
404 if _, exists := os.LookupEnv(k); exists {
405 continue
406 }
407 os.Setenv(k, v)
408 }
409 }
410
411 // HelpRequested determines if the command line arguments indicate a request for help
412 //
413 // # Return Values
414 //
415 // - help: A boolean value indicating true if a help flag was detected in the
416 // command line arguments, false otherwise
417 //
418 // # Expected Behaviour
419 //
420 // The function checks the first command line argument for common help flags and
421 // returns true if any of them are present. Returns false if no help flag is found
422 func HelpRequested() (help bool) {
423 if len(os.Args) > 1 {
424 switch strings.ToLower(os.Args[1]) {
425 case "help", "-h", "--h", "-help", "--help", "?":
426 help = true
427 }
428 }
429 return
430 }
431
432 // GetEnv checks if the first command line argument is "env" and returns
433 // whether the environment configuration should be printed.
434 //
435 // # Return Values
436 //
437 // - requested: A boolean indicating true if the 'env' argument was
438 // provided, false otherwise.
439 //
440 // # Expected Behaviour
441 //
442 // The function returns true when the first command line argument is "env"
443 // (case-insensitive), signalling that the environment configuration should be
444 // printed. Otherwise, it returns false.
445 func GetEnv() (requested bool) {
446 if len(os.Args) > 1 {
447 switch strings.ToLower(os.Args[1]) {
448 case "env":
449 requested = true
450 }
451 }
452 return
453 }
454
455 // IdentityRequested checks if the first command line argument is "identity" and returns
456 // whether the relay identity should be printed and the program should exit.
457 //
458 // Return Values
459 // - requested: true if the 'identity' subcommand was provided, false otherwise.
460 func IdentityRequested() (requested bool) {
461 if len(os.Args) > 1 {
462 switch strings.ToLower(os.Args[1]) {
463 case "identity":
464 requested = true
465 }
466 }
467 return
468 }
469
470 // ServeRequested checks if the first command line argument is "serve" and returns
471 // whether the relay should start in ephemeral serve mode with RAM-based storage.
472 //
473 // Return Values
474 // - requested: true if the 'serve' subcommand was provided, false otherwise.
475 func ServeRequested() (requested bool) {
476 if len(os.Args) > 1 {
477 switch strings.ToLower(os.Args[1]) {
478 case "serve":
479 requested = true
480 }
481 }
482 return
483 }
484
485 // VersionRequested checks if the first command line argument is "version" and returns
486 // whether the version should be printed and the program should exit.
487 //
488 // Return Values
489 // - requested: true if the 'version' subcommand was provided, false otherwise.
490 func VersionRequested() (requested bool) {
491 if len(os.Args) > 1 {
492 switch strings.ToLower(os.Args[1]) {
493 case "version", "-v", "--v", "-version", "--version":
494 requested = true
495 }
496 }
497 return
498 }
499
500 // CuratingModeRequested checks if the first command line argument is "curatingmode"
501 // and returns the owner npub/hex pubkey if provided.
502 //
503 // Return Values
504 // - requested: true if the 'curatingmode' subcommand was provided
505 // - ownerKey: the npub or hex pubkey provided as the second argument (empty if not provided)
506 func CuratingModeRequested() (requested bool, ownerKey string) {
507 if len(os.Args) > 1 {
508 switch strings.ToLower(os.Args[1]) {
509 case "curatingmode":
510 requested = true
511 if len(os.Args) > 2 {
512 ownerKey = os.Args[2]
513 }
514 }
515 }
516 return
517 }
518
519 // MigrateRequested checks if the first command line argument is "migrate"
520 // and returns the migration parameters.
521 //
522 // Return Values
523 // - requested: true if the 'migrate' subcommand was provided
524 // - fromType: source database type (badger, bbolt, neo4j)
525 // - toType: destination database type
526 // - targetPath: optional target path for destination database
527 func MigrateRequested() (requested bool, fromType, toType, targetPath string) {
528 if len(os.Args) > 1 {
529 switch strings.ToLower(os.Args[1]) {
530 case "migrate":
531 requested = true
532 // Parse --from, --to, --target-path flags
533 for i := 2; i < len(os.Args); i++ {
534 arg := os.Args[i]
535 switch {
536 case strings.HasPrefix(arg, "--from="):
537 fromType = strings.TrimPrefix(arg, "--from=")
538 case strings.HasPrefix(arg, "--to="):
539 toType = strings.TrimPrefix(arg, "--to=")
540 case strings.HasPrefix(arg, "--target-path="):
541 targetPath = strings.TrimPrefix(arg, "--target-path=")
542 case arg == "--from" && i+1 < len(os.Args):
543 i++
544 fromType = os.Args[i]
545 case arg == "--to" && i+1 < len(os.Args):
546 i++
547 toType = os.Args[i]
548 case arg == "--target-path" && i+1 < len(os.Args):
549 i++
550 targetPath = os.Args[i]
551 }
552 }
553 }
554 }
555 return
556 }
557
558 // NRCRequested checks if the first command line argument is "nrc" and returns
559 // the NRC subcommand parameters.
560 //
561 // Return Values
562 // - requested: true if the 'nrc' subcommand was provided
563 // - subcommand: the NRC subcommand (generate, list, revoke)
564 // - args: additional arguments for the subcommand
565 func NRCRequested() (requested bool, subcommand string, args []string) {
566 if len(os.Args) > 1 {
567 switch strings.ToLower(os.Args[1]) {
568 case "nrc":
569 requested = true
570 if len(os.Args) > 2 {
571 subcommand = strings.ToLower(os.Args[2])
572 if len(os.Args) > 3 {
573 args = os.Args[3:]
574 }
575 }
576 }
577 }
578 return
579 }
580
581 // InitBrandingRequested checks if the first command line argument is "init-branding"
582 // and returns the target directory and style if provided.
583 //
584 // Return Values
585 // - requested: true if the 'init-branding' subcommand was provided
586 // - targetDir: optional target directory for branding files (default: ~/.config/ORLY/branding)
587 // - style: branding style ("orly" or "generic", default: "generic")
588 //
589 // Usage: orly init-branding [--style orly|generic] [path]
590 func InitBrandingRequested() (requested bool, targetDir, style string) {
591 style = "generic" // default to generic/white-label
592 if len(os.Args) > 1 {
593 switch strings.ToLower(os.Args[1]) {
594 case "init-branding":
595 requested = true
596 // Parse remaining arguments
597 for i := 2; i < len(os.Args); i++ {
598 arg := os.Args[i]
599 if arg == "--style" && i+1 < len(os.Args) {
600 style = strings.ToLower(os.Args[i+1])
601 i++ // skip next arg
602 } else if !strings.HasPrefix(arg, "-") {
603 targetDir = arg
604 }
605 }
606 }
607 }
608 return
609 }
610
611 // KV is a key/value pair.
612 type KV struct{ Key, Value string }
613
614 // KVSlice is a sortable slice of key/value pairs, designed for managing
615 // configuration data and enabling operations like merging and sorting based on
616 // keys.
617 type KVSlice []KV
618
619 func (kv KVSlice) Len() int { return len(kv) }
620 func (kv KVSlice) Less(i, j int) bool { return kv[i].Key < kv[j].Key }
621 func (kv KVSlice) Swap(i, j int) { kv[i], kv[j] = kv[j], kv[i] }
622
623 // Compose merges two KVSlice instances into a new slice where key-value pairs
624 // from the second slice override any duplicate keys from the first slice.
625 //
626 // # Parameters
627 //
628 // - kv2: The second KVSlice whose entries will be merged with the receiver.
629 //
630 // # Return Values
631 //
632 // - out: A new KVSlice containing all entries from both slices, with keys
633 // from kv2 taking precedence over keys from the receiver.
634 //
635 // # Expected Behaviour
636 //
637 // The method returns a new KVSlice that combines the contents of the receiver
638 // and kv2. If any key exists in both slices, the value from kv2 is used. The
639 // resulting slice remains sorted by keys as per the KVSlice implementation.
640 func (kv KVSlice) Compose(kv2 KVSlice) (out KVSlice) {
641 // duplicate the initial KVSlice
642 out = append(out, kv...)
643 out:
644 for i, p := range kv2 {
645 for j, q := range out {
646 // if the key is repeated, replace the value
647 if p.Key == q.Key {
648 out[j].Value = kv2[i].Value
649 continue out
650 }
651 }
652 out = append(out, p)
653 }
654 return
655 }
656
657 // EnvKV generates key/value pairs from a configuration object's struct tags
658 //
659 // # Parameters
660 //
661 // - cfg: A configuration object whose struct fields are processed for env tags
662 //
663 // # Return Values
664 //
665 // - m: A KVSlice containing key/value pairs derived from the config's env tags
666 //
667 // # Expected Behaviour
668 //
669 // Processes each field of the config object, extracting values tagged with
670 // "env" and converting them to strings. Skips fields without an "env" tag.
671 // Handles various value types including strings, integers, booleans, durations,
672 // and string slices by joining elements with commas.
673 func EnvKV(cfg any) (m KVSlice) {
674 t := reflect.TypeOf(cfg)
675 for i := 0; i < t.NumField(); i++ {
676 k := t.Field(i).Tag.Get("env")
677 v := reflect.ValueOf(cfg).Field(i).Interface()
678 var val string
679 switch v := v.(type) {
680 case string:
681 val = v
682 case int, bool, time.Duration:
683 val = fmt.Sprint(v)
684 case []string:
685 if len(v) > 0 {
686 val = strings.Join(v, ",")
687 }
688 }
689 // this can happen with embedded structs
690 if k == "" {
691 continue
692 }
693 m = append(m, KV{k, val})
694 }
695 return
696 }
697
698 // PrintEnv outputs sorted environment key/value pairs from a configuration object
699 // to the provided writer
700 //
701 // # Parameters
702 //
703 // - cfg: Pointer to the configuration object containing env tags
704 //
705 // - printer: Destination for the output, typically an io.Writer implementation
706 //
707 // # Expected Behaviour
708 //
709 // Outputs each environment variable derived from the config's struct tags in
710 // sorted order, formatted as "key=value\n" to the specified writer
711 func PrintEnv(cfg *C, printer io.Writer) {
712 kvs := EnvKV(*cfg)
713 sort.Sort(kvs)
714 for _, v := range kvs {
715 _, _ = fmt.Fprintf(printer, "%s=%s\n", v.Key, v.Value)
716 }
717 }
718
719 // PrintHelp prints help information including application version, environment
720 // variable configuration, and details about .env file handling to the provided
721 // writer
722 //
723 // # Parameters
724 //
725 // - cfg: Configuration object containing app name and config directory path
726 //
727 // - printer: Output destination for the help text
728 //
729 // # Expected Behaviour
730 //
731 // Prints application name and version followed by environment variable
732 // configuration details, explains .env file behaviour including automatic
733 // loading and custom path options, and displays current configuration values
734 // using PrintEnv. Outputs all information to the specified writer
735 func PrintHelp(cfg *C, printer io.Writer) {
736 _, _ = fmt.Fprintf(
737 printer,
738 "%s %s\n\n", cfg.AppName, version.V,
739 )
740 _, _ = fmt.Fprintf(
741 printer,
742 `Usage: %s [env|help|identity|init-branding|migrate|serve|version]
743
744 - env: print environment variables configuring %s
745 - help: print this help text
746 - identity: print the relay identity secret and public key
747 - init-branding: create branding directory with default assets and CSS templates
748 Example: %s init-branding [--style generic|orly] [/path/to/branding]
749 Styles: generic (default) - neutral white-label branding
750 orly - ORLY-branded assets
751 Default location: ~/.config/%s/branding
752 - migrate: migrate data between database backends
753 Example: %s migrate --from badger --to neo4j
754 - serve: start ephemeral relay with RAM-based storage at /dev/shm/orlyserve
755 listening on 0.0.0.0:10547 with 'none' ACL mode (open relay)
756 useful for testing and benchmarking
757 - version: print version and exit (also: -v, --v, -version, --version)
758
759 `,
760 cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName, cfg.AppName,
761 )
762 _, _ = fmt.Fprintf(
763 printer,
764 "Environment variables that configure %s:\n\n", cfg.AppName,
765 )
766 env.Usage(cfg, printer, &env.Options{SliceSep: ","})
767 fmt.Fprintf(printer, "\ncurrent configuration:\n\n")
768 PrintEnv(cfg, printer)
769 fmt.Fprintln(printer)
770 }
771
772 // GetDatabaseConfigValues returns the database configuration values as individual fields.
773 // This avoids circular imports with pkg/database while allowing main.go to construct
774 // a database.DatabaseConfig with the correct type.
775 func (cfg *C) GetDatabaseConfigValues() (
776 dataDir, logLevel string,
777 blockCacheMB, indexCacheMB, queryCacheSizeMB int,
778 queryCacheMaxAge time.Duration,
779 queryCacheDisabled bool,
780 serialCachePubkeys, serialCacheEventIds int,
781 zstdLevel int,
782 neo4jURI, neo4jUser, neo4jPassword string,
783 neo4jMaxConnPoolSize, neo4jFetchSize, neo4jMaxTxRetrySeconds, neo4jQueryResultLimit int,
784 ) {
785 // Parse query cache max age from string to duration
786 queryCacheMaxAge = 5 * time.Minute // Default
787 if cfg.QueryCacheMaxAge != "" {
788 if duration, err := time.ParseDuration(cfg.QueryCacheMaxAge); err == nil {
789 queryCacheMaxAge = duration
790 }
791 }
792
793 return cfg.DataDir, cfg.DBLogLevel,
794 cfg.DBBlockCacheMB, cfg.DBIndexCacheMB, cfg.QueryCacheSizeMB,
795 queryCacheMaxAge,
796 cfg.QueryCacheDisabled,
797 cfg.SerialCachePubkeys, cfg.SerialCacheEventIds,
798 cfg.DBZSTDLevel,
799 cfg.Neo4jURI, cfg.Neo4jUser, cfg.Neo4jPassword,
800 cfg.Neo4jMaxConnPoolSize, cfg.Neo4jFetchSize, cfg.Neo4jMaxTxRetrySeconds, cfg.Neo4jQueryResultLimit
801 }
802
803 // GetRateLimitConfigValues returns the rate limiting configuration values.
804 // This avoids circular imports with pkg/ratelimit while allowing main.go to construct
805 // a ratelimit.Config with the correct type.
806 func (cfg *C) GetRateLimitConfigValues() (
807 enabled bool,
808 targetMB int,
809 writeKp, writeKi, writeKd float64,
810 readKp, readKi, readKd float64,
811 maxWriteMs, maxReadMs int,
812 writeTarget, readTarget float64,
813 emergencyThreshold, recoveryThreshold float64,
814 emergencyMaxMs int,
815 ) {
816 return cfg.RateLimitEnabled,
817 cfg.RateLimitTargetMB,
818 cfg.RateLimitWriteKp, cfg.RateLimitWriteKi, cfg.RateLimitWriteKd,
819 cfg.RateLimitReadKp, cfg.RateLimitReadKi, cfg.RateLimitReadKd,
820 cfg.RateLimitMaxWriteMs, cfg.RateLimitMaxReadMs,
821 cfg.RateLimitWriteTarget, cfg.RateLimitReadTarget,
822 cfg.RateLimitEmergencyThreshold, cfg.RateLimitRecoveryThreshold,
823 cfg.RateLimitEmergencyMaxMs
824 }
825
826 // GetWireGuardConfigValues returns the WireGuard VPN configuration values.
827 // This avoids circular imports with pkg/wireguard while allowing main.go to construct
828 // the WireGuard server configuration.
829 func (cfg *C) GetWireGuardConfigValues() (
830 enabled bool,
831 port int,
832 endpoint string,
833 network string,
834 bunkerEnabled bool,
835 bunkerPort int,
836 ) {
837 return cfg.WGEnabled,
838 cfg.WGPort,
839 cfg.WGEndpoint,
840 cfg.WGNetwork,
841 cfg.BunkerEnabled,
842 cfg.BunkerPort
843 }
844
845 // GetArchiveConfigValues returns the archive relay configuration values.
846 // This avoids circular imports with pkg/archive while allowing main.go to construct
847 // the archive manager configuration.
848 func (cfg *C) GetArchiveConfigValues() (
849 enabled bool,
850 relays []string,
851 timeoutSec int,
852 cacheTTLHrs int,
853 ) {
854 return cfg.ArchiveEnabled,
855 cfg.ArchiveRelays,
856 cfg.ArchiveTimeoutSec,
857 cfg.ArchiveCacheTTLHrs
858 }
859
860 // GetStorageConfigValues returns the storage management configuration values.
861 // This avoids circular imports with pkg/storage while allowing main.go to construct
862 // the garbage collector and access tracker configuration.
863 func (cfg *C) GetStorageConfigValues() (
864 maxStorageBytes int64,
865 gcEnabled bool,
866 gcIntervalSec int,
867 gcBatchSize int,
868 ) {
869 return cfg.MaxStorageBytes,
870 cfg.GCEnabled,
871 cfg.GCIntervalSec,
872 cfg.GCBatchSize
873 }
874
875 // GetTorConfigValues returns the Tor hidden service configuration values.
876 // This avoids circular imports with pkg/tor while allowing main.go to construct
877 // the Tor service configuration.
878 func (cfg *C) GetTorConfigValues() (
879 enabled bool,
880 port int,
881 dataDir string,
882 binary string,
883 socksPort int,
884 ) {
885 dataDir = cfg.TorDataDir
886 if dataDir == "" {
887 dataDir = filepath.Join(cfg.DataDir, "tor")
888 }
889 return cfg.TorEnabled,
890 cfg.TorPort,
891 dataDir,
892 cfg.TorBinary,
893 cfg.TorSOCKS
894 }
895
896 // GetGraphConfigValues returns the graph query configuration values.
897 // This avoids circular imports with pkg/protocol/graph while allowing main.go
898 // to construct the graph executor configuration.
899 func (cfg *C) GetGraphConfigValues() (
900 enabled bool,
901 maxDepth int,
902 maxResults int,
903 rateLimitRPM int,
904 ) {
905 maxDepth = cfg.GraphMaxDepth
906 if maxDepth < 1 {
907 maxDepth = 1
908 }
909 if maxDepth > 16 {
910 maxDepth = 16
911 }
912 return cfg.GraphQueriesEnabled,
913 maxDepth,
914 cfg.GraphMaxResults,
915 cfg.GraphRateLimitRPM
916 }
917
918 // GetNRCConfigValues returns the NRC (Nostr Relay Connect) configuration values.
919 // This avoids circular imports with pkg/protocol/nrc while allowing main.go to construct
920 // the NRC bridge configuration.
921 func (cfg *C) GetNRCConfigValues() (
922 enabled bool,
923 rendezvousURL string,
924 authorizedKeys []string,
925 sessionTimeout time.Duration,
926 ) {
927 // Parse session timeout
928 sessionTimeout = 30 * time.Minute // Default
929 if cfg.NRCSessionTimeout != "" {
930 if d, err := time.ParseDuration(cfg.NRCSessionTimeout); err == nil {
931 sessionTimeout = d
932 }
933 }
934
935 // Parse authorized keys
936 if cfg.NRCAuthorizedKeys != "" {
937 keys := strings.Split(cfg.NRCAuthorizedKeys, ",")
938 for _, k := range keys {
939 k = strings.TrimSpace(k)
940 if k != "" {
941 authorizedKeys = append(authorizedKeys, k)
942 }
943 }
944 }
945
946 // Use explicit rendezvous URL if set, otherwise use relay's own address for self-rendezvous
947 rendezvousURL = cfg.NRCRendezvousURL
948 if rendezvousURL == "" && len(cfg.RelayAddresses) > 0 {
949 rendezvousURL = cfg.RelayAddresses[0]
950 }
951
952 return cfg.NRCEnabled,
953 rendezvousURL,
954 authorizedKeys,
955 sessionTimeout
956 }
957
958 // GetFollowsThrottleConfigValues returns the progressive throttle configuration values
959 // for the follows ACL mode. This allows non-followed users to write with increasing delay.
960 func (cfg *C) GetFollowsThrottleConfigValues() (
961 enabled bool,
962 perEvent time.Duration,
963 maxDelay time.Duration,
964 ) {
965 return cfg.FollowsThrottleEnabled,
966 cfg.FollowsThrottlePerEvent,
967 cfg.FollowsThrottleMaxDelay
968 }
969
970 // GetSocialConfigValues returns the social ACL mode configuration values.
971 func (cfg *C) GetSocialConfigValues() (
972 d2Increment, d2Max, d3Increment, d3Max, outsiderIncrement, outsiderMax time.Duration,
973 wotMaxDepth int,
974 wotRefresh time.Duration,
975 ) {
976 return cfg.SocialThrottleD2Increment,
977 cfg.SocialThrottleD2Max,
978 cfg.SocialThrottleD3Increment,
979 cfg.SocialThrottleD3Max,
980 cfg.SocialThrottleOutsiderIncrement,
981 cfg.SocialThrottleOutsiderMax,
982 cfg.SocialWoTMaxDepth,
983 cfg.SocialWoTRefreshInterval
984 }
985
986 // GetGRPCConfigValues returns the gRPC database client configuration values.
987 // This avoids circular imports with pkg/database/grpc while allowing main.go to construct
988 // the gRPC client configuration.
989 func (cfg *C) GetGRPCConfigValues() (
990 serverAddress string,
991 connectTimeout time.Duration,
992 ) {
993 return cfg.GRPCServerAddress,
994 cfg.GRPCConnectTimeout
995 }
996
997 // GetGRPCACLConfigValues returns the gRPC ACL client configuration values.
998 // This avoids circular imports with pkg/acl/grpc while allowing main.go to construct
999 // the gRPC ACL client configuration.
1000 func (cfg *C) GetGRPCACLConfigValues() (
1001 aclType string,
1002 serverAddress string,
1003 connectTimeout time.Duration,
1004 ) {
1005 return cfg.ACLType,
1006 cfg.GRPCACLServerAddress,
1007 cfg.GRPCACLConnectTimeout
1008 }
1009
1010 // GetGRPCSyncConfigValues returns the gRPC sync client configuration values.
1011 // This avoids circular imports with pkg/sync/*/grpc packages while allowing main.go
1012 // to construct the gRPC sync client configurations.
1013 func (cfg *C) GetGRPCSyncConfigValues() (
1014 syncType string,
1015 distributedAddress string,
1016 clusterAddress string,
1017 relayGroupAddress string,
1018 negentropyAddress string,
1019 connectTimeout time.Duration,
1020 negentropyEnabled bool,
1021 ) {
1022 return cfg.SyncType,
1023 cfg.GRPCSyncDistributedAddress,
1024 cfg.GRPCSyncClusterAddress,
1025 cfg.GRPCSyncRelayGroupAddress,
1026 cfg.GRPCSyncNegentropyAddress,
1027 cfg.GRPCSyncConnectTimeout,
1028 cfg.NegentropyEnabled
1029 }
1030
1031 // GetBridgeConfigValues returns the email bridge configuration values.
1032 // This avoids circular imports with pkg/bridge while allowing main.go to construct
1033 // the bridge configuration.
1034 func (cfg *C) GetBridgeConfigValues() (
1035 enabled bool,
1036 domain string,
1037 nsec string,
1038 relayURL string,
1039 smtpPort int,
1040 smtpHost string,
1041 dataDir string,
1042 dkimKeyPath string,
1043 dkimSelector string,
1044 nwcURI string,
1045 monthlyPriceSats int64,
1046 composeURL string,
1047 smtpRelayHost string,
1048 smtpRelayPort int,
1049 smtpRelayUsername string,
1050 smtpRelayPassword string,
1051 aclGRPCServer string,
1052 aliasPriceSats int64,
1053 profilePath string,
1054 ) {
1055 dataDir = cfg.BridgeDataDir
1056 if dataDir == "" {
1057 dataDir = filepath.Join(cfg.DataDir, "bridge")
1058 }
1059 // Fall back to relay NWC URI if bridge-specific not set
1060 nwcURI = cfg.BridgeNWCURI
1061 if nwcURI == "" {
1062 nwcURI = cfg.NWCUri
1063 }
1064 return cfg.BridgeEnabled,
1065 cfg.BridgeDomain,
1066 cfg.BridgeNSEC,
1067 cfg.BridgeRelayURL,
1068 cfg.BridgeSMTPPort,
1069 cfg.BridgeSMTPHost,
1070 dataDir,
1071 cfg.BridgeDKIMKeyPath,
1072 cfg.BridgeDKIMSelector,
1073 nwcURI,
1074 cfg.BridgeMonthlyPriceSats,
1075 cfg.BridgeComposeURL,
1076 cfg.BridgeSMTPRelayHost,
1077 cfg.BridgeSMTPRelayPort,
1078 cfg.BridgeSMTPRelayUsername,
1079 cfg.BridgeSMTPRelayPassword,
1080 cfg.BridgeACLGRPCServer,
1081 cfg.BridgeAliasPriceSats,
1082 func() string {
1083 if cfg.BridgeProfile != "" {
1084 return cfg.BridgeProfile
1085 }
1086 return filepath.Join(dataDir, "profile.txt")
1087 }()
1088 }
1089