diff --git a/.env.example b/.env.example index bec0de6..29574b9 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,5 @@ TELEGRAM_BOT_TOKEN= -REDIS_ADDR=localhost:6379 -REDIS_PASSWORD= -REDIS_DB=0 +REDIS_URL=redis://localhost:6379/0 OPENAI_STATUS_BASE_URL=https://status.openai.com POLL_INTERVAL=1m HTTP_TIMEOUT=10s diff --git a/README.md b/README.md index cd68a45..9a35096 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,14 @@ go run ./cmd/openai-status-bot | Variable | Default | Description | |----------|---------|-------------| | `TELEGRAM_BOT_TOKEN` | required | Telegram bot token from BotFather | -| `REDIS_ADDR` | `localhost:6379` | Redis address | -| `REDIS_PASSWORD` | empty | Redis password | -| `REDIS_DB` | `0` | Redis database number, valid `0`-`15` | +| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL, e.g. `redis://:password@localhost:6379/0` | | `OPENAI_STATUS_BASE_URL` | `https://status.openai.com` | OpenAI status page base URL | | `POLL_INTERVAL` | `1m` | Status check interval, valid `5s`-`1h` | | `HTTP_TIMEOUT` | `10s` | HTTP request timeout, valid `1s`-`5m` | | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`/`warning`, or `error` | +Percent-encode Redis usernames or passwords that contain URL-reserved characters such as `@`, `:`, `/`, `#`, or `%`. + ## Notes The first successful poll seeds Redis and does not send historical incidents. Notifications start from later changes. diff --git a/cmd/openai-status-bot/main.go b/cmd/openai-status-bot/main.go index f822880..83d1755 100644 --- a/cmd/openai-status-bot/main.go +++ b/cmd/openai-status-bot/main.go @@ -31,13 +31,9 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - redisClient := redis.NewClient(&redis.Options{ - Addr: cfg.RedisAddr, - Password: cfg.RedisPassword, - DB: cfg.RedisDB, - }) + redisClient := redis.NewClient(cfg.RedisOptions) if err := redisClient.Ping(ctx).Err(); err != nil { - logger.Error("connect redis", "addr", cfg.RedisAddr, "error", err) + logger.Error("connect redis", "network", cfg.RedisOptions.Network, "addr", cfg.RedisOptions.Addr, "db", cfg.RedisOptions.DB, "tls", cfg.RedisOptions.TLSConfig != nil, "error", err) os.Exit(1) } defer redisClient.Close() diff --git a/docker-compose.yml b/docker-compose.yml index 97d4849..17c69ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: env_file: - .env environment: - REDIS_ADDR: redis:6379 + REDIS_URL: redis://redis:6379/0 depends_on: - redis restart: unless-stopped diff --git a/docs/setup-guide.md b/docs/setup-guide.md index e1060c7..7f372b2 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -11,6 +11,7 @@ ```bash cp .env.example .env # set TELEGRAM_BOT_TOKEN in .env +# REDIS_URL is overridden to redis://redis:6379/0 by docker compose docker compose up --build ``` @@ -27,6 +28,7 @@ Run bot: ```bash cp .env.example .env # export TELEGRAM_BOT_TOKEN or source .env with your shell workflow +# REDIS_URL defaults to redis://localhost:6379/0 go run ./cmd/openai-status-bot ``` @@ -34,7 +36,7 @@ go run ./cmd/openai-status-bot ```bash go test ./... -go build ./cmd/openai-status-bot +go build -o /tmp/openai-status-bot ./cmd/openai-status-bot ``` Then send `/start` to the Telegram bot and run `/status`. If the same token was previously used by a webhook deployment, startup clears that webhook before long polling begins. diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 2cd7804..bbe5e5d 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -38,7 +38,7 @@ Subscriber set members are `chatID` or `chatID:threadID`. Missing settings defau ## Runtime -The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, which allows migration from a webhook-based deployment using the same bot token. Docker Compose starts Redis and the bot. +The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, which allows migration from a webhook-based deployment using the same bot token. Redis is configured with `REDIS_URL`. Docker Compose starts Redis and the bot. ## Failure Behavior diff --git a/internal/config/config.go b/internal/config/config.go index db8037c..5f6519d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,16 +5,15 @@ import ( "log/slog" "net/url" "os" - "strconv" "strings" "time" + + "github.com/redis/go-redis/v9" ) type Config struct { TelegramBotToken string - RedisAddr string - RedisPassword string - RedisDB int + RedisOptions *redis.Options OpenAIStatusBaseURL string PollInterval time.Duration HTTPTimeout time.Duration @@ -33,22 +32,20 @@ const ( func LoadFromEnv() (Config, error) { cfg := Config{ TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")), - RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), - RedisPassword: strings.TrimSpace(os.Getenv("REDIS_PASSWORD")), OpenAIStatusBaseURL: strings.TrimRight(getEnv("OPENAI_STATUS_BASE_URL", "https://status.openai.com"), "/"), } if cfg.TelegramBotToken == "" { return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required") } + var err error + cfg.RedisOptions, err = parseRedisURL("REDIS_URL", getEnv("REDIS_URL", "redis://localhost:6379/0")) + if err != nil { + return Config{}, err + } if err := validateBaseURL("OPENAI_STATUS_BASE_URL", cfg.OpenAIStatusBaseURL); err != nil { return Config{}, err } - var err error - cfg.RedisDB, err = parseBoundedIntEnv("REDIS_DB", 0, minRedisDB, maxRedisDB) - if err != nil { - return Config{}, err - } cfg.PollInterval, err = parseDurationEnv("POLL_INTERVAL", time.Minute, minPollInterval, maxPollInterval) if err != nil { return Config{}, err @@ -72,21 +69,6 @@ func getEnv(key, fallback string) string { return fallback } -func parseBoundedIntEnv(key string, fallback, minValue, maxValue int) (int, error) { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback, nil - } - parsed, err := strconv.Atoi(value) - if err != nil { - return 0, fmt.Errorf("%s must be an integer: %w", key, err) - } - if parsed < minValue || parsed > maxValue { - return 0, fmt.Errorf("%s must be between %d and %d", key, minValue, maxValue) - } - return parsed, nil -} - func parseDurationEnv(key string, fallback, minValue, maxValue time.Duration) (time.Duration, error) { value := strings.TrimSpace(os.Getenv(key)) if value == "" { @@ -102,6 +84,17 @@ func parseDurationEnv(key string, fallback, minValue, maxValue time.Duration) (t return parsed, nil } +func parseRedisURL(key, value string) (*redis.Options, error) { + options, err := redis.ParseURL(value) + if err != nil { + return nil, fmt.Errorf("%s must be a valid Redis URL", key) + } + if options.DB < minRedisDB || options.DB > maxRedisDB { + return nil, fmt.Errorf("%s database must be between %d and %d", key, minRedisDB, maxRedisDB) + } + return options, nil +} + func validateBaseURL(key, value string) error { parsed, err := url.Parse(value) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 47d8955..899a813 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,7 +10,6 @@ import ( func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) { setMinimalEnv(t) t.Setenv("TELEGRAM_BOT_TOKEN", " token ") - t.Setenv("REDIS_PASSWORD", " redis-pass ") cfg, err := LoadFromEnv() if err != nil { @@ -19,14 +18,58 @@ func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) { if cfg.TelegramBotToken != "token" { t.Fatalf("TelegramBotToken = %q", cfg.TelegramBotToken) } - if cfg.RedisPassword != "redis-pass" { - t.Fatalf("RedisPassword = %q", cfg.RedisPassword) + if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.DB != 0 { + t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions) } - if cfg.RedisDB != 0 || cfg.PollInterval != time.Minute || cfg.HTTPTimeout != 10*time.Second { + if cfg.PollInterval != time.Minute || cfg.HTTPTimeout != 10*time.Second { t.Fatalf("unexpected defaults: %+v", cfg) } } +func TestLoadFromEnvIgnoresLegacyRedisVariables(t *testing.T) { + setMinimalEnv(t) + t.Setenv("REDIS_ADDR", "legacy-host:6380") + t.Setenv("REDIS_PASSWORD", "legacy-password") + t.Setenv("REDIS_DB", "5") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.Password != "" || cfg.RedisOptions.DB != 0 { + t.Fatalf("legacy Redis variables affected options: %+v", cfg.RedisOptions) + } +} + +func TestLoadFromEnvAcceptsRedisURLCredentials(t *testing.T) { + setMinimalEnv(t) + t.Setenv("REDIS_URL", " redis://user:redis%40pass%3Awith%2Freserved@localhost:6380/2 ") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6380" || cfg.RedisOptions.Username != "user" || cfg.RedisOptions.Password != "redis@pass:with/reserved" || cfg.RedisOptions.DB != 2 { + t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions) + } +} + +func TestLoadFromEnvDoesNotLeakRedisURLCredentialsInParseErrors(t *testing.T) { + setMinimalEnv(t) + t.Setenv("REDIS_URL", "redis://:secret%zz@localhost:6379/0") + + _, err := LoadFromEnv() + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "secret%zz") { + t.Fatalf("error leaked Redis URL credentials: %q", err.Error()) + } + if !strings.Contains(err.Error(), "REDIS_URL must be a valid Redis URL") { + t.Fatalf("error = %q, want generic Redis URL error", err.Error()) + } +} + func TestLoadFromEnvAcceptsWarningLogAlias(t *testing.T) { setMinimalEnv(t) t.Setenv("LOG_LEVEL", "warning") @@ -47,8 +90,10 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) { value string wantErr string }{ - {name: "redis db below range", key: "REDIS_DB", value: "-1", wantErr: "REDIS_DB must be between 0 and 15"}, - {name: "redis db above range", key: "REDIS_DB", value: "16", wantErr: "REDIS_DB must be between 0 and 15"}, + {name: "redis url missing scheme", key: "REDIS_URL", value: "localhost:6379", wantErr: "REDIS_URL must be a valid Redis URL"}, + {name: "redis url invalid db", key: "REDIS_URL", value: "redis://localhost:6379/not-a-number", wantErr: "REDIS_URL must be a valid Redis URL"}, + {name: "redis url db below range", key: "REDIS_URL", value: "redis://localhost:6379/-1", wantErr: "REDIS_URL database must be between 0 and 15"}, + {name: "redis url db above range", key: "REDIS_URL", value: "redis://localhost:6379/16", wantErr: "REDIS_URL database must be between 0 and 15"}, {name: "poll interval too small", key: "POLL_INTERVAL", value: "1ns", wantErr: "POLL_INTERVAL must be between 5s and 1h0m0s"}, {name: "http timeout too small", key: "HTTP_TIMEOUT", value: "500ms", wantErr: "HTTP_TIMEOUT must be between 1s and 5m0s"}, {name: "base url missing scheme", key: "OPENAI_STATUS_BASE_URL", value: "status.openai.com", wantErr: "OPENAI_STATUS_BASE_URL must use http or https"}, @@ -77,9 +122,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) { func setMinimalEnv(t *testing.T) { t.Helper() t.Setenv("TELEGRAM_BOT_TOKEN", "token") - t.Setenv("REDIS_ADDR", "") - t.Setenv("REDIS_PASSWORD", "") - t.Setenv("REDIS_DB", "") + t.Setenv("REDIS_URL", "") t.Setenv("OPENAI_STATUS_BASE_URL", "") t.Setenv("POLL_INTERVAL", "") t.Setenv("HTTP_TIMEOUT", "")