diff options
| author | bndw <ben@bdw.to> | 2026-02-14 10:17:54 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-14 10:17:54 -0800 |
| commit | 702fa6c37b9f74e75404a0ea8e6f9023841143de (patch) | |
| tree | a3d686b0252c0c889696dc745b37810af73e46b7 | |
| parent | 606e0a3329a3534a00889eee19c25e7d432f7d2d (diff) | |
refactor: remove frivolous comments from auth and config
Removed ~100 lines of obvious comments that just repeated what the code does.
Kept only comments that add clarity or valuable detail.
| -rw-r--r-- | internal/auth/interceptor.go | 78 | ||||
| -rw-r--r-- | internal/config/config.go | 53 |
2 files changed, 16 insertions, 115 deletions
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go index d394102..42c2688 100644 --- a/internal/auth/interceptor.go +++ b/internal/auth/interceptor.go | |||
| @@ -11,52 +11,28 @@ import ( | |||
| 11 | "google.golang.org/grpc/status" | 11 | "google.golang.org/grpc/status" |
| 12 | ) | 12 | ) |
| 13 | 13 | ||
| 14 | // contextKey is a custom type for context keys to avoid collisions. | ||
| 15 | type contextKey string | 14 | type contextKey string |
| 16 | 15 | ||
| 17 | const ( | 16 | const ( |
| 18 | // pubkeyContextKey is the key for storing the authenticated pubkey in context. | ||
| 19 | pubkeyContextKey contextKey = "nostr-pubkey" | 17 | pubkeyContextKey contextKey = "nostr-pubkey" |
| 20 | ) | 18 | ) |
| 21 | 19 | ||
| 22 | // InterceptorOptions configures the authentication interceptor behavior. | ||
| 23 | type InterceptorOptions struct { | 20 | type InterceptorOptions struct { |
| 24 | // Read configures authentication for read operations (Subscribe, QueryEvents, etc.) | 21 | Read OperationAuthConfig |
| 25 | Read OperationAuthConfig | 22 | Write OperationAuthConfig |
| 26 | |||
| 27 | // Write configures authentication for write operations (PublishEvent, PublishBatch) | ||
| 28 | Write OperationAuthConfig | ||
| 29 | |||
| 30 | // TimestampWindow is the maximum age of auth events in seconds. | ||
| 31 | // Events older than this are rejected to prevent replay attacks. | ||
| 32 | // Default: 60 seconds | ||
| 33 | TimestampWindow int64 | 23 | TimestampWindow int64 |
| 34 | |||
| 35 | // ValidatePayload checks the payload hash tag if present. | ||
| 36 | // Default: false | ||
| 37 | ValidatePayload bool | 24 | ValidatePayload bool |
| 38 | 25 | SkipMethods []string | |
| 39 | // SkipMethods is a list of gRPC methods that bypass authentication. | ||
| 40 | // Useful for public endpoints like health checks or relay info. | ||
| 41 | // Example: []string{"/grpc.health.v1.Health/Check"} | ||
| 42 | // Default: nil (authenticate all methods) | ||
| 43 | SkipMethods []string | ||
| 44 | } | 26 | } |
| 45 | 27 | ||
| 46 | // OperationAuthConfig configures auth for a specific operation type. | 28 | // OperationAuthConfig configures auth for read or write operations. |
| 29 | // Three states: disabled (allow all), enabled with empty list (require auth), | ||
| 30 | // enabled with npubs (whitelist only). Npubs normalized to hex at load time. | ||
| 47 | type OperationAuthConfig struct { | 31 | type OperationAuthConfig struct { |
| 48 | // Enabled determines if auth is required. | 32 | Enabled bool |
| 49 | // false = no auth, allow all | ||
| 50 | // true = auth required | ||
| 51 | Enabled bool | ||
| 52 | |||
| 53 | // AllowedNpubs is an optional whitelist (hex format, normalized from npub at config load). | ||
| 54 | // If Enabled=true && AllowedNpubs=[]: any valid signature accepted | ||
| 55 | // If Enabled=true && AllowedNpubs=[...]: only whitelisted npubs accepted | ||
| 56 | AllowedNpubs []string | 33 | AllowedNpubs []string |
| 57 | } | 34 | } |
| 58 | 35 | ||
| 59 | // DefaultInterceptorOptions returns the default configuration. | ||
| 60 | func DefaultInterceptorOptions() *InterceptorOptions { | 36 | func DefaultInterceptorOptions() *InterceptorOptions { |
| 61 | return &InterceptorOptions{ | 37 | return &InterceptorOptions{ |
| 62 | Read: OperationAuthConfig{ | 38 | Read: OperationAuthConfig{ |
| @@ -73,19 +49,16 @@ func DefaultInterceptorOptions() *InterceptorOptions { | |||
| 73 | } | 49 | } |
| 74 | } | 50 | } |
| 75 | 51 | ||
| 76 | // NostrUnaryInterceptor creates a gRPC unary interceptor for NIP-98 authentication. | ||
| 77 | func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor { | 52 | func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor { |
| 78 | if opts == nil { | 53 | if opts == nil { |
| 79 | opts = DefaultInterceptorOptions() | 54 | opts = DefaultInterceptorOptions() |
| 80 | } | 55 | } |
| 81 | 56 | ||
| 82 | return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { | 57 | return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { |
| 83 | // Check if this method should skip auth | ||
| 84 | if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { | 58 | if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { |
| 85 | return handler(ctx, req) | 59 | return handler(ctx, req) |
| 86 | } | 60 | } |
| 87 | 61 | ||
| 88 | // Check if auth is required for this operation type | ||
| 89 | var authRequired bool | 62 | var authRequired bool |
| 90 | if isWriteMethod(info.FullMethod) { | 63 | if isWriteMethod(info.FullMethod) { |
| 91 | authRequired = opts.Write.Enabled | 64 | authRequired = opts.Write.Enabled |
| @@ -93,37 +66,31 @@ func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor | |||
| 93 | authRequired = opts.Read.Enabled | 66 | authRequired = opts.Read.Enabled |
| 94 | } | 67 | } |
| 95 | 68 | ||
| 96 | // If auth not required, skip validation | ||
| 97 | if !authRequired { | 69 | if !authRequired { |
| 98 | return handler(ctx, req) | 70 | return handler(ctx, req) |
| 99 | } | 71 | } |
| 100 | 72 | ||
| 101 | // Extract and validate auth | ||
| 102 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) | 73 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) |
| 103 | if err != nil { | 74 | if err != nil { |
| 104 | return nil, status.Error(codes.Unauthenticated, err.Error()) | 75 | return nil, status.Error(codes.Unauthenticated, err.Error()) |
| 105 | } | 76 | } |
| 106 | 77 | ||
| 107 | // Add pubkey to context for handlers | ||
| 108 | ctx = context.WithValue(ctx, pubkeyContextKey, pubkey) | 78 | ctx = context.WithValue(ctx, pubkeyContextKey, pubkey) |
| 109 | 79 | ||
| 110 | return handler(ctx, req) | 80 | return handler(ctx, req) |
| 111 | } | 81 | } |
| 112 | } | 82 | } |
| 113 | 83 | ||
| 114 | // NostrStreamInterceptor creates a gRPC stream interceptor for NIP-98 authentication. | ||
| 115 | func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerInterceptor { | 84 | func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerInterceptor { |
| 116 | if opts == nil { | 85 | if opts == nil { |
| 117 | opts = DefaultInterceptorOptions() | 86 | opts = DefaultInterceptorOptions() |
| 118 | } | 87 | } |
| 119 | 88 | ||
| 120 | return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { | 89 | return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { |
| 121 | // Check if this method should skip auth | ||
| 122 | if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { | 90 | if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { |
| 123 | return handler(srv, ss) | 91 | return handler(srv, ss) |
| 124 | } | 92 | } |
| 125 | 93 | ||
| 126 | // Check if auth is required for this operation type | ||
| 127 | var authRequired bool | 94 | var authRequired bool |
| 128 | if isWriteMethod(info.FullMethod) { | 95 | if isWriteMethod(info.FullMethod) { |
| 129 | authRequired = opts.Write.Enabled | 96 | authRequired = opts.Write.Enabled |
| @@ -131,19 +98,16 @@ func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerIntercept | |||
| 131 | authRequired = opts.Read.Enabled | 98 | authRequired = opts.Read.Enabled |
| 132 | } | 99 | } |
| 133 | 100 | ||
| 134 | // If auth not required, skip validation | ||
| 135 | if !authRequired { | 101 | if !authRequired { |
| 136 | return handler(srv, ss) | 102 | return handler(srv, ss) |
| 137 | } | 103 | } |
| 138 | 104 | ||
| 139 | // Extract and validate auth | ||
| 140 | ctx := ss.Context() | 105 | ctx := ss.Context() |
| 141 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) | 106 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) |
| 142 | if err != nil { | 107 | if err != nil { |
| 143 | return status.Error(codes.Unauthenticated, err.Error()) | 108 | return status.Error(codes.Unauthenticated, err.Error()) |
| 144 | } | 109 | } |
| 145 | 110 | ||
| 146 | // Wrap stream with authenticated context | ||
| 147 | wrappedStream := &authenticatedStream{ | 111 | wrappedStream := &authenticatedStream{ |
| 148 | ServerStream: ss, | 112 | ServerStream: ss, |
| 149 | ctx: context.WithValue(ctx, pubkeyContextKey, pubkey), | 113 | ctx: context.WithValue(ctx, pubkeyContextKey, pubkey), |
| @@ -153,8 +117,7 @@ func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerIntercept | |||
| 153 | } | 117 | } |
| 154 | } | 118 | } |
| 155 | 119 | ||
| 156 | // authenticatedStream wraps a ServerStream with an authenticated context. | 120 | type authenticatedStream struct{ |
| 157 | type authenticatedStream struct { | ||
| 158 | grpc.ServerStream | 121 | grpc.ServerStream |
| 159 | ctx context.Context | 122 | ctx context.Context |
| 160 | } | 123 | } |
| @@ -163,45 +126,34 @@ func (s *authenticatedStream) Context() context.Context { | |||
| 163 | return s.ctx | 126 | return s.ctx |
| 164 | } | 127 | } |
| 165 | 128 | ||
| 166 | // validateAuthFromContext extracts and validates the NIP-98 auth event from the context. | ||
| 167 | func validateAuthFromContext(ctx context.Context, method string, opts *InterceptorOptions) (string, error) { | 129 | func validateAuthFromContext(ctx context.Context, method string, opts *InterceptorOptions) (string, error) { |
| 168 | // Extract metadata from context | ||
| 169 | md, ok := metadata.FromIncomingContext(ctx) | 130 | md, ok := metadata.FromIncomingContext(ctx) |
| 170 | if !ok { | 131 | if !ok { |
| 171 | return "", fmt.Errorf("missing metadata") | 132 | return "", fmt.Errorf("missing metadata") |
| 172 | } | 133 | } |
| 173 | 134 | ||
| 174 | // Get authorization header | ||
| 175 | authHeaders := md.Get("authorization") | 135 | authHeaders := md.Get("authorization") |
| 176 | if len(authHeaders) == 0 { | 136 | if len(authHeaders) == 0 { |
| 177 | return "", fmt.Errorf("missing authorization header") | 137 | return "", fmt.Errorf("missing authorization header") |
| 178 | } | 138 | } |
| 179 | 139 | ||
| 180 | authHeader := authHeaders[0] | 140 | event, err := ParseAuthHeader(authHeaders[0]) |
| 181 | |||
| 182 | // Parse the auth event | ||
| 183 | event, err := ParseAuthHeader(authHeader) | ||
| 184 | if err != nil { | 141 | if err != nil { |
| 185 | return "", fmt.Errorf("invalid auth header: %w", err) | 142 | return "", fmt.Errorf("invalid auth header: %w", err) |
| 186 | } | 143 | } |
| 187 | 144 | ||
| 188 | // Validate the event | ||
| 189 | validationOpts := ValidationOptions{ | 145 | validationOpts := ValidationOptions{ |
| 190 | TimestampWindow: opts.TimestampWindow, | 146 | TimestampWindow: opts.TimestampWindow, |
| 191 | ValidatePayload: opts.ValidatePayload, | 147 | ValidatePayload: opts.ValidatePayload, |
| 192 | ExpectedMethod: "POST", // gRPC always uses POST | 148 | ExpectedMethod: "POST", |
| 193 | // Note: We don't validate URI here because the full URI isn't easily | ||
| 194 | // available in the interceptor context. The method name is validated instead. | ||
| 195 | } | 149 | } |
| 196 | 150 | ||
| 197 | if err := ValidateAuthEvent(event, validationOpts); err != nil { | 151 | if err := ValidateAuthEvent(event, validationOpts); err != nil { |
| 198 | return "", fmt.Errorf("invalid auth event: %w", err) | 152 | return "", fmt.Errorf("invalid auth event: %w", err) |
| 199 | } | 153 | } |
| 200 | 154 | ||
| 201 | // Extract pubkey | ||
| 202 | pubkey := ExtractPubkey(event) | 155 | pubkey := ExtractPubkey(event) |
| 203 | 156 | ||
| 204 | // Get the operation config based on method type | ||
| 205 | var opConfig OperationAuthConfig | 157 | var opConfig OperationAuthConfig |
| 206 | if isWriteMethod(method) { | 158 | if isWriteMethod(method) { |
| 207 | opConfig = opts.Write | 159 | opConfig = opts.Write |
| @@ -209,7 +161,6 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept | |||
| 209 | opConfig = opts.Read | 161 | opConfig = opts.Read |
| 210 | } | 162 | } |
| 211 | 163 | ||
| 212 | // Check whitelist if configured | ||
| 213 | if len(opConfig.AllowedNpubs) > 0 { | 164 | if len(opConfig.AllowedNpubs) > 0 { |
| 214 | if !contains(opConfig.AllowedNpubs, pubkey) { | 165 | if !contains(opConfig.AllowedNpubs, pubkey) { |
| 215 | if isWriteMethod(method) { | 166 | if isWriteMethod(method) { |
| @@ -219,18 +170,13 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept | |||
| 219 | } | 170 | } |
| 220 | } | 171 | } |
| 221 | 172 | ||
| 222 | // No whitelist or pubkey in whitelist - allow | ||
| 223 | return pubkey, nil | 173 | return pubkey, nil |
| 224 | } | 174 | } |
| 225 | 175 | ||
| 226 | // isWriteMethod determines if a gRPC method is a write operation. | ||
| 227 | // Write: PublishEvent, PublishBatch | ||
| 228 | // Read: Subscribe, Unsubscribe, QueryEvents, CountEvents | ||
| 229 | func isWriteMethod(method string) bool { | 176 | func isWriteMethod(method string) bool { |
| 230 | return strings.Contains(method, "/PublishEvent") || strings.Contains(method, "/PublishBatch") | 177 | return strings.Contains(method, "/PublishEvent") || strings.Contains(method, "/PublishBatch") |
| 231 | } | 178 | } |
| 232 | 179 | ||
| 233 | // shouldSkipAuth checks if a method should bypass authentication. | ||
| 234 | func shouldSkipAuth(method string, skipMethods []string) bool { | 180 | func shouldSkipAuth(method string, skipMethods []string) bool { |
| 235 | for _, skip := range skipMethods { | 181 | for _, skip := range skipMethods { |
| 236 | if skip == method { | 182 | if skip == method { |
| @@ -240,7 +186,6 @@ func shouldSkipAuth(method string, skipMethods []string) bool { | |||
| 240 | return false | 186 | return false |
| 241 | } | 187 | } |
| 242 | 188 | ||
| 243 | // contains checks if a slice contains a string. | ||
| 244 | func contains(slice []string, item string) bool { | 189 | func contains(slice []string, item string) bool { |
| 245 | for _, s := range slice { | 190 | for _, s := range slice { |
| 246 | if s == item { | 191 | if s == item { |
| @@ -250,14 +195,11 @@ func contains(slice []string, item string) bool { | |||
| 250 | return false | 195 | return false |
| 251 | } | 196 | } |
| 252 | 197 | ||
| 253 | // PubkeyFromContext retrieves the authenticated pubkey from the context. | ||
| 254 | // Returns the pubkey and true if authentication was successful, or empty string and false otherwise. | ||
| 255 | func PubkeyFromContext(ctx context.Context) (string, bool) { | 198 | func PubkeyFromContext(ctx context.Context) (string, bool) { |
| 256 | pubkey, ok := ctx.Value(pubkeyContextKey).(string) | 199 | pubkey, ok := ctx.Value(pubkeyContextKey).(string) |
| 257 | return pubkey, ok | 200 | return pubkey, ok |
| 258 | } | 201 | } |
| 259 | 202 | ||
| 260 | // RequireAuth is a helper that extracts the pubkey and returns an error if not authenticated. | ||
| 261 | func RequireAuth(ctx context.Context) (string, error) { | 203 | func RequireAuth(ctx context.Context) (string, error) { |
| 262 | pubkey, ok := PubkeyFromContext(ctx) | 204 | pubkey, ok := PubkeyFromContext(ctx) |
| 263 | if !ok || pubkey == "" { | 205 | if !ok || pubkey == "" { |
diff --git a/internal/config/config.go b/internal/config/config.go index 294510d..fc392b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go | |||
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | "gopkg.in/yaml.v3" | 10 | "gopkg.in/yaml.v3" |
| 11 | ) | 11 | ) |
| 12 | 12 | ||
| 13 | // Config holds all configuration for the relay. | ||
| 14 | type Config struct { | 13 | type Config struct { |
| 15 | Server ServerConfig `yaml:"server"` | 14 | Server ServerConfig `yaml:"server"` |
| 16 | Database DatabaseConfig `yaml:"database"` | 15 | Database DatabaseConfig `yaml:"database"` |
| @@ -21,7 +20,6 @@ type Config struct { | |||
| 21 | Storage StorageConfig `yaml:"storage"` | 20 | Storage StorageConfig `yaml:"storage"` |
| 22 | } | 21 | } |
| 23 | 22 | ||
| 24 | // ServerConfig holds server configuration. | ||
| 25 | type ServerConfig struct { | 23 | type ServerConfig struct { |
| 26 | GrpcAddr string `yaml:"grpc_addr"` | 24 | GrpcAddr string `yaml:"grpc_addr"` |
| 27 | HttpAddr string `yaml:"http_addr"` | 25 | HttpAddr string `yaml:"http_addr"` |
| @@ -30,14 +28,10 @@ type ServerConfig struct { | |||
| 30 | WriteTimeout time.Duration `yaml:"write_timeout"` | 28 | WriteTimeout time.Duration `yaml:"write_timeout"` |
| 31 | } | 29 | } |
| 32 | 30 | ||
| 33 | // DatabaseConfig holds database configuration. | ||
| 34 | type DatabaseConfig struct { | 31 | type DatabaseConfig struct { |
| 35 | Path string `yaml:"path"` | 32 | Path string `yaml:"path"` |
| 36 | // Note: SQLite connection pooling is handled internally in the storage layer. | ||
| 37 | // SQLite works best with a single connection due to its single-writer architecture. | ||
| 38 | } | 33 | } |
| 39 | 34 | ||
| 40 | // AuthConfig holds authentication configuration. | ||
| 41 | type AuthConfig struct { | 35 | type AuthConfig struct { |
| 42 | Read AuthOperationConfig `yaml:"read"` | 36 | Read AuthOperationConfig `yaml:"read"` |
| 43 | Write AuthOperationConfig `yaml:"write"` | 37 | Write AuthOperationConfig `yaml:"write"` |
| @@ -45,16 +39,14 @@ type AuthConfig struct { | |||
| 45 | SkipMethods []string `yaml:"skip_methods"` | 39 | SkipMethods []string `yaml:"skip_methods"` |
| 46 | } | 40 | } |
| 47 | 41 | ||
| 48 | // AuthOperationConfig configures auth for a specific operation type (read or write). | 42 | // AuthOperationConfig configures auth for read or write operations. |
| 43 | // Three states: disabled (allow all), enabled with empty list (require auth), | ||
| 44 | // enabled with npubs (whitelist only). Npubs normalized to hex at load time. | ||
| 49 | type AuthOperationConfig struct { | 45 | type AuthOperationConfig struct { |
| 50 | Enabled bool `yaml:"enabled"` // false = no auth required, true = auth required | 46 | Enabled bool `yaml:"enabled"` |
| 51 | AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only - normalized to hex internally | 47 | AllowedNpubs []string `yaml:"allowed_npubs"` |
| 52 | // If enabled=false: no auth, allow all | ||
| 53 | // If enabled=true && allowed_npubs=[]: auth required, any valid signature accepted | ||
| 54 | // If enabled=true && allowed_npubs=[...]: auth required, only whitelisted npubs | ||
| 55 | } | 48 | } |
| 56 | 49 | ||
| 57 | // RateLimitConfig holds rate limiting configuration. | ||
| 58 | type RateLimitConfig struct { | 50 | type RateLimitConfig struct { |
| 59 | Enabled bool `yaml:"enabled"` | 51 | Enabled bool `yaml:"enabled"` |
| 60 | DefaultRPS float64 `yaml:"default_rps"` | 52 | DefaultRPS float64 `yaml:"default_rps"` |
| @@ -69,20 +61,17 @@ type RateLimitConfig struct { | |||
| 69 | MaxIdleTime time.Duration `yaml:"max_idle_time"` | 61 | MaxIdleTime time.Duration `yaml:"max_idle_time"` |
| 70 | } | 62 | } |
| 71 | 63 | ||
| 72 | // MethodLimit defines rate limits for a specific method. | ||
| 73 | type MethodLimit struct { | 64 | type MethodLimit struct { |
| 74 | RPS float64 `yaml:"rps"` | 65 | RPS float64 `yaml:"rps"` |
| 75 | Burst int `yaml:"burst"` | 66 | Burst int `yaml:"burst"` |
| 76 | } | 67 | } |
| 77 | 68 | ||
| 78 | // UserLimit defines rate limits for a specific user. | ||
| 79 | type UserLimit struct { | 69 | type UserLimit struct { |
| 80 | RPS float64 `yaml:"rps"` | 70 | RPS float64 `yaml:"rps"` |
| 81 | Burst int `yaml:"burst"` | 71 | Burst int `yaml:"burst"` |
| 82 | Methods map[string]MethodLimit `yaml:"methods"` | 72 | Methods map[string]MethodLimit `yaml:"methods"` |
| 83 | } | 73 | } |
| 84 | 74 | ||
| 85 | // MetricsConfig holds metrics configuration. | ||
| 86 | type MetricsConfig struct { | 75 | type MetricsConfig struct { |
| 87 | Enabled bool `yaml:"enabled"` | 76 | Enabled bool `yaml:"enabled"` |
| 88 | Addr string `yaml:"addr"` | 77 | Addr string `yaml:"addr"` |
| @@ -91,14 +80,12 @@ type MetricsConfig struct { | |||
| 91 | Subsystem string `yaml:"subsystem"` | 80 | Subsystem string `yaml:"subsystem"` |
| 92 | } | 81 | } |
| 93 | 82 | ||
| 94 | // LoggingConfig holds logging configuration. | ||
| 95 | type LoggingConfig struct { | 83 | type LoggingConfig struct { |
| 96 | Level string `yaml:"level"` | 84 | Level string `yaml:"level"` |
| 97 | Format string `yaml:"format"` | 85 | Format string `yaml:"format"` |
| 98 | Output string `yaml:"output"` | 86 | Output string `yaml:"output"` |
| 99 | } | 87 | } |
| 100 | 88 | ||
| 101 | // StorageConfig holds storage configuration. | ||
| 102 | type StorageConfig struct { | 89 | type StorageConfig struct { |
| 103 | AutoCompact bool `yaml:"auto_compact"` | 90 | AutoCompact bool `yaml:"auto_compact"` |
| 104 | CompactInterval time.Duration `yaml:"compact_interval"` | 91 | CompactInterval time.Duration `yaml:"compact_interval"` |
| @@ -157,12 +144,9 @@ func Default() *Config { | |||
| 157 | } | 144 | } |
| 158 | } | 145 | } |
| 159 | 146 | ||
| 160 | // Load loads configuration from a YAML file and applies environment variable overrides. | ||
| 161 | func Load(filename string) (*Config, error) { | 147 | func Load(filename string) (*Config, error) { |
| 162 | // Start with defaults | ||
| 163 | cfg := Default() | 148 | cfg := Default() |
| 164 | 149 | ||
| 165 | // Read file if provided | ||
| 166 | if filename != "" { | 150 | if filename != "" { |
| 167 | data, err := os.ReadFile(filename) | 151 | data, err := os.ReadFile(filename) |
| 168 | if err != nil { | 152 | if err != nil { |
| @@ -174,15 +158,12 @@ func Load(filename string) (*Config, error) { | |||
| 174 | } | 158 | } |
| 175 | } | 159 | } |
| 176 | 160 | ||
| 177 | // Apply environment variable overrides | ||
| 178 | applyEnvOverrides(cfg) | 161 | applyEnvOverrides(cfg) |
| 179 | 162 | ||
| 180 | // Normalize npubs to hex pubkeys | ||
| 181 | if err := normalizeNpubs(cfg); err != nil { | 163 | if err := normalizeNpubs(cfg); err != nil { |
| 182 | return nil, fmt.Errorf("failed to normalize npubs: %w", err) | 164 | return nil, fmt.Errorf("failed to normalize npubs: %w", err) |
| 183 | } | 165 | } |
| 184 | 166 | ||
| 185 | // Validate | ||
| 186 | if err := cfg.Validate(); err != nil { | 167 | if err := cfg.Validate(); err != nil { |
| 187 | return nil, fmt.Errorf("invalid configuration: %w", err) | 168 | return nil, fmt.Errorf("invalid configuration: %w", err) |
| 188 | } | 169 | } |
| @@ -190,19 +171,15 @@ func Load(filename string) (*Config, error) { | |||
| 190 | return cfg, nil | 171 | return cfg, nil |
| 191 | } | 172 | } |
| 192 | 173 | ||
| 193 | // normalizeNpubs converts all npub (bech32) pubkeys to hex format. | 174 | // normalizeNpubs converts npub (bech32) to hex format for internal use. |
| 194 | // Config only accepts npub format (human-readable), which is converted | ||
| 195 | // to hex format (computer-readable) for internal use. | ||
| 196 | func normalizeNpubs(cfg *Config) error { | 175 | func normalizeNpubs(cfg *Config) error { |
| 197 | var err error | 176 | var err error |
| 198 | 177 | ||
| 199 | // Normalize read allowlist | ||
| 200 | cfg.Auth.Read.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Read.AllowedNpubs) | 178 | cfg.Auth.Read.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Read.AllowedNpubs) |
| 201 | if err != nil { | 179 | if err != nil { |
| 202 | return fmt.Errorf("auth.read.allowed_npubs: %w", err) | 180 | return fmt.Errorf("auth.read.allowed_npubs: %w", err) |
| 203 | } | 181 | } |
| 204 | 182 | ||
| 205 | // Normalize write allowlist | ||
| 206 | cfg.Auth.Write.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Write.AllowedNpubs) | 183 | cfg.Auth.Write.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Write.AllowedNpubs) |
| 207 | if err != nil { | 184 | if err != nil { |
| 208 | return fmt.Errorf("auth.write.allowed_npubs: %w", err) | 185 | return fmt.Errorf("auth.write.allowed_npubs: %w", err) |
| @@ -211,7 +188,6 @@ func normalizeNpubs(cfg *Config) error { | |||
| 211 | return nil | 188 | return nil |
| 212 | } | 189 | } |
| 213 | 190 | ||
| 214 | // normalizeNpubList converts a list of npubs to hex pubkeys. | ||
| 215 | func normalizeNpubList(npubs []string) ([]string, error) { | 191 | func normalizeNpubList(npubs []string) ([]string, error) { |
| 216 | if len(npubs) == 0 { | 192 | if len(npubs) == 0 { |
| 217 | return nil, nil | 193 | return nil, nil |
| @@ -219,33 +195,27 @@ func normalizeNpubList(npubs []string) ([]string, error) { | |||
| 219 | 195 | ||
| 220 | normalized := make([]string, 0, len(npubs)) | 196 | normalized := make([]string, 0, len(npubs)) |
| 221 | for _, npub := range npubs { | 197 | for _, npub := range npubs { |
| 222 | // Skip empty strings | ||
| 223 | npub = strings.TrimSpace(npub) | 198 | npub = strings.TrimSpace(npub) |
| 224 | if npub == "" { | 199 | if npub == "" { |
| 225 | continue | 200 | continue |
| 226 | } | 201 | } |
| 227 | 202 | ||
| 228 | // Validate npub format | ||
| 229 | if !strings.HasPrefix(npub, "npub1") { | 203 | if !strings.HasPrefix(npub, "npub1") { |
| 230 | return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) | 204 | return nil, fmt.Errorf("invalid npub format %q: must start with 'npub1'", npub) |
| 231 | } | 205 | } |
| 232 | 206 | ||
| 233 | // Parse npub to get hex pubkey | ||
| 234 | key, err := nostr.ParsePublicKey(npub) | 207 | key, err := nostr.ParsePublicKey(npub) |
| 235 | if err != nil { | 208 | if err != nil { |
| 236 | return nil, fmt.Errorf("invalid npub %q: %w", npub, err) | 209 | return nil, fmt.Errorf("invalid npub %q: %w", npub, err) |
| 237 | } | 210 | } |
| 238 | 211 | ||
| 239 | // Get the hex representation for internal use | ||
| 240 | normalized = append(normalized, key.Public()) | 212 | normalized = append(normalized, key.Public()) |
| 241 | } | 213 | } |
| 242 | 214 | ||
| 243 | return normalized, nil | 215 | return normalized, nil |
| 244 | } | 216 | } |
| 245 | 217 | ||
| 246 | // Validate validates the configuration. | ||
| 247 | func (c *Config) Validate() error { | 218 | func (c *Config) Validate() error { |
| 248 | // Validate server addresses | ||
| 249 | if c.Server.GrpcAddr == "" { | 219 | if c.Server.GrpcAddr == "" { |
| 250 | return fmt.Errorf("server.grpc_addr is required") | 220 | return fmt.Errorf("server.grpc_addr is required") |
| 251 | } | 221 | } |
| @@ -253,12 +223,10 @@ func (c *Config) Validate() error { | |||
| 253 | return fmt.Errorf("server.http_addr is required") | 223 | return fmt.Errorf("server.http_addr is required") |
| 254 | } | 224 | } |
| 255 | 225 | ||
| 256 | // Validate database path | ||
| 257 | if c.Database.Path == "" { | 226 | if c.Database.Path == "" { |
| 258 | return fmt.Errorf("database.path is required") | 227 | return fmt.Errorf("database.path is required") |
| 259 | } | 228 | } |
| 260 | 229 | ||
| 261 | // Validate metrics config if enabled | ||
| 262 | if c.Metrics.Enabled { | 230 | if c.Metrics.Enabled { |
| 263 | if c.Metrics.Addr == "" { | 231 | if c.Metrics.Addr == "" { |
| 264 | return fmt.Errorf("metrics.addr is required when metrics enabled") | 232 | return fmt.Errorf("metrics.addr is required when metrics enabled") |
| @@ -268,7 +236,6 @@ func (c *Config) Validate() error { | |||
| 268 | } | 236 | } |
| 269 | } | 237 | } |
| 270 | 238 | ||
| 271 | // Validate logging | ||
| 272 | validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} | 239 | validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} |
| 273 | if !validLevels[c.Logging.Level] { | 240 | if !validLevels[c.Logging.Level] { |
| 274 | return fmt.Errorf("invalid logging.level: %s (must be debug, info, warn, or error)", c.Logging.Level) | 241 | return fmt.Errorf("invalid logging.level: %s (must be debug, info, warn, or error)", c.Logging.Level) |
| @@ -282,10 +249,7 @@ func (c *Config) Validate() error { | |||
| 282 | return nil | 249 | return nil |
| 283 | } | 250 | } |
| 284 | 251 | ||
| 285 | // applyEnvOverrides applies environment variable overrides to the configuration. | ||
| 286 | // Environment variables follow the pattern: MUXSTR_<SECTION>_<KEY> | ||
| 287 | func applyEnvOverrides(cfg *Config) { | 252 | func applyEnvOverrides(cfg *Config) { |
| 288 | // Server | ||
| 289 | if val := os.Getenv("MUXSTR_SERVER_GRPC_ADDR"); val != "" { | 253 | if val := os.Getenv("MUXSTR_SERVER_GRPC_ADDR"); val != "" { |
| 290 | cfg.Server.GrpcAddr = val | 254 | cfg.Server.GrpcAddr = val |
| 291 | } | 255 | } |
| @@ -306,12 +270,10 @@ func applyEnvOverrides(cfg *Config) { | |||
| 306 | } | 270 | } |
| 307 | } | 271 | } |
| 308 | 272 | ||
| 309 | // Database | ||
| 310 | if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" { | 273 | if val := os.Getenv("MUXSTR_DATABASE_PATH"); val != "" { |
| 311 | cfg.Database.Path = val | 274 | cfg.Database.Path = val |
| 312 | } | 275 | } |
| 313 | 276 | ||
| 314 | // Auth | ||
| 315 | if val := os.Getenv("MUXSTR_AUTH_READ_ENABLED"); val != "" { | 277 | if val := os.Getenv("MUXSTR_AUTH_READ_ENABLED"); val != "" { |
| 316 | cfg.Auth.Read.Enabled = parseBool(val) | 278 | cfg.Auth.Read.Enabled = parseBool(val) |
| 317 | } | 279 | } |
| @@ -331,7 +293,6 @@ func applyEnvOverrides(cfg *Config) { | |||
| 331 | } | 293 | } |
| 332 | } | 294 | } |
| 333 | 295 | ||
| 334 | // Rate limit | ||
| 335 | if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" { | 296 | if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" { |
| 336 | cfg.RateLimit.Enabled = parseBool(val) | 297 | cfg.RateLimit.Enabled = parseBool(val) |
| 337 | } | 298 | } |
| @@ -348,7 +309,6 @@ func applyEnvOverrides(cfg *Config) { | |||
| 348 | } | 309 | } |
| 349 | } | 310 | } |
| 350 | 311 | ||
| 351 | // Metrics | ||
| 352 | if val := os.Getenv("MUXSTR_METRICS_ENABLED"); val != "" { | 312 | if val := os.Getenv("MUXSTR_METRICS_ENABLED"); val != "" { |
| 353 | cfg.Metrics.Enabled = parseBool(val) | 313 | cfg.Metrics.Enabled = parseBool(val) |
| 354 | } | 314 | } |
| @@ -359,7 +319,6 @@ func applyEnvOverrides(cfg *Config) { | |||
| 359 | cfg.Metrics.Path = val | 319 | cfg.Metrics.Path = val |
| 360 | } | 320 | } |
| 361 | 321 | ||
| 362 | // Logging | ||
| 363 | if val := os.Getenv("MUXSTR_LOGGING_LEVEL"); val != "" { | 322 | if val := os.Getenv("MUXSTR_LOGGING_LEVEL"); val != "" { |
| 364 | cfg.Logging.Level = val | 323 | cfg.Logging.Level = val |
| 365 | } | 324 | } |
