diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bae18ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git/ +bin/ +*.md +LICENSE +.editorconfig +.gitignore diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2fe0ce0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..cb2f909 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,9 @@ +name: check +on: [push] +jobs: + check: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2, 2026-02-28 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - run: docker build . diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9c9ebe5..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,68 +0,0 @@ -# Repository Rules - -Last Updated 2026-01-08 - -These rules MUST be followed at all times, it is very important. - -* Never use `git add -A` - add specific changes to a deliberate commit. A - commit should contain one change. After each change, make a commit with a - good one-line summary. - -* NEVER modify the linter config without asking first. - -* NEVER modify tests to exclude special cases or otherwise get them to pass - without asking first. In almost all cases, the code should be changed, - NOT the tests. If you think the test needs to be changed, make your case - for that and ask for permission to proceed, then stop. You need explicit - user approval to modify existing tests. (You do not need user approval - for writing NEW tests.) - -* When linting, assume the linter config is CORRECT, and that each item - output by the linter is something that legitimately needs fixing in the - code. - -* When running tests, use `make test`. - -* Before commits, run `make check`. This runs `make lint` and `make test` - and `make check-fmt`. Any issues discovered MUST be resolved before - committing unless explicitly told otherwise. - -* When fixing a bug, write a failing test for the bug FIRST. Add - appropriate logging to the test to ensure it is written correctly. Commit - that. Then go about fixing the bug until the test passes (without - modifying the test further). Then commit that. - -* When adding a new feature, do the same - implement a test first (TDD). It - doesn't have to be super complex. Commit the test, then commit the - feature. - -* When adding a new feature, use a feature branch. When the feature is - completely finished and the code is up to standards (passes `make check`) - then and only then can the feature branch be merged into `main` and the - branch deleted. - -* Write godoc documentation comments for all exported types and functions as - you go along. - -* ALWAYS be consistent in naming. If you name something one thing in one - place, name it the EXACT SAME THING in another place. - -* Be descriptive and specific in naming. `wl` is bad; - `SourceHostWhitelist` is good. `ConnsPerHost` is bad; - `MaxConnectionsPerHost` is good. - -* This is not prototype or teaching code - this is designed for production. - Any security issues (such as denial of service) or other web - vulnerabilities are P1 bugs and must be added to TODO.md at the top. - -* As this is production code, no stubbing of implementations unless - specifically instructed. We need working implementations. - -* Avoid vendoring deps unless specifically instructed to. NEVER commit - the vendor directory, NEVER commit compiled binaries. If these - directories or files exist, add them to .gitignore (and commit the - .gitignore) if they are not already in there. Keep the entire git - repository (with history) small - under 20MiB, unless you specifically - must commit larger files (e.g. test fixture example media files). Only - OUR source code and immediately supporting files (such as test examples) - goes into the repo/history. diff --git a/CONVENTIONS.md b/CONVENTIONS.md deleted file mode 100644 index 6f7c217..0000000 --- a/CONVENTIONS.md +++ /dev/null @@ -1,1225 +0,0 @@ -# Go HTTP Server Conventions - -This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards. - -## Table of Contents - -1. [Required Libraries](#1-required-libraries) -2. [Project Structure](#2-project-structure) -3. [Dependency Injection (Uber fx)](#3-dependency-injection-uber-fx) -4. [Server Architecture](#4-server-architecture) -5. [Routing (go-chi)](#5-routing-go-chi) -6. [Handler Conventions](#6-handler-conventions) -7. [Middleware Conventions](#7-middleware-conventions) -8. [Configuration (Viper)](#8-configuration-viper) -9. [Logging (slog)](#9-logging-slog) -10. [Database Wrapper](#10-database-wrapper) -11. [Globals Package](#11-globals-package) -12. [Static Assets & Templates](#12-static-assets--templates) -13. [Health Check](#13-health-check) -14. [External Integrations](#14-external-integrations) - ---- - -## 1. Required Libraries - -These libraries are **mandatory** for all new projects: - -| Purpose | Library | Import Path | -|---------|---------|-------------| -| Dependency Injection | Uber fx | `go.uber.org/fx` | -| HTTP Router | go-chi | `github.com/go-chi/chi` | -| Logging | slog (stdlib) | `log/slog` | -| Configuration | Viper | `github.com/spf13/viper` | -| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` | -| CORS | go-chi/cors | `github.com/go-chi/cors` | -| Error Reporting | Sentry | `github.com/getsentry/sentry-go` | -| Metrics | Prometheus | `github.com/prometheus/client_golang` | -| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` | -| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` | - ---- - -## 2. Project Structure - -``` -project-root/ -├── cmd/ -│ └── {appname}/ -│ └── main.go # Entry point -├── internal/ -│ ├── config/ -│ │ └── config.go # Configuration loading -│ ├── database/ -│ │ └── database.go # Database wrapper -│ ├── globals/ -│ │ └── globals.go # Build-time variables -│ ├── handlers/ -│ │ ├── handlers.go # Base handler struct and helpers -│ │ ├── index.go # Individual handlers... -│ │ ├── healthcheck.go -│ │ └── {feature}.go -│ ├── healthcheck/ -│ │ └── healthcheck.go # Health check service -│ ├── logger/ -│ │ └── logger.go # Logger setup -│ ├── middleware/ -│ │ └── middleware.go # All middleware definitions -│ └── server/ -│ ├── server.go # Server struct and lifecycle -│ ├── http.go # HTTP server setup -│ └── routes.go # Route definitions -├── static/ -│ ├── static.go # Embed directive -│ ├── css/ -│ └── js/ -├── templates/ -│ ├── templates.go # Embed and parse -│ └── *.html -├── go.mod -├── go.sum -├── Makefile -└── Dockerfile -``` - -### Key Principles - -- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. -- **`internal/`**: All application packages. Not importable by external projects. -- **One package per concern**: config, database, handlers, middleware, etc. -- **Flat handler files**: One file per handler or logical group of handlers. - ---- - -## 3. Dependency Injection (Uber fx) - -### Entry Point Pattern - -```go -// cmd/httpd/main.go -package main - -import ( - "yourproject/internal/config" - "yourproject/internal/database" - "yourproject/internal/globals" - "yourproject/internal/handlers" - "yourproject/internal/healthcheck" - "yourproject/internal/logger" - "yourproject/internal/middleware" - "yourproject/internal/server" - "go.uber.org/fx" -) - -var ( - Appname string = "CHANGEME" - Version string - Buildarch string -) - -func main() { - globals.Appname = Appname - globals.Version = Version - globals.Buildarch = Buildarch - - fx.New( - fx.Provide( - config.New, - database.New, - globals.New, - handlers.New, - logger.New, - server.New, - middleware.New, - healthcheck.New, - ), - fx.Invoke(func(*server.Server) {}), - ).Run() -} -``` - -### Params Struct Pattern - -Every component that receives dependencies uses a params struct with `fx.In`: - -```go -type HandlersParams struct { - fx.In - Logger *logger.Logger - Globals *globals.Globals - Database *database.Database - Healthcheck *healthcheck.Healthcheck -} - -type Handlers struct { - params *HandlersParams - log *slog.Logger - hc *healthcheck.Healthcheck -} -``` - -### Factory Function Pattern - -All components expose a `New` function with this signature: - -```go -func New(lc fx.Lifecycle, params SomeParams) (*Something, error) { - s := new(Something) - s.params = ¶ms - s.log = params.Logger.Get() - - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - // Initialize resources - return nil - }, - OnStop: func(ctx context.Context) error { - // Cleanup resources - return nil - }, - }) - return s, nil -} -``` - -### Dependency Order - -Providers are resolved automatically by fx, but conceptually follow this order: - -1. `globals.New` - Build-time variables (no dependencies) -2. `logger.New` - Logger (depends on Globals) -3. `config.New` - Configuration (depends on Globals, Logger) -4. `database.New` - Database (depends on Logger, Config) -5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database) -6. `middleware.New` - Middleware (depends on Logger, Globals, Config) -7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) -8. `server.New` - Server (depends on all above) - ---- - -## 4. Server Architecture - -### Server Struct - -The Server struct is the central orchestrator: - -```go -// internal/server/server.go -type ServerParams struct { - fx.In - Logger *logger.Logger - Globals *globals.Globals - Config *config.Config - Middleware *middleware.Middleware - Handlers *handlers.Handlers -} - -type Server struct { - startupTime time.Time - port int - exitCode int - sentryEnabled bool - log *slog.Logger - ctx context.Context - cancelFunc context.CancelFunc - httpServer *http.Server - router *chi.Mux - params ServerParams - mw *middleware.Middleware - h *handlers.Handlers -} -``` - -### Server Factory - -```go -func New(lc fx.Lifecycle, params ServerParams) (*Server, error) { - s := new(Server) - s.params = params - s.mw = params.Middleware - s.h = params.Handlers - s.log = params.Logger.Get() - - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - s.startupTime = time.Now() - go s.Run() - return nil - }, - OnStop: func(ctx context.Context) error { - // Server shutdown logic - return nil - }, - }) - return s, nil -} -``` - -### HTTP Server Setup - -```go -// internal/server/http.go -func (s *Server) serveUntilShutdown() { - listenAddr := fmt.Sprintf(":%d", s.params.Config.Port) - s.httpServer = &http.Server{ - Addr: listenAddr, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - MaxHeaderBytes: 1 << 20, - Handler: s, - } - - s.SetupRoutes() - - s.log.Info("http begin listen", "listenaddr", listenAddr) - if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - s.log.Error("listen error", "error", err) - if s.cancelFunc != nil { - s.cancelFunc() - } - } -} - -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.router.ServeHTTP(w, r) -} -``` - -### Signal Handling and Graceful Shutdown - -```go -func (s *Server) serve() int { - s.ctx, s.cancelFunc = context.WithCancel(context.Background()) - - // Signal watcher - go func() { - c := make(chan os.Signal, 1) - signal.Ignore(syscall.SIGPIPE) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - sig := <-c - s.log.Info("signal received", "signal", sig) - if s.cancelFunc != nil { - s.cancelFunc() - } - }() - - go s.serveUntilShutdown() - - for range s.ctx.Done() { - } - s.cleanShutdown() - return s.exitCode -} - -func (s *Server) cleanShutdown() { - s.exitCode = 0 - ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := s.httpServer.Shutdown(ctxShutdown); err != nil { - s.log.Error("server clean shutdown failed", "error", err) - } - if shutdownCancel != nil { - shutdownCancel() - } - s.cleanupForExit() - if s.sentryEnabled { - sentry.Flush(2 * time.Second) - } -} -``` - ---- - -## 5. Routing (go-chi) - -### Route Setup Pattern - -```go -// internal/server/routes.go -func (s *Server) SetupRoutes() { - s.router = chi.NewRouter() - - // Global middleware (applied to all routes) - s.router.Use(middleware.Recoverer) - s.router.Use(middleware.RequestID) - s.router.Use(s.mw.Logging()) - - // Conditional middleware - if viper.GetString("METRICS_USERNAME") != "" { - s.router.Use(s.mw.Metrics()) - } - - s.router.Use(s.mw.CORS()) - s.router.Use(middleware.Timeout(60 * time.Second)) - - if s.sentryEnabled { - sentryHandler := sentryhttp.New(sentryhttp.Options{ - Repanic: true, - }) - s.router.Use(sentryHandler.Handle) - } - - // Routes - s.router.Get("/", s.h.HandleIndex()) - - // Static files - s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) - - // API versioning - s.router.Route("/api/v1", func(r chi.Router) { - r.Get("/now", s.h.HandleNow()) - }) - - // Routes with specific middleware - auth := s.mw.Auth() - s.router.Get("/login", auth(s.h.HandleLoginGET()).ServeHTTP) - - // Health check (standard path) - s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck()) - - // Protected route groups - if viper.GetString("METRICS_USERNAME") != "" { - s.router.Group(func(r chi.Router) { - r.Use(s.mw.MetricsAuth()) - r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) - }) - } -} -``` - -### Middleware Ordering (Critical) - -1. `middleware.Recoverer` - Panic recovery (must be first) -2. `middleware.RequestID` - Generate request IDs -3. `s.mw.Logging()` - Request logging -4. `s.mw.Metrics()` - Prometheus metrics (if enabled) -5. `s.mw.CORS()` - CORS headers -6. `middleware.Timeout(60s)` - Request timeout -7. `sentryhttp.Handler` - Sentry error reporting (if enabled) - -### API Versioning - -Use route groups for API versioning: - -```go -s.router.Route("/api/v1", func(r chi.Router) { - r.Get("/resource", s.h.HandleResource()) -}) -``` - -### Static File Serving - -Static files are served at `/s/` prefix: - -```go -s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) -``` - ---- - -## 6. Handler Conventions - -### Handler Base Struct - -```go -// internal/handlers/handlers.go -type HandlersParams struct { - fx.In - Logger *logger.Logger - Globals *globals.Globals - Database *database.Database - Healthcheck *healthcheck.Healthcheck -} - -type Handlers struct { - params *HandlersParams - log *slog.Logger - hc *healthcheck.Healthcheck -} - -func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { - s := new(Handlers) - s.params = ¶ms - s.log = params.Logger.Get() - s.hc = params.Healthcheck - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - // Compile templates or other initialization - return nil - }, - }) - return s, nil -} -``` - -### Closure-Based Handler Pattern - -All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created: - -```go -// internal/handlers/index.go -func (s *Handlers) HandleIndex() http.HandlerFunc { - // Initialization runs once - t := templates.GetParsed() - - // Handler runs per-request - return func(w http.ResponseWriter, r *http.Request) { - err := t.ExecuteTemplate(w, "index.html", nil) - if err != nil { - s.log.Error("template execution failed", "error", err) - http.Error(w, http.StatusText(500), 500) - } - } -} -``` - -### JSON Handler Pattern - -```go -// internal/handlers/now.go -func (s *Handlers) HandleNow() http.HandlerFunc { - // Response struct defined in closure scope - type response struct { - Now time.Time `json:"now"` - } - return func(w http.ResponseWriter, r *http.Request) { - s.respondJSON(w, r, &response{Now: time.Now()}, 200) - } -} -``` - -### Response Helpers - -```go -// internal/handlers/handlers.go -func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) { - w.WriteHeader(status) - w.Header().Set("Content-Type", "application/json") - if data != nil { - err := json.NewEncoder(w).Encode(data) - if err != nil { - s.log.Error("json encode error", "error", err) - } - } -} - -func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { - return json.NewDecoder(r.Body).Decode(v) -} -``` - -### Handler Naming Convention - -- `HandleIndex()` - Main page -- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix -- `HandleNow()` - API endpoints -- `HandleHealthCheck()` - System endpoints - ---- - -## 7. Middleware Conventions - -### Middleware Struct - -```go -// internal/middleware/middleware.go -type MiddlewareParams struct { - fx.In - Logger *logger.Logger - Globals *globals.Globals - Config *config.Config -} - -type Middleware struct { - log *slog.Logger - params *MiddlewareParams -} - -func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) { - s := new(Middleware) - s.params = ¶ms - s.log = params.Logger.Get() - return s, nil -} -``` - -### Middleware Signature - -All custom middleware methods return `func(http.Handler) http.Handler`: - -```go -func (s *Middleware) Auth() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Before request - s.log.Info("AUTH: before request") - - next.ServeHTTP(w, r) - - // After request (optional) - }) - } -} -``` - -### Logging Middleware with Status Capture - -```go -type loggingResponseWriter struct { - http.ResponseWriter - statusCode int -} - -func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { - return &loggingResponseWriter{w, http.StatusOK} -} - -func (lrw *loggingResponseWriter) WriteHeader(code int) { - lrw.statusCode = code - lrw.ResponseWriter.WriteHeader(code) -} - -func (s *Middleware) Logging() func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - lrw := NewLoggingResponseWriter(w) - ctx := r.Context() - defer func() { - latency := time.Since(start) - s.log.InfoContext(ctx, "request", - "request_start", start, - "method", r.Method, - "url", r.URL.String(), - "useragent", r.UserAgent(), - "request_id", ctx.Value(middleware.RequestIDKey).(string), - "referer", r.Referer(), - "proto", r.Proto, - "remoteIP", ipFromHostPort(r.RemoteAddr), - "status", lrw.statusCode, - "latency_ms", latency.Milliseconds(), - ) - }() - next.ServeHTTP(lrw, r) - }) - } -} -``` - -### CORS Middleware - -```go -func (s *Middleware) CORS() func(http.Handler) http.Handler { - return cors.Handler(cors.Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, - ExposedHeaders: []string{"Link"}, - AllowCredentials: false, - MaxAge: 300, - }) -} -``` - -### Metrics Middleware - -```go -func (s *Middleware) Metrics() func(http.Handler) http.Handler { - mdlw := ghmm.New(ghmm.Config{ - Recorder: metrics.NewRecorder(metrics.Config{}), - }) - return func(next http.Handler) http.Handler { - return std.Handler("", mdlw, next) - } -} - -func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { - return basicauth.New( - "metrics", - map[string][]string{ - viper.GetString("METRICS_USERNAME"): { - viper.GetString("METRICS_PASSWORD"), - }, - }, - ) -} -``` - ---- - -## 8. Configuration (Viper) - -### Config Struct - -```go -// internal/config/config.go -type ConfigParams struct { - fx.In - Globals *globals.Globals - Logger *logger.Logger -} - -type Config struct { - DBURL string - Debug bool - MaintenanceMode bool - MetricsPassword string - MetricsUsername string - Port int - SentryDSN string - params *ConfigParams - log *slog.Logger -} -``` - -### Configuration Loading - -```go -func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { - log := params.Logger.Get() - name := params.Globals.Appname - - // Config file settings - viper.SetConfigName(name) - viper.SetConfigType("yaml") - viper.AddConfigPath(fmt.Sprintf("/etc/%s", name)) - viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", name)) - - // Environment variables override everything - viper.AutomaticEnv() - - // Defaults - viper.SetDefault("DEBUG", "false") - viper.SetDefault("MAINTENANCE_MODE", "false") - viper.SetDefault("PORT", "8080") - viper.SetDefault("DBURL", "") - viper.SetDefault("SENTRY_DSN", "") - viper.SetDefault("METRICS_USERNAME", "") - viper.SetDefault("METRICS_PASSWORD", "") - - // Read config file (optional) - if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - // Config file not found is OK - } else { - log.Error("config file malformed", "error", err) - panic(err) - } - } - - // Build config struct - s := &Config{ - DBURL: viper.GetString("DBURL"), - Debug: viper.GetBool("debug"), - Port: viper.GetInt("PORT"), - SentryDSN: viper.GetString("SENTRY_DSN"), - MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), - MetricsUsername: viper.GetString("METRICS_USERNAME"), - MetricsPassword: viper.GetString("METRICS_PASSWORD"), - log: log, - params: ¶ms, - } - - // Enable debug logging if configured - if s.Debug { - params.Logger.EnableDebugLogging() - s.log = params.Logger.Get() - } - - return s, nil -} -``` - -### Configuration Precedence - -1. **Environment variables** (highest priority via `AutomaticEnv()`) -2. **`.env` file** (loaded via `godotenv/autoload` import) -3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml` -4. **Defaults** (lowest priority) - -### Environment Loading - -Import godotenv with autoload to automatically load `.env` files: - -```go -import ( - _ "github.com/joho/godotenv/autoload" -) -``` - ---- - -## 9. Logging (slog) - -### Logger Struct - -```go -// internal/logger/logger.go -type LoggerParams struct { - fx.In - Globals *globals.Globals -} - -type Logger struct { - log *slog.Logger - level *slog.LevelVar - params LoggerParams -} -``` - -### Logger Setup with TTY Detection - -```go -func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) { - l := new(Logger) - l.level = new(slog.LevelVar) - l.level.Set(slog.LevelInfo) - - // TTY detection for dev vs prod output - tty := false - if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { - tty = true - } - - var handler slog.Handler - if tty { - // Text output for development - handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: l.level, - AddSource: true, - }) - } else { - // JSON output for production - handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: l.level, - AddSource: true, - }) - } - - l.log = slog.New(handler) - return l, nil -} -``` - -### Logger Methods - -```go -func (l *Logger) EnableDebugLogging() { - l.level.Set(slog.LevelDebug) - l.log.Debug("debug logging enabled", "debug", true) -} - -func (l *Logger) Get() *slog.Logger { - return l.log -} - -func (l *Logger) Identify() { - l.log.Info("starting", - "appname", l.params.Globals.Appname, - "version", l.params.Globals.Version, - "buildarch", l.params.Globals.Buildarch, - ) -} -``` - -### Logging Patterns - -```go -// Info with fields -s.log.Info("message", "key", "value") - -// Error with error object -s.log.Error("operation failed", "error", err) - -// With context -s.log.InfoContext(ctx, "processing request", "request_id", reqID) - -// Structured request logging -s.log.Info("request completed", - "request_start", start, - "method", r.Method, - "url", r.URL.String(), - "status", statusCode, - "latency_ms", latency.Milliseconds(), -) - -// Using slog.Group for nested attributes -s.log.Info("request", - slog.Group("http", - "method", r.Method, - "url", r.URL.String(), - ), - slog.Group("timing", - "start", start, - "latency_ms", latency.Milliseconds(), - ), -) -``` - ---- - -## 10. Database Wrapper - -### Database Struct - -```go -// internal/database/database.go -type DatabaseParams struct { - fx.In - Logger *logger.Logger - Config *config.Config -} - -type Database struct { - URL string - log *slog.Logger - params *DatabaseParams -} -``` - -### Database Factory with Lifecycle - -```go -func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) { - s := new(Database) - s.params = ¶ms - s.log = params.Logger.Get() - - s.log.Info("Database instantiated") - - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - s.log.Info("Database OnStart Hook") - // Connect to database here - // Example: s.db, err = sql.Open("postgres", s.params.Config.DBURL) - return nil - }, - OnStop: func(ctx context.Context) error { - // Disconnect from database here - // Example: s.db.Close() - return nil - }, - }) - return s, nil -} -``` - -### Usage Pattern - -The Database struct is injected into handlers and other services: - -```go -type HandlersParams struct { - fx.In - Database *database.Database - // ... -} - -// Access in handler -func (s *Handlers) HandleSomething() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Use s.params.Database - } -} -``` - ---- - -## 11. Globals Package - -### Package Variables and Struct - -```go -// internal/globals/globals.go -package globals - -import "go.uber.org/fx" - -// Package-level variables (set from main) -var ( - Appname string - Version string - Buildarch string -) - -// Struct for DI -type Globals struct { - Appname string - Version string - Buildarch string -} - -func New(lc fx.Lifecycle) (*Globals, error) { - n := &Globals{ - Appname: Appname, - Buildarch: Buildarch, - Version: Version, - } - return n, nil -} -``` - -### Setting Globals in Main - -```go -// cmd/httpd/main.go -var ( - Appname string = "CHANGEME" // Default, overridden by build - Version string // Set at build time - Buildarch string // Set at build time -) - -func main() { - globals.Appname = Appname - globals.Version = Version - globals.Buildarch = Buildarch - // ... -} -``` - -### Build-Time Variable Injection - -Use ldflags to inject version information at build time: - -```makefile -VERSION := $(shell git describe --tags --always) -BUILDARCH := $(shell go env GOARCH) - -build: - go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd -``` - ---- - -## 12. Static Assets & Templates - -### Static File Embedding - -```go -// static/static.go -package static - -import "embed" - -//go:embed css js -var Static embed.FS -``` - -Directory structure: -``` -static/ -├── static.go -├── css/ -│ ├── bootstrap-4.5.3.min.css -│ └── style.css -└── js/ - ├── bootstrap-4.5.3.bundle.min.js - └── jquery-3.5.1.slim.min.js -``` - -### Template Embedding and Lazy Parsing - -```go -// templates/templates.go -package templates - -import ( - "embed" - "text/template" -) - -//go:embed *.html -var TemplatesRaw embed.FS -var TemplatesParsed *template.Template - -func GetParsed() *template.Template { - if TemplatesParsed == nil { - TemplatesParsed = template.Must(template.ParseFS(TemplatesRaw, "*")) - } - return TemplatesParsed -} -``` - -### Template Composition - -Templates use Go's template composition: - -```html - -{{ template "htmlheader.html" . }} -{{ template "navbar.html" . }} - -
- -
- -{{ template "pagefooter.html" . }} -{{ template "htmlfooter.html" . }} -``` - -### Static Asset References - -Reference static files with `/s/` prefix: - -```html - - - -``` - ---- - -## 13. Health Check - -### Health Check Service - -```go -// internal/healthcheck/healthcheck.go -type HealthcheckParams struct { - fx.In - Globals *globals.Globals - Config *config.Config - Logger *logger.Logger - Database *database.Database -} - -type Healthcheck struct { - StartupTime time.Time - log *slog.Logger - params *HealthcheckParams -} - -func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) { - s := new(Healthcheck) - s.params = ¶ms - s.log = params.Logger.Get() - - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - s.StartupTime = time.Now() - return nil - }, - OnStop: func(ctx context.Context) error { - return nil - }, - }) - return s, nil -} -``` - -### Health Check Response - -```go -type HealthcheckResponse struct { - Status string `json:"status"` - Now string `json:"now"` - UptimeSeconds int64 `json:"uptime_seconds"` - UptimeHuman string `json:"uptime_human"` - Version string `json:"version"` - Appname string `json:"appname"` - Maintenance bool `json:"maintenance_mode"` -} - -func (s *Healthcheck) uptime() time.Duration { - return time.Since(s.StartupTime) -} - -func (s *Healthcheck) Healthcheck() *HealthcheckResponse { - resp := &HealthcheckResponse{ - Status: "ok", - Now: time.Now().UTC().Format(time.RFC3339Nano), - UptimeSeconds: int64(s.uptime().Seconds()), - UptimeHuman: s.uptime().String(), - Appname: s.params.Globals.Appname, - Version: s.params.Globals.Version, - } - return resp -} -``` - -### Standard Endpoint - -Health check is served at the standard `.well-known` path: - -```go -s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck()) -``` - ---- - -## 14. External Integrations - -### Sentry Error Reporting - -Sentry is conditionally enabled based on `SENTRY_DSN` environment variable: - -```go -func (s *Server) enableSentry() { - s.sentryEnabled = false - - if s.params.Config.SentryDSN == "" { - return - } - - err := sentry.Init(sentry.ClientOptions{ - Dsn: s.params.Config.SentryDSN, - Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version), - }) - if err != nil { - s.log.Error("sentry init failure", "error", err) - os.Exit(1) - return - } - s.log.Info("sentry error reporting activated") - s.sentryEnabled = true -} -``` - -Sentry middleware with repanic (bubbles panics to chi's Recoverer): - -```go -if s.sentryEnabled { - sentryHandler := sentryhttp.New(sentryhttp.Options{ - Repanic: true, - }) - s.router.Use(sentryHandler.Handle) -} -``` - -Flush Sentry on shutdown: - -```go -if s.sentryEnabled { - sentry.Flush(2 * time.Second) -} -``` - -### Prometheus Metrics - -Metrics are conditionally enabled and protected by basic auth: - -```go -// Only enable if credentials are configured -if viper.GetString("METRICS_USERNAME") != "" { - s.router.Use(s.mw.Metrics()) -} - -// Protected /metrics endpoint -if viper.GetString("METRICS_USERNAME") != "" { - s.router.Group(func(r chi.Router) { - r.Use(s.mw.MetricsAuth()) - r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) - }) -} -``` - -### Environment Variables Summary - -| Variable | Description | Default | -|----------|-------------|---------| -| `PORT` | HTTP listen port | 8080 | -| `DEBUG` | Enable debug logging | false | -| `DBURL` | Database connection URL | "" | -| `SENTRY_DSN` | Sentry DSN for error reporting | "" | -| `MAINTENANCE_MODE` | Enable maintenance mode | false | -| `METRICS_USERNAME` | Basic auth username for /metrics | "" | -| `METRICS_PASSWORD` | Basic auth password for /metrics | "" | diff --git a/Dockerfile b/Dockerfile index dd40645..243bf5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ # Build stage -FROM golang:1.25-alpine AS builder +# golang 1.25-alpine, 2026-02-28 +FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder -RUN apk add --no-cache git make gcc musl-dev +RUN apk add --no-cache git make gcc musl-dev binutils-gold -# Install golangci-lint v2 -RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest -RUN go install golang.org/x/tools/cmd/goimports@latest +# golangci-lint v2.10.1 +RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee +# goimports v0.42.0 +RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 WORKDIR /src COPY go.mod go.sum ./ @@ -20,7 +22,8 @@ RUN make check RUN make build # Runtime stage -FROM alpine:3.21 +# alpine 3.21, 2026-02-28 +FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 RUN apk add --no-cache ca-certificates tzdata diff --git a/Makefile b/Makefile index 1da1f2d..0aadca3 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,8 @@ -.PHONY: all build lint fmt test check clean +.PHONY: all build lint fmt fmt-check test check clean hooks docker BINARY := dnswatcher VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -BUILDARCH := $(shell go env GOARCH) -LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH) +LDFLAGS := -X main.Version=$(VERSION) all: check build @@ -17,8 +16,11 @@ fmt: gofmt -s -w . goimports -w . +fmt-check: + @test -z "$$(gofmt -l .)" || (echo "gofmt: files not formatted:" && gofmt -l . && exit 1) + test: - go test -v -race -cover ./... + go test -v -race -timeout 30s -cover ./... # Check runs all validation without making changes # Used by CI and Docker build - fails if anything is wrong @@ -28,10 +30,19 @@ check: @echo "==> Running linter..." golangci-lint run --config .golangci.yml ./... @echo "==> Running tests..." - go test -v -race ./... + go test -v -race -timeout 30s ./... @echo "==> Building..." go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher @echo "==> All checks passed!" clean: rm -rf bin/ + +hooks: + @echo '#!/bin/sh' > .git/hooks/pre-commit + @echo 'make check' >> .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "Pre-commit hook installed." + +docker: + docker build . diff --git a/README.md b/README.md index 972a8ac..c4e6a6d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # dnswatcher +dnswatcher is a pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks. + > ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice. -dnswatcher is a production DNS and infrastructure monitoring daemon written in -Go. It watches configured DNS domains and hostnames for changes, monitors TCP +dnswatcher watches configured DNS domains and hostnames for changes, monitors TCP port availability, tracks TLS certificate expiry, and delivers real-time notifications via Slack, Mattermost, and/or ntfy webhooks. @@ -51,10 +52,6 @@ without requiring an external database. responding again. - **Inconsistency detected**: Two nameservers that previously agreed now return different record sets for the same hostname. - - **Inconsistency resolved**: Nameservers that previously disagreed - are now back in agreement. - - **Empty response**: A nameserver that previously returned records - now returns an authoritative empty response (NODATA/NXDOMAIN). ### TCP Port Monitoring @@ -109,8 +106,8 @@ includes: - **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS inconsistencies**: Which nameservers disagree, what each one returned, which hostname affected. -- **Port changes**: Which IP:port, old state, new state, associated - hostname. +- **Port changes**: Which IP:port, old state, new state, all associated + hostnames. - **TLS expiry warnings**: Which certificate, days remaining, CN, issuer, associated hostname and IP. - **TLS certificate changes**: Old and new CN/issuer/SANs, associated @@ -127,16 +124,42 @@ includes: - State is written atomically (write to temp file, then rename) to prevent corruption. +### Web Dashboard + +dnswatcher includes an unauthenticated, read-only web dashboard at the +root URL (`/`). It displays: + +- **Summary counts** for monitored domains, hostnames, ports, and + certificates. +- **Domains** with their discovered nameservers. +- **Hostnames** with per-nameserver DNS records and status. +- **Ports** with open/closed state and associated hostnames. +- **TLS certificates** with CN, issuer, expiry, and status. +- **Recent alerts** (last 100 notifications sent since the process + started), displayed in reverse chronological order. + +Every data point shows its age (e.g. "5m ago") so you can tell at a +glance how fresh the information is. The page auto-refreshes every 30 +seconds. + +The dashboard intentionally does not expose any configuration details +such as webhook URLs, notification endpoints, or API tokens. + +All assets (CSS) are embedded in the binary and served from the +application itself. The dashboard makes zero external HTTP requests — +no CDN dependencies or third-party resources are loaded at runtime. + ### HTTP API dnswatcher exposes a lightweight HTTP API for operational visibility: | Endpoint | Description | |---------------------------------------|--------------------------------| -| `GET /health` | Health check (JSON) | +| `GET /` | Web dashboard (HTML) | +| `GET /s/...` | Static assets (embedded CSS) | +| `GET /.well-known/healthcheck` | Health check (JSON) | +| `GET /health` | Health check (JSON, legacy) | | `GET /api/v1/status` | Current monitoring state | -| `GET /api/v1/domains` | Configured domains and status | -| `GET /api/v1/hostnames` | Configured hostnames and status| | `GET /metrics` | Prometheus metrics (optional) | --- @@ -148,7 +171,7 @@ cmd/dnswatcher/main.go Entry point (uber/fx bootstrap) internal/ config/config.go Viper-based configuration - globals/globals.go Build-time variables (version, arch) + globals/globals.go Build-time variables (version) logger/logger.go slog structured logging (TTY detection) healthcheck/healthcheck.go Health check service middleware/middleware.go HTTP middleware (logging, CORS, metrics auth) @@ -196,7 +219,7 @@ the following precedence (highest to lowest): |---------------------------------|--------------------------------------------|-------------| | `PORT` | HTTP listen port | `8080` | | `DNSWATCHER_DEBUG` | Enable debug logging | `false` | -| `DNSWATCHER_DATA_DIR` | Directory for state file | `./data` | +| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | | `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | | `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | | `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | @@ -208,17 +231,25 @@ the following precedence (highest to lowest): | `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` | | `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` | | `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | +| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | + +**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no +monitoring targets are configured. A monitoring daemon with nothing to monitor +is a misconfiguration, so dnswatcher fails fast with a clear error message +rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated +list of DNS names before starting. ### Example `.env` ```sh PORT=8080 DNSWATCHER_DEBUG=false -DNSWATCHER_DATA_DIR=./data +DNSWATCHER_DATA_DIR=/var/lib/dnswatcher DNSWATCHER_TARGETS=example.com,example.org,www.example.com,api.example.com,mail.example.org DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts +DNSWATCHER_SEND_TEST_NOTIFICATION=true ``` --- @@ -289,12 +320,12 @@ not as a merged view, to enable inconsistency detection. "ports": { "93.184.216.34:80": { "open": true, - "hostname": "www.example.com", + "hostnames": ["www.example.com"], "lastChecked": "2026-02-19T12:00:00Z" }, "93.184.216.34:443": { "open": true, - "hostname": "www.example.com", + "hostnames": ["www.example.com"], "lastChecked": "2026-02-19T12:00:00Z" } }, @@ -318,8 +349,6 @@ tracks reachability: |-------------|-------------------------------------------------| | `ok` | Query succeeded, records are current | | `error` | Query failed (timeout, SERVFAIL, network error) | -| `nxdomain` | Authoritative NXDOMAIN response | -| `nodata` | Authoritative empty response (NODATA) | --- @@ -336,11 +365,10 @@ make clean # Remove build artifacts ### Build-Time Variables -Version and architecture are injected via `-ldflags`: +Version is injected via `-ldflags`: ```sh -go build -ldflags "-X main.Version=$(git describe --tags --always) \ - -X main.Buildarch=$(go env GOARCH)" ./cmd/dnswatcher +go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher ``` --- @@ -354,6 +382,7 @@ docker run -d \ -v dnswatcher-data:/var/lib/dnswatcher \ -e DNSWATCHER_TARGETS=example.com,www.example.com \ -e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \ + -e DNSWATCHER_SEND_TEST_NOTIFICATION=true \ dnswatcher ``` @@ -366,9 +395,15 @@ docker run -d \ triggering change notifications). 2. **Initial check**: Immediately perform all DNS, port, and TLS checks on startup. -3. **Periodic checks**: - - DNS and port checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). - - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h). +3. **Periodic checks** (DNS always runs first): + - DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also + re-run before every TLS check cycle to ensure fresh IPs. + - Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes. + - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after + DNS completes. + - Port and TLS checks always use freshly resolved IP addresses from + the DNS phase that immediately precedes them — never stale IPs + from a previous cycle. 4. **On change detection**: Send notifications to all configured endpoints, update in-memory state, persist to disk. 5. **Shutdown**: Persist final state to disk, complete in-flight @@ -376,9 +411,27 @@ docker run -d \ --- +## Planned Future Features (Post-1.0) + +- **DNSSEC validation**: Validate the DNSSEC chain of trust during + iterative resolution and report DNSSEC failures as notifications. + +--- + ## Project Structure -Follows the conventions defined in `CONVENTIONS.md`, adapted from the +Follows the conventions defined in `REPO_POLICIES.md`, adapted from the [upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx for dependency injection, go-chi for HTTP routing, slog for logging, and Viper for configuration. + +--- + +## License + +License has not yet been chosen for this project. Pending decision by the +author (MIT, GPL, or WTFPL). + +## Author + +[@sneak](https://sneak.berlin) diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md new file mode 100644 index 0000000..a024cbd --- /dev/null +++ b/REPO_POLICIES.md @@ -0,0 +1,188 @@ +--- +title: Repository Policies +last_modified: 2026-02-22 +--- + +This document covers repository structure, tooling, and workflow standards. Code +style conventions are in separate documents: + +- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md) + (general, bash, Docker) +- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md) +- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md) +- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md) +- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md) + +--- + +- Cross-project documentation (such as this file) must include + `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync + with the authoritative source as policies evolve. + +- **ALL external references must be pinned by cryptographic hash.** This + includes Docker base images, Go modules, npm packages, GitHub Actions, and + anything else fetched from a remote source. Version tags (`@v4`, `@latest`, + `:3.21`, etc.) are server-mutable and therefore remote code execution + vulnerabilities. The ONLY acceptable way to reference an external dependency + is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm + integrity hash in lockfile, GitHub Actions `@`). No exceptions. + This also means never `curl | bash` to install tools like pyenv, nvm, rustup, + etc. Instead, download a specific release archive from GitHub, verify its hash + (hardcoded in the Dockerfile or script), and only then install. Unverified + install scripts are arbitrary remote code execution. This is the single most + important rule in this document. Double-check every external reference in + every file before committing. There are zero exceptions to this rule. + +- Every repo with software must have a root `Makefile` with these targets: + `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), + `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and + `make hooks` (installs pre-commit hook). A model Makefile is at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + +- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) + instead of invoking the underlying tools directly. The Makefile is the single + source of truth for how these operations are run. + +- The Makefile is authoritative documentation for how the repo is used. Beyond + the required targets above, it should have targets for every common operation: + running a local development server (`make run`, `make dev`), re-initializing + or migrating the database (`make db-reset`, `make migrate`), building + artifacts (`make build`), generating code, seeding data, or anything else a + developer would do regularly. If someone checks out the repo and types + `make`, they should see every meaningful operation available. A new + contributor should be able to understand the entire development workflow by + reading the Makefile. + +- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` + as a build step so the build fails if the branch is not green. For non-server + repos, the Dockerfile should bring up a development environment and run + `make check`. For server repos, `make check` should run as an early build + stage before the final image is assembled. + +- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that + runs `docker build .` on push. Since the Dockerfile already runs `make check`, + a successful build implies all checks pass. + +- Use platform-standard formatters: `black` for Python, `prettier` for + JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with + two exceptions: four-space indents (except Go), and `proseWrap: always` for + Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, + HTML, CSS) should also have `.prettierrc` and `.prettierignore`. + +- Pre-commit hook: `make check` if local testing is possible, otherwise + `make lint && make fmt-check`. The Makefile should provide a `make hooks` + target to install the pre-commit hook. + +- All repos with software must have tests that run via the platform-standard + test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful + tests exist yet, add the most minimal test possible — e.g. importing the + module under test to verify it compiles/parses. There is no excuse for + `make test` to be a no-op. + +- `make test` must complete in under 20 seconds. Add a 30-second timeout in the + Makefile. + +- Docker builds must complete in under 5 minutes. + +- `make check` must not modify any files in the repo. Tests may use temporary + directories. + +- `main` must always pass `make check`, no exceptions. + +- Never commit secrets. `.env` files, credentials, API keys, and private keys + must be in `.gitignore`. No exceptions. + +- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), + editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. + Fetch the standard `.gitignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up + a new repo. + +- Never use `git add -A` or `git add .`. Always stage files explicitly by name. + +- Never force-push to `main`. + +- Make all changes on a feature branch. You can do whatever you want on a + feature branch. + +- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only + manually by the user. Fetch from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. + +- When pinning images or packages by hash, add a comment above the reference + with the version and date (YYYY-MM-DD). + +- Use `yarn`, not `npm`. + +- Write all dates as YYYY-MM-DD (ISO 8601). + +- Simple projects should be configured with environment variables. + +- Dockerized web services listen on port 8080 by default, overridable with + `PORT`. + +- `README.md` is the primary documentation. Required sections: + - **Description**: First line must include the project name, purpose, + category (web server, SPA, CLI tool, etc.), license, and author. Example: + "µPaaS is an MIT-licensed Go web application by @sneak that receives + git-frontend webhooks and deploys applications via Docker in realtime." + - **Getting Started**: Copy-pasteable install/usage code block. + - **Rationale**: Why does this exist? + - **Design**: How is the program structured? + - **TODO**: Update meticulously, even between commits. When planning, put + the todo list in the README so a new agent can pick up where the last one + left off. + - **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a + `LICENSE` file in the repo root and a License section in the README. + - **Author**: [@sneak](https://sneak.berlin). + +- First commit of a new repo should contain only `README.md`. + +- Go module root: `sneak.berlin/go/`. Always run `go mod tidy` before + committing. + +- Use SemVer. + +- Database migrations live in `internal/db/migrations/` and must be embedded in + the binary. + - `000_migration.sql` — contains ONLY the creation of the migrations tracking + table itself. Nothing else. + - `001_schema.sql` — the full application schema. + - **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There + is no installed base to migrate. Edit `001_schema.sql` directly. + - **Post-1.0.0:** add new numbered migration files for each schema change. + Never edit existing migrations after release. + +- All repos should have an `.editorconfig` enforcing the project's indentation + settings. + +- Avoid putting files in the repo root unless necessary. Root should contain + only project-level config files (`README.md`, `Makefile`, `Dockerfile`, + `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and + language-specific config). Everything else goes in a subdirectory. Canonical + subdirectory names: + - `bin/` — executable scripts and tools + - `cmd/` — Go command entrypoints + - `configs/` — configuration templates and examples + - `deploy/` — deployment manifests (k8s, compose, terraform) + - `docs/` — documentation and markdown (README.md stays in root) + - `internal/` — Go internal packages + - `internal/db/migrations/` — database migrations + - `pkg/` — Go library packages + - `share/` — systemd units, data files + - `static/` — static assets (images, fonts, etc.) + - `web/` — web frontend source + +- When setting up a new repo, files from the `prompts` repo may be used as + templates. Fetch them from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/`. + +- New repos must contain at minimum: + - `README.md`, `.git`, `.gitignore`, `.editorconfig` + - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) + - `Makefile` + - `Dockerfile`, `.dockerignore` + - `.gitea/workflows/check.yml` + - Go: `go.mod`, `go.sum`, `.golangci.yml` + - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` + - Python: `pyproject.toml` diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..3dd34c4 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,34 @@ +# Testing Policy + +## DNS Resolution Tests + +All resolver tests **MUST** use live queries against real DNS servers. +No mocking of the DNS client layer is permitted. + +### Rationale + +The resolver performs iterative resolution from root nameservers through +the full delegation chain. Mocked responses cannot faithfully represent +the variety of real-world DNS behavior (truncation, referrals, glue +records, DNSSEC, varied response times, EDNS, etc.). Testing against +real servers ensures the resolver works correctly in production. + +### Constraints + +- Tests hit real DNS infrastructure and require network access +- Test duration depends on network conditions; timeout tuning keeps + the suite within the 30-second target +- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) + plus processing margin +- Root server fan-out is limited to reduce parallel query load +- Flaky failures from transient network issues are acceptable and + should be investigated as potential resolver bugs, not papered over + with mocks or skip flags + +### What NOT to do + +- **Do not mock `DNSClient`** for resolver tests (the mock constructor + exists for unit-testing other packages that consume the resolver) +- **Do not add `-short` flags** to skip slow tests +- **Do not increase `-timeout`** to hide hanging queries +- **Do not modify linter configuration** to suppress findings diff --git a/cmd/dnswatcher/main.go b/cmd/dnswatcher/main.go index 03b38da..baff13b 100644 --- a/cmd/dnswatcher/main.go +++ b/cmd/dnswatcher/main.go @@ -25,15 +25,13 @@ import ( // //nolint:gochecknoglobals // build-time variables var ( - Appname = "dnswatcher" - Version string - Buildarch string + Appname = "dnswatcher" + Version string ) func main() { globals.SetAppname(Appname) globals.SetVersion(Version) - globals.SetBuildarch(Buildarch) fx.New( fx.Provide( diff --git a/go.mod b/go.mod index 32ad532..53638c9 100644 --- a/go.mod +++ b/go.mod @@ -7,19 +7,24 @@ require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/joho/godotenv v1.5.1 + github.com/miekg/dns v1.1.72 github.com/prometheus/client_golang v1.23.2 github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 go.uber.org/fx v1.24.0 golang.org/x/net v0.50.0 + golang.org/x/sync v0.19.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -34,7 +39,11 @@ require ( go.uber.org/zap v1.26.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 66cc528..720b18f 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -74,12 +76,18 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 0acf89e..264b90b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,11 @@ const ( defaultTLSExpiryWarning = 7 ) +// ErrNoTargets is returned when no monitoring targets are configured. +var ErrNoTargets = errors.New( + "no monitoring targets configured: set DNSWATCHER_TARGETS environment variable", +) + // Params contains dependencies for Config. type Params struct { fx.In @@ -33,23 +38,24 @@ type Params struct { // Config holds application configuration. type Config struct { - Port int - Debug bool - DataDir string - Domains []string - Hostnames []string - SlackWebhook string - MattermostWebhook string - NtfyTopic string - DNSInterval time.Duration - TLSInterval time.Duration - TLSExpiryWarning int - SentryDSN string - MaintenanceMode bool - MetricsUsername string - MetricsPassword string - params *Params - log *slog.Logger + Port int + Debug bool + DataDir string + Domains []string + Hostnames []string + SlackWebhook string + MattermostWebhook string + NtfyTopic string + DNSInterval time.Duration + TLSInterval time.Duration + TLSExpiryWarning int + SentryDSN string + MaintenanceMode bool + MetricsUsername string + MetricsPassword string + SendTestNotification bool + params *Params + log *slog.Logger } // New creates a new Config instance from environment and config files. @@ -88,7 +94,7 @@ func setupViper(name string) { viper.SetDefault("PORT", defaultPort) viper.SetDefault("DEBUG", false) - viper.SetDefault("DATA_DIR", "./data") + viper.SetDefault("DATA_DIR", "/var/lib/"+name) viper.SetDefault("TARGETS", "") viper.SetDefault("SLACK_WEBHOOK", "") viper.SetDefault("MATTERMOST_WEBHOOK", "") @@ -100,6 +106,7 @@ func setupViper(name string) { viper.SetDefault("MAINTENANCE_MODE", false) viper.SetDefault("METRICS_USERNAME", "") viper.SetDefault("METRICS_PASSWORD", "") + viper.SetDefault("SEND_TEST_NOTIFICATION", false) } func buildConfig( @@ -132,34 +139,50 @@ func buildConfig( tlsInterval = defaultTLSInterval } + domains, hostnames, err := parseAndValidateTargets() + if err != nil { + return nil, err + } + + cfg := &Config{ + Port: viper.GetInt("PORT"), + Debug: viper.GetBool("DEBUG"), + DataDir: viper.GetString("DATA_DIR"), + Domains: domains, + Hostnames: hostnames, + SlackWebhook: viper.GetString("SLACK_WEBHOOK"), + MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), + NtfyTopic: viper.GetString("NTFY_TOPIC"), + DNSInterval: dnsInterval, + TLSInterval: tlsInterval, + TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"), + SentryDSN: viper.GetString("SENTRY_DSN"), + MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), + MetricsUsername: viper.GetString("METRICS_USERNAME"), + MetricsPassword: viper.GetString("METRICS_PASSWORD"), + SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"), + params: params, + log: log, + } + + return cfg, nil +} + +func parseAndValidateTargets() ([]string, []string, error) { domains, hostnames, err := ClassifyTargets( parseCSV(viper.GetString("TARGETS")), ) if err != nil { - return nil, fmt.Errorf("invalid targets configuration: %w", err) + return nil, nil, fmt.Errorf( + "invalid targets configuration: %w", err, + ) } - cfg := &Config{ - Port: viper.GetInt("PORT"), - Debug: viper.GetBool("DEBUG"), - DataDir: viper.GetString("DATA_DIR"), - Domains: domains, - Hostnames: hostnames, - SlackWebhook: viper.GetString("SLACK_WEBHOOK"), - MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), - NtfyTopic: viper.GetString("NTFY_TOPIC"), - DNSInterval: dnsInterval, - TLSInterval: tlsInterval, - TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"), - SentryDSN: viper.GetString("SENTRY_DSN"), - MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), - MetricsUsername: viper.GetString("METRICS_USERNAME"), - MetricsPassword: viper.GetString("METRICS_PASSWORD"), - params: params, - log: log, + if len(domains) == 0 && len(hostnames) == 0 { + return nil, nil, ErrNoTargets } - return cfg, nil + return domains, hostnames, nil } func parseCSV(input string) []string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2917818 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,262 @@ +package config_test + +import ( + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/globals" + "sneak.berlin/go/dnswatcher/internal/logger" +) + +// newTestParams creates config.Params suitable for testing +// without requiring the fx dependency injection framework. +func newTestParams(t *testing.T) config.Params { + t.Helper() + + g := &globals.Globals{ + Appname: "dnswatcher", + Version: "test", + } + + l, err := logger.New(nil, logger.Params{Globals: g}) + require.NoError(t, err, "failed to create logger") + + return config.Params{ + Globals: g, + Logger: l, + } +} + +// These tests exercise viper global state and MUST NOT use +// t.Parallel(). Each test resets viper for isolation. + +func TestNew_DefaultValues(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + + assert.Equal(t, 8080, cfg.Port) + assert.False(t, cfg.Debug) + assert.Equal(t, "/var/lib/dnswatcher", cfg.DataDir) + assert.Equal(t, time.Hour, cfg.DNSInterval) + assert.Equal(t, 12*time.Hour, cfg.TLSInterval) + assert.Equal(t, 7, cfg.TLSExpiryWarning) + assert.False(t, cfg.MaintenanceMode) + assert.Empty(t, cfg.SlackWebhook) + assert.Empty(t, cfg.MattermostWebhook) + assert.Empty(t, cfg.NtfyTopic) + assert.Empty(t, cfg.SentryDSN) + assert.Empty(t, cfg.MetricsUsername) + assert.Empty(t, cfg.MetricsPassword) + assert.False(t, cfg.SendTestNotification) +} + +func TestNew_EnvironmentOverrides(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("PORT", "9090") + t.Setenv("DNSWATCHER_DEBUG", "true") + t.Setenv("DNSWATCHER_DATA_DIR", "/tmp/test-data") + t.Setenv("DNSWATCHER_DNS_INTERVAL", "30m") + t.Setenv("DNSWATCHER_TLS_INTERVAL", "6h") + t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "14") + t.Setenv("DNSWATCHER_SLACK_WEBHOOK", "https://hooks.slack.com/t") + t.Setenv("DNSWATCHER_MATTERMOST_WEBHOOK", "https://mm.test/hooks/t") + t.Setenv("DNSWATCHER_NTFY_TOPIC", "https://ntfy.sh/test") + t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1") + t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "true") + t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin") + t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret") + t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "true") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + + assert.Equal(t, 9090, cfg.Port) + assert.True(t, cfg.Debug) + assert.Equal(t, "/tmp/test-data", cfg.DataDir) + assert.Equal(t, 30*time.Minute, cfg.DNSInterval) + assert.Equal(t, 6*time.Hour, cfg.TLSInterval) + assert.Equal(t, 14, cfg.TLSExpiryWarning) + assert.Equal(t, "https://hooks.slack.com/t", cfg.SlackWebhook) + assert.Equal(t, "https://mm.test/hooks/t", cfg.MattermostWebhook) + assert.Equal(t, "https://ntfy.sh/test", cfg.NtfyTopic) + assert.Equal(t, "https://sentry.test/1", cfg.SentryDSN) + assert.True(t, cfg.MaintenanceMode) + assert.Equal(t, "admin", cfg.MetricsUsername) + assert.Equal(t, "secret", cfg.MetricsPassword) + assert.True(t, cfg.SendTestNotification) +} + +func TestNew_NoTargetsError(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "") + + _, err := config.New(nil, newTestParams(t)) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrNoTargets) +} + +func TestNew_OnlyEmptyCSVSegments(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", " , , ") + + _, err := config.New(nil, newTestParams(t)) + require.Error(t, err) + assert.ErrorIs(t, err, config.ErrNoTargets) +} + +func TestNew_InvalidDNSInterval_FallsBackToDefault(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_DNS_INTERVAL", "banana") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, time.Hour, cfg.DNSInterval, + "invalid DNS interval should fall back to 1h default") +} + +func TestNew_InvalidTLSInterval_FallsBackToDefault(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_TLS_INTERVAL", "notaduration") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, 12*time.Hour, cfg.TLSInterval, + "invalid TLS interval should fall back to 12h default") +} + +func TestNew_BothIntervalsInvalid(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_DNS_INTERVAL", "xyz") + t.Setenv("DNSWATCHER_TLS_INTERVAL", "abc") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, time.Hour, cfg.DNSInterval) + assert.Equal(t, 12*time.Hour, cfg.TLSInterval) +} + +func TestNew_DebugEnablesDebugLogging(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_DEBUG", "true") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.True(t, cfg.Debug) +} + +func TestNew_PortEnvNotPrefixed(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("PORT", "3000") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, 3000, cfg.Port, + "PORT env should work without DNSWATCHER_ prefix") +} + +func TestNew_TargetClassification(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", + "example.com,www.example.com,api.example.com,example.org") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + + // example.com and example.org are apex domains + assert.Len(t, cfg.Domains, 2) + // www.example.com and api.example.com are hostnames + assert.Len(t, cfg.Hostnames, 2) +} + +func TestNew_InvalidTargetPublicSuffix(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "co.uk") + + _, err := config.New(nil, newTestParams(t)) + require.Error(t, err, "public suffix should be rejected") +} + +func TestNew_EmptyAppnameDefaultsToDnswatcher(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + + g := &globals.Globals{Appname: "", Version: "test"} + + l, err := logger.New(nil, logger.Params{Globals: g}) + require.NoError(t, err) + + cfg, err := config.New( + nil, config.Params{Globals: g, Logger: l}, + ) + require.NoError(t, err) + assert.Equal(t, 8080, cfg.Port, + "defaults should load when appname is empty") +} + +func TestNew_TargetsWithWhitespace(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", " example.com , www.example.com ") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames), + "whitespace around targets should be trimmed") +} + +func TestNew_TargetsWithTrailingComma(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com,") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames), + "trailing comma should be ignored") +} + +func TestNew_CustomDNSIntervalDuration(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_DNS_INTERVAL", "5s") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, 5*time.Second, cfg.DNSInterval) +} + +func TestStatePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dataDir string + want string + }{ + {"default", "/var/lib/dnswatcher", "/var/lib/dnswatcher/state.json"}, + {"absolute", "/var/lib/dw", "/var/lib/dw/state.json"}, + {"nested", "/opt/app/data", "/opt/app/data/state.json"}, + {"empty", "", "/state.json"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := &config.Config{DataDir: tt.dataDir} + assert.Equal(t, tt.want, cfg.StatePath()) + }) + } +} diff --git a/internal/config/export_test.go b/internal/config/export_test.go new file mode 100644 index 0000000..83e0bee --- /dev/null +++ b/internal/config/export_test.go @@ -0,0 +1,6 @@ +package config + +// ParseCSVForTest exports parseCSV for use in external tests. +func ParseCSVForTest(input string) []string { + return parseCSV(input) +} diff --git a/internal/config/parsecsv_test.go b/internal/config/parsecsv_test.go new file mode 100644 index 0000000..21d184e --- /dev/null +++ b/internal/config/parsecsv_test.go @@ -0,0 +1,44 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/dnswatcher/internal/config" +) + +func TestParseCSV(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want []string + }{ + {"empty string", "", nil}, + {"single value", "a", []string{"a"}}, + {"multiple values", "a,b,c", []string{"a", "b", "c"}}, + {"whitespace trimmed", " a , b ", []string{"a", "b"}}, + {"trailing comma", "a,b,", []string{"a", "b"}}, + {"leading comma", ",a,b", []string{"a", "b"}}, + {"consecutive commas", "a,,b", []string{"a", "b"}}, + {"all empty segments", ",,,", nil}, + {"whitespace only", " , , ", nil}, + {"tabs", "\ta\t,\tb\t", []string{"a", "b"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := config.ParseCSVForTest(tt.input) + require.Len(t, got, len(tt.want)) + + for i, w := range tt.want { + assert.Equal(t, w, got[i]) + } + }) + } +} diff --git a/internal/globals/globals.go b/internal/globals/globals.go index 02ce645..0b51cff 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -12,17 +12,15 @@ import ( // //nolint:gochecknoglobals // Required for ldflags injection at build time var ( - mu sync.RWMutex - appname string - version string - buildarch string + mu sync.RWMutex + appname string + version string ) // Globals holds build-time variables for dependency injection. type Globals struct { - Appname string - Version string - Buildarch string + Appname string + Version string } // New creates a new Globals instance from package-level variables. @@ -31,9 +29,8 @@ func New(_ fx.Lifecycle) (*Globals, error) { defer mu.RUnlock() return &Globals{ - Appname: appname, - Version: version, - Buildarch: buildarch, + Appname: appname, + Version: version, }, nil } @@ -52,11 +49,3 @@ func SetVersion(ver string) { version = ver } - -// SetBuildarch sets the build architecture. -func SetBuildarch(arch string) { - mu.Lock() - defer mu.Unlock() - - buildarch = arch -} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..6d4ac57 --- /dev/null +++ b/internal/handlers/dashboard.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "embed" + "fmt" + "html/template" + "math" + "net/http" + "strings" + "time" + + "sneak.berlin/go/dnswatcher/internal/notify" + "sneak.berlin/go/dnswatcher/internal/state" +) + +//go:embed templates/dashboard.html +var dashboardFS embed.FS + +// Time unit constants for relative time calculations. +const ( + secondsPerMinute = 60 + minutesPerHour = 60 + hoursPerDay = 24 +) + +// newDashboardTemplate parses the embedded dashboard HTML +// template with helper functions. +func newDashboardTemplate() *template.Template { + funcs := template.FuncMap{ + "relTime": relTime, + "joinStrings": joinStrings, + "formatRecords": formatRecords, + "expiryDays": expiryDays, + } + + return template.Must( + template.New("dashboard.html"). + Funcs(funcs). + ParseFS(dashboardFS, "templates/dashboard.html"), + ) +} + +// dashboardData is the data passed to the dashboard template. +type dashboardData struct { + Snapshot state.Snapshot + Alerts []notify.AlertEntry + StateAge string + GeneratedAt string +} + +// HandleDashboard returns the dashboard page handler. +func (h *Handlers) HandleDashboard() http.HandlerFunc { + tmpl := newDashboardTemplate() + + return func( + writer http.ResponseWriter, + _ *http.Request, + ) { + snap := h.state.GetSnapshot() + alerts := h.notifyHistory.Recent() + + data := dashboardData{ + Snapshot: snap, + Alerts: alerts, + StateAge: relTime(snap.LastUpdated), + GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), + } + + writer.Header().Set( + "Content-Type", "text/html; charset=utf-8", + ) + + err := tmpl.Execute(writer, data) + if err != nil { + h.log.Error( + "dashboard template error", + "error", err, + ) + } + } +} + +// relTime returns a human-readable relative time string such +// as "2 minutes ago" or "never" for zero times. +func relTime(t time.Time) string { + if t.IsZero() { + return "never" + } + + d := time.Since(t) + if d < 0 { + return "just now" + } + + seconds := int(math.Round(d.Seconds())) + if seconds < secondsPerMinute { + return fmt.Sprintf("%ds ago", seconds) + } + + minutes := seconds / secondsPerMinute + if minutes < minutesPerHour { + return fmt.Sprintf("%dm ago", minutes) + } + + hours := minutes / minutesPerHour + if hours < hoursPerDay { + return fmt.Sprintf( + "%dh %dm ago", hours, minutes%minutesPerHour, + ) + } + + days := hours / hoursPerDay + + return fmt.Sprintf( + "%dd %dh ago", days, hours%hoursPerDay, + ) +} + +// joinStrings joins a string slice with a separator. +func joinStrings(items []string, sep string) string { + return strings.Join(items, sep) +} + +// formatRecords formats a map of record type → values into a +// compact display string. +func formatRecords(records map[string][]string) string { + if len(records) == 0 { + return "-" + } + + var parts []string + + for rtype, values := range records { + for _, v := range values { + parts = append(parts, rtype+": "+v) + } + } + + return strings.Join(parts, ", ") +} + +// expiryDays returns the number of days until the given time, +// rounded down. Returns 0 if already expired. +func expiryDays(t time.Time) int { + d := time.Until(t).Hours() / hoursPerDay + if d < 0 { + return 0 + } + + return int(d) +} diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go new file mode 100644 index 0000000..554cf9d --- /dev/null +++ b/internal/handlers/dashboard_test.go @@ -0,0 +1,80 @@ +package handlers_test + +import ( + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/handlers" +) + +func TestRelTime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dur time.Duration + want string + }{ + {"zero", 0, "never"}, + {"seconds", 30 * time.Second, "30s ago"}, + {"minutes", 5 * time.Minute, "5m ago"}, + {"hours", 2*time.Hour + 15*time.Minute, "2h 15m ago"}, + {"days", 48*time.Hour + 3*time.Hour, "2d 3h ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var input time.Time + if tt.dur > 0 { + input = time.Now().Add(-tt.dur) + } + + got := handlers.RelTime(input) + if got != tt.want { + t.Errorf( + "RelTime(%v) = %q, want %q", + tt.dur, got, tt.want, + ) + } + }) + } +} + +func TestExpiryDays(t *testing.T) { + t.Parallel() + + // 10 days from now. + future := time.Now().Add(10 * 24 * time.Hour) + + days := handlers.ExpiryDays(future) + if days < 9 || days > 10 { + t.Errorf("expected ~10 days, got %d", days) + } + + // Already expired. + past := time.Now().Add(-24 * time.Hour) + + days = handlers.ExpiryDays(past) + if days != 0 { + t.Errorf("expected 0 for expired, got %d", days) + } +} + +func TestFormatRecords(t *testing.T) { + t.Parallel() + + got := handlers.FormatRecords(nil) + if got != "-" { + t.Errorf("expected -, got %q", got) + } + + got = handlers.FormatRecords(map[string][]string{ + "A": {"1.2.3.4"}, + }) + + if got != "A: 1.2.3.4" { + t.Errorf("unexpected format: %q", got) + } +} diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go new file mode 100644 index 0000000..53165b9 --- /dev/null +++ b/internal/handlers/export_test.go @@ -0,0 +1,18 @@ +package handlers + +import "time" + +// RelTime exports relTime for testing. +func RelTime(t time.Time) string { + return relTime(t) +} + +// ExpiryDays exports expiryDays for testing. +func ExpiryDays(t time.Time) int { + return expiryDays(t) +} + +// FormatRecords exports formatRecords for testing. +func FormatRecords(records map[string][]string) string { + return formatRecords(records) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 474c1bc..1ecd7e2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -11,6 +11,8 @@ import ( "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/healthcheck" "sneak.berlin/go/dnswatcher/internal/logger" + "sneak.berlin/go/dnswatcher/internal/notify" + "sneak.berlin/go/dnswatcher/internal/state" ) // Params contains dependencies for Handlers. @@ -20,23 +22,29 @@ type Params struct { Logger *logger.Logger Globals *globals.Globals Healthcheck *healthcheck.Healthcheck + State *state.State + Notify *notify.Service } // Handlers provides HTTP request handlers. type Handlers struct { - log *slog.Logger - params *Params - globals *globals.Globals - hc *healthcheck.Healthcheck + log *slog.Logger + params *Params + globals *globals.Globals + hc *healthcheck.Healthcheck + state *state.State + notifyHistory *notify.AlertHistory } // New creates a new Handlers instance. func New(_ fx.Lifecycle, params Params) (*Handlers, error) { return &Handlers{ - log: params.Logger.Get(), - params: ¶ms, - globals: params.Globals, - hc: params.Healthcheck, + log: params.Logger.Get(), + params: ¶ms, + globals: params.Globals, + hc: params.Healthcheck, + state: params.State, + notifyHistory: params.Notify.History(), }, nil } diff --git a/internal/handlers/status.go b/internal/handlers/status.go index 9996fc8..b01fd6b 100644 --- a/internal/handlers/status.go +++ b/internal/handlers/status.go @@ -2,22 +2,217 @@ package handlers import ( "net/http" + "sort" + "time" + + "sneak.berlin/go/dnswatcher/internal/state" ) +// statusDomainInfo holds status information for a monitored domain. +type statusDomainInfo struct { + Nameservers []string `json:"nameservers"` + LastChecked time.Time `json:"lastChecked"` +} + +// statusHostnameNSInfo holds per-nameserver status for a hostname. +type statusHostnameNSInfo struct { + Records map[string][]string `json:"records"` + Status string `json:"status"` + LastChecked time.Time `json:"lastChecked"` +} + +// statusHostnameInfo holds status information for a monitored hostname. +type statusHostnameInfo struct { + Nameservers map[string]*statusHostnameNSInfo `json:"nameservers"` + LastChecked time.Time `json:"lastChecked"` +} + +// statusPortInfo holds status information for a monitored port. +type statusPortInfo struct { + Open bool `json:"open"` + Hostnames []string `json:"hostnames"` + LastChecked time.Time `json:"lastChecked"` +} + +// statusCertificateInfo holds status information for a TLS certificate. +type statusCertificateInfo struct { + CommonName string `json:"commonName"` + Issuer string `json:"issuer"` + NotAfter time.Time `json:"notAfter"` + SubjectAlternativeNames []string `json:"subjectAlternativeNames"` + Status string `json:"status"` + LastChecked time.Time `json:"lastChecked"` +} + +// statusCounts holds summary counts of monitored resources. +type statusCounts struct { + Domains int `json:"domains"` + Hostnames int `json:"hostnames"` + Ports int `json:"ports"` + PortsOpen int `json:"portsOpen"` + Certificates int `json:"certificates"` + CertsOK int `json:"certificatesOk"` + CertsError int `json:"certificatesError"` +} + +// statusResponse is the full /api/v1/status response. +type statusResponse struct { + Status string `json:"status"` + LastUpdated time.Time `json:"lastUpdated"` + Counts statusCounts `json:"counts"` + Domains map[string]*statusDomainInfo `json:"domains"` + Hostnames map[string]*statusHostnameInfo `json:"hostnames"` + Ports map[string]*statusPortInfo `json:"ports"` + Certificates map[string]*statusCertificateInfo `json:"certificates"` +} + // HandleStatus returns the monitoring status handler. func (h *Handlers) HandleStatus() http.HandlerFunc { - type response struct { - Status string `json:"status"` - } - return func( writer http.ResponseWriter, request *http.Request, ) { + snap := h.state.GetSnapshot() + + resp := buildStatusResponse(snap) + h.respondJSON( writer, request, - &response{Status: "ok"}, + resp, http.StatusOK, ) } } + +// buildStatusResponse constructs the full status response from +// the current monitoring snapshot. +func buildStatusResponse( + snap state.Snapshot, +) *statusResponse { + resp := &statusResponse{ + Status: "ok", + LastUpdated: snap.LastUpdated, + Domains: make(map[string]*statusDomainInfo), + Hostnames: make(map[string]*statusHostnameInfo), + Ports: make(map[string]*statusPortInfo), + Certificates: make(map[string]*statusCertificateInfo), + } + + buildDomains(snap, resp) + buildHostnames(snap, resp) + buildPorts(snap, resp) + buildCertificates(snap, resp) + buildCounts(resp) + + return resp +} + +func buildDomains( + snap state.Snapshot, + resp *statusResponse, +) { + for name, ds := range snap.Domains { + ns := make([]string, len(ds.Nameservers)) + copy(ns, ds.Nameservers) + sort.Strings(ns) + + resp.Domains[name] = &statusDomainInfo{ + Nameservers: ns, + LastChecked: ds.LastChecked, + } + } +} + +func buildHostnames( + snap state.Snapshot, + resp *statusResponse, +) { + for name, hs := range snap.Hostnames { + info := &statusHostnameInfo{ + Nameservers: make(map[string]*statusHostnameNSInfo), + LastChecked: hs.LastChecked, + } + + for ns, nsState := range hs.RecordsByNameserver { + recs := make(map[string][]string, len(nsState.Records)) + for rtype, vals := range nsState.Records { + copied := make([]string, len(vals)) + copy(copied, vals) + recs[rtype] = copied + } + + info.Nameservers[ns] = &statusHostnameNSInfo{ + Records: recs, + Status: nsState.Status, + LastChecked: nsState.LastChecked, + } + } + + resp.Hostnames[name] = info + } +} + +func buildPorts( + snap state.Snapshot, + resp *statusResponse, +) { + for key, ps := range snap.Ports { + hostnames := make([]string, len(ps.Hostnames)) + copy(hostnames, ps.Hostnames) + sort.Strings(hostnames) + + resp.Ports[key] = &statusPortInfo{ + Open: ps.Open, + Hostnames: hostnames, + LastChecked: ps.LastChecked, + } + } +} + +func buildCertificates( + snap state.Snapshot, + resp *statusResponse, +) { + for key, cs := range snap.Certificates { + sans := make([]string, len(cs.SubjectAlternativeNames)) + copy(sans, cs.SubjectAlternativeNames) + + resp.Certificates[key] = &statusCertificateInfo{ + CommonName: cs.CommonName, + Issuer: cs.Issuer, + NotAfter: cs.NotAfter, + SubjectAlternativeNames: sans, + Status: cs.Status, + LastChecked: cs.LastChecked, + } + } +} + +func buildCounts(resp *statusResponse) { + var portsOpen, certsOK, certsError int + + for _, ps := range resp.Ports { + if ps.Open { + portsOpen++ + } + } + + for _, cs := range resp.Certificates { + switch cs.Status { + case "ok": + certsOK++ + case "error": + certsError++ + } + } + + resp.Counts = statusCounts{ + Domains: len(resp.Domains), + Hostnames: len(resp.Hostnames), + Ports: len(resp.Ports), + PortsOpen: portsOpen, + Certificates: len(resp.Certificates), + CertsOK: certsOK, + CertsError: certsError, + } +} diff --git a/internal/handlers/templates/dashboard.html b/internal/handlers/templates/dashboard.html new file mode 100644 index 0000000..5ee6063 --- /dev/null +++ b/internal/handlers/templates/dashboard.html @@ -0,0 +1,370 @@ + + + + + + + dnswatcher + + + +
+ {{/* ---- Header ---- */}} +
+

+ dnswatcher +

+

+ state updated {{ .StateAge }} · page generated + {{ .GeneratedAt }} UTC · auto-refresh 30s +

+
+ + {{/* ---- Summary bar ---- */}} +
+
+
+ Domains +
+
+ {{ len .Snapshot.Domains }} +
+
+
+
+ Hostnames +
+
+ {{ len .Snapshot.Hostnames }} +
+
+
+
+ Ports +
+
+ {{ len .Snapshot.Ports }} +
+
+
+
+ Certificates +
+
+ {{ len .Snapshot.Certificates }} +
+
+
+ + {{/* ---- Domains ---- */}} +
+

+ Domains +

+ {{ if .Snapshot.Domains }} +
+ + + + + + + + + + {{ range $name, $ds := .Snapshot.Domains }} + + + + + + {{ end }} + +
DomainNameserversChecked
+ {{ $name }} + + {{ joinStrings $ds.Nameservers ", " }} + + {{ relTime $ds.LastChecked }} +
+
+ {{ else }} +

+ No domains configured. +

+ {{ end }} +
+ + {{/* ---- Hostnames ---- */}} +
+

+ Hostnames +

+ {{ if .Snapshot.Hostnames }} +
+ + + + + + + + + + + + {{ range $name, $hs := .Snapshot.Hostnames }} + {{ range $ns, $nsr := $hs.RecordsByNameserver }} + + + + + + + + {{ end }} + {{ end }} + +
HostnameNSStatusRecordsChecked
+ {{ $name }} + + {{ $ns }} + + {{ if eq $nsr.Status "ok" }} + ok + {{ else }} + {{ $nsr.Status }} + {{ end }} + + {{ formatRecords $nsr.Records }} + + {{ relTime $nsr.LastChecked }} +
+
+ {{ else }} +

+ No hostnames configured. +

+ {{ end }} +
+ + {{/* ---- Ports ---- */}} +
+

+ Ports +

+ {{ if .Snapshot.Ports }} +
+ + + + + + + + + + + {{ range $key, $ps := .Snapshot.Ports }} + + + + + + + {{ end }} + +
AddressStateHostnamesChecked
+ {{ $key }} + + {{ if $ps.Open }} + open + {{ else }} + closed + {{ end }} + + {{ joinStrings $ps.Hostnames ", " }} + + {{ relTime $ps.LastChecked }} +
+
+ {{ else }} +

+ No port data yet. +

+ {{ end }} +
+ + {{/* ---- Certificates ---- */}} +
+

+ Certificates +

+ {{ if .Snapshot.Certificates }} +
+ + + + + + + + + + + + + {{ range $key, $cs := .Snapshot.Certificates }} + + + + + + + + + {{ end }} + +
EndpointStatusCNIssuerExpiresChecked
+ {{ $key }} + + {{ if eq $cs.Status "ok" }} + ok + {{ else }} + {{ $cs.Status }} + {{ end }} + + {{ $cs.CommonName }} + + {{ $cs.Issuer }} + + {{ if not $cs.NotAfter.IsZero }} + {{ $days := expiryDays $cs.NotAfter }} + {{ if lt $days 7 }} + {{ $cs.NotAfter.Format "2006-01-02" }} + ({{ $days }}d) + {{ else if lt $days 30 }} + {{ $cs.NotAfter.Format "2006-01-02" }} + ({{ $days }}d) + {{ else }} + {{ $cs.NotAfter.Format "2006-01-02" }} + ({{ $days }}d) + {{ end }} + {{ end }} + + {{ relTime $cs.LastChecked }} +
+
+ {{ else }} +

+ No certificate data yet. +

+ {{ end }} +
+ + {{/* ---- Recent Alerts ---- */}} +
+

+ Recent Alerts ({{ len .Alerts }}) +

+ {{ if .Alerts }} +
+ {{ range .Alerts }} +
+
+ {{ if eq .Priority "error" }} + error + {{ else if eq .Priority "warning" }} + warning + {{ else if eq .Priority "success" }} + success + {{ else }} + info + {{ end }} + + {{ .Title }} + + + {{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC + ({{ relTime .Timestamp }}) + +
+

+ {{ .Message }} +

+
+ {{ end }} +
+ {{ else }} +

+ No alerts recorded since last restart. +

+ {{ end }} +
+ + {{/* ---- Footer ---- */}} +
+ dnswatcher · monitoring {{ len .Snapshot.Domains }} domains + + {{ len .Snapshot.Hostnames }} hostnames +
+
+ + diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 0bcdd28..8aaaad1 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -78,6 +78,5 @@ func (l *Logger) Identify() { l.log.Info("starting", "appname", l.params.Globals.Appname, "version", l.params.Globals.Version, - "buildarch", l.params.Globals.Buildarch, ) } diff --git a/internal/notify/delivery_test.go b/internal/notify/delivery_test.go new file mode 100644 index 0000000..1822950 --- /dev/null +++ b/internal/notify/delivery_test.go @@ -0,0 +1,1130 @@ +package notify_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/notify" +) + +// Color constants used across multiple tests. +const ( + colorError = "#dc3545" + colorWarning = "#ffc107" + colorSuccess = "#28a745" + colorInfo = "#17a2b8" + colorDefault = "#6c757d" +) + +// errSimulated is a static error for transport failures. +var errSimulated = errors.New("simulated transport failure") + +// failingTransport always returns an error on RoundTrip. +type failingTransport struct { + err error +} + +func (ft *failingTransport) RoundTrip( + _ *http.Request, +) (*http.Response, error) { + return nil, ft.err +} + +// waitForCondition polls until fn returns true or the test +// times out. This accommodates the goroutine-based dispatch +// in SendNotification. +func waitForCondition(t *testing.T, fn func() bool) { + t.Helper() + + const ( + maxAttempts = 200 + pollDelay = 10 * time.Millisecond + ) + + for range maxAttempts { + if fn() { + return + } + + time.Sleep(pollDelay) + } + + t.Fatal("condition not met within timeout") +} + +// slackCapture holds values captured from a Slack/Mattermost +// webhook request, protected by a mutex for goroutine safety. +type slackCapture struct { + mu sync.Mutex + called bool + payload notify.SlackPayload +} + +func newSlackCaptureServer() ( + *httptest.Server, *slackCapture, +) { + c := &slackCapture{} + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + defer c.mu.Unlock() + + c.called = true + + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &c.payload) + + w.WriteHeader(http.StatusOK) + }), + ) + + return srv, c +} + +// ── ntfyPriority ────────────────────────────────────────── + +func TestNtfyPriority(t *testing.T) { + t.Parallel() + + cases := []struct { + input string + want string + }{ + {"error", "urgent"}, + {"warning", "high"}, + {"success", "default"}, + {"info", "low"}, + {"", "default"}, + {"unknown", "default"}, + {"critical", "default"}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + + got := notify.NtfyPriority(tc.input) + if got != tc.want { + t.Errorf( + "NtfyPriority(%q) = %q, want %q", + tc.input, got, tc.want, + ) + } + }) + } +} + +// ── slackColor ──────────────────────────────────────────── + +func TestSlackColor(t *testing.T) { + t.Parallel() + + cases := []struct { + input string + want string + }{ + {"error", colorError}, + {"warning", colorWarning}, + {"success", colorSuccess}, + {"info", colorInfo}, + {"", colorDefault}, + {"unknown", colorDefault}, + {"critical", colorDefault}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + + got := notify.SlackColor(tc.input) + if got != tc.want { + t.Errorf( + "SlackColor(%q) = %q, want %q", + tc.input, got, tc.want, + ) + } + }) + } +} + +// ── newRequest ──────────────────────────────────────────── + +func TestNewRequest(t *testing.T) { + t.Parallel() + + target := &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/webhook", + } + body := bytes.NewBufferString("hello") + ctx := context.Background() + + req := notify.NewRequestForTest( + ctx, http.MethodPost, target, body, + ) + + if req.Method != http.MethodPost { + t.Errorf("Method = %q, want POST", req.Method) + } + + if req.URL.String() != "https://example.com/webhook" { + t.Errorf( + "URL = %q, want %q", + req.URL.String(), + "https://example.com/webhook", + ) + } + + if req.Host != "example.com" { + t.Errorf( + "Host = %q, want %q", req.Host, "example.com", + ) + } + + if req.Header == nil { + t.Error("Header map should be initialized") + } + + got, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("reading body: %v", err) + } + + if string(got) != "hello" { + t.Errorf("Body = %q, want %q", string(got), "hello") + } +} + +func TestNewRequestPreservesContext(t *testing.T) { + t.Parallel() + + type ctxKey string + + ctx := context.WithValue( + context.Background(), + ctxKey("k"), + "v", + ) + target := &url.URL{Scheme: "https", Host: "example.com"} + + req := notify.NewRequestForTest( + ctx, http.MethodGet, target, http.NoBody, + ) + + if req.Context().Value(ctxKey("k")) != "v" { + t.Error("context value not preserved") + } +} + +// ── sendNtfy ────────────────────────────────────────────── + +// ntfyCapture holds values captured from an ntfy request. +type ntfyCapture struct { + method string + title string + priority string + body string +} + +func newNtfyCaptureServer() (*httptest.Server, *ntfyCapture) { + c := &ntfyCapture{} + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + c.method = r.Method + c.title = r.Header.Get("Title") + c.priority = r.Header.Get("Priority") + + b, _ := io.ReadAll(r.Body) + c.body = string(b) + + w.WriteHeader(http.StatusOK) + }), + ) + + return srv, c +} + +func TestSendNtfyHeaders(t *testing.T) { + t.Parallel() + + srv, captured := newNtfyCaptureServer() + defer srv.Close() + + svc := notify.NewTestService(srv.Client().Transport) + topicURL, _ := url.Parse(srv.URL + "/test-topic") + + err := svc.SendNtfy( + context.Background(), + topicURL, + "Test Title", + "Test message body", + "error", + ) + if err != nil { + t.Fatalf("SendNtfy returned error: %v", err) + } + + if captured.method != http.MethodPost { + t.Errorf("method = %q, want POST", captured.method) + } + + if captured.title != "Test Title" { + t.Errorf( + "Title header = %q, want %q", + captured.title, "Test Title", + ) + } + + if captured.priority != "urgent" { + t.Errorf( + "Priority header = %q, want %q", + captured.priority, "urgent", + ) + } + + if captured.body != "Test message body" { + t.Errorf( + "body = %q, want %q", + captured.body, "Test message body", + ) + } +} + +func TestSendNtfyAllPriorities(t *testing.T) { + t.Parallel() + + priorities := []struct { + input string + want string + }{ + {"error", "urgent"}, + {"warning", "high"}, + {"success", "default"}, + {"info", "low"}, + } + + for _, tc := range priorities { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + + var gotPriority string + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + gotPriority = r.Header.Get("Priority") + + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + svc := notify.NewTestService( + srv.Client().Transport, + ) + topicURL, _ := url.Parse(srv.URL) + + err := svc.SendNtfy( + context.Background(), + topicURL, "t", "m", tc.input, + ) + if err != nil { + t.Fatalf("SendNtfy error: %v", err) + } + + if gotPriority != tc.want { + t.Errorf( + "priority %q: got %q, want %q", + tc.input, gotPriority, tc.want, + ) + } + }) + } +} + +func TestSendNtfyClientError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + }), + ) + defer srv.Close() + + svc := notify.NewTestService(srv.Client().Transport) + topicURL, _ := url.Parse(srv.URL) + + err := svc.SendNtfy( + context.Background(), topicURL, "t", "m", "info", + ) + if err == nil { + t.Fatal("expected error for 403 response") + } + + if !errors.Is(err, notify.ErrNtfyFailed) { + t.Errorf("error = %v, want ErrNtfyFailed", err) + } +} + +func TestSendNtfyServerError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }), + ) + defer srv.Close() + + svc := notify.NewTestService(srv.Client().Transport) + topicURL, _ := url.Parse(srv.URL) + + err := svc.SendNtfy( + context.Background(), topicURL, "t", "m", "info", + ) + if err == nil { + t.Fatal("expected error for 500 response") + } + + if !errors.Is(err, notify.ErrNtfyFailed) { + t.Errorf("error = %v, want ErrNtfyFailed", err) + } +} + +func TestSendNtfySuccess(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + svc := notify.NewTestService(srv.Client().Transport) + topicURL, _ := url.Parse(srv.URL) + + err := svc.SendNtfy( + context.Background(), topicURL, "t", "m", "info", + ) + if err != nil { + t.Fatalf("expected success for 200: %v", err) + } +} + +func TestSendNtfyNetworkError(t *testing.T) { + t.Parallel() + + transport := &failingTransport{err: errSimulated} + + svc := notify.NewTestService(transport) + topicURL, _ := url.Parse( + "http://unreachable.invalid/topic", + ) + + err := svc.SendNtfy( + context.Background(), topicURL, "t", "m", "info", + ) + if err == nil { + t.Fatal("expected error for network failure") + } +} + +// ── sendSlack ───────────────────────────────────────────── + +func TestSendSlackPayloadFields(t *testing.T) { + t.Parallel() + + var ( + gotContentType string + gotPayload notify.SlackPayload + gotMethod string + ) + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotContentType = r.Header.Get("Content-Type") + + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotPayload) + + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + svc := notify.NewTestService(srv.Client().Transport) + webhookURL, _ := url.Parse(srv.URL + "/hooks/test") + + err := svc.SendSlack( + context.Background(), + webhookURL, + "Alert Title", + "Alert body text", + "warning", + ) + if err != nil { + t.Fatalf("SendSlack returned error: %v", err) + } + + assertSlackPayload( + t, + gotMethod, + gotContentType, + gotPayload, + "Alert Title", + "Alert body text", + colorWarning, + ) +} + +func assertSlackPayload( + t *testing.T, + method, contentType string, + payload notify.SlackPayload, + wantTitle, wantText, wantColor string, +) { + t.Helper() + + if method != http.MethodPost { + t.Errorf("method = %q, want POST", method) + } + + if contentType != "application/json" { + t.Errorf( + "Content-Type = %q, want application/json", + contentType, + ) + } + + if len(payload.Attachments) != 1 { + t.Fatalf( + "attachments length = %d, want 1", + len(payload.Attachments), + ) + } + + att := payload.Attachments[0] + + if att.Title != wantTitle { + t.Errorf( + "title = %q, want %q", att.Title, wantTitle, + ) + } + + if att.Text != wantText { + t.Errorf("text = %q, want %q", att.Text, wantText) + } + + if att.Color != wantColor { + t.Errorf( + "color = %q, want %q", att.Color, wantColor, + ) + } +} + +func TestSendSlackAllColors(t *testing.T) { + t.Parallel() + + colors := []struct { + priority string + want string + }{ + {"error", colorError}, + {"warning", colorWarning}, + {"success", colorSuccess}, + {"info", colorInfo}, + {"unknown", colorDefault}, + } + + for _, tc := range colors { + t.Run(tc.priority, func(t *testing.T) { + t.Parallel() + + var gotPayload notify.SlackPayload + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotPayload) + + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + svc := notify.NewTestService( + srv.Client().Transport, + ) + webhookURL, _ := url.Parse(srv.URL) + + err := svc.SendSlack( + context.Background(), + webhookURL, "t", "m", tc.priority, + ) + if err != nil { + t.Fatalf("SendSlack error: %v", err) + } + + if len(gotPayload.Attachments) == 0 { + t.Fatal("no attachments in payload") + } + + if gotPayload.Attachments[0].Color != tc.want { + t.Errorf( + "priority %q: color = %q, want %q", + tc.priority, + gotPayload.Attachments[0].Color, + tc.want, + ) + } + }) + } +} + +func TestSendSlackClientError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }), + ) + defer srv.Close() + + svc := notify.NewTestService(srv.Client().Transport) + webhookURL, _ := url.Parse(srv.URL) + + err := svc.SendSlack( + context.Background(), webhookURL, "t", "m", "info", + ) + if err == nil { + t.Fatal("expected error for 400 response") + } + + if !errors.Is(err, notify.ErrSlackFailed) { + t.Errorf("error = %v, want ErrSlackFailed", err) + } +} + +func TestSendSlackServerError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }), + ) + defer srv.Close() + + svc := notify.NewTestService(srv.Client().Transport) + webhookURL, _ := url.Parse(srv.URL) + + err := svc.SendSlack( + context.Background(), webhookURL, "t", "m", "error", + ) + if err == nil { + t.Fatal("expected error for 502 response") + } + + if !errors.Is(err, notify.ErrSlackFailed) { + t.Errorf("error = %v, want ErrSlackFailed", err) + } +} + +func TestSendSlackNetworkError(t *testing.T) { + t.Parallel() + + transport := &failingTransport{err: errSimulated} + + svc := notify.NewTestService(transport) + webhookURL, _ := url.Parse( + "http://unreachable.invalid/hooks", + ) + + err := svc.SendSlack( + context.Background(), webhookURL, "t", "m", "info", + ) + if err == nil { + t.Fatal("expected error for network failure") + } +} + +// ── SendNotification (exported) ─────────────────────────── + +// endpointResult captures concurrent results from all three +// notification endpoints. +type endpointResult struct { + mu sync.Mutex + ntfyCalled bool + ntfyTitle string + slackCalled bool + slackPayload notify.SlackPayload + mmCalled bool + mmPayload notify.SlackPayload +} + +func newEndpointServers( + r *endpointResult, +) (*httptest.Server, *httptest.Server, *httptest.Server) { + ntfy := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + + r.ntfyCalled = true + r.ntfyTitle = req.Header.Get("Title") + + w.WriteHeader(http.StatusOK) + }), + ) + + slack := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + + r.slackCalled = true + + b, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(b, &r.slackPayload) + + w.WriteHeader(http.StatusOK) + }), + ) + + mm := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + defer r.mu.Unlock() + + r.mmCalled = true + + b, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(b, &r.mmPayload) + + w.WriteHeader(http.StatusOK) + }), + ) + + return ntfy, slack, mm +} + +func assertAllEndpointsResult( + t *testing.T, r *endpointResult, +) { + t.Helper() + + if r.ntfyTitle != "DNS Changed" { + t.Errorf( + "ntfy Title = %q, want %q", + r.ntfyTitle, "DNS Changed", + ) + } + + if len(r.slackPayload.Attachments) == 0 { + t.Fatal("slack payload has no attachments") + } + + if r.slackPayload.Attachments[0].Title != "DNS Changed" { + t.Errorf( + "slack title = %q, want %q", + r.slackPayload.Attachments[0].Title, + "DNS Changed", + ) + } + + if len(r.mmPayload.Attachments) == 0 { + t.Fatal("mattermost payload has no attachments") + } + + wantText := "example.com A record updated" + if r.mmPayload.Attachments[0].Text != wantText { + t.Errorf( + "mattermost text = %q, want %q", + r.mmPayload.Attachments[0].Text, + wantText, + ) + } +} + +func TestSendNotificationAllEndpoints(t *testing.T) { + t.Parallel() + + result := &endpointResult{} + ntfySrv, slackSrv, mmSrv := newEndpointServers(result) + + defer ntfySrv.Close() + defer slackSrv.Close() + defer mmSrv.Close() + + ntfyURL, _ := url.Parse(ntfySrv.URL) + slackURL, _ := url.Parse(slackSrv.URL) + mmURL, _ := url.Parse(mmSrv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetNtfyURL(ntfyURL) + svc.SetSlackWebhookURL(slackURL) + svc.SetMattermostWebhookURL(mmURL) + + svc.SendNotification( + context.Background(), + "DNS Changed", + "example.com A record updated", + "warning", + ) + + waitForCondition(t, func() bool { + result.mu.Lock() + defer result.mu.Unlock() + + return result.ntfyCalled && + result.slackCalled && + result.mmCalled + }) + + result.mu.Lock() + defer result.mu.Unlock() + + assertAllEndpointsResult(t, result) +} + +func TestSendNotificationNoWebhooks(t *testing.T) { + t.Parallel() + + svc := notify.NewTestService(http.DefaultTransport) + + // All URL fields are nil — this should be a no-op. + svc.SendNotification( + context.Background(), "Title", "Message", "info", + ) +} + +func TestSendNotificationNtfyOnly(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + called bool + gotTitle string + ) + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + called = true + gotTitle = r.Header.Get("Title") + + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + ntfyURL, _ := url.Parse(srv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetNtfyURL(ntfyURL) + + svc.SendNotification( + context.Background(), "Only Ntfy", "body", "info", + ) + + waitForCondition(t, func() bool { + mu.Lock() + defer mu.Unlock() + + return called + }) + + mu.Lock() + defer mu.Unlock() + + if gotTitle != "Only Ntfy" { + t.Errorf( + "title = %q, want %q", gotTitle, "Only Ntfy", + ) + } +} + +func TestSendNotificationSlackOnly(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + called bool + payload notify.SlackPayload + ) + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + called = true + + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &payload) + + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + slackURL, _ := url.Parse(srv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetSlackWebhookURL(slackURL) + + svc.SendNotification( + context.Background(), + "Slack Only", "body", "error", + ) + + waitForCondition(t, func() bool { + mu.Lock() + defer mu.Unlock() + + return called + }) + + mu.Lock() + defer mu.Unlock() + + if len(payload.Attachments) == 0 { + t.Fatal("no attachments") + } + + if payload.Attachments[0].Color != colorError { + t.Errorf( + "color = %q, want %q", + payload.Attachments[0].Color, colorError, + ) + } +} + +func TestSendNotificationMattermostOnly(t *testing.T) { + t.Parallel() + + srv, capture := newSlackCaptureServer() + defer srv.Close() + + mmURL, _ := url.Parse(srv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetMattermostWebhookURL(mmURL) + + svc.SendNotification( + context.Background(), + "MM Only", "body", "success", + ) + + waitForCondition(t, func() bool { + capture.mu.Lock() + defer capture.mu.Unlock() + + return capture.called + }) + + capture.mu.Lock() + defer capture.mu.Unlock() + + if len(capture.payload.Attachments) == 0 { + t.Fatal("no attachments") + } + + att := capture.payload.Attachments[0] + + if att.Title != "MM Only" { + t.Errorf( + "title = %q, want %q", att.Title, "MM Only", + ) + } + + if att.Color != colorSuccess { + t.Errorf( + "color = %q, want %q", att.Color, colorSuccess, + ) + } +} + +func TestSendNotificationNtfyError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }), + ) + defer srv.Close() + + ntfyURL, _ := url.Parse(srv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetNtfyURL(ntfyURL) + + // Should not panic or block. + svc.SendNotification( + context.Background(), "t", "m", "error", + ) + + time.Sleep(100 * time.Millisecond) +} + +func TestSendNotificationSlackError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + }), + ) + defer srv.Close() + + slackURL, _ := url.Parse(srv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetSlackWebhookURL(slackURL) + + svc.SendNotification( + context.Background(), "t", "m", "error", + ) + + time.Sleep(100 * time.Millisecond) +} + +func TestSendNotificationMattermostError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }), + ) + defer srv.Close() + + mmURL, _ := url.Parse(srv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetMattermostWebhookURL(mmURL) + + svc.SendNotification( + context.Background(), "t", "m", "warning", + ) + + time.Sleep(100 * time.Millisecond) +} + +// ── SlackPayload JSON marshaling ────────────────────────── + +func TestSlackPayloadJSON(t *testing.T) { + t.Parallel() + + payload := notify.SlackPayload{ + Text: "fallback", + Attachments: []notify.SlackAttachment{ + { + Color: colorSuccess, + Title: "Test", + Text: "body", + }, + }, + } + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var decoded notify.SlackPayload + + err = json.Unmarshal(data, &decoded) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if decoded.Text != "fallback" { + t.Errorf( + "Text = %q, want %q", decoded.Text, "fallback", + ) + } + + if len(decoded.Attachments) != 1 { + t.Fatalf( + "Attachments len = %d, want 1", + len(decoded.Attachments), + ) + } + + att := decoded.Attachments[0] + + if att.Color != colorSuccess { + t.Errorf( + "Color = %q, want %q", att.Color, colorSuccess, + ) + } + + if att.Title != "Test" { + t.Errorf("Title = %q, want %q", att.Title, "Test") + } + + if att.Text != "body" { + t.Errorf("Text = %q, want %q", att.Text, "body") + } +} + +func TestSlackPayloadEmptyAttachments(t *testing.T) { + t.Parallel() + + payload := notify.SlackPayload{Text: "no attachments"} + + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var raw map[string]json.RawMessage + + err = json.Unmarshal(data, &raw) + if err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + + if _, exists := raw["attachments"]; exists { + t.Error( + "attachments should be omitted when empty", + ) + } +} diff --git a/internal/notify/export_test.go b/internal/notify/export_test.go new file mode 100644 index 0000000..ae6818d --- /dev/null +++ b/internal/notify/export_test.go @@ -0,0 +1,105 @@ +package notify + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/url" + "time" +) + +// NtfyPriority exports ntfyPriority for testing. +func NtfyPriority(priority string) string { + return ntfyPriority(priority) +} + +// SlackColor exports slackColor for testing. +func SlackColor(priority string) string { + return slackColor(priority) +} + +// NewRequestForTest exports newRequest for testing. +func NewRequestForTest( + ctx context.Context, + method string, + target *url.URL, + body io.Reader, +) *http.Request { + return newRequest(ctx, method, target, body) +} + +// NewTestService creates a Service suitable for unit testing. +// It discards log output and uses the given transport. +func NewTestService(transport http.RoundTripper) *Service { + return &Service{ + log: slog.New(slog.DiscardHandler), + transport: transport, + history: NewAlertHistory(), + } +} + +// SetNtfyURL sets the ntfy URL on a Service for testing. +func (svc *Service) SetNtfyURL(u *url.URL) { + svc.ntfyURL = u +} + +// SetSlackWebhookURL sets the Slack webhook URL on a +// Service for testing. +func (svc *Service) SetSlackWebhookURL(u *url.URL) { + svc.slackWebhookURL = u +} + +// SetMattermostWebhookURL sets the Mattermost webhook URL on +// a Service for testing. +func (svc *Service) SetMattermostWebhookURL(u *url.URL) { + svc.mattermostWebhookURL = u +} + +// SendNtfy exports sendNtfy for testing. +func (svc *Service) SendNtfy( + ctx context.Context, + topicURL *url.URL, + title, message, priority string, +) error { + return svc.sendNtfy(ctx, topicURL, title, message, priority) +} + +// SendSlack exports sendSlack for testing. +func (svc *Service) SendSlack( + ctx context.Context, + webhookURL *url.URL, + title, message, priority string, +) error { + return svc.sendSlack( + ctx, webhookURL, title, message, priority, + ) +} + +// SetRetryConfig overrides the retry configuration for +// testing. +func (svc *Service) SetRetryConfig(cfg RetryConfig) { + svc.retryConfig = cfg +} + +// SetSleepFunc overrides the sleep function so tests can +// eliminate real delays. +func (svc *Service) SetSleepFunc( + fn func(time.Duration) <-chan time.Time, +) { + svc.sleepFn = fn +} + +// DeliverWithRetry exports deliverWithRetry for testing. +func (svc *Service) DeliverWithRetry( + ctx context.Context, + endpoint string, + fn func(context.Context) error, +) error { + return svc.deliverWithRetry(ctx, endpoint, fn) +} + +// BackoffDuration exports RetryConfig.backoff for testing. +func (rc RetryConfig) BackoffDuration(attempt int) time.Duration { + return rc.defaults().backoff(attempt) +} diff --git a/internal/notify/history.go b/internal/notify/history.go new file mode 100644 index 0000000..53a9b97 --- /dev/null +++ b/internal/notify/history.go @@ -0,0 +1,62 @@ +package notify + +import ( + "sync" + "time" +) + +// maxAlertHistory is the maximum number of alerts to retain. +const maxAlertHistory = 100 + +// AlertEntry represents a single notification that was sent. +type AlertEntry struct { + Timestamp time.Time + Title string + Message string + Priority string +} + +// AlertHistory is a thread-safe ring buffer that stores +// the most recent alerts. +type AlertHistory struct { + mu sync.RWMutex + entries [maxAlertHistory]AlertEntry + count int + index int +} + +// NewAlertHistory creates a new empty AlertHistory. +func NewAlertHistory() *AlertHistory { + return &AlertHistory{} +} + +// Add records a new alert entry in the ring buffer. +func (h *AlertHistory) Add(entry AlertEntry) { + h.mu.Lock() + defer h.mu.Unlock() + + h.entries[h.index] = entry + h.index = (h.index + 1) % maxAlertHistory + + if h.count < maxAlertHistory { + h.count++ + } +} + +// Recent returns the stored alerts in reverse chronological +// order (newest first). Returns at most maxAlertHistory entries. +func (h *AlertHistory) Recent() []AlertEntry { + h.mu.RLock() + defer h.mu.RUnlock() + + result := make([]AlertEntry, h.count) + + for i := range h.count { + // Walk backwards from the most recent entry. + idx := (h.index - 1 - i + maxAlertHistory) % + maxAlertHistory + result[i] = h.entries[idx] + } + + return result +} diff --git a/internal/notify/history_test.go b/internal/notify/history_test.go new file mode 100644 index 0000000..a60804d --- /dev/null +++ b/internal/notify/history_test.go @@ -0,0 +1,88 @@ +package notify_test + +import ( + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/notify" +) + +func TestAlertHistoryEmpty(t *testing.T) { + t.Parallel() + + h := notify.NewAlertHistory() + + entries := h.Recent() + if len(entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(entries)) + } +} + +func TestAlertHistoryAddAndRecent(t *testing.T) { + t.Parallel() + + h := notify.NewAlertHistory() + + now := time.Now().UTC() + + h.Add(notify.AlertEntry{ + Timestamp: now.Add(-2 * time.Minute), + Title: "first", + Message: "msg1", + Priority: "info", + }) + + h.Add(notify.AlertEntry{ + Timestamp: now.Add(-1 * time.Minute), + Title: "second", + Message: "msg2", + Priority: "warning", + }) + + entries := h.Recent() + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + // Newest first. + if entries[0].Title != "second" { + t.Errorf( + "expected newest first, got %q", entries[0].Title, + ) + } + + if entries[1].Title != "first" { + t.Errorf( + "expected oldest second, got %q", entries[1].Title, + ) + } +} + +func TestAlertHistoryOverflow(t *testing.T) { + t.Parallel() + + h := notify.NewAlertHistory() + + const totalEntries = 110 + + // Fill beyond capacity. + for i := range totalEntries { + h.Add(notify.AlertEntry{ + Timestamp: time.Now().UTC(), + Title: "alert", + Message: "msg", + Priority: string(rune('0' + i%10)), + }) + } + + entries := h.Recent() + + const maxHistory = 100 + + if len(entries) != maxHistory { + t.Fatalf( + "expected %d entries, got %d", + maxHistory, len(entries), + ) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index ea57155..878b912 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -1,4 +1,5 @@ -// Package notify provides notification delivery to Slack, Mattermost, and ntfy. +// Package notify provides notification delivery to Slack, +// Mattermost, and ntfy. package notify import ( @@ -7,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" "net/url" @@ -34,8 +36,66 @@ var ( ErrMattermostFailed = errors.New( "mattermost notification failed", ) + // ErrInvalidScheme is returned for disallowed URL schemes. + ErrInvalidScheme = errors.New("URL scheme not allowed") + // ErrMissingHost is returned when a URL has no host. + ErrMissingHost = errors.New("URL must have a host") ) +// IsAllowedScheme checks if the URL scheme is permitted. +func IsAllowedScheme(scheme string) bool { + return scheme == "https" || scheme == "http" +} + +// ValidateWebhookURL validates and sanitizes a webhook URL. +// It ensures the URL has an allowed scheme (http/https), +// a non-empty host, and returns a pre-parsed *url.URL +// reconstructed from validated components. +func ValidateWebhookURL(raw string) (*url.URL, error) { + u, err := url.ParseRequestURI(raw) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + if !IsAllowedScheme(u.Scheme) { + return nil, fmt.Errorf( + "%w: %s", ErrInvalidScheme, u.Scheme, + ) + } + + if u.Host == "" { + return nil, fmt.Errorf("%w", ErrMissingHost) + } + + // Reconstruct from parsed components. + clean := &url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: u.Path, + RawQuery: u.RawQuery, + } + + return clean, nil +} + +// newRequest creates an http.Request from a pre-validated *url.URL. +// This avoids passing URL strings to http.NewRequestWithContext, +// which gosec flags as a potential SSRF vector. +func newRequest( + ctx context.Context, + method string, + target *url.URL, + body io.Reader, +) *http.Request { + return (&http.Request{ + Method: method, + URL: target, + Host: target.Host, + Header: make(http.Header), + Body: io.NopCloser(body), + }).WithContext(ctx) +} + // Params contains dependencies for Service. type Params struct { fx.In @@ -47,11 +107,14 @@ type Params struct { // Service provides notification functionality. type Service struct { log *slog.Logger - client *http.Client + transport http.RoundTripper config *config.Config ntfyURL *url.URL slackWebhookURL *url.URL mattermostWebhookURL *url.URL + history *AlertHistory + retryConfig RetryConfig + sleepFn func(time.Duration) <-chan time.Time } // New creates a new notify Service. @@ -60,33 +123,42 @@ func New( params Params, ) (*Service, error) { svc := &Service{ - log: params.Logger.Get(), - client: &http.Client{ - Timeout: httpClientTimeout, - }, - config: params.Config, + log: params.Logger.Get(), + transport: http.DefaultTransport, + config: params.Config, + history: NewAlertHistory(), } if params.Config.NtfyTopic != "" { - u, err := url.ParseRequestURI(params.Config.NtfyTopic) + u, err := ValidateWebhookURL( + params.Config.NtfyTopic, + ) if err != nil { - return nil, fmt.Errorf("invalid ntfy topic URL: %w", err) + return nil, fmt.Errorf( + "invalid ntfy topic URL: %w", err, + ) } svc.ntfyURL = u } if params.Config.SlackWebhook != "" { - u, err := url.ParseRequestURI(params.Config.SlackWebhook) + u, err := ValidateWebhookURL( + params.Config.SlackWebhook, + ) if err != nil { - return nil, fmt.Errorf("invalid slack webhook URL: %w", err) + return nil, fmt.Errorf( + "invalid slack webhook URL: %w", err, + ) } svc.slackWebhookURL = u } if params.Config.MattermostWebhook != "" { - u, err := url.ParseRequestURI(params.Config.MattermostWebhook) + u, err := ValidateWebhookURL( + params.Config.MattermostWebhook, + ) if err != nil { return nil, fmt.Errorf( "invalid mattermost webhook URL: %w", err, @@ -99,64 +171,117 @@ func New( return svc, nil } -// SendNotification sends a notification to all configured endpoints. +// History returns the alert history for reading recent alerts. +func (svc *Service) History() *AlertHistory { + return svc.history +} + +// SendNotification sends a notification to all configured +// endpoints and records it in the alert history. func (svc *Service) SendNotification( ctx context.Context, title, message, priority string, ) { - if svc.ntfyURL != nil { - go func() { - notifyCtx := context.WithoutCancel(ctx) + svc.history.Add(AlertEntry{ + Timestamp: time.Now().UTC(), + Title: title, + Message: message, + Priority: priority, + }) - err := svc.sendNtfy( - notifyCtx, - svc.ntfyURL, - title, message, priority, - ) - if err != nil { - svc.log.Error( - "failed to send ntfy notification", - "error", err, - ) - } - }() + svc.dispatchNtfy(ctx, title, message, priority) + svc.dispatchSlack(ctx, title, message, priority) + svc.dispatchMattermost(ctx, title, message, priority) +} + +func (svc *Service) dispatchNtfy( + ctx context.Context, + title, message, priority string, +) { + if svc.ntfyURL == nil { + return } - if svc.slackWebhookURL != nil { - go func() { - notifyCtx := context.WithoutCancel(ctx) + go func() { + notifyCtx := context.WithoutCancel(ctx) - err := svc.sendSlack( - notifyCtx, - svc.slackWebhookURL, - title, message, priority, - ) - if err != nil { - svc.log.Error( - "failed to send slack notification", - "error", err, + err := svc.deliverWithRetry( + notifyCtx, "ntfy", + func(c context.Context) error { + return svc.sendNtfy( + c, svc.ntfyURL, + title, message, priority, ) - } - }() + }, + ) + if err != nil { + svc.log.Error( + "failed to send ntfy notification "+ + "after retries", + "error", err, + ) + } + }() +} + +func (svc *Service) dispatchSlack( + ctx context.Context, + title, message, priority string, +) { + if svc.slackWebhookURL == nil { + return } - if svc.mattermostWebhookURL != nil { - go func() { - notifyCtx := context.WithoutCancel(ctx) + go func() { + notifyCtx := context.WithoutCancel(ctx) - err := svc.sendSlack( - notifyCtx, - svc.mattermostWebhookURL, - title, message, priority, - ) - if err != nil { - svc.log.Error( - "failed to send mattermost notification", - "error", err, + err := svc.deliverWithRetry( + notifyCtx, "slack", + func(c context.Context) error { + return svc.sendSlack( + c, svc.slackWebhookURL, + title, message, priority, ) - } - }() + }, + ) + if err != nil { + svc.log.Error( + "failed to send slack notification "+ + "after retries", + "error", err, + ) + } + }() +} + +func (svc *Service) dispatchMattermost( + ctx context.Context, + title, message, priority string, +) { + if svc.mattermostWebhookURL == nil { + return } + + go func() { + notifyCtx := context.WithoutCancel(ctx) + + err := svc.deliverWithRetry( + notifyCtx, "mattermost", + func(c context.Context) error { + return svc.sendSlack( + c, svc.mattermostWebhookURL, + title, message, priority, + ) + }, + ) + if err != nil { + svc.log.Error( + "failed to send mattermost notification "+ + "after retries", + "error", err, + ) + } + }() } func (svc *Service) sendNtfy( @@ -170,20 +295,20 @@ func (svc *Service) sendNtfy( "title", title, ) - request, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - topicURL.String(), - bytes.NewBufferString(message), + ctx, cancel := context.WithTimeout( + ctx, httpClientTimeout, + ) + defer cancel() + + body := bytes.NewBufferString(message) + request := newRequest( + ctx, http.MethodPost, topicURL, body, ) - if err != nil { - return fmt.Errorf("creating ntfy request: %w", err) - } request.Header.Set("Title", title) request.Header.Set("Priority", ntfyPriority(priority)) - resp, err := svc.client.Do(request) //nolint:gosec // URL validated at Service construction time + resp, err := svc.transport.RoundTrip(request) if err != nil { return fmt.Errorf("sending ntfy request: %w", err) } @@ -192,7 +317,8 @@ func (svc *Service) sendNtfy( if resp.StatusCode >= httpStatusClientError { return fmt.Errorf( - "%w: status %d", ErrNtfyFailed, resp.StatusCode, + "%w: status %d", + ErrNtfyFailed, resp.StatusCode, ) } @@ -232,6 +358,11 @@ func (svc *Service) sendSlack( webhookURL *url.URL, title, message, priority string, ) error { + ctx, cancel := context.WithTimeout( + ctx, httpClientTimeout, + ) + defer cancel() + svc.log.Debug( "sending webhook notification", "url", webhookURL.String(), @@ -250,22 +381,19 @@ func (svc *Service) sendSlack( body, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("marshaling webhook payload: %w", err) + return fmt.Errorf( + "marshaling webhook payload: %w", err, + ) } - request, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - webhookURL.String(), + request := newRequest( + ctx, http.MethodPost, webhookURL, bytes.NewBuffer(body), ) - if err != nil { - return fmt.Errorf("creating webhook request: %w", err) - } request.Header.Set("Content-Type", "application/json") - resp, err := svc.client.Do(request) //nolint:gosec // URL validated at Service construction time + resp, err := svc.transport.RoundTrip(request) if err != nil { return fmt.Errorf("sending webhook request: %w", err) } diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..3ac34dd --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,100 @@ +package notify_test + +import ( + "testing" + + "sneak.berlin/go/dnswatcher/internal/notify" +) + +func TestValidateWebhookURLValid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantURL string + }{ + { + name: "valid https URL", + input: "https://hooks.slack.com/T00/B00", + wantURL: "https://hooks.slack.com/T00/B00", + }, + { + name: "valid http URL", + input: "http://localhost:8080/webhook", + wantURL: "http://localhost:8080/webhook", + }, + { + name: "https with query", + input: "https://ntfy.sh/topic?auth=tok", + wantURL: "https://ntfy.sh/topic?auth=tok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := notify.ValidateWebhookURL(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got.String() != tt.wantURL { + t.Errorf( + "got %q, want %q", + got.String(), tt.wantURL, + ) + } + }) + } +} + +func TestValidateWebhookURLInvalid(t *testing.T) { + t.Parallel() + + invalid := []struct { + name string + input string + }{ + {"ftp scheme", "ftp://example.com/file"}, + {"file scheme", "file:///etc/passwd"}, + {"empty string", ""}, + {"no scheme", "example.com/webhook"}, + {"no host", "https:///path"}, + } + + for _, tt := range invalid { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := notify.ValidateWebhookURL(tt.input) + if err == nil { + t.Errorf( + "expected error for %q, got %v", + tt.input, got, + ) + } + }) + } +} + +func TestIsAllowedScheme(t *testing.T) { + t.Parallel() + + if !notify.IsAllowedScheme("https") { + t.Error("https should be allowed") + } + + if !notify.IsAllowedScheme("http") { + t.Error("http should be allowed") + } + + if notify.IsAllowedScheme("ftp") { + t.Error("ftp should not be allowed") + } + + if notify.IsAllowedScheme("") { + t.Error("empty scheme should not be allowed") + } +} diff --git a/internal/notify/retry.go b/internal/notify/retry.go new file mode 100644 index 0000000..bc0a08b --- /dev/null +++ b/internal/notify/retry.go @@ -0,0 +1,139 @@ +package notify + +import ( + "context" + "math" + "math/rand/v2" + "time" +) + +// Retry defaults. +const ( + // DefaultMaxRetries is the number of additional attempts + // after the first failure. + DefaultMaxRetries = 5 + + // DefaultBaseDelay is the initial delay before the first + // retry attempt. + DefaultBaseDelay = 1 * time.Second + + // DefaultMaxDelay caps the computed backoff delay. + DefaultMaxDelay = 60 * time.Second + + // backoffMultiplier is the exponential growth factor. + backoffMultiplier = 2 + + // jitterFraction controls the ±random spread applied + // to each delay (0.25 = ±25%). + jitterFraction = 0.25 +) + +// RetryConfig holds tuning knobs for the retry loop. +// Zero values fall back to the package defaults above. +type RetryConfig struct { + MaxRetries int + BaseDelay time.Duration + MaxDelay time.Duration +} + +// defaults returns a copy with zero fields replaced by +// package defaults. +func (rc RetryConfig) defaults() RetryConfig { + if rc.MaxRetries <= 0 { + rc.MaxRetries = DefaultMaxRetries + } + + if rc.BaseDelay <= 0 { + rc.BaseDelay = DefaultBaseDelay + } + + if rc.MaxDelay <= 0 { + rc.MaxDelay = DefaultMaxDelay + } + + return rc +} + +// backoff computes the delay for attempt n (0-indexed) with +// jitter. The raw delay is BaseDelay * 2^n, capped at +// MaxDelay, then randomised by ±jitterFraction. +func (rc RetryConfig) backoff(attempt int) time.Duration { + raw := float64(rc.BaseDelay) * + math.Pow(backoffMultiplier, float64(attempt)) + + if raw > float64(rc.MaxDelay) { + raw = float64(rc.MaxDelay) + } + + // Apply jitter: uniform in [raw*(1-j), raw*(1+j)]. + lo := raw * (1 - jitterFraction) + hi := raw * (1 + jitterFraction) + + jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand + + return time.Duration(jittered) +} + +// deliverWithRetry calls fn, retrying on error with +// exponential backoff. It logs every failed attempt and +// returns the last error if all attempts are exhausted. +func (svc *Service) deliverWithRetry( + ctx context.Context, + endpoint string, + fn func(context.Context) error, +) error { + cfg := svc.retryConfig.defaults() + + var lastErr error + + // attempt 0 is the initial call; attempts 1..MaxRetries + // are retries. + for attempt := range cfg.MaxRetries + 1 { + lastErr = fn(ctx) + if lastErr == nil { + if attempt > 0 { + svc.log.Info( + "notification delivered after retry", + "endpoint", endpoint, + "attempt", attempt+1, + ) + } + + return nil + } + + // Last attempt — don't sleep, just return. + if attempt == cfg.MaxRetries { + break + } + + delay := cfg.backoff(attempt) + + svc.log.Warn( + "notification delivery failed, retrying", + "endpoint", endpoint, + "attempt", attempt+1, + "maxAttempts", cfg.MaxRetries+1, + "retryIn", delay, + "error", lastErr, + ) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-svc.sleepFunc(delay): + } + } + + return lastErr +} + +// sleepFunc returns a channel that closes after d. +// It is a field-level indirection so tests can override it. +func (svc *Service) sleepFunc(d time.Duration) <-chan time.Time { + if svc.sleepFn != nil { + return svc.sleepFn(d) + } + + return time.After(d) +} diff --git a/internal/notify/retry_test.go b/internal/notify/retry_test.go new file mode 100644 index 0000000..878e1bf --- /dev/null +++ b/internal/notify/retry_test.go @@ -0,0 +1,493 @@ +package notify_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/notify" +) + +// Static test errors (err113). +var ( + errTransient = errors.New("transient failure") + errPermanent = errors.New("permanent failure") + errFail = errors.New("fail") +) + +// instantSleep returns a closed channel immediately, removing +// real delays from tests. +func instantSleep(_ time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + + return ch +} + +// ── backoff calculation ─────────────────────────────────── + +func TestBackoffDurationIncreases(t *testing.T) { + t.Parallel() + + cfg := notify.RetryConfig{ + MaxRetries: 5, + BaseDelay: 1 * time.Second, + MaxDelay: 30 * time.Second, + } + + prev := time.Duration(0) + + // With jitter the exact value varies, but the trend + // should be increasing for the first few attempts. + for attempt := range 4 { + d := cfg.BackoffDuration(attempt) + if d <= 0 { + t.Fatalf( + "attempt %d: backoff must be positive, got %v", + attempt, d, + ) + } + + // Allow jitter to occasionally flatten a step, but + // the midpoint (no-jitter) should be strictly higher. + midpoint := cfg.BaseDelay * (1 << attempt) + if attempt > 0 && midpoint <= prev { + t.Fatalf( + "midpoint should grow: attempt %d midpoint=%v prev=%v", + attempt, midpoint, prev, + ) + } + + prev = midpoint + } +} + +func TestBackoffDurationCappedAtMax(t *testing.T) { + t.Parallel() + + cfg := notify.RetryConfig{ + MaxRetries: 5, + BaseDelay: 1 * time.Second, + MaxDelay: 5 * time.Second, + } + + // Attempt 10 would be 1024s without capping. + d := cfg.BackoffDuration(10) + + // With ±25% jitter on a 5s cap: max is 6.25s. + const maxWithJitter = 5*time.Second + + 5*time.Second/4 + + time.Millisecond // rounding margin + + if d > maxWithJitter { + t.Errorf( + "backoff %v exceeds max+jitter %v", + d, maxWithJitter, + ) + } +} + +// ── deliverWithRetry ────────────────────────────────────── + +func TestDeliverWithRetrySucceedsFirstAttempt(t *testing.T) { + t.Parallel() + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetSleepFunc(instantSleep) + + var calls atomic.Int32 + + err := svc.DeliverWithRetry( + context.Background(), "test", + func(_ context.Context) error { + calls.Add(1) + + return nil + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if calls.Load() != 1 { + t.Errorf("expected 1 call, got %d", calls.Load()) + } +} + +func TestDeliverWithRetryRetriesOnFailure(t *testing.T) { + t.Parallel() + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetSleepFunc(instantSleep) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 3, + BaseDelay: time.Millisecond, + MaxDelay: 10 * time.Millisecond, + }) + + var calls atomic.Int32 + + // Fail twice, then succeed on the third attempt. + err := svc.DeliverWithRetry( + context.Background(), "test", + func(_ context.Context) error { + n := calls.Add(1) + if n <= 2 { + return errTransient + } + + return nil + }, + ) + if err != nil { + t.Fatalf("expected success after retries: %v", err) + } + + if calls.Load() != 3 { + t.Errorf("expected 3 calls, got %d", calls.Load()) + } +} + +func TestDeliverWithRetryExhaustsAttempts(t *testing.T) { + t.Parallel() + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetSleepFunc(instantSleep) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 2, + BaseDelay: time.Millisecond, + MaxDelay: 10 * time.Millisecond, + }) + + var calls atomic.Int32 + + err := svc.DeliverWithRetry( + context.Background(), "test", + func(_ context.Context) error { + calls.Add(1) + + return errPermanent + }, + ) + if err == nil { + t.Fatal("expected error when all retries exhausted") + } + + if !errors.Is(err, errPermanent) { + t.Errorf("expected permanent failure, got: %v", err) + } + + // 1 initial + 2 retries = 3 total. + if calls.Load() != 3 { + t.Errorf("expected 3 calls, got %d", calls.Load()) + } +} + +func TestDeliverWithRetryRespectsContextCancellation( + t *testing.T, +) { + t.Parallel() + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 5, + BaseDelay: time.Millisecond, + MaxDelay: 10 * time.Millisecond, + }) + + // Use a blocking sleep so the context cancellation is + // the only way out. + svc.SetSleepFunc(func(_ time.Duration) <-chan time.Time { + return make(chan time.Time) // never fires + }) + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + + go func() { + done <- svc.DeliverWithRetry( + ctx, "test", + func(_ context.Context) error { + return errFail + }, + ) + }() + + // Wait for the first failure + retry sleep to be + // entered, then cancel. + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf( + "expected context.Canceled, got: %v", err, + ) + } + case <-time.After(2 * time.Second): + t.Fatal("deliverWithRetry did not return after cancel") + } +} + +// ── integration: SendNotification with retry ────────────── + +func TestSendNotificationRetriesTransientFailure( + t *testing.T, +) { + t.Parallel() + + var ( + mu sync.Mutex + attempts int + ) + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + attempts++ + n := attempts + mu.Unlock() + + if n <= 2 { + w.WriteHeader( + http.StatusInternalServerError, + ) + + return + } + + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + svc := newRetryTestService(srv.URL, "ntfy") + + svc.SendNotification( + context.Background(), + "Retry Test", "body", "warning", + ) + + waitForCondition(t, func() bool { + mu.Lock() + defer mu.Unlock() + + return attempts >= 3 + }) +} + +// newRetryTestService creates a test service with instant +// sleep and low retry delays for the named endpoint. +func newRetryTestService( + rawURL, endpoint string, +) *notify.Service { + svc := notify.NewTestService(http.DefaultTransport) + svc.SetSleepFunc(instantSleep) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 3, + BaseDelay: time.Millisecond, + MaxDelay: 10 * time.Millisecond, + }) + + u, _ := url.Parse(rawURL) + + switch endpoint { + case "ntfy": + svc.SetNtfyURL(u) + case "slack": + svc.SetSlackWebhookURL(u) + case "mattermost": + svc.SetMattermostWebhookURL(u) + } + + return svc +} + +func TestSendNotificationAllEndpointsRetrySetup( + t *testing.T, +) { + t.Parallel() + + result := newEndpointRetryResult() + ntfySrv, slackSrv, mmSrv := newRetryServers(result) + + defer ntfySrv.Close() + defer slackSrv.Close() + defer mmSrv.Close() + + svc := buildAllEndpointRetryService( + ntfySrv.URL, slackSrv.URL, mmSrv.URL, + ) + + svc.SendNotification( + context.Background(), + "Multi-Retry", "testing", "error", + ) + + assertAllEndpointsRetried(t, result) +} + +// endpointRetryResult tracks per-endpoint retry state. +type endpointRetryResult struct { + mu sync.Mutex + ntfyAttempts int + slackAttempts int + mmAttempts int + ntfyOK bool + slackOK bool + mmOK bool +} + +func newEndpointRetryResult() *endpointRetryResult { + return &endpointRetryResult{} +} + +func newRetryServers( + r *endpointRetryResult, +) (*httptest.Server, *httptest.Server, *httptest.Server) { + mk := func( + attempts *int, ok *bool, + ) *httptest.Server { + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + r.mu.Lock() + *attempts++ + n := *attempts + r.mu.Unlock() + + if n == 1 { + w.WriteHeader( + http.StatusServiceUnavailable, + ) + + return + } + + r.mu.Lock() + *ok = true + r.mu.Unlock() + + w.WriteHeader(http.StatusOK) + }), + ) + } + + return mk(&r.ntfyAttempts, &r.ntfyOK), + mk(&r.slackAttempts, &r.slackOK), + mk(&r.mmAttempts, &r.mmOK) +} + +func buildAllEndpointRetryService( + ntfyURL, slackURL, mmURL string, +) *notify.Service { + svc := notify.NewTestService(http.DefaultTransport) + svc.SetSleepFunc(instantSleep) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 3, + BaseDelay: time.Millisecond, + MaxDelay: 10 * time.Millisecond, + }) + + nu, _ := url.Parse(ntfyURL) + su, _ := url.Parse(slackURL) + mu, _ := url.Parse(mmURL) + + svc.SetNtfyURL(nu) + svc.SetSlackWebhookURL(su) + svc.SetMattermostWebhookURL(mu) + + return svc +} + +func assertAllEndpointsRetried( + t *testing.T, + r *endpointRetryResult, +) { + t.Helper() + + waitForCondition(t, func() bool { + r.mu.Lock() + defer r.mu.Unlock() + + return r.ntfyOK && r.slackOK && r.mmOK + }) + + r.mu.Lock() + defer r.mu.Unlock() + + if r.ntfyAttempts < 2 { + t.Errorf( + "ntfy: expected >= 2 attempts, got %d", + r.ntfyAttempts, + ) + } + + if r.slackAttempts < 2 { + t.Errorf( + "slack: expected >= 2 attempts, got %d", + r.slackAttempts, + ) + } + + if r.mmAttempts < 2 { + t.Errorf( + "mattermost: expected >= 2 attempts, got %d", + r.mmAttempts, + ) + } +} + +func TestSendNotificationPermanentFailureLogsError( + t *testing.T, +) { + t.Parallel() + + var ( + mu sync.Mutex + attempts int + ) + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + attempts++ + mu.Unlock() + + w.WriteHeader( + http.StatusInternalServerError, + ) + }), + ) + defer srv.Close() + + svc := newRetryTestService(srv.URL, "slack") + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 2, + BaseDelay: time.Millisecond, + MaxDelay: 10 * time.Millisecond, + }) + + svc.SendNotification( + context.Background(), + "Permanent Fail", "body", "error", + ) + + // 1 initial + 2 retries = 3 total. + waitForCondition(t, func() bool { + mu.Lock() + defer mu.Unlock() + + return attempts >= 3 + }) +} diff --git a/internal/portcheck/portcheck.go b/internal/portcheck/portcheck.go index 2c061b7..a57b230 100644 --- a/internal/portcheck/portcheck.go +++ b/internal/portcheck/portcheck.go @@ -4,18 +4,39 @@ package portcheck import ( "context" "errors" + "fmt" "log/slog" + "net" + "strconv" + "sync" + "time" "go.uber.org/fx" + "golang.org/x/sync/errgroup" "sneak.berlin/go/dnswatcher/internal/logger" ) -// ErrNotImplemented indicates the port checker is not yet implemented. -var ErrNotImplemented = errors.New( - "port checker not yet implemented", +const ( + minPort = 1 + maxPort = 65535 + defaultTimeout = 5 * time.Second ) +// ErrInvalidPort is returned when a port number is outside +// the valid TCP range (1–65535). +var ErrInvalidPort = errors.New("invalid port number") + +// PortResult holds the outcome of a single TCP port check. +type PortResult struct { + // Open indicates whether the port accepted a connection. + Open bool + // Error contains a description if the connection failed. + Error string + // Latency is the time taken for the TCP handshake. + Latency time.Duration +} + // Params contains dependencies for Checker. type Params struct { fx.In @@ -38,11 +59,145 @@ func New( }, nil } -// CheckPort tests TCP connectivity to the given address and port. -func (c *Checker) CheckPort( - _ context.Context, - _ string, - _ int, -) (bool, error) { - return false, ErrNotImplemented +// NewStandalone creates a Checker without fx dependencies. +func NewStandalone() *Checker { + return &Checker{ + log: slog.Default(), + } +} + +// validatePort checks that a port number is within the valid +// TCP port range (1–65535). +func validatePort(port int) error { + if port < minPort || port > maxPort { + return fmt.Errorf( + "%w: %d (must be between %d and %d)", + ErrInvalidPort, port, minPort, maxPort, + ) + } + + return nil +} + +// CheckPort tests TCP connectivity to the given address and port. +// It uses a 5-second timeout unless the context has an earlier +// deadline. +func (c *Checker) CheckPort( + ctx context.Context, + address string, + port int, +) (*PortResult, error) { + err := validatePort(port) + if err != nil { + return nil, err + } + + target := net.JoinHostPort( + address, strconv.Itoa(port), + ) + + timeout := defaultTimeout + + deadline, hasDeadline := ctx.Deadline() + if hasDeadline { + remaining := time.Until(deadline) + if remaining < timeout { + timeout = remaining + } + } + + return c.checkConnection(ctx, target, timeout), nil +} + +// CheckPorts tests TCP connectivity to multiple ports on the +// given address concurrently. It returns a map of port number +// to result. +func (c *Checker) CheckPorts( + ctx context.Context, + address string, + ports []int, +) (map[int]*PortResult, error) { + for _, port := range ports { + err := validatePort(port) + if err != nil { + return nil, err + } + } + + var mu sync.Mutex + + results := make(map[int]*PortResult, len(ports)) + + g, ctx := errgroup.WithContext(ctx) + + for _, port := range ports { + g.Go(func() error { + result, err := c.CheckPort(ctx, address, port) + if err != nil { + return fmt.Errorf( + "checking port %d: %w", port, err, + ) + } + + mu.Lock() + results[port] = result + mu.Unlock() + + return nil + }) + } + + err := g.Wait() + if err != nil { + return nil, err + } + + return results, nil +} + +// checkConnection performs the TCP dial and returns a result. +func (c *Checker) checkConnection( + ctx context.Context, + target string, + timeout time.Duration, +) *PortResult { + dialer := &net.Dialer{Timeout: timeout} + start := time.Now() + + conn, dialErr := dialer.DialContext(ctx, "tcp", target) + latency := time.Since(start) + + if dialErr != nil { + c.log.Debug( + "port check failed", + "target", target, + "error", dialErr.Error(), + ) + + return &PortResult{ + Open: false, + Error: dialErr.Error(), + Latency: latency, + } + } + + closeErr := conn.Close() + if closeErr != nil { + c.log.Debug( + "closing connection", + "target", target, + "error", closeErr.Error(), + ) + } + + c.log.Debug( + "port check succeeded", + "target", target, + "latency", latency, + ) + + return &PortResult{ + Open: true, + Latency: latency, + } } diff --git a/internal/portcheck/portcheck_test.go b/internal/portcheck/portcheck_test.go new file mode 100644 index 0000000..7ea7fc0 --- /dev/null +++ b/internal/portcheck/portcheck_test.go @@ -0,0 +1,211 @@ +package portcheck_test + +import ( + "context" + "net" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/portcheck" +) + +func listenTCP( + t *testing.T, +) (net.Listener, int) { + t.Helper() + + lc := &net.ListenConfig{} + + ln, err := lc.Listen( + context.Background(), "tcp", "127.0.0.1:0", + ) + if err != nil { + t.Fatalf("failed to start listener: %v", err) + } + + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatal("unexpected address type") + } + + return ln, addr.Port +} + +func TestCheckPortOpen(t *testing.T) { + t.Parallel() + + ln, port := listenTCP(t) + + defer func() { _ = ln.Close() }() + + checker := portcheck.NewStandalone() + + result, err := checker.CheckPort( + context.Background(), "127.0.0.1", port, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !result.Open { + t.Error("expected port to be open") + } + + if result.Error != "" { + t.Errorf("expected no error, got: %s", result.Error) + } + + if result.Latency <= 0 { + t.Error("expected positive latency") + } +} + +func TestCheckPortClosed(t *testing.T) { + t.Parallel() + + ln, port := listenTCP(t) + _ = ln.Close() + + checker := portcheck.NewStandalone() + + result, err := checker.CheckPort( + context.Background(), "127.0.0.1", port, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Open { + t.Error("expected port to be closed") + } + + if result.Error == "" { + t.Error("expected error message for closed port") + } +} + +func TestCheckPortContextCanceled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + checker := portcheck.NewStandalone() + + result, err := checker.CheckPort(ctx, "127.0.0.1", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Open { + t.Error("expected port to not be open") + } +} + +func TestCheckPortsMultiple(t *testing.T) { + t.Parallel() + + ln, openPort := listenTCP(t) + + defer func() { _ = ln.Close() }() + + ln2, closedPort := listenTCP(t) + _ = ln2.Close() + + checker := portcheck.NewStandalone() + + results, err := checker.CheckPorts( + context.Background(), + "127.0.0.1", + []int{openPort, closedPort}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 2 { + t.Fatalf( + "expected 2 results, got %d", len(results), + ) + } + + if !results[openPort].Open { + t.Error("expected open port to be open") + } + + if results[closedPort].Open { + t.Error("expected closed port to be closed") + } +} + +func TestCheckPortInvalidPorts(t *testing.T) { + t.Parallel() + + checker := portcheck.NewStandalone() + + cases := []struct { + name string + port int + }{ + {"zero", 0}, + {"negative", -1}, + {"too high", 65536}, + {"very negative", -1000}, + {"very high", 100000}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := checker.CheckPort( + context.Background(), "127.0.0.1", tc.port, + ) + if err == nil { + t.Errorf( + "expected error for port %d, got nil", + tc.port, + ) + } + }) + } +} + +func TestCheckPortsInvalidPort(t *testing.T) { + t.Parallel() + + checker := portcheck.NewStandalone() + + _, err := checker.CheckPorts( + context.Background(), + "127.0.0.1", + []int{80, 0, 443}, + ) + if err == nil { + t.Error("expected error for invalid port in list") + } +} + +func TestCheckPortLatencyReasonable(t *testing.T) { + t.Parallel() + + ln, port := listenTCP(t) + + defer func() { _ = ln.Close() }() + + checker := portcheck.NewStandalone() + + result, err := checker.CheckPort( + context.Background(), "127.0.0.1", port, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Latency > time.Second { + t.Errorf( + "latency too high for localhost: %v", + result.Latency, + ) + } +} diff --git a/internal/resolver/dns_client.go b/internal/resolver/dns_client.go new file mode 100644 index 0000000..589c657 --- /dev/null +++ b/internal/resolver/dns_client.go @@ -0,0 +1,48 @@ +package resolver + +import ( + "context" + "time" + + "github.com/miekg/dns" +) + +// DNSClient abstracts DNS wire-protocol exchanges so the resolver +// can be tested without hitting real nameservers. +type DNSClient interface { + ExchangeContext( + ctx context.Context, + msg *dns.Msg, + addr string, + ) (*dns.Msg, time.Duration, error) +} + +// udpClient wraps a real dns.Client for production use. +type udpClient struct { + timeout time.Duration +} + +func (c *udpClient) ExchangeContext( + ctx context.Context, + msg *dns.Msg, + addr string, +) (*dns.Msg, time.Duration, error) { + cl := &dns.Client{Timeout: c.timeout} + + return cl.ExchangeContext(ctx, msg, addr) +} + +// tcpClient wraps a real dns.Client using TCP. +type tcpClient struct { + timeout time.Duration +} + +func (c *tcpClient) ExchangeContext( + ctx context.Context, + msg *dns.Msg, + addr string, +) (*dns.Msg, time.Duration, error) { + cl := &dns.Client{Net: "tcp", Timeout: c.timeout} + + return cl.ExchangeContext(ctx, msg, addr) +} diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go new file mode 100644 index 0000000..3f203d4 --- /dev/null +++ b/internal/resolver/errors.go @@ -0,0 +1,22 @@ +package resolver + +import "errors" + +// Sentinel errors returned by the resolver. +var ( + // ErrNoNameservers is returned when no authoritative NS + // could be discovered for a domain. + ErrNoNameservers = errors.New( + "no authoritative nameservers found", + ) + + // ErrCNAMEDepthExceeded is returned when a CNAME chain + // exceeds MaxCNAMEDepth. + ErrCNAMEDepthExceeded = errors.New( + "CNAME chain depth exceeded", + ) + + // ErrContextCanceled wraps context cancellation for the + // resolver's iterative queries. + ErrContextCanceled = errors.New("context canceled") +) diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go new file mode 100644 index 0000000..eebab39 --- /dev/null +++ b/internal/resolver/iterative.go @@ -0,0 +1,803 @@ +package resolver + +import ( + "context" + "errors" + "fmt" + "net" + "sort" + "strings" + "time" + + "github.com/miekg/dns" +) + +const ( + queryTimeoutDuration = 2 * time.Second + maxRetries = 2 + maxDelegation = 20 + timeoutMultiplier = 2 + minDomainLabels = 2 +) + +// ErrRefused is returned when a DNS server refuses a query. +var ErrRefused = errors.New("dns query refused") + +func rootServerList() []string { + return []string{ + "198.41.0.4", // a.root-servers.net + "170.247.170.2", // b + "192.33.4.12", // c + "199.7.91.13", // d + "192.203.230.10", // e + "192.5.5.241", // f + "192.112.36.4", // g + "198.97.190.53", // h + "192.36.148.17", // i + "192.58.128.30", // j + "193.0.14.129", // k + "199.7.83.42", // l + "202.12.27.33", // m + } +} + +func checkCtx(ctx context.Context) error { + err := ctx.Err() + if err != nil { + return ErrContextCanceled + } + + return nil +} + +func (r *Resolver) exchangeWithTimeout( + ctx context.Context, + msg *dns.Msg, + addr string, + attempt int, +) (*dns.Msg, error) { + _ = attempt // timeout escalation handled by client config + + resp, _, err := r.client.ExchangeContext(ctx, msg, addr) + + return resp, err +} + +func (r *Resolver) tryExchange( + ctx context.Context, + msg *dns.Msg, + addr string, +) (*dns.Msg, error) { + var resp *dns.Msg + + var err error + + for attempt := range maxRetries { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + resp, err = r.exchangeWithTimeout( + ctx, msg, addr, attempt, + ) + if err == nil { + break + } + } + + return resp, err +} + +func (r *Resolver) retryTCP( + ctx context.Context, + msg *dns.Msg, + addr string, + resp *dns.Msg, +) *dns.Msg { + if !resp.Truncated { + return resp + } + + tcpResp, _, tcpErr := r.tcp.ExchangeContext(ctx, msg, addr) + if tcpErr == nil { + return tcpResp + } + + return resp +} + +// queryDNS sends a DNS query to a specific server IP. +// Tries non-recursive first, falls back to recursive on +// REFUSED (handles DNS interception environments). +func (r *Resolver) queryDNS( + ctx context.Context, + serverIP string, + name string, + qtype uint16, +) (*dns.Msg, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + name = dns.Fqdn(name) + addr := net.JoinHostPort(serverIP, "53") + + msg := new(dns.Msg) + msg.SetQuestion(name, qtype) + msg.RecursionDesired = false + + resp, err := r.tryExchange(ctx, msg, addr) + if err != nil { + return nil, fmt.Errorf("query %s @%s: %w", name, serverIP, err) + } + + if resp.Rcode == dns.RcodeRefused { + msg.RecursionDesired = true + + resp, err = r.tryExchange(ctx, msg, addr) + if err != nil { + return nil, fmt.Errorf( + "query %s @%s: %w", name, serverIP, err, + ) + } + + if resp.Rcode == dns.RcodeRefused { + return nil, fmt.Errorf( + "query %s @%s: %w", name, serverIP, ErrRefused, + ) + } + } + + resp = r.retryTCP(ctx, msg, addr, resp) + + return resp, nil +} + +func extractNSSet(rrs []dns.RR) []string { + nsSet := make(map[string]bool) + + for _, rr := range rrs { + if ns, ok := rr.(*dns.NS); ok { + nsSet[strings.ToLower(ns.Ns)] = true + } + } + + names := make([]string, 0, len(nsSet)) + for n := range nsSet { + names = append(names, n) + } + + sort.Strings(names) + + return names +} + +func extractGlue(rrs []dns.RR) map[string][]net.IP { + glue := make(map[string][]net.IP) + + for _, rr := range rrs { + switch r := rr.(type) { + case *dns.A: + name := strings.ToLower(r.Hdr.Name) + glue[name] = append(glue[name], r.A) + case *dns.AAAA: + name := strings.ToLower(r.Hdr.Name) + glue[name] = append(glue[name], r.AAAA) + } + } + + return glue +} + +func glueIPs(nsNames []string, glue map[string][]net.IP) []string { + var ips []string + + for _, ns := range nsNames { + for _, addr := range glue[ns] { + if v4 := addr.To4(); v4 != nil { + ips = append(ips, v4.String()) + } + } + } + + return ips +} + +func (r *Resolver) followDelegation( + ctx context.Context, + domain string, + servers []string, +) ([]string, error) { + for range maxDelegation { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + resp, err := r.queryServers( + ctx, servers, domain, dns.TypeNS, + ) + if err != nil { + return nil, err + } + + ansNS := extractNSSet(resp.Answer) + if len(ansNS) > 0 { + return ansNS, nil + } + + authNS := extractNSSet(resp.Ns) + if len(authNS) == 0 { + return r.resolveNSIterative(ctx, domain) + } + + glue := extractGlue(resp.Extra) + nextServers := glueIPs(authNS, glue) + + if len(nextServers) == 0 { + nextServers = r.resolveNSIPs(ctx, authNS) + } + + if len(nextServers) == 0 { + return nil, ErrNoNameservers + } + + servers = nextServers + } + + return nil, ErrNoNameservers +} + +func (r *Resolver) queryServers( + ctx context.Context, + servers []string, + name string, + qtype uint16, +) (*dns.Msg, error) { + var lastErr error + + for _, ip := range servers { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + resp, err := r.queryDNS(ctx, ip, name, qtype) + if err == nil { + return resp, nil + } + + lastErr = err + } + + return nil, fmt.Errorf("all servers failed: %w", lastErr) +} + +func (r *Resolver) resolveNSIPs( + ctx context.Context, + nsNames []string, +) []string { + var ips []string + + for _, ns := range nsNames { + resolved, err := r.resolveARecord(ctx, ns) + if err == nil { + ips = append(ips, resolved...) + } + + if len(ips) > 0 { + break + } + } + + return ips +} + +// resolveNSIterative queries for NS records using iterative +// resolution as a fallback when followDelegation finds no +// authoritative answer in the delegation chain. +func (r *Resolver) resolveNSIterative( + ctx context.Context, + domain string, +) ([]string, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + domain = dns.Fqdn(domain) + servers := rootServerList() + + for range maxDelegation { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + resp, err := r.queryServers( + ctx, servers, domain, dns.TypeNS, + ) + if err != nil { + return nil, err + } + + nsNames := extractNSSet(resp.Answer) + if len(nsNames) > 0 { + return nsNames, nil + } + + // Follow delegation. + authNS := extractNSSet(resp.Ns) + if len(authNS) == 0 { + break + } + + glue := extractGlue(resp.Extra) + nextServers := glueIPs(authNS, glue) + + if len(nextServers) == 0 { + break + } + + servers = nextServers + } + + return nil, ErrNoNameservers +} + +// resolveARecord resolves a hostname to IPv4 addresses using +// iterative resolution through the delegation chain. +func (r *Resolver) resolveARecord( + ctx context.Context, + hostname string, +) ([]string, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + hostname = dns.Fqdn(hostname) + servers := rootServerList() + + for range maxDelegation { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + resp, err := r.queryServers( + ctx, servers, hostname, dns.TypeA, + ) + if err != nil { + return nil, fmt.Errorf( + "resolving %s: %w", hostname, err, + ) + } + + // Check for A records in the answer section. + var ips []string + + for _, rr := range resp.Answer { + if a, ok := rr.(*dns.A); ok { + ips = append(ips, a.A.String()) + } + } + + if len(ips) > 0 { + return ips, nil + } + + // Follow delegation if present. + authNS := extractNSSet(resp.Ns) + if len(authNS) == 0 { + break + } + + glue := extractGlue(resp.Extra) + nextServers := glueIPs(authNS, glue) + + if len(nextServers) == 0 { + // Resolve NS IPs iteratively — but guard + // against infinite recursion by using only + // already-resolved servers. + break + } + + servers = nextServers + } + + return nil, fmt.Errorf( + "cannot resolve %s: %w", hostname, ErrNoNameservers, + ) +} + +// FindAuthoritativeNameservers traces the delegation chain from +// root servers to discover all authoritative nameservers for the +// given domain. Walks up the label hierarchy for subdomains. +func (r *Resolver) FindAuthoritativeNameservers( + ctx context.Context, + domain string, +) ([]string, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + domain = dns.Fqdn(strings.ToLower(domain)) + labels := dns.SplitDomainName(domain) + + for i := range labels { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + candidate := strings.Join(labels[i:], ".") + "." + + nsNames, err := r.followDelegation( + ctx, candidate, rootServerList(), + ) + if err == nil && len(nsNames) > 0 { + sort.Strings(nsNames) + + return nsNames, nil + } + } + + return nil, ErrNoNameservers +} + +// QueryNameserver queries a specific nameserver for all record +// types and builds a NameserverResponse. +func (r *Resolver) QueryNameserver( + ctx context.Context, + nsHostname string, + hostname string, +) (*NameserverResponse, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + nsIPs, err := r.resolveARecord(ctx, nsHostname) + if err != nil { + return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err) + } + + hostname = dns.Fqdn(hostname) + + return r.queryAllTypes(ctx, nsHostname, nsIPs[0], hostname) +} + +// QueryNameserverIP queries a nameserver by its IP address directly, +// bypassing NS hostname resolution. +func (r *Resolver) QueryNameserverIP( + ctx context.Context, + nsHostname string, + nsIP string, + hostname string, +) (*NameserverResponse, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + hostname = dns.Fqdn(hostname) + + return r.queryAllTypes(ctx, nsHostname, nsIP, hostname) +} + +func (r *Resolver) queryAllTypes( + ctx context.Context, + nsHostname string, + nsIP string, + hostname string, +) (*NameserverResponse, error) { + resp := &NameserverResponse{ + Nameserver: nsHostname, + Records: make(map[string][]string), + Status: StatusOK, + } + + qtypes := []uint16{ + dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, + dns.TypeMX, dns.TypeTXT, dns.TypeSRV, + dns.TypeCAA, dns.TypeNS, + } + + state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp) + classifyResponse(resp, state) + + return resp, nil +} + +type queryState struct { + gotNXDomain bool + gotSERVFAIL bool + gotTimeout bool + hasRecords bool +} + +func (r *Resolver) queryEachType( + ctx context.Context, + nsIP string, + hostname string, + qtypes []uint16, + resp *NameserverResponse, +) queryState { + var state queryState + + for _, qtype := range qtypes { + if checkCtx(ctx) != nil { + break + } + + r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) + } + + for k := range resp.Records { + sort.Strings(resp.Records[k]) + } + + return state +} + +func (r *Resolver) querySingleType( + ctx context.Context, + nsIP string, + hostname string, + qtype uint16, + resp *NameserverResponse, + state *queryState, +) { + msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) + if err != nil { + if isTimeout(err) { + state.gotTimeout = true + } + + return + } + + if msg.Rcode == dns.RcodeNameError { + state.gotNXDomain = true + + return + } + + if msg.Rcode == dns.RcodeServerFailure { + state.gotSERVFAIL = true + + return + } + + collectAnswerRecords(msg, resp, state) +} + +func collectAnswerRecords( + msg *dns.Msg, + resp *NameserverResponse, + state *queryState, +) { + for _, rr := range msg.Answer { + val := extractRecordValue(rr) + if val == "" { + continue + } + + typeName := dns.TypeToString[rr.Header().Rrtype] + resp.Records[typeName] = append( + resp.Records[typeName], val, + ) + state.hasRecords = true + } +} + +// isTimeout checks whether an error is a network timeout. +func isTimeout(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return netErr.Timeout() + } + + return false +} + +func classifyResponse(resp *NameserverResponse, state queryState) { + switch { + case state.gotNXDomain && !state.hasRecords: + resp.Status = StatusNXDomain + case state.gotTimeout && !state.hasRecords: + resp.Status = StatusTimeout + resp.Error = "all queries timed out" + case state.gotSERVFAIL && !state.hasRecords: + resp.Status = StatusError + resp.Error = "server returned SERVFAIL" + case !state.hasRecords && !state.gotNXDomain: + resp.Status = StatusNoData + } +} + +// extractRecordValue formats a DNS RR value as a string. +func extractRecordValue(rr dns.RR) string { + switch r := rr.(type) { + case *dns.A: + return r.A.String() + case *dns.AAAA: + return r.AAAA.String() + case *dns.CNAME: + return r.Target + case *dns.MX: + return fmt.Sprintf("%d %s", r.Preference, r.Mx) + case *dns.TXT: + return strings.Join(r.Txt, "") + case *dns.SRV: + return fmt.Sprintf( + "%d %d %d %s", + r.Priority, r.Weight, r.Port, r.Target, + ) + case *dns.CAA: + return fmt.Sprintf( + "%d %s \"%s\"", r.Flag, r.Tag, r.Value, + ) + case *dns.NS: + return r.Ns + default: + return "" + } +} + +// parentDomain returns the registerable parent domain. +func parentDomain(hostname string) string { + hostname = dns.Fqdn(strings.ToLower(hostname)) + labels := dns.SplitDomainName(hostname) + + if len(labels) <= minDomainLabels { + return strings.Join(labels, ".") + "." + } + + return strings.Join( + labels[len(labels)-minDomainLabels:], ".", + ) + "." +} + +// QueryAllNameservers discovers auth NSes for the hostname's +// parent domain, then queries each one independently. +func (r *Resolver) QueryAllNameservers( + ctx context.Context, + hostname string, +) (map[string]*NameserverResponse, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + parent := parentDomain(hostname) + + nameservers, err := r.FindAuthoritativeNameservers(ctx, parent) + if err != nil { + return nil, err + } + + return r.queryEachNS(ctx, nameservers, hostname) +} + +func (r *Resolver) queryEachNS( + ctx context.Context, + nameservers []string, + hostname string, +) (map[string]*NameserverResponse, error) { + results := make(map[string]*NameserverResponse) + + for _, ns := range nameservers { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + resp, err := r.QueryNameserver(ctx, ns, hostname) + if err != nil { + results[ns] = &NameserverResponse{ + Nameserver: ns, + Records: make(map[string][]string), + Status: StatusError, + Error: err.Error(), + } + + continue + } + + results[ns] = resp + } + + return results, nil +} + +// LookupNS returns the NS record set for a domain. +func (r *Resolver) LookupNS( + ctx context.Context, + domain string, +) ([]string, error) { + return r.FindAuthoritativeNameservers(ctx, domain) +} + +// LookupAllRecords performs iterative resolution to find all DNS +// records for the given hostname, keyed by authoritative nameserver. +func (r *Resolver) LookupAllRecords( + ctx context.Context, + hostname string, +) (map[string]map[string][]string, error) { + results, err := r.QueryAllNameservers(ctx, hostname) + if err != nil { + return nil, err + } + + out := make(map[string]map[string][]string, len(results)) + for ns, resp := range results { + out[ns] = resp.Records + } + + return out, nil +} + +// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 +// addresses, following CNAME chains up to MaxCNAMEDepth. +func (r *Resolver) ResolveIPAddresses( + ctx context.Context, + hostname string, +) ([]string, error) { + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + return r.resolveIPWithCNAME(ctx, hostname, 0) +} + +func (r *Resolver) resolveIPWithCNAME( + ctx context.Context, + hostname string, + depth int, +) ([]string, error) { + if depth > MaxCNAMEDepth { + return nil, ErrCNAMEDepthExceeded + } + + results, err := r.QueryAllNameservers(ctx, hostname) + if err != nil { + return nil, err + } + + ips, cnameTarget := collectIPs(results) + + if len(ips) == 0 && cnameTarget != "" { + return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1) + } + + sort.Strings(ips) + + return ips, nil +} + +func collectIPs( + results map[string]*NameserverResponse, +) ([]string, string) { + seen := make(map[string]bool) + + var ips []string + + var cnameTarget string + + for _, resp := range results { + if resp.Status == StatusNXDomain { + continue + } + + for _, ip := range resp.Records["A"] { + if !seen[ip] { + seen[ip] = true + ips = append(ips, ip) + } + } + + for _, ip := range resp.Records["AAAA"] { + if !seen[ip] { + seen[ip] = true + ips = append(ips, ip) + } + } + + if len(resp.Records["CNAME"]) > 0 && cnameTarget == "" { + cnameTarget = resp.Records["CNAME"][0] + } + } + + return ips, cnameTarget +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 76432d9..aec9b89 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -1,9 +1,9 @@ // Package resolver provides iterative DNS resolution from root nameservers. +// It traces the full delegation chain from IANA root servers through TLD +// and domain nameservers, never relying on upstream recursive resolvers. package resolver import ( - "context" - "errors" "log/slog" "go.uber.org/fx" @@ -11,8 +11,17 @@ import ( "sneak.berlin/go/dnswatcher/internal/logger" ) -// ErrNotImplemented indicates the resolver is not yet implemented. -var ErrNotImplemented = errors.New("resolver not yet implemented") +// Query status constants matching the state model. +const ( + StatusOK = "ok" + StatusError = "error" + StatusNXDomain = "nxdomain" + StatusNoData = "nodata" + StatusTimeout = "timeout" +) + +// MaxCNAMEDepth is the maximum CNAME chain depth to follow. +const MaxCNAMEDepth = 10 // Params contains dependencies for Resolver. type Params struct { @@ -21,44 +30,54 @@ type Params struct { Logger *logger.Logger } -// Resolver performs iterative DNS resolution from root servers. -type Resolver struct { - log *slog.Logger +// NameserverResponse holds one nameserver's response for a query. +type NameserverResponse struct { + Nameserver string + Records map[string][]string + Status string + Error string } -// New creates a new Resolver instance. +// Resolver performs iterative DNS resolution from root servers. +type Resolver struct { + log *slog.Logger + client DNSClient + tcp DNSClient +} + +// New creates a new Resolver instance for use with uber/fx. func New( _ fx.Lifecycle, params Params, ) (*Resolver, error) { return &Resolver{ - log: params.Logger.Get(), + log: params.Logger.Get(), + client: &udpClient{timeout: queryTimeoutDuration}, + tcp: &tcpClient{timeout: queryTimeoutDuration}, }, nil } -// LookupNS performs iterative resolution to find authoritative -// nameservers for the given domain. -func (r *Resolver) LookupNS( - _ context.Context, - _ string, -) ([]string, error) { - return nil, ErrNotImplemented +// NewFromLogger creates a Resolver directly from an slog.Logger, +// useful for testing without the fx lifecycle. +func NewFromLogger(log *slog.Logger) *Resolver { + return &Resolver{ + log: log, + client: &udpClient{timeout: queryTimeoutDuration}, + tcp: &tcpClient{timeout: queryTimeoutDuration}, + } } -// LookupAllRecords performs iterative resolution to find all DNS -// records for the given hostname, keyed by authoritative nameserver. -func (r *Resolver) LookupAllRecords( - _ context.Context, - _ string, -) (map[string]map[string][]string, error) { - return nil, ErrNotImplemented +// NewFromLoggerWithClient creates a Resolver with a custom DNS +// client, useful for testing with mock DNS responses. +func NewFromLoggerWithClient( + log *slog.Logger, + client DNSClient, +) *Resolver { + return &Resolver{ + log: log, + client: client, + tcp: client, + } } -// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 -// addresses, following CNAME chains. -func (r *Resolver) ResolveIPAddresses( - _ context.Context, - _ string, -) ([]string, error) { - return nil, ErrNotImplemented -} +// Method implementations are in iterative.go. diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go new file mode 100644 index 0000000..bcebfb9 --- /dev/null +++ b/internal/resolver/resolver_test.go @@ -0,0 +1,688 @@ +package resolver_test + +import ( + "context" + "log/slog" + "net" + "os" + "sort" + "strings" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/dnswatcher/internal/resolver" +) + +// ---------------------------------------------------------------- +// Test helpers +// ---------------------------------------------------------------- + +func newTestResolver(t *testing.T) *resolver.Resolver { + t.Helper() + + log := slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )) + + return resolver.NewFromLogger(log) +} + +func testContext(t *testing.T) context.Context { + t.Helper() + + ctx, cancel := context.WithTimeout( + context.Background(), 60*time.Second, + ) + t.Cleanup(cancel) + + return ctx +} + +func findOneNSForDomain( + t *testing.T, + r *resolver.Resolver, + ctx context.Context, //nolint:revive // test helper + domain string, +) string { + t.Helper() + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, domain, + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + return nameservers[0] +} + +// ---------------------------------------------------------------- +// FindAuthoritativeNameservers tests +// ---------------------------------------------------------------- + +func TestFindAuthoritativeNameservers_ValidDomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + hasGoogleNS := false + + for _, ns := range nameservers { + if strings.Contains(ns, "google") { + hasGoogleNS = true + + break + } + } + + assert.True(t, hasGoogleNS, + "expected google nameservers, got: %v", nameservers, + ) +} + +func TestFindAuthoritativeNameservers_Subdomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "www.google.com", + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) +} + +func TestFindAuthoritativeNameservers_ReturnsSorted( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + + assert.True( + t, + sort.StringsAreSorted(nameservers), + "nameservers should be sorted, got: %v", nameservers, + ) +} + +func TestFindAuthoritativeNameservers_Deterministic( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + first, err := r.FindAuthoritativeNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + + second, err := r.FindAuthoritativeNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + + assert.Equal(t, first, second) +} + +func TestFindAuthoritativeNameservers_TrailingDot( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ns1, err := r.FindAuthoritativeNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + + ns2, err := r.FindAuthoritativeNameservers( + ctx, "google.com.", + ) + require.NoError(t, err) + + assert.Equal(t, ns1, ns2) +} + +func TestFindAuthoritativeNameservers_CloudflareDomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "cloudflare.com", + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + for _, ns := range nameservers { + assert.True(t, strings.HasSuffix(ns, "."), + "NS should be FQDN with trailing dot: %s", ns, + ) + } +} + +// ---------------------------------------------------------------- +// QueryNameserver tests +// ---------------------------------------------------------------- + +func TestQueryNameserver_BasicA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + resp, err := r.QueryNameserver( + ctx, ns, "www.google.com", + ) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, resolver.StatusOK, resp.Status) + assert.Equal(t, ns, resp.Nameserver) + + hasRecords := len(resp.Records["A"]) > 0 || + len(resp.Records["CNAME"]) > 0 + assert.True(t, hasRecords, + "expected A or CNAME records for www.google.com", + ) +} + +func TestQueryNameserver_AAAA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "cloudflare.com") + + resp, err := r.QueryNameserver( + ctx, ns, "cloudflare.com", + ) + require.NoError(t, err) + + aaaaRecords := resp.Records["AAAA"] + require.NotEmpty(t, aaaaRecords, + "cloudflare.com should have AAAA records", + ) + + for _, ip := range aaaaRecords { + parsed := net.ParseIP(ip) + require.NotNil(t, parsed, + "should be valid IP: %s", ip, + ) + } +} + +func TestQueryNameserver_MX(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + resp, err := r.QueryNameserver( + ctx, ns, "google.com", + ) + require.NoError(t, err) + + mxRecords := resp.Records["MX"] + require.NotEmpty(t, mxRecords, + "google.com should have MX records", + ) +} + +func TestQueryNameserver_TXT(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + resp, err := r.QueryNameserver( + ctx, ns, "google.com", + ) + require.NoError(t, err) + + txtRecords := resp.Records["TXT"] + require.NotEmpty(t, txtRecords, + "google.com should have TXT records", + ) + + hasSPF := false + + for _, txt := range txtRecords { + if strings.Contains(txt, "v=spf1") { + hasSPF = true + + break + } + } + + assert.True(t, hasSPF, + "google.com should have SPF TXT record", + ) +} + +func TestQueryNameserver_NXDomain(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + resp, err := r.QueryNameserver( + ctx, ns, + "this-surely-does-not-exist-xyz.google.com", + ) + require.NoError(t, err) + + assert.Equal(t, resolver.StatusNXDomain, resp.Status) +} + +func TestQueryNameserver_RecordsSorted(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + resp, err := r.QueryNameserver( + ctx, ns, "google.com", + ) + require.NoError(t, err) + + for recordType, values := range resp.Records { + assert.True( + t, + sort.StringsAreSorted(values), + "%s records should be sorted", recordType, + ) + } +} + +func TestQueryNameserver_ResponseIncludesNameserver( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "cloudflare.com") + + resp, err := r.QueryNameserver( + ctx, ns, "cloudflare.com", + ) + require.NoError(t, err) + + assert.Equal(t, ns, resp.Nameserver) +} + +func TestQueryNameserver_EmptyRecordsOnNXDomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + resp, err := r.QueryNameserver( + ctx, ns, + "this-surely-does-not-exist-xyz.google.com", + ) + require.NoError(t, err) + + totalRecords := 0 + for _, values := range resp.Records { + totalRecords += len(values) + } + + assert.Zero(t, totalRecords) +} + +func TestQueryNameserver_TrailingDotHandling(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + resp1, err := r.QueryNameserver( + ctx, ns, "google.com", + ) + require.NoError(t, err) + + resp2, err := r.QueryNameserver( + ctx, ns, "google.com.", + ) + require.NoError(t, err) + + assert.Equal(t, resp1.Status, resp2.Status) +} + +// ---------------------------------------------------------------- +// QueryAllNameservers tests +// ---------------------------------------------------------------- + +func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + results, err := r.QueryAllNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + require.NotEmpty(t, results) + + assert.GreaterOrEqual(t, len(results), 2) + + for ns, resp := range results { + assert.Equal(t, ns, resp.Nameserver) + } +} + +func TestQueryAllNameservers_AllReturnOK(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + results, err := r.QueryAllNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + + for ns, resp := range results { + assert.Equal( + t, resolver.StatusOK, resp.Status, + "NS %s should return OK", ns, + ) + } +} + +func TestQueryAllNameservers_NXDomainFromAllNS( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + results, err := r.QueryAllNameservers( + ctx, + "this-surely-does-not-exist-xyz.google.com", + ) + require.NoError(t, err) + + for ns, resp := range results { + assert.Equal( + t, resolver.StatusNXDomain, resp.Status, + "NS %s should return nxdomain", ns, + ) + } +} + +// ---------------------------------------------------------------- +// LookupNS tests +// ---------------------------------------------------------------- + +func TestLookupNS_ValidDomain(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.LookupNS(ctx, "google.com") + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + for _, ns := range nameservers { + assert.True(t, strings.HasSuffix(ns, "."), + "NS should have trailing dot: %s", ns, + ) + } +} + +func TestLookupNS_Sorted(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.LookupNS(ctx, "google.com") + require.NoError(t, err) + + assert.True(t, sort.StringsAreSorted(nameservers)) +} + +func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + fromLookup, err := r.LookupNS(ctx, "google.com") + require.NoError(t, err) + + fromFind, err := r.FindAuthoritativeNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + + assert.Equal(t, fromFind, fromLookup) +} + +// ---------------------------------------------------------------- +// ResolveIPAddresses tests +// ---------------------------------------------------------------- + +func TestResolveIPAddresses_ReturnsIPs(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, "google.com") + require.NoError(t, err) + require.NotEmpty(t, ips) + + for _, ip := range ips { + parsed := net.ParseIP(ip) + assert.NotNil(t, parsed, + "should be valid IP: %s", ip, + ) + } +} + +func TestResolveIPAddresses_Deduplicated(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, "google.com") + require.NoError(t, err) + + seen := make(map[string]bool) + + for _, ip := range ips { + assert.False(t, seen[ip], "duplicate IP: %s", ip) + seen[ip] = true + } +} + +func TestResolveIPAddresses_Sorted(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, "google.com") + require.NoError(t, err) + + assert.True(t, sort.StringsAreSorted(ips)) +} + +func TestResolveIPAddresses_NXDomainReturnsEmpty( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses( + ctx, + "this-surely-does-not-exist-xyz.google.com", + ) + require.NoError(t, err) + assert.Empty(t, ips) +} + +func TestResolveIPAddresses_CloudflareDomain(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + ips, err := r.ResolveIPAddresses(ctx, "cloudflare.com") + require.NoError(t, err) + require.NotEmpty(t, ips) +} + +// ---------------------------------------------------------------- +// Context cancellation tests +// ---------------------------------------------------------------- + +func TestFindAuthoritativeNameservers_ContextCanceled( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := r.FindAuthoritativeNameservers(ctx, "google.com") + assert.Error(t, err) +} + +func TestQueryNameserver_ContextCanceled(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := r.QueryNameserver( + ctx, "ns1.google.com.", "google.com", + ) + assert.Error(t, err) +} + +func TestQueryAllNameservers_ContextCanceled(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := r.QueryAllNameservers(ctx, "google.com") + assert.Error(t, err) +} + +// ---------------------------------------------------------------- +// Timeout tests +// ---------------------------------------------------------------- + +func TestQueryNameserverIP_Timeout(t *testing.T) { + t.Parallel() + + log := slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )) + + r := resolver.NewFromLoggerWithClient( + log, &timeoutClient{}, + ) + + ctx, cancel := context.WithTimeout( + context.Background(), 10*time.Second, + ) + t.Cleanup(cancel) + + // Query any IP — the client always returns a timeout error. + resp, err := r.QueryNameserverIP( + ctx, "unreachable.test.", "192.0.2.1", + "example.com", + ) + require.NoError(t, err) + + assert.Equal(t, resolver.StatusTimeout, resp.Status) + assert.NotEmpty(t, resp.Error) +} + +// timeoutClient simulates DNS timeout errors for testing. +type timeoutClient struct{} + +func (c *timeoutClient) ExchangeContext( + _ context.Context, + _ *dns.Msg, + _ string, +) (*dns.Msg, time.Duration, error) { + return nil, 0, &net.OpError{ + Op: "read", + Net: "udp", + Err: &timeoutError{}, + } +} + +type timeoutError struct{} + +func (e *timeoutError) Error() string { return "i/o timeout" } +func (e *timeoutError) Timeout() bool { return true } +func (e *timeoutError) Temporary() bool { return true } + +func TestResolveIPAddresses_ContextCanceled(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := r.ResolveIPAddresses(ctx, "google.com") + assert.Error(t, err) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index cd07ba1..fa99177 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,11 +1,14 @@ package server import ( + "net/http" "time" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" + + "sneak.berlin/go/dnswatcher/static" ) // requestTimeout is the maximum duration for handling a request. @@ -22,7 +25,25 @@ func (s *Server) SetupRoutes() { s.router.Use(s.mw.CORS()) s.router.Use(chimw.Timeout(requestTimeout)) - // Health check + // Dashboard (read-only web UI) + s.router.Get("/", s.handlers.HandleDashboard()) + + // Static assets (embedded CSS/JS) + s.router.Mount( + "/s", + http.StripPrefix( + "/s", + http.FileServer(http.FS(static.Static)), + ), + ) + + // Health check (standard well-known path) + s.router.Get( + "/.well-known/healthcheck", + s.handlers.HandleHealthCheck(), + ) + + // Legacy health check (keep for backward compatibility) s.router.Get("/health", s.handlers.HandleHealthCheck()) // API v1 routes diff --git a/internal/state/state.go b/internal/state/state.go index fca7276..efe681c 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -57,10 +57,49 @@ type HostnameState struct { // PortState holds the monitoring state for a port. type PortState struct { Open bool `json:"open"` - Hostname string `json:"hostname"` + Hostnames []string `json:"hostnames"` LastChecked time.Time `json:"lastChecked"` } +// UnmarshalJSON implements custom unmarshaling to handle both +// the old single-hostname format and the new multi-hostname +// format for backward compatibility with existing state files. +func (ps *PortState) UnmarshalJSON(data []byte) error { + // Use an alias to prevent infinite recursion. + type portStateAlias struct { + Open bool `json:"open"` + Hostnames []string `json:"hostnames"` + LastChecked time.Time `json:"lastChecked"` + } + + var alias portStateAlias + + err := json.Unmarshal(data, &alias) + if err != nil { + return fmt.Errorf("unmarshaling port state: %w", err) + } + + ps.Open = alias.Open + ps.Hostnames = alias.Hostnames + ps.LastChecked = alias.LastChecked + + // If Hostnames is empty, try reading the old single-hostname + // format for backward compatibility. + if len(ps.Hostnames) == 0 { + var old struct { + Hostname string `json:"hostname"` + } + + // Best-effort: ignore errors since the main unmarshal + // already succeeded. + if json.Unmarshal(data, &old) == nil && old.Hostname != "" { + ps.Hostnames = []string{old.Hostname} + } + } + + return nil +} + // CertificateState holds TLS certificate monitoring state. type CertificateState struct { CommonName string `json:"commonName"` @@ -156,8 +195,8 @@ func (s *State) Load() error { // Save writes the current state to disk atomically. func (s *State) Save() error { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() s.snapshot.LastUpdated = time.Now().UTC() @@ -263,6 +302,27 @@ func (s *State) GetPortState(key string) (*PortState, bool) { return ps, ok } +// DeletePortState removes a port state entry. +func (s *State) DeletePortState(key string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.snapshot.Ports, key) +} + +// GetAllPortKeys returns all port state keys. +func (s *State) GetAllPortKeys() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + keys := make([]string, 0, len(s.snapshot.Ports)) + for k := range s.snapshot.Ports { + keys = append(keys, k) + } + + return keys +} + // SetCertificateState updates the state for a certificate. func (s *State) SetCertificateState( key string, diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..5e95eb9 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,1302 @@ +package state_test + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/state" +) + +const testHostname = "www.example.com" + +// populateState fills a State with representative test data across all categories. +func populateState(t *testing.T, s *state.State) { + t.Helper() + + now := time.Now().UTC().Truncate(time.Second) + + s.SetDomainState("example.com", &state.DomainState{ + Nameservers: []string{"ns1.example.com.", "ns2.example.com."}, + LastChecked: now, + }) + + s.SetDomainState("example.org", &state.DomainState{ + Nameservers: []string{"ns1.example.org."}, + LastChecked: now, + }) + + s.SetHostnameState(testHostname, &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + "ns1.example.com.": { + Records: map[string][]string{ + "A": {"93.184.216.34"}, + "AAAA": {"2606:2800:220:1:248:1893:25c8:1946"}, + }, + Status: "ok", + LastChecked: now, + }, + "ns2.example.com.": { + Records: map[string][]string{ + "A": {"93.184.216.34"}, + }, + Status: "ok", + LastChecked: now, + }, + }, + LastChecked: now, + }) + + s.SetPortState("93.184.216.34:80", &state.PortState{ + Open: true, + Hostnames: []string{testHostname}, + LastChecked: now, + }) + + s.SetPortState("93.184.216.34:443", &state.PortState{ + Open: true, + Hostnames: []string{testHostname, "api.example.com"}, + LastChecked: now, + }) + + s.SetCertificateState("93.184.216.34:443:www.example.com", &state.CertificateState{ + CommonName: testHostname, + Issuer: "DigiCert TLS RSA SHA256 2020 CA1", + NotAfter: now.Add(365 * 24 * time.Hour), + SubjectAlternativeNames: []string{testHostname, "example.com"}, + Status: "ok", + LastChecked: now, + }) +} + +// TestSaveLoadRoundTrip_Domains verifies domain data survives a save/load cycle. +func TestSaveLoadRoundTrip_Domains(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + populateState(t, s) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + snap := loaded.GetSnapshot() + + if len(snap.Domains) != 2 { + t.Fatalf("expected 2 domains, got %d", len(snap.Domains)) + } + + dom, ok := snap.Domains["example.com"] + if !ok { + t.Fatal("missing domain example.com") + } + + if len(dom.Nameservers) != 2 { + t.Errorf("expected 2 nameservers, got %d", len(dom.Nameservers)) + } +} + +// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle. +func TestSaveLoadRoundTrip_Hostnames(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + populateState(t, s) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + snap := loaded.GetSnapshot() + + if len(snap.Hostnames) != 1 { + t.Fatalf("expected 1 hostname, got %d", len(snap.Hostnames)) + } + + hn, ok := snap.Hostnames[testHostname] + if !ok { + t.Fatal("missing hostname") + } + + if len(hn.RecordsByNameserver) != 2 { + t.Errorf("expected 2 NS entries, got %d", len(hn.RecordsByNameserver)) + } + + verifyNS1Records(t, hn) +} + +// verifyNS1Records checks the ns1.example.com. records in the loaded hostname state. +func verifyNS1Records(t *testing.T, hn *state.HostnameState) { + t.Helper() + + ns1, ok := hn.RecordsByNameserver["ns1.example.com."] + if !ok { + t.Fatal("missing nameserver ns1.example.com.") + } + + aRecords := ns1.Records["A"] + if len(aRecords) != 1 || aRecords[0] != "93.184.216.34" { + t.Errorf("ns1 A records: got %v", aRecords) + } + + aaaaRecords := ns1.Records["AAAA"] + if len(aaaaRecords) != 1 || aaaaRecords[0] != "2606:2800:220:1:248:1893:25c8:1946" { + t.Errorf("ns1 AAAA records: got %v", aaaaRecords) + } + + if ns1.Status != "ok" { + t.Errorf("ns1 status: got %q, want %q", ns1.Status, "ok") + } +} + +// TestSaveLoadRoundTrip_Ports verifies port data survives a save/load cycle. +func TestSaveLoadRoundTrip_Ports(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + populateState(t, s) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + snap := loaded.GetSnapshot() + + if len(snap.Ports) != 2 { + t.Fatalf("expected 2 ports, got %d", len(snap.Ports)) + } + + port443, ok := snap.Ports["93.184.216.34:443"] + if !ok { + t.Fatal("missing port 93.184.216.34:443") + } + + if !port443.Open { + t.Error("port 443: expected open") + } + + if len(port443.Hostnames) != 2 { + t.Errorf("expected 2 hostnames, got %d", len(port443.Hostnames)) + } +} + +// TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle. +func TestSaveLoadRoundTrip_Certificates(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + populateState(t, s) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + snap := loaded.GetSnapshot() + + if len(snap.Certificates) != 1 { + t.Fatalf("expected 1 certificate, got %d", len(snap.Certificates)) + } + + cert, ok := snap.Certificates["93.184.216.34:443:www.example.com"] + if !ok { + t.Fatal("missing certificate") + } + + if cert.CommonName != testHostname { + t.Errorf("cert CN: got %q", cert.CommonName) + } + + if cert.Issuer != "DigiCert TLS RSA SHA256 2020 CA1" { + t.Errorf("cert issuer: got %q", cert.Issuer) + } + + if len(cert.SubjectAlternativeNames) != 2 { + t.Errorf("cert SANs: expected 2, got %d", len(cert.SubjectAlternativeNames)) + } + + if cert.Status != "ok" { + t.Errorf("cert status: got %q", cert.Status) + } +} + +// TestSaveCreatesDirectory verifies Save creates the data directory if missing. +func TestSaveCreatesDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + nested := filepath.Join(dir, "nested", "deep") + + s := state.NewForTestWithDataDir(nested) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + statePath := filepath.Join(nested, "state.json") + + info, err := os.Stat(statePath) + if err != nil { + t.Fatalf("state file not found: %v", err) + } + + if info.Size() == 0 { + t.Error("state file is empty") + } +} + +// TestSaveAtomicWrite verifies the atomic write pattern (no leftover temp files). +func TestSaveAtomicWrite(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + populateState(t, s) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + statePath := filepath.Join(dir, "state.json") + tmpPath := statePath + ".tmp" + + // No .tmp file should remain after successful save. + _, err = os.Stat(tmpPath) + if !os.IsNotExist(err) { + t.Error("temp file should not exist after successful save") + } + + // Modify state and save again; verify updated content. + s.SetDomainState("new.example.com", &state.DomainState{ + Nameservers: []string{"ns1.new.example.com."}, + LastChecked: time.Now().UTC(), + }) + + err = s.Save() + if err != nil { + t.Fatalf("second Save() error: %v", err) + } + + _, err = os.Stat(tmpPath) + if !os.IsNotExist(err) { + t.Error("temp file should not exist after second save") + } + + // Verify new domain is present by loading. + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + _, ok := loaded.GetDomainState("new.example.com") + if !ok { + t.Error("new domain not found after second save") + } +} + +// TestLoadMissingFile verifies that loading a nonexistent file is not an error. +func TestLoadMissingFile(t *testing.T) { + t.Parallel() + + s := state.NewForTestWithDataDir(t.TempDir()) + + err := s.Load() + if err != nil { + t.Fatalf("Load() with missing file should not error: %v", err) + } + + snap := s.GetSnapshot() + + if len(snap.Domains) != 0 { + t.Error("expected empty domains") + } + + if len(snap.Hostnames) != 0 { + t.Error("expected empty hostnames") + } + + if len(snap.Ports) != 0 { + t.Error("expected empty ports") + } + + if len(snap.Certificates) != 0 { + t.Error("expected empty certificates") + } +} + +// TestLoadCorruptFile verifies that a corrupt state file returns an error. +func TestLoadCorruptFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := os.WriteFile( + filepath.Join(dir, "state.json"), + []byte("not valid json{{{"), + 0o600, + ) + if err != nil { + t.Fatalf("writing corrupt file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err == nil { + t.Fatal("Load() should return error for corrupt file") + } +} + +// TestLoadEmptyFile verifies that an empty state file returns an error. +func TestLoadEmptyFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(""), 0o600) + if err != nil { + t.Fatalf("writing empty file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err == nil { + t.Fatal("Load() should return error for empty file") + } +} + +// TestLoadReadPermissionError verifies that an unreadable state file returns an error. +func TestLoadReadPermissionError(t *testing.T) { + t.Parallel() + + if os.Getuid() == 0 { + t.Skip("skipping permission test when running as root") + } + + dir := t.TempDir() + statePath := filepath.Join(dir, "state.json") + + err := os.WriteFile(statePath, []byte("{}"), 0o600) + if err != nil { + t.Fatalf("writing file: %v", err) + } + + err = os.Chmod(statePath, 0o000) + if err != nil { + t.Fatalf("chmod: %v", err) + } + + t.Cleanup(func() { + _ = os.Chmod(statePath, 0o600) + }) + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err == nil { + t.Fatal("Load() should return error for unreadable file") + } +} + +// TestSaveWritePermissionError verifies that Save returns an error +// when the directory is not writable. +func TestSaveWritePermissionError(t *testing.T) { + t.Parallel() + + if os.Getuid() == 0 { + t.Skip("skipping permission test when running as root") + } + + dir := t.TempDir() + dataDir := filepath.Join(dir, "readonly") + + err := os.MkdirAll(dataDir, 0o700) + if err != nil { + t.Fatalf("creating dir: %v", err) + } + + //nolint:gosec // intentionally making dir read-only+execute for test + err = os.Chmod(dataDir, 0o500) + if err != nil { + t.Fatalf("chmod: %v", err) + } + + t.Cleanup(func() { + //nolint:gosec // restoring write permission for cleanup + _ = os.Chmod(dataDir, 0o700) + }) + + s := state.NewForTestWithDataDir(dataDir) + + err = s.Save() + if err == nil { + t.Fatal("Save() should return error for read-only directory") + } +} + +// TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the +// current multi-hostname format. +func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) { + t.Parallel() + + data := []byte(`{ + "open": true, + "hostnames": ["www.example.com", "api.example.com"], + "lastChecked": "2026-02-19T12:00:00Z" + }`) + + var ps state.PortState + + err := json.Unmarshal(data, &ps) + if err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if !ps.Open { + t.Error("expected open=true") + } + + if len(ps.Hostnames) != 2 { + t.Fatalf("expected 2 hostnames, got %d", len(ps.Hostnames)) + } + + if ps.Hostnames[0] != testHostname { + t.Errorf("hostname[0]: got %q", ps.Hostnames[0]) + } + + if ps.Hostnames[1] != "api.example.com" { + t.Errorf("hostname[1]: got %q", ps.Hostnames[1]) + } + + if ps.LastChecked.IsZero() { + t.Error("expected non-zero LastChecked") + } +} + +// TestPortStateUnmarshalJSON_OldFormat verifies backward-compatible +// deserialization of the old single-hostname format. +func TestPortStateUnmarshalJSON_OldFormat(t *testing.T) { + t.Parallel() + + data := []byte(`{ + "open": false, + "hostname": "legacy.example.com", + "lastChecked": "2026-01-15T10:30:00Z" + }`) + + var ps state.PortState + + err := json.Unmarshal(data, &ps) + if err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if ps.Open { + t.Error("expected open=false") + } + + if len(ps.Hostnames) != 1 { + t.Fatalf("expected 1 hostname, got %d", len(ps.Hostnames)) + } + + if ps.Hostnames[0] != "legacy.example.com" { + t.Errorf("hostname: got %q", ps.Hostnames[0]) + } +} + +// TestPortStateUnmarshalJSON_EmptyHostnames verifies that when neither +// hostnames nor hostname is present, Hostnames remains nil. +func TestPortStateUnmarshalJSON_EmptyHostnames(t *testing.T) { + t.Parallel() + + data := []byte(`{ + "open": true, + "lastChecked": "2026-02-19T12:00:00Z" + }`) + + var ps state.PortState + + err := json.Unmarshal(data, &ps) + if err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if len(ps.Hostnames) != 0 { + t.Errorf("expected empty hostnames, got %v", ps.Hostnames) + } +} + +// TestPortStateUnmarshalJSON_InvalidJSON verifies that invalid JSON returns an error. +func TestPortStateUnmarshalJSON_InvalidJSON(t *testing.T) { + t.Parallel() + + data := []byte(`{invalid json}`) + + var ps state.PortState + + err := json.Unmarshal(data, &ps) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +// TestPortStateUnmarshalJSON_BothFormats verifies that when both "hostnames" +// and "hostname" are present, the new format takes precedence. +func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) { + t.Parallel() + + data := []byte(`{ + "open": true, + "hostnames": ["new.example.com"], + "hostname": "old.example.com", + "lastChecked": "2026-02-19T12:00:00Z" + }`) + + var ps state.PortState + + err := json.Unmarshal(data, &ps) + if err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + // When hostnames is non-empty, the old hostname field is ignored. + if len(ps.Hostnames) != 1 { + t.Fatalf("expected 1 hostname, got %d", len(ps.Hostnames)) + } + + if ps.Hostnames[0] != "new.example.com" { + t.Errorf("hostname: got %q, want %q", ps.Hostnames[0], "new.example.com") + } +} + +// TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy. +func TestGetSnapshot_ReturnsCopy(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + populateState(t, s) + + snap := s.GetSnapshot() + + // Mutate the returned snapshot's map. + snap.Domains["mutated.example.com"] = &state.DomainState{ + Nameservers: []string{"ns1.mutated.com."}, + } + + // The original domain data should still be accessible. + _, ok := s.GetDomainState("example.com") + if !ok { + t.Error("original domain should still exist") + } +} + +// TestDomainState_GetSet tests domain state getter and setter. +func TestDomainState_GetSet(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + // Get on missing key returns false. + _, ok := s.GetDomainState("nonexistent.com") + if ok { + t.Error("expected false for missing domain") + } + + now := time.Now().UTC().Truncate(time.Second) + ds := &state.DomainState{ + Nameservers: []string{"ns1.test.com."}, + LastChecked: now, + } + + s.SetDomainState("test.com", ds) + + got, ok := s.GetDomainState("test.com") + if !ok { + t.Fatal("expected true for existing domain") + } + + if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." { + t.Errorf("nameservers: got %v", got.Nameservers) + } + + if !got.LastChecked.Equal(now) { + t.Errorf("lastChecked: got %v, want %v", got.LastChecked, now) + } + + // Overwrite. + ds2 := &state.DomainState{ + Nameservers: []string{"ns1.test.com.", "ns2.test.com."}, + LastChecked: now.Add(time.Hour), + } + + s.SetDomainState("test.com", ds2) + + got2, ok := s.GetDomainState("test.com") + if !ok { + t.Fatal("expected true after overwrite") + } + + if len(got2.Nameservers) != 2 { + t.Errorf("expected 2 nameservers after overwrite, got %d", len(got2.Nameservers)) + } +} + +// TestHostnameState_GetSet tests hostname state getter and setter. +func TestHostnameState_GetSet(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + _, ok := s.GetHostnameState("missing.example.com") + if ok { + t.Error("expected false for missing hostname") + } + + now := time.Now().UTC().Truncate(time.Second) + hs := &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + "ns1.example.com.": { + Records: map[string][]string{"A": {"1.2.3.4"}}, + Status: "ok", + LastChecked: now, + }, + }, + LastChecked: now, + } + + s.SetHostnameState(testHostname, hs) + + got, ok := s.GetHostnameState(testHostname) + if !ok { + t.Fatal("expected true for existing hostname") + } + + nsState, ok := got.RecordsByNameserver["ns1.example.com."] + if !ok { + t.Fatal("missing nameserver entry") + } + + if nsState.Status != "ok" { + t.Errorf("status: got %q", nsState.Status) + } + + aRecords := nsState.Records["A"] + if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" { + t.Errorf("A records: got %v", aRecords) + } +} + +// TestPortState_GetSetDelete tests port state getter, setter, and deletion. +func TestPortState_GetSetDelete(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + _, ok := s.GetPortState("1.2.3.4:80") + if ok { + t.Error("expected false for missing port") + } + + now := time.Now().UTC().Truncate(time.Second) + ps := &state.PortState{ + Open: true, + Hostnames: []string{testHostname}, + LastChecked: now, + } + + s.SetPortState("1.2.3.4:80", ps) + + got, ok := s.GetPortState("1.2.3.4:80") + if !ok { + t.Fatal("expected true for existing port") + } + + if !got.Open { + t.Error("expected open=true") + } + + // Delete the port state. + s.DeletePortState("1.2.3.4:80") + + _, ok = s.GetPortState("1.2.3.4:80") + if ok { + t.Error("expected false after delete") + } +} + +// TestGetAllPortKeys tests retrieving all port state keys. +func TestGetAllPortKeys(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + keys := s.GetAllPortKeys() + if len(keys) != 0 { + t.Errorf("expected 0 keys, got %d", len(keys)) + } + + now := time.Now().UTC() + + s.SetPortState("1.2.3.4:80", &state.PortState{ + Open: true, Hostnames: []string{"a.example.com"}, LastChecked: now, + }) + + s.SetPortState("1.2.3.4:443", &state.PortState{ + Open: true, Hostnames: []string{"a.example.com"}, LastChecked: now, + }) + + s.SetPortState("5.6.7.8:80", &state.PortState{ + Open: false, Hostnames: []string{"b.example.com"}, LastChecked: now, + }) + + keys = s.GetAllPortKeys() + if len(keys) != 3 { + t.Fatalf("expected 3 keys, got %d", len(keys)) + } + + keySet := make(map[string]bool) + for _, k := range keys { + keySet[k] = true + } + + for _, want := range []string{"1.2.3.4:80", "1.2.3.4:443", "5.6.7.8:80"} { + if !keySet[want] { + t.Errorf("missing key %q", want) + } + } +} + +// TestCertificateState_GetSet tests certificate state getter and setter. +func TestCertificateState_GetSet(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + _, ok := s.GetCertificateState("1.2.3.4:443:www.example.com") + if ok { + t.Error("expected false for missing certificate") + } + + now := time.Now().UTC().Truncate(time.Second) + cs := &state.CertificateState{ + CommonName: testHostname, + Issuer: "Let's Encrypt Authority X3", + NotAfter: now.Add(90 * 24 * time.Hour), + SubjectAlternativeNames: []string{testHostname}, + Status: "ok", + LastChecked: now, + } + + s.SetCertificateState("1.2.3.4:443:www.example.com", cs) + + got, ok := s.GetCertificateState("1.2.3.4:443:www.example.com") + if !ok { + t.Fatal("expected true for existing certificate") + } + + if got.CommonName != testHostname { + t.Errorf("CN: got %q", got.CommonName) + } + + if got.Issuer != "Let's Encrypt Authority X3" { + t.Errorf("issuer: got %q", got.Issuer) + } + + if got.Status != "ok" { + t.Errorf("status: got %q", got.Status) + } + + if len(got.SubjectAlternativeNames) != 1 { + t.Errorf("SANs: expected 1, got %d", len(got.SubjectAlternativeNames)) + } +} + +// TestCertificateState_ErrorField tests that the error field round-trips. +func TestCertificateState_ErrorField(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + now := time.Now().UTC().Truncate(time.Second) + cs := &state.CertificateState{ + Status: "error", + Error: "connection refused", + LastChecked: now, + } + + s.SetCertificateState("10.0.0.1:443:err.example.com", cs) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + got, ok := loaded.GetCertificateState("10.0.0.1:443:err.example.com") + if !ok { + t.Fatal("missing certificate after load") + } + + if got.Status != "error" { + t.Errorf("status: got %q, want %q", got.Status, "error") + } + + if got.Error != "connection refused" { + t.Errorf("error field: got %q", got.Error) + } +} + +// TestHostnameState_ErrorField tests that hostname error states round-trip. +func TestHostnameState_ErrorField(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + now := time.Now().UTC().Truncate(time.Second) + hs := &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + "ns1.example.com.": { + Records: nil, + Status: "error", + Error: "SERVFAIL", + LastChecked: now, + }, + }, + LastChecked: now, + } + + s.SetHostnameState("fail.example.com", hs) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + got, ok := loaded.GetHostnameState("fail.example.com") + if !ok { + t.Fatal("missing hostname after load") + } + + nsState := got.RecordsByNameserver["ns1.example.com."] + if nsState.Status != "error" { + t.Errorf("status: got %q, want %q", nsState.Status, "error") + } + + if nsState.Error != "SERVFAIL" { + t.Errorf("error: got %q, want %q", nsState.Error, "SERVFAIL") + } +} + +// TestSnapshotVersion verifies that the snapshot version is written correctly. +func TestSnapshotVersion(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + //nolint:gosec // test file with known path + data, err := os.ReadFile(filepath.Join(dir, "state.json")) + if err != nil { + t.Fatalf("reading state file: %v", err) + } + + var snap state.Snapshot + + err = json.Unmarshal(data, &snap) + if err != nil { + t.Fatalf("parsing state: %v", err) + } + + if snap.Version != 1 { + t.Errorf("version: got %d, want 1", snap.Version) + } + + if snap.LastUpdated.IsZero() { + t.Error("lastUpdated should be set after save") + } +} + +// TestSaveUpdatesLastUpdated verifies that Save sets LastUpdated. +func TestSaveUpdatesLastUpdated(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + before := time.Now().UTC().Add(-time.Second) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + after := time.Now().UTC().Add(time.Second) + + snap := s.GetSnapshot() + if snap.LastUpdated.Before(before) || snap.LastUpdated.After(after) { + t.Errorf( + "LastUpdated %v not in expected range [%v, %v]", + snap.LastUpdated, before, after, + ) + } +} + +// TestLoadPreservesExistingStateOnMissingFile verifies that when Load is +// called and the file doesn't exist, the existing in-memory state is kept. +func TestLoadPreservesExistingStateOnMissingFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + // Add some data before loading. + s.SetDomainState("pre.example.com", &state.DomainState{ + Nameservers: []string{"ns1.pre.example.com."}, + LastChecked: time.Now().UTC(), + }) + + err := s.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + // Since the file doesn't exist, the existing data should persist. + _, ok := s.GetDomainState("pre.example.com") + if !ok { + t.Error("existing domain state should persist when file is missing") + } +} + +// TestConcurrentGetSet verifies that concurrent reads and writes don't race. +func TestConcurrentGetSet(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + const goroutines = 20 + + var wg sync.WaitGroup + + wg.Add(goroutines) + + for i := range goroutines { + go func(id int) { + defer wg.Done() + + now := time.Now().UTC() + key := string(rune('a' + id%26)) + + runConcurrentOps(s, key, now) + }(i) + } + + wg.Wait() +} + +// runConcurrentOps performs a series of get/set/delete operations for concurrency testing. +func runConcurrentOps(s *state.State, key string, now time.Time) { + const iterations = 50 + + for j := range iterations { + s.SetDomainState(key+".com", &state.DomainState{ + Nameservers: []string{"ns1." + key + ".com."}, + LastChecked: now, + }) + + s.GetDomainState(key + ".com") + + s.SetPortState(key+":80", &state.PortState{ + Open: j%2 == 0, Hostnames: []string{key + ".com"}, LastChecked: now, + }) + + s.GetPortState(key + ":80") + s.GetAllPortKeys() + s.GetSnapshot() + + s.SetHostnameState(key+".example.com", &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + "ns1.test.": { + Records: map[string][]string{"A": {"1.2.3.4"}}, + Status: "ok", + LastChecked: now, + }, + }, + LastChecked: now, + }) + + s.GetHostnameState(key + ".example.com") + + s.SetCertificateState("1.2.3.4:443:"+key+".com", &state.CertificateState{ + CommonName: key + ".com", Status: "ok", LastChecked: now, + }) + + s.GetCertificateState("1.2.3.4:443:" + key + ".com") + + if j%10 == 0 { + s.DeletePortState(key + ":80") + } + } +} + +// TestConcurrentSaveLoad verifies that concurrent Save and Load +// operations don't race or corrupt state. +func TestConcurrentSaveLoad(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + populateState(t, s) + + const goroutines = 10 + + var wg sync.WaitGroup + + wg.Add(goroutines) + + for i := range goroutines { + go func(id int) { + defer wg.Done() + + if id%2 == 0 { + _ = s.Save() + } else { + _ = s.Load() + } + }(i) + } + + wg.Wait() +} + +// TestPortStateRoundTripThroughSaveLoad verifies backward-compat deserialization +// works through the full Save→Load cycle with old-format data on disk. +func TestPortStateRoundTripThroughSaveLoad(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Write a state file with old single-hostname port format. + oldFormatJSON := `{ + "version": 1, + "lastUpdated": "2026-02-19T12:00:00Z", + "domains": {}, + "hostnames": {}, + "ports": { + "10.0.0.1:80": { + "open": true, + "hostname": "legacy.example.com", + "lastChecked": "2026-02-19T12:00:00Z" + }, + "10.0.0.1:443": { + "open": true, + "hostnames": ["modern.example.com"], + "lastChecked": "2026-02-19T12:00:00Z" + } + }, + "certificates": {} + }` + + err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(oldFormatJSON), 0o600) + if err != nil { + t.Fatalf("writing old format state: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + // Verify old format was migrated. + ps80, ok := s.GetPortState("10.0.0.1:80") + if !ok { + t.Fatal("missing port 10.0.0.1:80") + } + + if len(ps80.Hostnames) != 1 || ps80.Hostnames[0] != "legacy.example.com" { + t.Errorf("old format port hostnames: got %v", ps80.Hostnames) + } + + // Verify new format loaded correctly. + ps443, ok := s.GetPortState("10.0.0.1:443") + if !ok { + t.Fatal("missing port 10.0.0.1:443") + } + + if len(ps443.Hostnames) != 1 || ps443.Hostnames[0] != "modern.example.com" { + t.Errorf("new format port hostnames: got %v", ps443.Hostnames) + } +} + +// TestMultipleSavesOverwrite verifies that subsequent saves overwrite the +// previous state completely. +func TestMultipleSavesOverwrite(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + s.SetDomainState("first.com", &state.DomainState{ + Nameservers: []string{"ns1.first.com."}, + LastChecked: time.Now().UTC(), + }) + + err := s.Save() + if err != nil { + t.Fatalf("first Save() error: %v", err) + } + + // Create a brand new state with the same dir and set different data. + s2 := state.NewForTestWithDataDir(dir) + + s2.SetDomainState("second.com", &state.DomainState{ + Nameservers: []string{"ns1.second.com."}, + LastChecked: time.Now().UTC(), + }) + + err = s2.Save() + if err != nil { + t.Fatalf("second Save() error: %v", err) + } + + // Load and verify only the second domain exists. + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + snap := loaded.GetSnapshot() + + if _, ok := snap.Domains["first.com"]; ok { + t.Error("first.com should not exist after overwrite") + } + + if _, ok := snap.Domains["second.com"]; !ok { + t.Error("second.com should exist after overwrite") + } +} + +// TestNewForTest verifies the test helper creates a valid empty state. +func TestNewForTest(t *testing.T) { + t.Parallel() + + s := state.NewForTest() + + snap := s.GetSnapshot() + + if snap.Version != 1 { + t.Errorf("version: got %d, want 1", snap.Version) + } + + if snap.Domains == nil { + t.Error("Domains map should be initialized") + } + + if snap.Hostnames == nil { + t.Error("Hostnames map should be initialized") + } + + if snap.Ports == nil { + t.Error("Ports map should be initialized") + } + + if snap.Certificates == nil { + t.Error("Certificates map should be initialized") + } +} + +// TestSaveFilePermissions verifies the saved file has restricted permissions. +func TestSaveFilePermissions(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + info, err := os.Stat(filepath.Join(dir, "state.json")) + if err != nil { + t.Fatalf("stat error: %v", err) + } + + perm := info.Mode().Perm() + if perm != 0o600 { + t.Errorf("file permissions: got %o, want 600", perm) + } +} diff --git a/internal/state/state_test_helper.go b/internal/state/state_test_helper.go index 2142bfb..7e9aac7 100644 --- a/internal/state/state_test_helper.go +++ b/internal/state/state_test_helper.go @@ -20,3 +20,19 @@ func NewForTest() *State { config: &config.Config{DataDir: ""}, } } + +// NewForTestWithDataDir creates a State backed by the given directory +// for tests that need file persistence. +func NewForTestWithDataDir(dataDir string) *State { + return &State{ + log: slog.Default(), + snapshot: &Snapshot{ + Version: stateVersion, + Domains: make(map[string]*DomainState), + Hostnames: make(map[string]*HostnameState), + Ports: make(map[string]*PortState), + Certificates: make(map[string]*CertificateState), + }, + config: &config.Config{DataDir: dataDir}, + } +} diff --git a/internal/tlscheck/extractcertinfo_test.go b/internal/tlscheck/extractcertinfo_test.go new file mode 100644 index 0000000..56830e8 --- /dev/null +++ b/internal/tlscheck/extractcertinfo_test.go @@ -0,0 +1,67 @@ +package tlscheck_test + +import ( + "context" + "crypto/tls" + "errors" + "net" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/tlscheck" +) + +func TestCheckCertificateNoPeerCerts(t *testing.T) { + t.Parallel() + + lc := &net.ListenConfig{} + + ln, err := lc.Listen( + context.Background(), "tcp", "127.0.0.1:0", + ) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = ln.Close() }() + + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatal("unexpected address type") + } + + // Accept and immediately close to cause TLS handshake failure. + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + + _ = conn.Close() + }() + + checker := tlscheck.NewStandalone( + tlscheck.WithTimeout(2*time.Second), + tlscheck.WithTLSConfig(&tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // test + MinVersion: tls.VersionTLS12, + }), + tlscheck.WithPort(addr.Port), + ) + + _, err = checker.CheckCertificate( + context.Background(), "127.0.0.1", "localhost", + ) + if err == nil { + t.Fatal("expected error when server presents no certs") + } +} + +func TestErrNoPeerCertificatesIsSentinel(t *testing.T) { + t.Parallel() + + err := tlscheck.ErrNoPeerCertificates + if !errors.Is(err, tlscheck.ErrNoPeerCertificates) { + t.Fatal("expected sentinel error to match") + } +} diff --git a/internal/tlscheck/tlscheck.go b/internal/tlscheck/tlscheck.go index 42f086d..60f349a 100644 --- a/internal/tlscheck/tlscheck.go +++ b/internal/tlscheck/tlscheck.go @@ -3,8 +3,12 @@ package tlscheck import ( "context" + "crypto/tls" "errors" + "fmt" "log/slog" + "net" + "strconv" "time" "go.uber.org/fx" @@ -12,11 +16,56 @@ import ( "sneak.berlin/go/dnswatcher/internal/logger" ) -// ErrNotImplemented indicates the TLS checker is not yet implemented. -var ErrNotImplemented = errors.New( - "tls checker not yet implemented", +const ( + defaultTimeout = 10 * time.Second + defaultPort = 443 ) +// ErrUnexpectedConnType indicates the connection was not a TLS +// connection. +var ErrUnexpectedConnType = errors.New( + "unexpected connection type", +) + +// ErrNoPeerCertificates indicates the TLS connection had no peer +// certificates. +var ErrNoPeerCertificates = errors.New( + "no peer certificates", +) + +// CertificateInfo holds information about a TLS certificate. +type CertificateInfo struct { + CommonName string + Issuer string + NotAfter time.Time + SubjectAlternativeNames []string + SerialNumber string +} + +// Option configures a Checker. +type Option func(*Checker) + +// WithTimeout sets the connection timeout. +func WithTimeout(d time.Duration) Option { + return func(c *Checker) { + c.timeout = d + } +} + +// WithTLSConfig sets a custom TLS configuration. +func WithTLSConfig(cfg *tls.Config) Option { + return func(c *Checker) { + c.tlsConfig = cfg + } +} + +// WithPort sets the TLS port to connect to. +func WithPort(port int) Option { + return func(c *Checker) { + c.port = port + } +} + // Params contains dependencies for Checker. type Params struct { fx.In @@ -26,15 +75,10 @@ type Params struct { // Checker performs TLS certificate inspection. type Checker struct { - log *slog.Logger -} - -// CertificateInfo holds information about a TLS certificate. -type CertificateInfo struct { - CommonName string - Issuer string - NotAfter time.Time - SubjectAlternativeNames []string + log *slog.Logger + timeout time.Duration + tlsConfig *tls.Config + port int } // New creates a new TLS Checker instance. @@ -43,16 +87,110 @@ func New( params Params, ) (*Checker, error) { return &Checker{ - log: params.Logger.Get(), + log: params.Logger.Get(), + timeout: defaultTimeout, + port: defaultPort, }, nil } -// CheckCertificate connects to the given IP:port using SNI and -// returns certificate information. -func (c *Checker) CheckCertificate( - _ context.Context, - _ string, - _ string, -) (*CertificateInfo, error) { - return nil, ErrNotImplemented +// NewStandalone creates a Checker without fx dependencies. +func NewStandalone(opts ...Option) *Checker { + checker := &Checker{ + log: slog.Default(), + timeout: defaultTimeout, + port: defaultPort, + } + + for _, opt := range opts { + opt(checker) + } + + return checker +} + +// CheckCertificate connects to the given IP address using the +// specified SNI hostname and returns certificate information. +func (c *Checker) CheckCertificate( + ctx context.Context, + ipAddress string, + sniHostname string, +) (*CertificateInfo, error) { + target := net.JoinHostPort( + ipAddress, strconv.Itoa(c.port), + ) + + tlsCfg := c.buildTLSConfig(sniHostname) + dialer := &tls.Dialer{ + NetDialer: &net.Dialer{Timeout: c.timeout}, + Config: tlsCfg, + } + + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return nil, fmt.Errorf( + "TLS dial to %s: %w", target, err, + ) + } + + defer func() { + closeErr := conn.Close() + if closeErr != nil { + c.log.Debug( + "closing TLS connection", + "target", target, + "error", closeErr.Error(), + ) + } + }() + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + return nil, fmt.Errorf( + "%s: %w", target, ErrUnexpectedConnType, + ) + } + + return c.extractCertInfo(tlsConn) +} + +func (c *Checker) buildTLSConfig( + sniHostname string, +) *tls.Config { + if c.tlsConfig != nil { + cfg := c.tlsConfig.Clone() + cfg.ServerName = sniHostname + + return cfg + } + + return &tls.Config{ + ServerName: sniHostname, + MinVersion: tls.VersionTLS12, + } +} + +func (c *Checker) extractCertInfo( + conn *tls.Conn, +) (*CertificateInfo, error) { + state := conn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return nil, ErrNoPeerCertificates + } + + cert := state.PeerCertificates[0] + + sans := make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses)) + sans = append(sans, cert.DNSNames...) + + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + + return &CertificateInfo{ + CommonName: cert.Subject.CommonName, + Issuer: cert.Issuer.CommonName, + NotAfter: cert.NotAfter, + SubjectAlternativeNames: sans, + SerialNumber: cert.SerialNumber.String(), + }, nil } diff --git a/internal/tlscheck/tlscheck_test.go b/internal/tlscheck/tlscheck_test.go new file mode 100644 index 0000000..715f474 --- /dev/null +++ b/internal/tlscheck/tlscheck_test.go @@ -0,0 +1,169 @@ +package tlscheck_test + +import ( + "context" + "crypto/tls" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/tlscheck" +) + +func startTLSServer( + t *testing.T, +) (*httptest.Server, string, int) { + t.Helper() + + srv := httptest.NewTLSServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + ), + ) + + addr, ok := srv.Listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatal("unexpected address type") + } + + return srv, addr.IP.String(), addr.Port +} + +func TestCheckCertificateValid(t *testing.T) { + t.Parallel() + + srv, ip, port := startTLSServer(t) + + defer srv.Close() + + checker := tlscheck.NewStandalone( + tlscheck.WithTimeout(5*time.Second), + tlscheck.WithTLSConfig(&tls.Config{ + //nolint:gosec // test uses self-signed cert + InsecureSkipVerify: true, + }), + tlscheck.WithPort(port), + ) + + info, err := checker.CheckCertificate( + context.Background(), ip, "localhost", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if info == nil { + t.Fatal("expected non-nil CertificateInfo") + } + + if info.NotAfter.IsZero() { + t.Error("expected non-zero NotAfter") + } + + if info.SerialNumber == "" { + t.Error("expected non-empty SerialNumber") + } +} + +func TestCheckCertificateConnectionRefused(t *testing.T) { + t.Parallel() + + lc := &net.ListenConfig{} + + ln, err := lc.Listen( + context.Background(), "tcp", "127.0.0.1:0", + ) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatal("unexpected address type") + } + + port := addr.Port + + _ = ln.Close() + + checker := tlscheck.NewStandalone( + tlscheck.WithTimeout(2*time.Second), + tlscheck.WithPort(port), + ) + + _, err = checker.CheckCertificate( + context.Background(), "127.0.0.1", "localhost", + ) + if err == nil { + t.Fatal("expected error for connection refused") + } +} + +func TestCheckCertificateContextCanceled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + checker := tlscheck.NewStandalone( + tlscheck.WithTimeout(2*time.Second), + tlscheck.WithPort(1), + ) + + _, err := checker.CheckCertificate( + ctx, "127.0.0.1", "localhost", + ) + if err == nil { + t.Fatal("expected error for canceled context") + } +} + +func TestCheckCertificateTimeout(t *testing.T) { + t.Parallel() + + checker := tlscheck.NewStandalone( + tlscheck.WithTimeout(1*time.Millisecond), + tlscheck.WithPort(1), + ) + + _, err := checker.CheckCertificate( + context.Background(), + "192.0.2.1", + "example.com", + ) + if err == nil { + t.Fatal("expected error for timeout") + } +} + +func TestCheckCertificateSANs(t *testing.T) { + t.Parallel() + + srv, ip, port := startTLSServer(t) + + defer srv.Close() + + checker := tlscheck.NewStandalone( + tlscheck.WithTimeout(5*time.Second), + tlscheck.WithTLSConfig(&tls.Config{ + //nolint:gosec // test uses self-signed cert + InsecureSkipVerify: true, + }), + tlscheck.WithPort(port), + ) + + info, err := checker.CheckCertificate( + context.Background(), ip, "localhost", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if info.CommonName == "" && len(info.SubjectAlternativeNames) == 0 { + t.Error("expected CN or SANs to be populated") + } +} diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index 695139d..dd68017 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -4,6 +4,7 @@ package watcher import ( "context" + "sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/tlscheck" ) @@ -36,7 +37,7 @@ type PortChecker interface { ctx context.Context, address string, port int, - ) (bool, error) + ) (*portcheck.PortResult, error) } // TLSChecker inspects TLS certificates. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2493264..bdf4f22 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,6 +6,7 @@ import ( "log/slog" "sort" "strings" + "sync" "time" "go.uber.org/fx" @@ -40,15 +41,17 @@ type Params struct { // Watcher orchestrates all monitoring checks on a schedule. type Watcher struct { - log *slog.Logger - config *config.Config - state *state.State - resolver DNSResolver - portCheck PortChecker - tlsCheck TLSChecker - notify Notifier - cancel context.CancelFunc - firstRun bool + log *slog.Logger + config *config.Config + state *state.State + resolver DNSResolver + portCheck PortChecker + tlsCheck TLSChecker + notify Notifier + cancel context.CancelFunc + firstRun bool + expiryNotifiedMu sync.Mutex + expiryNotified map[string]time.Time } // New creates a new Watcher instance wired into the fx lifecycle. @@ -57,24 +60,27 @@ func New( params Params, ) (*Watcher, error) { w := &Watcher{ - log: params.Logger.Get(), - config: params.Config, - state: params.State, - resolver: params.Resolver, - portCheck: params.PortCheck, - tlsCheck: params.TLSCheck, - notify: params.Notify, - firstRun: true, + log: params.Logger.Get(), + config: params.Config, + state: params.State, + resolver: params.Resolver, + portCheck: params.PortCheck, + tlsCheck: params.TLSCheck, + notify: params.Notify, + firstRun: true, + expiryNotified: make(map[string]time.Time), } lifecycle.Append(fx.Hook{ - OnStart: func(startCtx context.Context) error { - ctx, cancel := context.WithCancel( - context.WithoutCancel(startCtx), - ) + OnStart: func(_ context.Context) error { + // Use context.Background() — the fx startup context + // expires after startup completes, so deriving from it + // would cancel the watcher immediately. The watcher's + // lifetime is controlled by w.cancel in OnStop. + ctx, cancel := context.WithCancel(context.Background()) w.cancel = cancel - go w.Run(ctx) + go w.Run(ctx) //nolint:contextcheck // intentionally not derived from startCtx return nil }, @@ -100,14 +106,15 @@ func NewForTest( n Notifier, ) *Watcher { return &Watcher{ - log: slog.Default(), - config: cfg, - state: st, - resolver: res, - portCheck: pc, - tlsCheck: tc, - notify: n, - firstRun: true, + log: slog.Default(), + config: cfg, + state: st, + resolver: res, + portCheck: pc, + tlsCheck: tc, + notify: n, + firstRun: true, + expiryNotified: make(map[string]time.Time), } } @@ -122,6 +129,7 @@ func (w *Watcher) Run(ctx context.Context) { ) w.RunOnce(ctx) + w.maybeSendTestNotification(ctx) dnsTicker := time.NewTicker(w.config.DNSInterval) tlsTicker := time.NewTicker(w.config.TLSInterval) @@ -136,9 +144,16 @@ func (w *Watcher) Run(ctx context.Context) { return case <-dnsTicker.C: - w.runDNSAndPortChecks(ctx) + w.runDNSChecks(ctx) + + w.checkAllPorts(ctx) w.saveState() case <-tlsTicker.C: + // Run DNS first so TLS checks use freshly + // resolved IP addresses, not stale ones from + // a previous cycle. + w.runDNSChecks(ctx) + w.runTLSChecks(ctx) w.saveState() } @@ -146,10 +161,26 @@ func (w *Watcher) Run(ctx context.Context) { } // RunOnce performs a single complete monitoring cycle. +// DNS checks run first so that port and TLS checks use +// freshly resolved IP addresses. Port checks run before +// TLS because TLS checks only target IPs with an open +// port 443. func (w *Watcher) RunOnce(ctx context.Context) { w.detectFirstRun() - w.runDNSAndPortChecks(ctx) + + // Phase 1: DNS resolution must complete first so that + // subsequent checks use fresh IP addresses. + w.runDNSChecks(ctx) + + // Phase 2: Port checks populate port state that TLS + // checks depend on (TLS only targets IPs where port + // 443 is open). + w.checkAllPorts(ctx) + + // Phase 3: TLS checks use fresh DNS IPs and current + // port state. w.runTLSChecks(ctx) + w.saveState() w.firstRun = false } @@ -166,7 +197,11 @@ func (w *Watcher) detectFirstRun() { } } -func (w *Watcher) runDNSAndPortChecks(ctx context.Context) { +// runDNSChecks performs DNS resolution for all configured domains +// and hostnames, updating state with freshly resolved records. +// This must complete before port or TLS checks run so those +// checks operate on current IP addresses. +func (w *Watcher) runDNSChecks(ctx context.Context) { for _, domain := range w.config.Domains { w.checkDomain(ctx, domain) } @@ -174,8 +209,6 @@ func (w *Watcher) runDNSAndPortChecks(ctx context.Context) { for _, hostname := range w.config.Hostnames { w.checkHostname(ctx, hostname) } - - w.checkAllPorts(ctx) } func (w *Watcher) checkDomain( @@ -206,6 +239,28 @@ func (w *Watcher) checkDomain( Nameservers: nameservers, LastChecked: now, }) + + // Also look up A/AAAA records for the apex domain so that + // port and TLS checks (which read HostnameState) can find + // the domain's IP addresses. + records, err := w.resolver.LookupAllRecords(ctx, domain) + if err != nil { + w.log.Error( + "failed to lookup records for domain", + "domain", domain, + "error", err, + ) + + return + } + + prevHS, hasPrevHS := w.state.GetHostnameState(domain) + if hasPrevHS && !w.firstRun { + w.detectHostnameChanges(ctx, domain, prevHS, records) + } + + newState := buildHostnameState(records, now) + w.state.SetHostnameState(domain, newState) } func (w *Watcher) detectNSChanges( @@ -421,24 +476,94 @@ func (w *Watcher) detectInconsistencies( } func (w *Watcher) checkAllPorts(ctx context.Context) { - for _, hostname := range w.config.Hostnames { - w.checkPortsForHostname(ctx, hostname) + // Phase 1: Build current IP:port → hostname associations + // from fresh DNS data. + associations := w.buildPortAssociations() + + // Phase 2: Check each unique IP:port and update state + // with the full set of associated hostnames. + for key, hostnames := range associations { + ip, port := parsePortKey(key) + if port == 0 { + continue + } + + w.checkSinglePort(ctx, ip, port, hostnames) } - for _, domain := range w.config.Domains { - w.checkPortsForHostname(ctx, domain) - } + // Phase 3: Remove port state entries that no longer have + // any hostname referencing them. + w.cleanupStalePorts(associations) } -func (w *Watcher) checkPortsForHostname( - ctx context.Context, - hostname string, -) { - ips := w.collectIPs(hostname) +// buildPortAssociations constructs a map from IP:port keys to +// the sorted set of hostnames currently resolving to that IP. +func (w *Watcher) buildPortAssociations() map[string][]string { + assoc := make(map[string]map[string]bool) - for _, ip := range ips { - for _, port := range monitoredPorts { - w.checkSinglePort(ctx, ip, port, hostname) + allNames := make( + []string, 0, + len(w.config.Hostnames)+len(w.config.Domains), + ) + allNames = append(allNames, w.config.Hostnames...) + allNames = append(allNames, w.config.Domains...) + + for _, name := range allNames { + ips := w.collectIPs(name) + for _, ip := range ips { + for _, port := range monitoredPorts { + key := fmt.Sprintf("%s:%d", ip, port) + if assoc[key] == nil { + assoc[key] = make(map[string]bool) + } + + assoc[key][name] = true + } + } + } + + result := make(map[string][]string, len(assoc)) + for key, set := range assoc { + hostnames := make([]string, 0, len(set)) + for h := range set { + hostnames = append(hostnames, h) + } + + sort.Strings(hostnames) + + result[key] = hostnames + } + + return result +} + +// parsePortKey splits an "ip:port" key into its components. +func parsePortKey(key string) (string, int) { + lastColon := strings.LastIndex(key, ":") + if lastColon < 0 { + return key, 0 + } + + ip := key[:lastColon] + + var p int + + _, err := fmt.Sscanf(key[lastColon+1:], "%d", &p) + if err != nil { + return ip, 0 + } + + return ip, p +} + +// cleanupStalePorts removes port state entries that are no +// longer referenced by any hostname in the current DNS data. +func (w *Watcher) cleanupStalePorts( + currentAssociations map[string][]string, +) { + for _, key := range w.state.GetAllPortKeys() { + if _, exists := currentAssociations[key]; !exists { + w.state.DeletePortState(key) } } } @@ -475,9 +600,9 @@ func (w *Watcher) checkSinglePort( ctx context.Context, ip string, port int, - hostname string, + hostnames []string, ) { - open, err := w.portCheck.CheckPort(ctx, ip, port) + result, err := w.portCheck.CheckPort(ctx, ip, port) if err != nil { w.log.Error( "port check failed", @@ -493,15 +618,15 @@ func (w *Watcher) checkSinglePort( now := time.Now().UTC() prev, hasPrev := w.state.GetPortState(key) - if hasPrev && !w.firstRun && prev.Open != open { + if hasPrev && !w.firstRun && prev.Open != result.Open { stateStr := "closed" - if open { + if result.Open { stateStr = "open" } msg := fmt.Sprintf( - "Host: %s\nAddress: %s\nPort now %s", - hostname, key, stateStr, + "Hosts: %s\nAddress: %s\nPort now %s", + strings.Join(hostnames, ", "), key, stateStr, ) w.notify.SendNotification( @@ -513,8 +638,8 @@ func (w *Watcher) checkSinglePort( } w.state.SetPortState(key, &state.PortState{ - Open: open, - Hostname: hostname, + Open: result.Open, + Hostnames: hostnames, LastChecked: now, }) } @@ -691,6 +816,22 @@ func (w *Watcher) checkTLSExpiry( return } + // Deduplicate expiry warnings: don't re-notify for the same + // hostname within the TLS check interval. + dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip) + + w.expiryNotifiedMu.Lock() + + lastNotified, seen := w.expiryNotified[dedupKey] + if seen && time.Since(lastNotified) < w.config.TLSInterval { + w.expiryNotifiedMu.Unlock() + + return + } + + w.expiryNotified[dedupKey] = time.Now() + w.expiryNotifiedMu.Unlock() + msg := fmt.Sprintf( "Host: %s\nIP: %s\nCN: %s\n"+ "Expires: %s (%.0f days)", @@ -714,6 +855,38 @@ func (w *Watcher) saveState() { } } +// maybeSendTestNotification sends a startup status notification +// after the first full scan completes, if SEND_TEST_NOTIFICATION +// is enabled. The message is clearly informational ("all ok") +// and not an error or anomaly alert. +func (w *Watcher) maybeSendTestNotification(ctx context.Context) { + if !w.config.SendTestNotification { + return + } + + snap := w.state.GetSnapshot() + + msg := fmt.Sprintf( + "dnswatcher has started and completed its initial scan.\n"+ + "Monitoring %d domain(s) and %d hostname(s).\n"+ + "Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+ + "All notification channels are working.", + len(snap.Domains), + len(snap.Hostnames), + len(snap.Ports), + len(snap.Certificates), + ) + + w.log.Info("sending startup test notification") + + w.notify.SendNotification( + ctx, + "✅ dnswatcher startup complete", + msg, + "success", + ) +} + // --- Utility functions --- func toSet(items []string) map[string]bool { diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 69772aa..ea6dbef 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -9,6 +9,7 @@ import ( "time" "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/tlscheck" "sneak.berlin/go/dnswatcher/internal/watcher" @@ -109,24 +110,20 @@ func (m *mockPortChecker) CheckPort( _ context.Context, address string, port int, -) (bool, error) { +) (*portcheck.PortResult, error) { m.mu.Lock() defer m.mu.Unlock() m.calls++ if m.err != nil { - return false, m.err + return nil, m.err } key := fmt.Sprintf("%s:%d", address, port) - open, ok := m.results[key] + open := m.results[key] - if !ok { - return false, nil - } - - return open, nil + return &portcheck.PortResult{Open: open}, nil } type mockTLSChecker struct { @@ -276,6 +273,10 @@ func setupBaselineMocks(deps *testDeps) { "ns1.example.com.", "ns2.example.com.", } + deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"93.184.216.34"}}, + "ns2.example.com.": {"A": {"93.184.216.34"}}, + } deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ "ns1.example.com.": {"A": {"93.184.216.34"}}, "ns2.example.com.": {"A": {"93.184.216.34"}}, @@ -293,6 +294,14 @@ func setupBaselineMocks(deps *testDeps) { "www.example.com", }, } + deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ + CommonName: "example.com", + Issuer: "DigiCert", + NotAfter: time.Now().Add(90 * 24 * time.Hour), + SubjectAlternativeNames: []string{ + "example.com", + }, + } } func assertNoNotifications( @@ -325,14 +334,74 @@ func assertStatePopulated( ) } - if len(snap.Hostnames) != 1 { + // Hostnames includes both explicit hostnames and domains + // (domains now also get hostname state for port/TLS checks). + if len(snap.Hostnames) < 1 { t.Errorf( - "expected 1 hostname in state, got %d", + "expected at least 1 hostname in state, got %d", len(snap.Hostnames), ) } } +func TestDomainPortAndTLSChecks(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + + w, deps := newTestWatcher(t, cfg) + + deps.resolver.nsRecords["example.com"] = []string{ + "ns1.example.com.", + } + deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"93.184.216.34"}}, + } + deps.portChecker.results["93.184.216.34:80"] = true + deps.portChecker.results["93.184.216.34:443"] = true + deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ + CommonName: "example.com", + Issuer: "DigiCert", + NotAfter: time.Now().Add(90 * 24 * time.Hour), + SubjectAlternativeNames: []string{ + "example.com", + }, + } + + w.RunOnce(t.Context()) + + snap := deps.state.GetSnapshot() + + // Domain should have port state populated + if len(snap.Ports) == 0 { + t.Error("expected port state for domain, got none") + } + + // Domain should have certificate state populated + if len(snap.Certificates) == 0 { + t.Error("expected certificate state for domain, got none") + } + + // Verify port checker was actually called + deps.portChecker.mu.Lock() + calls := deps.portChecker.calls + deps.portChecker.mu.Unlock() + + if calls == 0 { + t.Error("expected port checker to be called for domain") + } + + // Verify TLS checker was actually called + deps.tlsChecker.mu.Lock() + tlsCalls := deps.tlsChecker.calls + deps.tlsChecker.mu.Unlock() + + if tlsCalls == 0 { + t.Error("expected TLS checker to be called for domain") + } +} + func TestNSChangeDetection(t *testing.T) { t.Parallel() @@ -345,6 +414,12 @@ func TestNSChangeDetection(t *testing.T) { "ns1.example.com.", "ns2.example.com.", } + deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"1.2.3.4"}}, + "ns2.example.com.": {"A": {"1.2.3.4"}}, + } + deps.portChecker.results["1.2.3.4:80"] = false + deps.portChecker.results["1.2.3.4:443"] = false ctx := t.Context() w.RunOnce(ctx) @@ -354,6 +429,10 @@ func TestNSChangeDetection(t *testing.T) { "ns1.example.com.", "ns3.example.com.", } + deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"1.2.3.4"}}, + "ns3.example.com.": {"A": {"1.2.3.4"}}, + } deps.resolver.mu.Unlock() w.RunOnce(ctx) @@ -509,6 +588,61 @@ func TestTLSExpiryWarning(t *testing.T) { } } +func TestTLSExpiryWarningDedup(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{"www.example.com"} + cfg.TLSInterval = 24 * time.Hour + + w, deps := newTestWatcher(t, cfg) + + deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"1.2.3.4"}}, + } + deps.resolver.ipAddresses["www.example.com"] = []string{ + "1.2.3.4", + } + deps.portChecker.results["1.2.3.4:80"] = true + deps.portChecker.results["1.2.3.4:443"] = true + deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ + CommonName: "www.example.com", + Issuer: "DigiCert", + NotAfter: time.Now().Add(3 * 24 * time.Hour), + SubjectAlternativeNames: []string{ + "www.example.com", + }, + } + + ctx := t.Context() + + // First run = baseline, no notifications + w.RunOnce(ctx) + + // Second run should fire one expiry warning + w.RunOnce(ctx) + + // Third run should NOT fire another warning (dedup) + w.RunOnce(ctx) + + notifications := deps.notifier.getNotifications() + + expiryCount := 0 + + for _, n := range notifications { + if n.Title == "TLS Expiry Warning: www.example.com" { + expiryCount++ + } + } + + if expiryCount != 1 { + t.Errorf( + "expected exactly 1 expiry warning (dedup), got %d", + expiryCount, + ) + } +} + func TestGracefulShutdown(t *testing.T) { t.Parallel() @@ -522,6 +656,11 @@ func TestGracefulShutdown(t *testing.T) { deps.resolver.nsRecords["example.com"] = []string{ "ns1.example.com.", } + deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"1.2.3.4"}}, + } + deps.portChecker.results["1.2.3.4:80"] = false + deps.portChecker.results["1.2.3.4:443"] = false ctx, cancel := context.WithCancel(t.Context()) @@ -543,6 +682,191 @@ func TestGracefulShutdown(t *testing.T) { } } +func setupHostnameIP( + deps *testDeps, + hostname, ip string, +) { + deps.resolver.allRecords[hostname] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {ip}}, + } + deps.portChecker.results[ip+":80"] = true + deps.portChecker.results[ip+":443"] = true + deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ + CommonName: hostname, + Issuer: "DigiCert", + NotAfter: time.Now().Add(90 * 24 * time.Hour), + SubjectAlternativeNames: []string{hostname}, + } +} + +func updateHostnameIP(deps *testDeps, hostname, ip string) { + deps.resolver.mu.Lock() + deps.resolver.allRecords[hostname] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {ip}}, + } + deps.resolver.mu.Unlock() + + deps.portChecker.mu.Lock() + deps.portChecker.results[ip+":80"] = true + deps.portChecker.results[ip+":443"] = true + deps.portChecker.mu.Unlock() + + deps.tlsChecker.mu.Lock() + deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ + CommonName: hostname, + Issuer: "DigiCert", + NotAfter: time.Now().Add(90 * 24 * time.Hour), + SubjectAlternativeNames: []string{hostname}, + } + deps.tlsChecker.mu.Unlock() +} + +func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{"www.example.com"} + + w, deps := newTestWatcher(t, cfg) + + setupHostnameIP(deps, "www.example.com", "10.0.0.1") + + ctx := t.Context() + w.RunOnce(ctx) + + snap := deps.state.GetSnapshot() + if _, ok := snap.Ports["10.0.0.1:80"]; !ok { + t.Fatal("expected port state for 10.0.0.1:80") + } + + // DNS changes to a new IP; port and TLS must pick it up. + updateHostnameIP(deps, "www.example.com", "10.0.0.2") + + w.RunOnce(ctx) + + snap = deps.state.GetSnapshot() + + if _, ok := snap.Ports["10.0.0.2:80"]; !ok { + t.Error("port check used stale DNS: missing 10.0.0.2:80") + } + + certKey := "10.0.0.2:443:www.example.com" + if _, ok := snap.Certificates[certKey]; !ok { + t.Error("TLS check used stale DNS: missing " + certKey) + } +} + +func TestSendTestNotification_Enabled(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.Hostnames = []string{"www.example.com"} + cfg.SendTestNotification = true + + w, deps := newTestWatcher(t, cfg) + setupBaselineMocks(deps) + + w.RunOnce(t.Context()) + + // RunOnce does not send the test notification — it is + // sent by Run after RunOnce completes. Call the exported + // RunOnce then check that no test notification was sent + // (only Run triggers it). We test the full path via Run. + notifications := deps.notifier.getNotifications() + if len(notifications) != 0 { + t.Errorf( + "RunOnce should not send test notification, got %d", + len(notifications), + ) + } +} + +func TestSendTestNotification_ViaRun(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.Hostnames = []string{"www.example.com"} + cfg.SendTestNotification = true + cfg.DNSInterval = 24 * time.Hour + cfg.TLSInterval = 24 * time.Hour + + w, deps := newTestWatcher(t, cfg) + setupBaselineMocks(deps) + + ctx, cancel := context.WithCancel(t.Context()) + + done := make(chan struct{}) + + go func() { + w.Run(ctx) + close(done) + }() + + // Wait for the initial scan and test notification. + time.Sleep(500 * time.Millisecond) + cancel() + + <-done + + notifications := deps.notifier.getNotifications() + + found := false + + for _, n := range notifications { + if n.Priority == "success" && + n.Title == "✅ dnswatcher startup complete" { + found = true + } + } + + if !found { + t.Errorf( + "expected startup test notification, got: %v", + notifications, + ) + } +} + +func TestSendTestNotification_Disabled(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.Hostnames = []string{"www.example.com"} + cfg.SendTestNotification = false + cfg.DNSInterval = 24 * time.Hour + cfg.TLSInterval = 24 * time.Hour + + w, deps := newTestWatcher(t, cfg) + setupBaselineMocks(deps) + + ctx, cancel := context.WithCancel(t.Context()) + + done := make(chan struct{}) + + go func() { + w.Run(ctx) + close(done) + }() + + time.Sleep(500 * time.Millisecond) + cancel() + + <-done + + notifications := deps.notifier.getNotifications() + + for _, n := range notifications { + if n.Title == "✅ dnswatcher startup complete" { + t.Error( + "test notification should not be sent when disabled", + ) + } + } +} + func TestNSFailureAndRecovery(t *testing.T) { t.Parallel() diff --git a/static/css/tailwind.min.css b/static/css/tailwind.min.css new file mode 100644 index 0000000..ade9507 --- /dev/null +++ b/static/css/tailwind.min.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-6xl{max-width:72rem}.max-w-xs{max-width:20rem}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-slate-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(30 41 59/var(--tw-divide-opacity,1))}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-amber-700\/30{border-color:rgba(180,83,9,.3)}.border-amber-700\/40{border-color:rgba(180,83,9,.4)}.border-blue-700\/30{border-color:rgba(29,78,216,.3)}.border-blue-700\/40{border-color:rgba(29,78,216,.4)}.border-red-700\/30{border-color:rgba(185,28,28,.3)}.border-red-700\/40{border-color:rgba(185,28,28,.4)}.border-slate-700\/50{border-color:rgba(51,65,85,.5)}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-teal-700\/30{border-color:rgba(15,118,110,.3)}.border-teal-700\/40{border-color:rgba(15,118,110,.4)}.bg-amber-900\/50{background-color:rgba(120,53,15,.5)}.bg-blue-900\/50{background-color:rgba(30,58,138,.5)}.bg-red-900\/50{background-color:rgba(127,29,29,.5)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-surface-800{--tw-bg-opacity:1;background-color:rgb(26 35 50/var(--tw-bg-opacity,1))}.bg-surface-950{--tw-bg-opacity:1;background-color:rgb(12 18 34/var(--tw-bg-opacity,1))}.bg-teal-900\/50{background-color:rgba(19,78,74,.5)}.p-4{padding:1rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-teal-300{--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.hover\:bg-surface-800\/50:hover{background-color:rgba(26,35,50,.5)}@media (min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/static/static.go b/static/static.go new file mode 100644 index 0000000..28ee133 --- /dev/null +++ b/static/static.go @@ -0,0 +1,10 @@ +// Package static provides embedded static assets. +package static + +import "embed" + +// Static contains the embedded static assets (CSS, JS) served +// at the /s/ URL prefix. +// +//go:embed css +var Static embed.FS