diff options
| author | bndw <ben@bdw.to> | 2026-02-14 10:11:16 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-02-14 10:11:16 -0800 |
| commit | 606e0a3329a3534a00889eee19c25e7d432f7d2d (patch) | |
| tree | 526b1419eaa6b9b91126adbfa5990ec47f5d3a07 | |
| parent | a90009e6b887a8a7ca67f49566af2caffb807776 (diff) | |
refactor: restructure auth config for better UX
Changed from flat structure to hierarchical read/write config:
Before:
auth:
enabled: bool
required: bool
allowed_npubs_read: []
allowed_npubs_write: []
After:
auth:
read:
enabled: bool
allowed_npubs: []
write:
enabled: bool
allowed_npubs: []
Three states per operation:
- enabled=false: no auth, allow all
- enabled=true, allowed_npubs=[]: auth required, any valid signature
- enabled=true, allowed_npubs=[...]: auth required, whitelist only
Much clearer semantics and easier to reason about.
| -rw-r--r-- | internal/auth/README.md | 37 | ||||
| -rw-r--r-- | internal/auth/auth_test.go | 9 | ||||
| -rw-r--r-- | internal/auth/interceptor.go | 118 | ||||
| -rw-r--r-- | internal/config/README.md | 70 | ||||
| -rw-r--r-- | internal/config/config.go | 57 | ||||
| -rw-r--r-- | internal/config/config_test.go | 102 |
6 files changed, 242 insertions, 151 deletions
diff --git a/internal/auth/README.md b/internal/auth/README.md index 366e110..de37010 100644 --- a/internal/auth/README.md +++ b/internal/auth/README.md | |||
| @@ -142,8 +142,15 @@ import ( | |||
| 142 | 142 | ||
| 143 | // Create auth options | 143 | // Create auth options |
| 144 | authOpts := &auth.InterceptorOptions{ | 144 | authOpts := &auth.InterceptorOptions{ |
| 145 | Read: auth.OperationAuthConfig{ | ||
| 146 | Enabled: true, // Require auth for reads | ||
| 147 | AllowedNpubs: nil, // Accept any valid signature | ||
| 148 | }, | ||
| 149 | Write: auth.OperationAuthConfig{ | ||
| 150 | Enabled: true, | ||
| 151 | AllowedNpubs: []string{"hex-pubkey-1", "hex-pubkey-2"}, // Whitelist | ||
| 152 | }, | ||
| 145 | TimestampWindow: 60, // Accept events within 60 seconds | 153 | TimestampWindow: 60, // Accept events within 60 seconds |
| 146 | Required: true, // Reject unauthenticated requests | ||
| 147 | } | 154 | } |
| 148 | 155 | ||
| 149 | // Create gRPC server with interceptors | 156 | // Create gRPC server with interceptors |
| @@ -206,23 +213,25 @@ authOpts := &auth.InterceptorOptions{ | |||
| 206 | 213 | ||
| 207 | ### InterceptorOptions | 214 | ### InterceptorOptions |
| 208 | 215 | ||
| 216 | - **`Read`**: Authentication config for read operations (Subscribe, QueryEvents, CountEvents) | ||
| 217 | - **`Enabled`**: false = no auth (allow all), true = auth required | ||
| 218 | - **`AllowedNpubs`**: Optional whitelist (hex format, normalized from npub in config) | ||
| 219 | - If `Enabled=false`: no auth required | ||
| 220 | - If `Enabled=true && AllowedNpubs=[]`: auth required, any valid signature accepted | ||
| 221 | - If `Enabled=true && AllowedNpubs=[...]`: auth required, only whitelisted npubs accepted | ||
| 222 | |||
| 223 | - **`Write`**: Authentication config for write operations (PublishEvent, PublishBatch) | ||
| 224 | - Same structure as `Read` | ||
| 225 | |||
| 209 | - **`TimestampWindow`**: Maximum age of events in seconds (default: 60) | 226 | - **`TimestampWindow`**: Maximum age of events in seconds (default: 60) |
| 210 | - **`Required`**: Whether to reject unauthenticated requests (default: false) | ||
| 211 | - **`ValidatePayload`**: Whether to verify payload hash when present (default: false) | 227 | - **`ValidatePayload`**: Whether to verify payload hash when present (default: false) |
| 212 | - **`AllowedNpubsRead`**: Optional whitelist of allowed pubkeys for read operations (nil = allow all) | 228 | - **`SkipMethods`**: List of methods that bypass auth (e.g., health checks) |
| 213 | - Config accepts npub format only (human-readable bech32) | ||
| 214 | - Automatically normalized to hex format (computer-readable) at config load time | ||
| 215 | - Controls access to Query, Get, List, Subscribe, and other read methods | ||
| 216 | - **`AllowedNpubsWrite`**: Optional whitelist of allowed pubkeys for write operations (nil = allow all) | ||
| 217 | - Config accepts npub format only (human-readable bech32) | ||
| 218 | - Automatically normalized to hex format (computer-readable) at config load time | ||
| 219 | - Controls access to Publish, Delete, Create, Update, and other write methods | ||
| 220 | 229 | ||
| 221 | **Access Control Patterns:** | 230 | **Access Control Patterns:** |
| 222 | - **Public relay**: Set `AllowedNpubsWrite` (only some can publish), leave `AllowedNpubsRead` empty (everyone can read) | 231 | - **Public relay**: `Read.Enabled=false`, `Write.Enabled=true` with whitelist |
| 223 | - **Private relay**: Set both lists (restricted read and write access) | 232 | - **Private relay**: Both `Enabled=true` with whitelists |
| 224 | - **Open relay**: Leave both empty (everyone can read and write) | 233 | - **Open relay**: Both `Enabled=false` |
| 225 | - **Read-only relay**: Set `AllowedNpubsRead`, block all writes | 234 | - **Authenticated reads, open writes**: `Read.Enabled=true`, `Write.Enabled=false` |
| 226 | 235 | ||
| 227 | ### NostrCredentials Options | 236 | ### NostrCredentials Options |
| 228 | 237 | ||
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index d5f3257..bcbb4a3 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go | |||
| @@ -243,8 +243,15 @@ func TestValidateAuthFromContext(t *testing.T) { | |||
| 243 | ctx := metadata.NewIncomingContext(context.Background(), md) | 243 | ctx := metadata.NewIncomingContext(context.Background(), md) |
| 244 | 244 | ||
| 245 | opts := &InterceptorOptions{ | 245 | opts := &InterceptorOptions{ |
| 246 | Read: OperationAuthConfig{ | ||
| 247 | Enabled: true, | ||
| 248 | AllowedNpubs: nil, | ||
| 249 | }, | ||
| 250 | Write: OperationAuthConfig{ | ||
| 251 | Enabled: true, | ||
| 252 | AllowedNpubs: nil, | ||
| 253 | }, | ||
| 246 | TimestampWindow: 60, | 254 | TimestampWindow: 60, |
| 247 | Required: true, | ||
| 248 | } | 255 | } |
| 249 | 256 | ||
| 250 | pubkey, err := validateAuthFromContext(ctx, "/test.Service/Method", opts) | 257 | pubkey, err := validateAuthFromContext(ctx, "/test.Service/Method", opts) |
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go index 149cc01..d394102 100644 --- a/internal/auth/interceptor.go +++ b/internal/auth/interceptor.go | |||
| @@ -21,49 +21,55 @@ const ( | |||
| 21 | 21 | ||
| 22 | // InterceptorOptions configures the authentication interceptor behavior. | 22 | // InterceptorOptions configures the authentication interceptor behavior. |
| 23 | type InterceptorOptions struct { | 23 | type InterceptorOptions struct { |
| 24 | // Read configures authentication for read operations (Subscribe, QueryEvents, etc.) | ||
| 25 | Read OperationAuthConfig | ||
| 26 | |||
| 27 | // Write configures authentication for write operations (PublishEvent, PublishBatch) | ||
| 28 | Write OperationAuthConfig | ||
| 29 | |||
| 24 | // TimestampWindow is the maximum age of auth events in seconds. | 30 | // TimestampWindow is the maximum age of auth events in seconds. |
| 25 | // Events older than this are rejected to prevent replay attacks. | 31 | // Events older than this are rejected to prevent replay attacks. |
| 26 | // Default: 60 seconds | 32 | // Default: 60 seconds |
| 27 | TimestampWindow int64 | 33 | TimestampWindow int64 |
| 28 | 34 | ||
| 29 | // Required determines whether authentication is mandatory. | ||
| 30 | // If true, requests without valid auth are rejected. | ||
| 31 | // If false, unauthenticated requests are allowed (pubkey will be empty). | ||
| 32 | // Default: false | ||
| 33 | Required bool | ||
| 34 | |||
| 35 | // ValidatePayload checks the payload hash tag if present. | 35 | // ValidatePayload checks the payload hash tag if present. |
| 36 | // Default: false | 36 | // Default: false |
| 37 | ValidatePayload bool | 37 | ValidatePayload bool |
| 38 | 38 | ||
| 39 | // AllowedNpubsRead is an optional whitelist of allowed pubkeys for read operations (hex format). | ||
| 40 | // Config accepts npub format only, normalized to hex at load time. | ||
| 41 | // If nil or empty, all valid signatures are accepted for reads. | ||
| 42 | // Default: nil (allow all) | ||
| 43 | AllowedNpubsRead []string | ||
| 44 | |||
| 45 | // AllowedNpubsWrite is an optional whitelist of allowed pubkeys for write operations (hex format). | ||
| 46 | // Config accepts npub format only, normalized to hex at load time. | ||
| 47 | // If nil or empty, all valid signatures are accepted for writes. | ||
| 48 | // Default: nil (allow all) | ||
| 49 | AllowedNpubsWrite []string | ||
| 50 | |||
| 51 | // SkipMethods is a list of gRPC methods that bypass authentication. | 39 | // SkipMethods is a list of gRPC methods that bypass authentication. |
| 52 | // Useful for public endpoints like health checks or relay info. | 40 | // Useful for public endpoints like health checks or relay info. |
| 53 | // Example: []string{"/nostr.v1.NostrRelay/QueryEvents"} | 41 | // Example: []string{"/grpc.health.v1.Health/Check"} |
| 54 | // Default: nil (authenticate all methods) | 42 | // Default: nil (authenticate all methods) |
| 55 | SkipMethods []string | 43 | SkipMethods []string |
| 56 | } | 44 | } |
| 57 | 45 | ||
| 46 | // OperationAuthConfig configures auth for a specific operation type. | ||
| 47 | type OperationAuthConfig struct { | ||
| 48 | // Enabled determines if auth is required. | ||
| 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 | ||
| 57 | } | ||
| 58 | |||
| 58 | // DefaultInterceptorOptions returns the default configuration. | 59 | // DefaultInterceptorOptions returns the default configuration. |
| 59 | func DefaultInterceptorOptions() *InterceptorOptions { | 60 | func DefaultInterceptorOptions() *InterceptorOptions { |
| 60 | return &InterceptorOptions{ | 61 | return &InterceptorOptions{ |
| 61 | TimestampWindow: 60, | 62 | Read: OperationAuthConfig{ |
| 62 | Required: false, | 63 | Enabled: false, |
| 63 | ValidatePayload: false, | 64 | AllowedNpubs: nil, |
| 64 | AllowedNpubsRead: nil, | 65 | }, |
| 65 | AllowedNpubsWrite: nil, | 66 | Write: OperationAuthConfig{ |
| 66 | SkipMethods: nil, | 67 | Enabled: false, |
| 68 | AllowedNpubs: nil, | ||
| 69 | }, | ||
| 70 | TimestampWindow: 60, | ||
| 71 | ValidatePayload: false, | ||
| 72 | SkipMethods: nil, | ||
| 67 | } | 73 | } |
| 68 | } | 74 | } |
| 69 | 75 | ||
| @@ -79,14 +85,23 @@ func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor | |||
| 79 | return handler(ctx, req) | 85 | return handler(ctx, req) |
| 80 | } | 86 | } |
| 81 | 87 | ||
| 88 | // Check if auth is required for this operation type | ||
| 89 | var authRequired bool | ||
| 90 | if isWriteMethod(info.FullMethod) { | ||
| 91 | authRequired = opts.Write.Enabled | ||
| 92 | } else { | ||
| 93 | authRequired = opts.Read.Enabled | ||
| 94 | } | ||
| 95 | |||
| 96 | // If auth not required, skip validation | ||
| 97 | if !authRequired { | ||
| 98 | return handler(ctx, req) | ||
| 99 | } | ||
| 100 | |||
| 82 | // Extract and validate auth | 101 | // Extract and validate auth |
| 83 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) | 102 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) |
| 84 | if err != nil { | 103 | if err != nil { |
| 85 | if opts.Required { | 104 | return nil, status.Error(codes.Unauthenticated, err.Error()) |
| 86 | return nil, status.Error(codes.Unauthenticated, err.Error()) | ||
| 87 | } | ||
| 88 | // Auth not required, continue without pubkey | ||
| 89 | return handler(ctx, req) | ||
| 90 | } | 105 | } |
| 91 | 106 | ||
| 92 | // Add pubkey to context for handlers | 107 | // Add pubkey to context for handlers |
| @@ -108,15 +123,24 @@ func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerIntercept | |||
| 108 | return handler(srv, ss) | 123 | return handler(srv, ss) |
| 109 | } | 124 | } |
| 110 | 125 | ||
| 126 | // Check if auth is required for this operation type | ||
| 127 | var authRequired bool | ||
| 128 | if isWriteMethod(info.FullMethod) { | ||
| 129 | authRequired = opts.Write.Enabled | ||
| 130 | } else { | ||
| 131 | authRequired = opts.Read.Enabled | ||
| 132 | } | ||
| 133 | |||
| 134 | // If auth not required, skip validation | ||
| 135 | if !authRequired { | ||
| 136 | return handler(srv, ss) | ||
| 137 | } | ||
| 138 | |||
| 111 | // Extract and validate auth | 139 | // Extract and validate auth |
| 112 | ctx := ss.Context() | 140 | ctx := ss.Context() |
| 113 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) | 141 | pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) |
| 114 | if err != nil { | 142 | if err != nil { |
| 115 | if opts.Required { | 143 | return status.Error(codes.Unauthenticated, err.Error()) |
| 116 | return status.Error(codes.Unauthenticated, err.Error()) | ||
| 117 | } | ||
| 118 | // Auth not required, continue without pubkey | ||
| 119 | return handler(srv, ss) | ||
| 120 | } | 144 | } |
| 121 | 145 | ||
| 122 | // Wrap stream with authenticated context | 146 | // Wrap stream with authenticated context |
| @@ -177,23 +201,25 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept | |||
| 177 | // Extract pubkey | 201 | // Extract pubkey |
| 178 | pubkey := ExtractPubkey(event) | 202 | pubkey := ExtractPubkey(event) |
| 179 | 203 | ||
| 180 | // Check whitelist based on operation type (all values are already normalized to hex) | 204 | // Get the operation config based on method type |
| 205 | var opConfig OperationAuthConfig | ||
| 181 | if isWriteMethod(method) { | 206 | if isWriteMethod(method) { |
| 182 | // Write operation - check write allowlist | 207 | opConfig = opts.Write |
| 183 | if len(opts.AllowedNpubsWrite) > 0 { | ||
| 184 | if !contains(opts.AllowedNpubsWrite, pubkey) { | ||
| 185 | return "", fmt.Errorf("pubkey not authorized for write operations") | ||
| 186 | } | ||
| 187 | } | ||
| 188 | } else { | 208 | } else { |
| 189 | // Read operation - check read allowlist | 209 | opConfig = opts.Read |
| 190 | if len(opts.AllowedNpubsRead) > 0 { | 210 | } |
| 191 | if !contains(opts.AllowedNpubsRead, pubkey) { | 211 | |
| 192 | return "", fmt.Errorf("pubkey not authorized for read operations") | 212 | // Check whitelist if configured |
| 213 | if len(opConfig.AllowedNpubs) > 0 { | ||
| 214 | if !contains(opConfig.AllowedNpubs, pubkey) { | ||
| 215 | if isWriteMethod(method) { | ||
| 216 | return "", fmt.Errorf("pubkey not authorized for write operations") | ||
| 193 | } | 217 | } |
| 218 | return "", fmt.Errorf("pubkey not authorized for read operations") | ||
| 194 | } | 219 | } |
| 195 | } | 220 | } |
| 196 | 221 | ||
| 222 | // No whitelist or pubkey in whitelist - allow | ||
| 197 | return pubkey, nil | 223 | return pubkey, nil |
| 198 | } | 224 | } |
| 199 | 225 | ||
diff --git a/internal/config/README.md b/internal/config/README.md index 7deb38f..3dcf215 100644 --- a/internal/config/README.md +++ b/internal/config/README.md | |||
| @@ -80,41 +80,51 @@ database: | |||
| 80 | 80 | ||
| 81 | # Authentication configuration | 81 | # Authentication configuration |
| 82 | auth: | 82 | auth: |
| 83 | # Enable authentication | 83 | # Read authentication (Subscribe, QueryEvents, CountEvents) |
| 84 | enabled: false | 84 | read: |
| 85 | 85 | enabled: false # false = no auth, allow all | |
| 86 | # Require authentication for all requests | 86 | allowed_npubs: [] # npub format only (e.g., npub1...) |
| 87 | # If false, authentication is optional (pubkey available if provided) | 87 | # If enabled=false: no auth, allow all reads |
| 88 | required: false | 88 | # If enabled=true && allowed_npubs=[]: auth required, any valid signature accepted |
| 89 | # If enabled=true && allowed_npubs=[...]: auth required, only whitelisted npubs | ||
| 90 | |||
| 91 | # Write authentication (PublishEvent, PublishBatch) | ||
| 92 | write: | ||
| 93 | enabled: false | ||
| 94 | allowed_npubs: [] | ||
| 89 | 95 | ||
| 90 | # Timestamp window in seconds for replay protection | 96 | # Timestamp window in seconds for replay protection |
| 91 | timestamp_window: 60 | 97 | timestamp_window: 60 |
| 92 | 98 | ||
| 93 | # Allowed npubs for read operations (optional, whitelist) | ||
| 94 | # If empty, all valid signatures are accepted for reads | ||
| 95 | # Use npub format only (e.g., npub1...) | ||
| 96 | allowed_npubs_read: [] | ||
| 97 | |||
| 98 | # Allowed npubs for write operations (optional, whitelist) | ||
| 99 | # If empty, all valid signatures are accepted for writes | ||
| 100 | # Use npub format only (e.g., npub1...) | ||
| 101 | allowed_npubs_write: [] | ||
| 102 | |||
| 103 | # Example use cases: | ||
| 104 | # - Public relay: allowed_npubs_write (only some can publish), empty read (everyone can read) | ||
| 105 | # - Private relay: both lists populated (restricted read and write) | ||
| 106 | # - Open relay: both lists empty (everyone can read and write) | ||
| 107 | # | ||
| 108 | # Example: | ||
| 109 | # allowed_npubs_read: | ||
| 110 | # - npub1a2b3c4d5e6f... | ||
| 111 | # allowed_npubs_write: | ||
| 112 | # - npub1a2b3c4d5e6f... | ||
| 113 | |||
| 114 | # Skip authentication for these methods | 99 | # Skip authentication for these methods |
| 115 | skip_methods: | 100 | skip_methods: |
| 116 | - "/grpc.health.v1.Health/Check" | 101 | - "/grpc.health.v1.Health/Check" |
| 117 | 102 | ||
| 103 | # Common patterns: | ||
| 104 | # Public relay (anyone can read, only whitelisted can write): | ||
| 105 | # read: | ||
| 106 | # enabled: false | ||
| 107 | # write: | ||
| 108 | # enabled: true | ||
| 109 | # allowed_npubs: | ||
| 110 | # - npub1... | ||
| 111 | # | ||
| 112 | # Private relay (whitelisted read and write): | ||
| 113 | # read: | ||
| 114 | # enabled: true | ||
| 115 | # allowed_npubs: | ||
| 116 | # - npub1... | ||
| 117 | # write: | ||
| 118 | # enabled: true | ||
| 119 | # allowed_npubs: | ||
| 120 | # - npub1... | ||
| 121 | # | ||
| 122 | # Open relay (everyone can read and write): | ||
| 123 | # read: | ||
| 124 | # enabled: false | ||
| 125 | # write: | ||
| 126 | # enabled: false | ||
| 127 | |||
| 118 | # Rate limiting configuration | 128 | # Rate limiting configuration |
| 119 | rate_limit: | 129 | rate_limit: |
| 120 | # Enable rate limiting | 130 | # Enable rate limiting |
| @@ -235,8 +245,10 @@ Complex types: | |||
| 235 | 245 | ||
| 236 | ```bash | 246 | ```bash |
| 237 | # Lists (comma-separated, npub format) | 247 | # Lists (comma-separated, npub format) |
| 238 | export MUXSTR_AUTH_ALLOWED_NPUBS_READ="npub1...,npub1..." | 248 | export MUXSTR_AUTH_READ_ENABLED=true |
| 239 | export MUXSTR_AUTH_ALLOWED_NPUBS_WRITE="npub1..." | 249 | export MUXSTR_AUTH_READ_ALLOWED_NPUBS="npub1...,npub1..." |
| 250 | export MUXSTR_AUTH_WRITE_ENABLED=true | ||
| 251 | export MUXSTR_AUTH_WRITE_ALLOWED_NPUBS="npub1..." | ||
| 240 | 252 | ||
| 241 | # Durations | 253 | # Durations |
| 242 | export MUXSTR_SERVER_READ_TIMEOUT="30s" | 254 | export MUXSTR_SERVER_READ_TIMEOUT="30s" |
diff --git a/internal/config/config.go b/internal/config/config.go index 3e52272..294510d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go | |||
| @@ -39,12 +39,19 @@ type DatabaseConfig struct { | |||
| 39 | 39 | ||
| 40 | // AuthConfig holds authentication configuration. | 40 | // AuthConfig holds authentication configuration. |
| 41 | type AuthConfig struct { | 41 | type AuthConfig struct { |
| 42 | Enabled bool `yaml:"enabled"` | 42 | Read AuthOperationConfig `yaml:"read"` |
| 43 | Required bool `yaml:"required"` | 43 | Write AuthOperationConfig `yaml:"write"` |
| 44 | TimestampWindow int64 `yaml:"timestamp_window"` | 44 | TimestampWindow int64 `yaml:"timestamp_window"` |
| 45 | AllowedNpubsRead []string `yaml:"allowed_npubs_read"` // npub format only (bech32) - normalized to hex internally | 45 | SkipMethods []string `yaml:"skip_methods"` |
| 46 | AllowedNpubsWrite []string `yaml:"allowed_npubs_write"` // npub format only (bech32) - normalized to hex internally | 46 | } |
| 47 | SkipMethods []string `yaml:"skip_methods"` | 47 | |
| 48 | // AuthOperationConfig configures auth for a specific operation type (read or write). | ||
| 49 | type AuthOperationConfig struct { | ||
| 50 | Enabled bool `yaml:"enabled"` // false = no auth required, true = auth required | ||
| 51 | AllowedNpubs []string `yaml:"allowed_npubs"` // npub format only - normalized to hex internally | ||
| 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 | ||
| 48 | } | 55 | } |
| 49 | 56 | ||
| 50 | // RateLimitConfig holds rate limiting configuration. | 57 | // RateLimitConfig holds rate limiting configuration. |
| @@ -111,8 +118,14 @@ func Default() *Config { | |||
| 111 | Path: "relay.db", | 118 | Path: "relay.db", |
| 112 | }, | 119 | }, |
| 113 | Auth: AuthConfig{ | 120 | Auth: AuthConfig{ |
| 114 | Enabled: false, | 121 | Read: AuthOperationConfig{ |
| 115 | Required: false, | 122 | Enabled: false, |
| 123 | AllowedNpubs: nil, | ||
| 124 | }, | ||
| 125 | Write: AuthOperationConfig{ | ||
| 126 | Enabled: false, | ||
| 127 | AllowedNpubs: nil, | ||
| 128 | }, | ||
| 116 | TimestampWindow: 60, | 129 | TimestampWindow: 60, |
| 117 | }, | 130 | }, |
| 118 | RateLimit: RateLimitConfig{ | 131 | RateLimit: RateLimitConfig{ |
| @@ -184,15 +197,15 @@ func normalizeNpubs(cfg *Config) error { | |||
| 184 | var err error | 197 | var err error |
| 185 | 198 | ||
| 186 | // Normalize read allowlist | 199 | // Normalize read allowlist |
| 187 | cfg.Auth.AllowedNpubsRead, err = normalizeNpubList(cfg.Auth.AllowedNpubsRead) | 200 | cfg.Auth.Read.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Read.AllowedNpubs) |
| 188 | if err != nil { | 201 | if err != nil { |
| 189 | return fmt.Errorf("allowed_npubs_read: %w", err) | 202 | return fmt.Errorf("auth.read.allowed_npubs: %w", err) |
| 190 | } | 203 | } |
| 191 | 204 | ||
| 192 | // Normalize write allowlist | 205 | // Normalize write allowlist |
| 193 | cfg.Auth.AllowedNpubsWrite, err = normalizeNpubList(cfg.Auth.AllowedNpubsWrite) | 206 | cfg.Auth.Write.AllowedNpubs, err = normalizeNpubList(cfg.Auth.Write.AllowedNpubs) |
| 194 | if err != nil { | 207 | if err != nil { |
| 195 | return fmt.Errorf("allowed_npubs_write: %w", err) | 208 | return fmt.Errorf("auth.write.allowed_npubs: %w", err) |
| 196 | } | 209 | } |
| 197 | 210 | ||
| 198 | return nil | 211 | return nil |
| @@ -299,11 +312,17 @@ func applyEnvOverrides(cfg *Config) { | |||
| 299 | } | 312 | } |
| 300 | 313 | ||
| 301 | // Auth | 314 | // Auth |
| 302 | if val := os.Getenv("MUXSTR_AUTH_ENABLED"); val != "" { | 315 | if val := os.Getenv("MUXSTR_AUTH_READ_ENABLED"); val != "" { |
| 303 | cfg.Auth.Enabled = parseBool(val) | 316 | cfg.Auth.Read.Enabled = parseBool(val) |
| 317 | } | ||
| 318 | if val := os.Getenv("MUXSTR_AUTH_READ_ALLOWED_NPUBS"); val != "" { | ||
| 319 | cfg.Auth.Read.AllowedNpubs = strings.Split(val, ",") | ||
| 304 | } | 320 | } |
| 305 | if val := os.Getenv("MUXSTR_AUTH_REQUIRED"); val != "" { | 321 | if val := os.Getenv("MUXSTR_AUTH_WRITE_ENABLED"); val != "" { |
| 306 | cfg.Auth.Required = parseBool(val) | 322 | cfg.Auth.Write.Enabled = parseBool(val) |
| 323 | } | ||
| 324 | if val := os.Getenv("MUXSTR_AUTH_WRITE_ALLOWED_NPUBS"); val != "" { | ||
| 325 | cfg.Auth.Write.AllowedNpubs = strings.Split(val, ",") | ||
| 307 | } | 326 | } |
| 308 | if val := os.Getenv("MUXSTR_AUTH_TIMESTAMP_WINDOW"); val != "" { | 327 | if val := os.Getenv("MUXSTR_AUTH_TIMESTAMP_WINDOW"); val != "" { |
| 309 | var n int64 | 328 | var n int64 |
| @@ -311,12 +330,6 @@ func applyEnvOverrides(cfg *Config) { | |||
| 311 | cfg.Auth.TimestampWindow = n | 330 | cfg.Auth.TimestampWindow = n |
| 312 | } | 331 | } |
| 313 | } | 332 | } |
| 314 | if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_READ"); val != "" { | ||
| 315 | cfg.Auth.AllowedNpubsRead = strings.Split(val, ",") | ||
| 316 | } | ||
| 317 | if val := os.Getenv("MUXSTR_AUTH_ALLOWED_NPUBS_WRITE"); val != "" { | ||
| 318 | cfg.Auth.AllowedNpubsWrite = strings.Split(val, ",") | ||
| 319 | } | ||
| 320 | 333 | ||
| 321 | // Rate limit | 334 | // Rate limit |
| 322 | if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" { | 335 | if val := os.Getenv("MUXSTR_RATE_LIMIT_ENABLED"); val != "" { |
diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c0d4555..65a742a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go | |||
| @@ -40,8 +40,10 @@ database: | |||
| 40 | path: "test.db" | 40 | path: "test.db" |
| 41 | 41 | ||
| 42 | auth: | 42 | auth: |
| 43 | enabled: true | 43 | read: |
| 44 | required: true | 44 | enabled: true |
| 45 | write: | ||
| 46 | enabled: true | ||
| 45 | timestamp_window: 120 | 47 | timestamp_window: 120 |
| 46 | 48 | ||
| 47 | rate_limit: | 49 | rate_limit: |
| @@ -75,12 +77,12 @@ metrics: | |||
| 75 | t.Errorf("expected db path test.db, got %s", cfg.Database.Path) | 77 | t.Errorf("expected db path test.db, got %s", cfg.Database.Path) |
| 76 | } | 78 | } |
| 77 | 79 | ||
| 78 | if !cfg.Auth.Enabled { | 80 | if !cfg.Auth.Read.Enabled { |
| 79 | t.Error("expected auth enabled") | 81 | t.Error("expected auth read enabled") |
| 80 | } | 82 | } |
| 81 | 83 | ||
| 82 | if !cfg.Auth.Required { | 84 | if !cfg.Auth.Write.Enabled { |
| 83 | t.Error("expected auth required") | 85 | t.Error("expected auth write enabled") |
| 84 | } | 86 | } |
| 85 | 87 | ||
| 86 | if cfg.Auth.TimestampWindow != 120 { | 88 | if cfg.Auth.TimestampWindow != 120 { |
| @@ -99,11 +101,13 @@ metrics: | |||
| 99 | func TestEnvOverrides(t *testing.T) { | 101 | func TestEnvOverrides(t *testing.T) { |
| 100 | // Set environment variables | 102 | // Set environment variables |
| 101 | os.Setenv("MUXSTR_SERVER_GRPC_ADDR", ":7777") | 103 | os.Setenv("MUXSTR_SERVER_GRPC_ADDR", ":7777") |
| 102 | os.Setenv("MUXSTR_AUTH_ENABLED", "true") | 104 | os.Setenv("MUXSTR_AUTH_READ_ENABLED", "true") |
| 105 | os.Setenv("MUXSTR_AUTH_WRITE_ENABLED", "true") | ||
| 103 | os.Setenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS", "200") | 106 | os.Setenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS", "200") |
| 104 | defer func() { | 107 | defer func() { |
| 105 | os.Unsetenv("MUXSTR_SERVER_GRPC_ADDR") | 108 | os.Unsetenv("MUXSTR_SERVER_GRPC_ADDR") |
| 106 | os.Unsetenv("MUXSTR_AUTH_ENABLED") | 109 | os.Unsetenv("MUXSTR_AUTH_READ_ENABLED") |
| 110 | os.Unsetenv("MUXSTR_AUTH_WRITE_ENABLED") | ||
| 107 | os.Unsetenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS") | 111 | os.Unsetenv("MUXSTR_RATE_LIMIT_DEFAULT_RPS") |
| 108 | }() | 112 | }() |
| 109 | 113 | ||
| @@ -118,8 +122,12 @@ func TestEnvOverrides(t *testing.T) { | |||
| 118 | t.Errorf("expected env override :7777, got %s", cfg.Server.GrpcAddr) | 122 | t.Errorf("expected env override :7777, got %s", cfg.Server.GrpcAddr) |
| 119 | } | 123 | } |
| 120 | 124 | ||
| 121 | if !cfg.Auth.Enabled { | 125 | if !cfg.Auth.Read.Enabled { |
| 122 | t.Error("expected auth enabled from env") | 126 | t.Error("expected auth read enabled from env") |
| 127 | } | ||
| 128 | |||
| 129 | if !cfg.Auth.Write.Enabled { | ||
| 130 | t.Error("expected auth write enabled from env") | ||
| 123 | } | 131 | } |
| 124 | 132 | ||
| 125 | if cfg.RateLimit.DefaultRPS != 200 { | 133 | if cfg.RateLimit.DefaultRPS != 200 { |
| @@ -206,7 +214,8 @@ func TestSaveAndLoad(t *testing.T) { | |||
| 206 | // Create config | 214 | // Create config |
| 207 | cfg := Default() | 215 | cfg := Default() |
| 208 | cfg.Server.GrpcAddr = ":9999" | 216 | cfg.Server.GrpcAddr = ":9999" |
| 209 | cfg.Auth.Enabled = true | 217 | cfg.Auth.Read.Enabled = true |
| 218 | cfg.Auth.Write.Enabled = true | ||
| 210 | cfg.RateLimit.DefaultRPS = 100 | 219 | cfg.RateLimit.DefaultRPS = 100 |
| 211 | 220 | ||
| 212 | // Save to temp file | 221 | // Save to temp file |
| @@ -232,8 +241,12 @@ func TestSaveAndLoad(t *testing.T) { | |||
| 232 | t.Errorf("expected grpc_addr :9999, got %s", loaded.Server.GrpcAddr) | 241 | t.Errorf("expected grpc_addr :9999, got %s", loaded.Server.GrpcAddr) |
| 233 | } | 242 | } |
| 234 | 243 | ||
| 235 | if !loaded.Auth.Enabled { | 244 | if !loaded.Auth.Read.Enabled { |
| 236 | t.Error("expected auth enabled") | 245 | t.Error("expected auth read enabled") |
| 246 | } | ||
| 247 | |||
| 248 | if !loaded.Auth.Write.Enabled { | ||
| 249 | t.Error("expected auth write enabled") | ||
| 237 | } | 250 | } |
| 238 | 251 | ||
| 239 | if loaded.RateLimit.DefaultRPS != 100 { | 252 | if loaded.RateLimit.DefaultRPS != 100 { |
| @@ -259,12 +272,15 @@ database: | |||
| 259 | path: "test.db" | 272 | path: "test.db" |
| 260 | 273 | ||
| 261 | auth: | 274 | auth: |
| 262 | enabled: true | 275 | read: |
| 263 | allowed_npubs_read: | 276 | enabled: true |
| 264 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 | 277 | allowed_npubs: |
| 265 | - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft | 278 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 |
| 266 | allowed_npubs_write: | 279 | - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft |
| 267 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 | 280 | write: |
| 281 | enabled: true | ||
| 282 | allowed_npubs: | ||
| 283 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 | ||
| 268 | ` | 284 | ` |
| 269 | 285 | ||
| 270 | if _, err := tmpfile.Write([]byte(configData)); err != nil { | 286 | if _, err := tmpfile.Write([]byte(configData)); err != nil { |
| @@ -278,17 +294,17 @@ auth: | |||
| 278 | } | 294 | } |
| 279 | 295 | ||
| 280 | // Verify read npubs were normalized to hex | 296 | // Verify read npubs were normalized to hex |
| 281 | if len(cfg.Auth.AllowedNpubsRead) != 2 { | 297 | if len(cfg.Auth.Read.AllowedNpubs) != 2 { |
| 282 | t.Errorf("expected 2 allowed npubs for read, got %d", len(cfg.Auth.AllowedNpubsRead)) | 298 | t.Errorf("expected 2 allowed npubs for read, got %d", len(cfg.Auth.Read.AllowedNpubs)) |
| 283 | } | 299 | } |
| 284 | 300 | ||
| 285 | // Verify write npubs were normalized to hex | 301 | // Verify write npubs were normalized to hex |
| 286 | if len(cfg.Auth.AllowedNpubsWrite) != 1 { | 302 | if len(cfg.Auth.Write.AllowedNpubs) != 1 { |
| 287 | t.Errorf("expected 1 allowed npub for write, got %d", len(cfg.Auth.AllowedNpubsWrite)) | 303 | t.Errorf("expected 1 allowed npub for write, got %d", len(cfg.Auth.Write.AllowedNpubs)) |
| 288 | } | 304 | } |
| 289 | 305 | ||
| 290 | // Check that they're hex format (64 chars, not npub1...) | 306 | // Check that they're hex format (64 chars, not npub1...) |
| 291 | for i, pubkey := range cfg.Auth.AllowedNpubsRead { | 307 | for i, pubkey := range cfg.Auth.Read.AllowedNpubs { |
| 292 | if len(pubkey) != 64 { | 308 | if len(pubkey) != 64 { |
| 293 | t.Errorf("read npub %d: expected 64 hex chars, got %d", i, len(pubkey)) | 309 | t.Errorf("read npub %d: expected 64 hex chars, got %d", i, len(pubkey)) |
| 294 | } | 310 | } |
| @@ -297,7 +313,7 @@ auth: | |||
| 297 | } | 313 | } |
| 298 | } | 314 | } |
| 299 | 315 | ||
| 300 | for i, pubkey := range cfg.Auth.AllowedNpubsWrite { | 316 | for i, pubkey := range cfg.Auth.Write.AllowedNpubs { |
| 301 | if len(pubkey) != 64 { | 317 | if len(pubkey) != 64 { |
| 302 | t.Errorf("write npub %d: expected 64 hex chars, got %d", i, len(pubkey)) | 318 | t.Errorf("write npub %d: expected 64 hex chars, got %d", i, len(pubkey)) |
| 303 | } | 319 | } |
| @@ -310,14 +326,14 @@ auth: | |||
| 310 | expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" | 326 | expectedHex1 := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" |
| 311 | expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" | 327 | expectedHex2 := "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" |
| 312 | 328 | ||
| 313 | if cfg.Auth.AllowedNpubsRead[0] != expectedHex1 { | 329 | if cfg.Auth.Read.AllowedNpubs[0] != expectedHex1 { |
| 314 | t.Errorf("read npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsRead[0]) | 330 | t.Errorf("read npub 0: expected %s, got %s", expectedHex1, cfg.Auth.Read.AllowedNpubs[0]) |
| 315 | } | 331 | } |
| 316 | if cfg.Auth.AllowedNpubsRead[1] != expectedHex2 { | 332 | if cfg.Auth.Read.AllowedNpubs[1] != expectedHex2 { |
| 317 | t.Errorf("read npub 1: expected %s, got %s", expectedHex2, cfg.Auth.AllowedNpubsRead[1]) | 333 | t.Errorf("read npub 1: expected %s, got %s", expectedHex2, cfg.Auth.Read.AllowedNpubs[1]) |
| 318 | } | 334 | } |
| 319 | if cfg.Auth.AllowedNpubsWrite[0] != expectedHex1 { | 335 | if cfg.Auth.Write.AllowedNpubs[0] != expectedHex1 { |
| 320 | t.Errorf("write npub 0: expected %s, got %s", expectedHex1, cfg.Auth.AllowedNpubsWrite[0]) | 336 | t.Errorf("write npub 0: expected %s, got %s", expectedHex1, cfg.Auth.Write.AllowedNpubs[0]) |
| 321 | } | 337 | } |
| 322 | } | 338 | } |
| 323 | 339 | ||
| @@ -337,8 +353,10 @@ server: | |||
| 337 | database: | 353 | database: |
| 338 | path: "test.db" | 354 | path: "test.db" |
| 339 | auth: | 355 | auth: |
| 340 | allowed_npubs_read: | 356 | read: |
| 341 | - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d | 357 | enabled: true |
| 358 | allowed_npubs: | ||
| 359 | - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d | ||
| 342 | `, | 360 | `, |
| 343 | expectError: true, | 361 | expectError: true, |
| 344 | errorMsg: "must start with 'npub1'", | 362 | errorMsg: "must start with 'npub1'", |
| @@ -352,8 +370,10 @@ server: | |||
| 352 | database: | 370 | database: |
| 353 | path: "test.db" | 371 | path: "test.db" |
| 354 | auth: | 372 | auth: |
| 355 | allowed_npubs_write: | 373 | write: |
| 356 | - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d | 374 | enabled: true |
| 375 | allowed_npubs: | ||
| 376 | - 3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d | ||
| 357 | `, | 377 | `, |
| 358 | expectError: true, | 378 | expectError: true, |
| 359 | errorMsg: "must start with 'npub1'", | 379 | errorMsg: "must start with 'npub1'", |
| @@ -367,10 +387,14 @@ server: | |||
| 367 | database: | 387 | database: |
| 368 | path: "test.db" | 388 | path: "test.db" |
| 369 | auth: | 389 | auth: |
| 370 | allowed_npubs_read: | 390 | read: |
| 371 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 | 391 | enabled: true |
| 372 | allowed_npubs_write: | 392 | allowed_npubs: |
| 373 | - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft | 393 | - npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 |
| 394 | write: | ||
| 395 | enabled: true | ||
| 396 | allowed_npubs: | ||
| 397 | - npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft | ||
| 374 | `, | 398 | `, |
| 375 | expectError: false, | 399 | expectError: false, |
| 376 | }, | 400 | }, |
