feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
160
poller/internal/config/config.go
Normal file
160
poller/internal/config/config.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Package config loads poller configuration from environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration for the poller service.
|
||||
type Config struct {
|
||||
// Environment is the deployment environment (dev, staging, production).
|
||||
// Controls startup validation of security-sensitive defaults.
|
||||
Environment string
|
||||
|
||||
// DatabaseURL is the PostgreSQL connection string for the poller_user role.
|
||||
// Example: postgres://poller_user:poller_password@localhost:5432/mikrotik
|
||||
DatabaseURL string
|
||||
|
||||
// RedisURL is the Redis connection URL.
|
||||
RedisURL string
|
||||
|
||||
// NatsURL is the NATS server URL.
|
||||
NatsURL string
|
||||
|
||||
// CredentialEncryptionKey is the 32-byte AES key decoded from base64.
|
||||
// MUST match the Python backend CREDENTIAL_ENCRYPTION_KEY environment variable.
|
||||
// OPTIONAL when OpenBao Transit is configured (OPENBAO_ADDR set).
|
||||
CredentialEncryptionKey []byte
|
||||
|
||||
// OpenBaoAddr is the OpenBao server address for Transit API calls.
|
||||
// Example: http://openbao:8200
|
||||
OpenBaoAddr string
|
||||
|
||||
// OpenBaoToken is the authentication token for OpenBao API calls.
|
||||
OpenBaoToken string
|
||||
|
||||
// PollIntervalSeconds is how often each device is polled.
|
||||
PollIntervalSeconds int
|
||||
|
||||
// DeviceRefreshSeconds is how often the DB is queried for new/removed devices.
|
||||
DeviceRefreshSeconds int
|
||||
|
||||
// ConnectionTimeoutSeconds is the TLS connection timeout per device.
|
||||
ConnectionTimeoutSeconds int
|
||||
|
||||
// LogLevel controls log verbosity (debug, info, warn, error).
|
||||
LogLevel string
|
||||
|
||||
// CircuitBreakerMaxFailures is the number of consecutive connection failures
|
||||
// before the circuit breaker enters backoff mode for a device.
|
||||
CircuitBreakerMaxFailures int
|
||||
|
||||
// CircuitBreakerBaseBackoffSeconds is the base backoff duration in seconds.
|
||||
// Actual backoff is exponential: base * 2^(failures-1), capped at max.
|
||||
CircuitBreakerBaseBackoffSeconds int
|
||||
|
||||
// CircuitBreakerMaxBackoffSeconds is the maximum backoff duration in seconds.
|
||||
CircuitBreakerMaxBackoffSeconds int
|
||||
|
||||
// CommandTimeoutSeconds is the per-command timeout for RouterOS API calls.
|
||||
// Each API call (DetectVersion, CollectInterfaces, etc.) is wrapped with
|
||||
// this timeout to prevent indefinite blocking on unresponsive devices.
|
||||
CommandTimeoutSeconds int
|
||||
}
|
||||
|
||||
// knownInsecureEncryptionKey is the base64-encoded dev default encryption key.
|
||||
// Production environments MUST NOT use this value.
|
||||
const knownInsecureEncryptionKey = "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w="
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults where appropriate.
|
||||
// Returns an error if any required variable is missing or invalid.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Environment: getEnv("ENVIRONMENT", "dev"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379/0"),
|
||||
NatsURL: getEnv("NATS_URL", "nats://localhost:4222"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
PollIntervalSeconds: getEnvInt("POLL_INTERVAL_SECONDS", 60),
|
||||
DeviceRefreshSeconds: getEnvInt("DEVICE_REFRESH_SECONDS", 60),
|
||||
ConnectionTimeoutSeconds: getEnvInt("CONNECTION_TIMEOUT_SECONDS", 10),
|
||||
CircuitBreakerMaxFailures: getEnvInt("CIRCUIT_BREAKER_MAX_FAILURES", 5),
|
||||
CircuitBreakerBaseBackoffSeconds: getEnvInt("CIRCUIT_BREAKER_BASE_BACKOFF_SECONDS", 30),
|
||||
CircuitBreakerMaxBackoffSeconds: getEnvInt("CIRCUIT_BREAKER_MAX_BACKOFF_SECONDS", 900),
|
||||
CommandTimeoutSeconds: getEnvInt("COMMAND_TIMEOUT_SECONDS", 30),
|
||||
}
|
||||
|
||||
if cfg.DatabaseURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL environment variable is required")
|
||||
}
|
||||
|
||||
// OpenBao Transit configuration (optional -- required for Phase 29+ envelope encryption)
|
||||
cfg.OpenBaoAddr = getEnv("OPENBAO_ADDR", "")
|
||||
cfg.OpenBaoToken = getEnv("OPENBAO_TOKEN", "")
|
||||
|
||||
if cfg.OpenBaoAddr != "" && cfg.OpenBaoToken == "" {
|
||||
return nil, fmt.Errorf("OPENBAO_TOKEN is required when OPENBAO_ADDR is set")
|
||||
}
|
||||
|
||||
// Decode the AES-256-GCM encryption key from base64.
|
||||
// Must use StdEncoding (NOT URLEncoding) to match Python's base64.b64encode output.
|
||||
// OPTIONAL when OpenBao Transit is configured (OPENBAO_ADDR set).
|
||||
keyB64 := getEnv("CREDENTIAL_ENCRYPTION_KEY", "")
|
||||
if keyB64 == "" {
|
||||
if cfg.OpenBaoAddr == "" {
|
||||
return nil, fmt.Errorf("CREDENTIAL_ENCRYPTION_KEY environment variable is required (or configure OPENBAO_ADDR for Transit encryption)")
|
||||
}
|
||||
// OpenBao configured without legacy key -- OK for post-migration
|
||||
slog.Info("CREDENTIAL_ENCRYPTION_KEY not set; OpenBao Transit will handle all credential decryption")
|
||||
} else {
|
||||
// Validate production safety BEFORE decode: reject known insecure defaults in non-dev environments.
|
||||
// This runs first so placeholder values like "CHANGE_ME_IN_PRODUCTION" get a clear security
|
||||
// error instead of a confusing "not valid base64" error.
|
||||
if cfg.Environment != "dev" {
|
||||
if keyB64 == knownInsecureEncryptionKey || keyB64 == "CHANGE_ME_IN_PRODUCTION" {
|
||||
return nil, fmt.Errorf(
|
||||
"FATAL: CREDENTIAL_ENCRYPTION_KEY uses a known insecure default in '%s' environment. "+
|
||||
"Generate a secure key for production: "+
|
||||
"python -c \"import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())\"",
|
||||
cfg.Environment,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
key, err := base64.StdEncoding.DecodeString(keyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CREDENTIAL_ENCRYPTION_KEY is not valid base64: %w", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("CREDENTIAL_ENCRYPTION_KEY must decode to exactly 32 bytes, got %d", len(key))
|
||||
}
|
||||
cfg.CredentialEncryptionKey = key
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// getEnv returns the value of an environment variable, or the defaultValue if not set.
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvInt returns the integer value of an environment variable, or the defaultValue if not set or invalid.
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultValue
|
||||
}
|
||||
n, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return n
|
||||
}
|
||||
79
poller/internal/config/config_prod_test.go
Normal file
79
poller/internal/config/config_prod_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProductionValidationRejectsInsecureKey(t *testing.T) {
|
||||
// Save and restore env
|
||||
origEnv := os.Getenv("ENVIRONMENT")
|
||||
origDB := os.Getenv("DATABASE_URL")
|
||||
origKey := os.Getenv("CREDENTIAL_ENCRYPTION_KEY")
|
||||
defer func() {
|
||||
os.Setenv("ENVIRONMENT", origEnv)
|
||||
os.Setenv("DATABASE_URL", origDB)
|
||||
os.Setenv("CREDENTIAL_ENCRYPTION_KEY", origKey)
|
||||
}()
|
||||
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
|
||||
// Test: production with known insecure default key should fail
|
||||
os.Setenv("ENVIRONMENT", "production")
|
||||
os.Setenv("CREDENTIAL_ENCRYPTION_KEY", "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for insecure key in production, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "FATAL") {
|
||||
t.Fatalf("expected FATAL in error message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionValidationRejectsPlaceholder(t *testing.T) {
|
||||
origEnv := os.Getenv("ENVIRONMENT")
|
||||
origDB := os.Getenv("DATABASE_URL")
|
||||
origKey := os.Getenv("CREDENTIAL_ENCRYPTION_KEY")
|
||||
defer func() {
|
||||
os.Setenv("ENVIRONMENT", origEnv)
|
||||
os.Setenv("DATABASE_URL", origDB)
|
||||
os.Setenv("CREDENTIAL_ENCRYPTION_KEY", origKey)
|
||||
}()
|
||||
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
os.Setenv("ENVIRONMENT", "production")
|
||||
os.Setenv("CREDENTIAL_ENCRYPTION_KEY", "CHANGE_ME_IN_PRODUCTION")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for CHANGE_ME_IN_PRODUCTION in production, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "FATAL") {
|
||||
t.Fatalf("expected FATAL in error message for placeholder, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevModeAcceptsInsecureDefaults(t *testing.T) {
|
||||
origEnv := os.Getenv("ENVIRONMENT")
|
||||
origDB := os.Getenv("DATABASE_URL")
|
||||
origKey := os.Getenv("CREDENTIAL_ENCRYPTION_KEY")
|
||||
defer func() {
|
||||
os.Setenv("ENVIRONMENT", origEnv)
|
||||
os.Setenv("DATABASE_URL", origDB)
|
||||
os.Setenv("CREDENTIAL_ENCRYPTION_KEY", origKey)
|
||||
}()
|
||||
|
||||
os.Setenv("ENVIRONMENT", "dev")
|
||||
os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test")
|
||||
os.Setenv("CREDENTIAL_ENCRYPTION_KEY", "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("dev mode should accept insecure defaults, got: %s", err.Error())
|
||||
}
|
||||
if cfg.Environment != "dev" {
|
||||
t.Fatalf("expected Environment=dev, got %s", cfg.Environment)
|
||||
}
|
||||
}
|
||||
104
poller/internal/config/config_test.go
Normal file
104
poller/internal/config/config_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoad_RequiredDatabaseURL(t *testing.T) {
|
||||
// Clear DATABASE_URL to trigger required field error
|
||||
t.Setenv("DATABASE_URL", "")
|
||||
t.Setenv("CREDENTIAL_ENCRYPTION_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
|
||||
_, err := Load()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "DATABASE_URL")
|
||||
}
|
||||
|
||||
func TestLoad_RequiredEncryptionKey(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("CREDENTIAL_ENCRYPTION_KEY", "")
|
||||
|
||||
_, err := Load()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "CREDENTIAL_ENCRYPTION_KEY")
|
||||
}
|
||||
|
||||
func TestLoad_InvalidBase64Key(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("CREDENTIAL_ENCRYPTION_KEY", "not-valid-base64!!!")
|
||||
|
||||
_, err := Load()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "base64")
|
||||
}
|
||||
|
||||
func TestLoad_WrongKeyLength(t *testing.T) {
|
||||
// Encode a 16-byte key (too short -- must be 32)
|
||||
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("CREDENTIAL_ENCRYPTION_KEY", base64.StdEncoding.EncodeToString(make([]byte, 16)))
|
||||
|
||||
_, err := Load()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "32 bytes")
|
||||
}
|
||||
|
||||
func TestLoad_DefaultValues(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("CREDENTIAL_ENCRYPTION_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
// Clear optional vars to test defaults
|
||||
t.Setenv("REDIS_URL", "")
|
||||
t.Setenv("NATS_URL", "")
|
||||
t.Setenv("LOG_LEVEL", "")
|
||||
t.Setenv("POLL_INTERVAL_SECONDS", "")
|
||||
t.Setenv("DEVICE_REFRESH_SECONDS", "")
|
||||
t.Setenv("CONNECTION_TIMEOUT_SECONDS", "")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "redis://localhost:6379/0", cfg.RedisURL)
|
||||
assert.Equal(t, "nats://localhost:4222", cfg.NatsURL)
|
||||
assert.Equal(t, "info", cfg.LogLevel)
|
||||
assert.Equal(t, 60, cfg.PollIntervalSeconds)
|
||||
assert.Equal(t, 60, cfg.DeviceRefreshSeconds)
|
||||
assert.Equal(t, 10, cfg.ConnectionTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoad_CustomValues(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgres://custom:pass@db:5432/mydb")
|
||||
t.Setenv("CREDENTIAL_ENCRYPTION_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
t.Setenv("REDIS_URL", "redis://custom-redis:6380/1")
|
||||
t.Setenv("NATS_URL", "nats://custom-nats:4223")
|
||||
t.Setenv("LOG_LEVEL", "debug")
|
||||
t.Setenv("POLL_INTERVAL_SECONDS", "30")
|
||||
t.Setenv("DEVICE_REFRESH_SECONDS", "120")
|
||||
t.Setenv("CONNECTION_TIMEOUT_SECONDS", "5")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "postgres://custom:pass@db:5432/mydb", cfg.DatabaseURL)
|
||||
assert.Equal(t, "redis://custom-redis:6380/1", cfg.RedisURL)
|
||||
assert.Equal(t, "nats://custom-nats:4223", cfg.NatsURL)
|
||||
assert.Equal(t, "debug", cfg.LogLevel)
|
||||
assert.Equal(t, 30, cfg.PollIntervalSeconds)
|
||||
assert.Equal(t, 120, cfg.DeviceRefreshSeconds)
|
||||
assert.Equal(t, 5, cfg.ConnectionTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoad_ValidEncryptionKey(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i) // deterministic test key
|
||||
}
|
||||
t.Setenv("DATABASE_URL", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("CREDENTIAL_ENCRYPTION_KEY", base64.StdEncoding.EncodeToString(key))
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, key, cfg.CredentialEncryptionKey)
|
||||
}
|
||||
Reference in New Issue
Block a user