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..a55bc55 --- /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: script/cibuild 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..7b2447f 100644 --- a/Makefile +++ b/Makefile @@ -1,37 +1,44 @@ -.PHONY: all build lint fmt test check clean +.PHONY: all bootstrap setup 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) + +# Standard targets are thin shims; the implementations live in script/ +# per the scripts-to-rule-them-all pattern (see the Entrypoints section +# of README.md). all: check build +bootstrap: + @script/bootstrap + +setup: + @script/setup + build: go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher +test: + @script/test + lint: - golangci-lint run --config .golangci.yml ./... + @script/lint fmt: - gofmt -s -w . - goimports -w . + @script/fmt -test: - go test -v -race -cover ./... +fmt-check: + @script/fmt-check -# Check runs all validation without making changes -# Used by CI and Docker build - fails if anything is wrong check: - @echo "==> Checking formatting..." - @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) - @echo "==> Running linter..." - golangci-lint run --config .golangci.yml ./... - @echo "==> Running tests..." - go test -v -race ./... - @echo "==> Building..." - go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher - @echo "==> All checks passed!" + @script/check + +docker: + @script/docker + +hooks: + @script/install-precommit clean: rm -rf bin/ diff --git a/README.md b/README.md index 25fa039..9ed5805 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ # dnswatcher -dnswatcher is a production DNS and infrastructure monitoring daemon written in -Go. It watches configured DNS domains and hostnames for changes, monitors TCP +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 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. @@ -49,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 @@ -107,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 @@ -125,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) | --- @@ -146,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) @@ -194,9 +219,8 @@ 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_DOMAINS` | Comma-separated list of apex domains | `""` | -| `DNSWATCHER_HOSTNAMES` | Comma-separated list of hostnames | `""` | +| `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 | `""` | | `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | @@ -207,18 +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_DOMAINS=example.com,example.org -DNSWATCHER_HOSTNAMES=www.example.com,api.example.com,mail.example.org +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,11 +349,35 @@ tracks reachability: |-------------|-------------------------------------------------| | `ok` | Query succeeded, records are current | | `error` | Query failed (timeout, SERVFAIL, network error) | -| `nxdomain` | Authoritative NXDOMAIN response | -| `nodata` | Authoritative empty response (NODATA) | --- +## Entrypoints + +This repository adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard: normalized scripts in `script/` are the entrypoints for the +development workflow, and the Makefile targets are thin shims that call +them. We provide: + +- `script/bootstrap` — install all dependencies (go, pinned + golangci-lint and goimports, `go mod download`) +- `script/setup` — make a fresh clone ready for development: bootstrap + plus the git pre-commit hook +- `script/projectname` — print the project name (used for the Docker + image tag) +- `script/test` — run the test suite (race detector, coverage) +- `script/lint` — run golangci-lint +- `script/fmt` — format all code (gofmt -s, goimports) +- `script/fmt-check` — check formatting (read-only) +- `script/check` — run test, lint, and fmt-check +- `script/docker` — build the Docker image tagged via + `script/projectname` +- `script/cibuild` — CI entrypoint: plain `docker build .` +- `script/precommit` — run by the git pre-commit hook; `go mod tidy` + guard, then `script/check` +- `script/install-precommit` — install the git pre-commit hook + ## Building ```sh @@ -330,17 +385,16 @@ make build # Build binary to bin/dnswatcher make test # Run tests with race detector make lint # Run golangci-lint make fmt # Format code -make check # Run all checks (format, lint, test, build) +make check # Run all checks (test, lint, fmt-check) 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 ``` --- @@ -352,9 +406,9 @@ docker build -t dnswatcher . docker run -d \ -p 8080:8080 \ -v dnswatcher-data:/var/lib/dnswatcher \ - -e DNSWATCHER_DOMAINS=example.com \ - -e DNSWATCHER_HOSTNAMES=www.example.com \ + -e DNSWATCHER_TARGETS=example.com,www.example.com \ -e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \ + -e DNSWATCHER_SEND_TEST_NOTIFICATION=true \ dnswatcher ``` @@ -367,9 +421,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 @@ -386,7 +446,18 @@ docker run -d \ ## 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..bc2f161 --- /dev/null +++ b/REPO_POLICIES.md @@ -0,0 +1,408 @@ +--- +title: Repository Policies +last_modified: 2026-07-06 +--- + +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 bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), + `make fmt-check` (read-only), `make check` (runs `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`. + +- Repos follow the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + pattern: the implementation of each Makefile target lives in an executable + script in `script/` (`script/bootstrap`, `script/setup`, `script/test`, + `script/lint`, `script/fmt`, `script/fmt-check`, `script/check`, + `script/docker`), and the Makefile targets are thin shims that call them. The + scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in + minimal containers (e.g. alpine images have no bash); locate the repo root + with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From + the standard's canonical set we use `bootstrap`, `setup` (make the repo ready + for development after a fresh clone: runs `bootstrap`, then + `install-precommit`, plus any repo-specific initialization), `test`, and + `cibuild`. `script/bootstrap` installs all dependencies idempotently and + assumes nothing is present: base tools come from nix, apt, brew, or apk + (detected in that order; apt runs noninteractive). For node it uses the + installed node if present; otherwise it installs a PINNED node version via + nvm, first installing nvm itself if missing — from a hash-verified GitHub + release archive (never `curl | sh`), with bash installed as an explicit + prerequisite since nvm requires bash. yarn is then pinned via + `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; + always exact versions. `script/cibuild` runs the CI build: it changes to the + repo root and runs `docker build .`; the Gitea workflow calls it. Four further + scripts are our own extensions to the standard: `script/check` runs + `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is + what the git pre-commit hook runs, and it calls `script/check`; + `script/install-precommit` installs the git pre-commit hook (the `make hooks` + target shims to it); and `script/projectname` (literally that filename) simply + outputs the project's name. Scripts that need the name call + `script/projectname` — e.g. `script/docker` assembles its image tag from it — + so those scripts stay byte-identical across all repos. Repo-type-specific + pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in + `script/precommit`, not in the hook itself. Model scripts are at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README + must document the provided scripts in an **Entrypoints** section (see the + README requirements below). + +- 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. Dockerfiles install development + prerequisites by running `script/bootstrap` rather than duplicating installs + inline; COPY `script/` and the dependency manifests (`package.json` + + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap + layer stays cached until dependencies change. + +- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go + repos use a multistage build where linting runs in an independent stage based + on the `golangci/golangci-lint` image (pinned by hash). This stage runs + `make fmt-check` and `make lint` before the full build begins. The build stage + then declares an explicit dependency on the lint stage via + `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete + linting before proceeding to compilation and tests. This ensures lint failures + surface in seconds rather than minutes, without blocking on dependency + download or compilation in the build stage. + + The standard pattern for a Go repo Dockerfile is: + + ```dockerfile + # Lint stage — fast feedback on formatting and lint issues + # golangci/golangci-lint:v2.x.x, YYYY-MM-DD + FROM golangci/golangci-lint@sha256:... AS lint + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN make fmt-check + RUN make lint + + # Build stage + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + WORKDIR /src + + # Force BuildKit to run the lint stage before proceeding + COPY --from=lint /src/go.sum /dev/null + + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN make test + + ARG VERSION=dev + RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage + FROM alpine@sha256:... + COPY --from=builder /app /usr/local/bin/app + ENTRYPOINT ["app"] + ``` + + Key points: + - The lint stage uses the `golangci/golangci-lint` image directly (it + includes both Go and the linter), so there is no need to install the + linter separately. + - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates + a stage dependency. BuildKit runs stages in parallel by default; without + this line, the build stage would not wait for lint to finish and a lint + failure might not fail the overall build. + - If the project uses `//go:embed` directives that reference build artifacts + (e.g. a web frontend compiled in a separate stage), the lint stage must + create placeholder files so the embed directives resolve. Example: + `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. + The lint stage should not depend on the actual build output — it exists to + fail fast. + - If the project requires CGO or system libraries for linting (e.g. + `vips-dev`), install them in the lint stage with `apk add`. + - The build stage runs `make test` after compilation setup. Tests run in the + build stage, not the lint stage, because they may require compiled + artifacts or heavier dependencies. + +- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that + runs `script/cibuild` (which 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: runs `script/precommit`, which calls `script/check`. If local + testing is not possible in the repo, `script/precommit` may skip `script/test` + and run only `script/lint` and `script/fmt-check`. The hook is installed by + `script/install-precommit`; the Makefile must provide a `make hooks` target + that shims to it. + +- 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. + +- **`make test` should use the conditional verbose rerun pattern.** Run tests + without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to + show full output. This keeps CI logs and `docker build` output clean on + success (just package/suite summaries) while providing full diagnostic detail + on failure (every test case, every assertion). The general shell pattern: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -timeout 30s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 30s -race -v ./...; exit 1; } + ``` + + Python example: + + ```makefile + test: + @python -m pytest || \ + { echo "--- Rerunning with -v for details ---"; \ + python -m pytest -v; exit 1; } + ``` + + The `exit 1` ensures the target always fails after a rerun — the first run + already proved the tests are broken, so the build must not pass even if a + flaky test happens to succeed on the second attempt. The rerun exists solely + for diagnostic output. + +- 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. + +- **No build artifacts in version control.** Code-derived data (compiled + bundles, minified output, generated assets) must never be committed to the + repository if it can be avoided. The build process (e.g. Dockerfile, Makefile) + should generate these at build time. Notable exception: Go protobuf generated + files (`.pb.go`) ARE committed because repos need to work with `go get`, which + downloads code but does not execute code generation. + +- 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`. + +- **HTTP/web services must be hardened for production internet exposure before + tagging 1.0.** This means full compliance with security best practices + including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year + and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default policy + (`default-src 'self'` as a baseline, tightened per-resource as + needed). Never use `unsafe-inline` or `unsafe-eval` unless + unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). + Prefer the `frame-ancestors` CSP directive as the primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). + - `Permissions-Policy` restricting access to browser features the + application does not use (camera, microphone, geolocation, etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never accept + unbounded input. + - Maximum response body size where applicable (e.g. paginated APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend + against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API keys are + high-entropy and not susceptible to brute force, so they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + authenticated via `Authorization` header (Bearer token, API key) are + exempt because the browser does not attach these automatically. + - Passwords stored using bcrypt, scrypt, or argon2 — never plain-text, + MD5, or SHA. + - Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or + `Strict`) attributes. + - **Reverse proxy awareness:** + - True client IP detection when behind a reverse proxy + (`X-Forwarded-For`, `X-Real-IP`). The application must accept + forwarded headers only from a configured set of trusted proxy + addresses — never trust `X-Forwarded-For` unconditionally. + - **CORS:** + - Authenticated endpoints must restrict `Access-Control-Allow-Origin` to + an explicit allowlist of known origins. Wildcard (`*`) is acceptable + only for public, unauthenticated read-only APIs. + - **Error handling:** + - Internal errors must never leak stack traces, SQL queries, file paths, + or other implementation details to the client. Return generic error + messages in production; detailed errors only when `DEBUG` is enabled. + - **TLS:** + - Services never terminate TLS directly. They are always deployed behind + a TLS-terminating reverse proxy. The service itself listens on plain + HTTP. However, HSTS headers and `Secure` cookie flags must still be + set by the application so that the browser enforces HTTPS end-to-end. + + This list is non-exhaustive. Apply defense-in-depth: if a standard security + hardening measure exists for HTTP services and is not listed here, it is + still expected. When in doubt, harden. + +- `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. + - **Entrypoints**: Opens by stating that the repo adheres to the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + standard (with that link), then documents each provided `script/` + entrypoint and its purpose. + - **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` + - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, + `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, + `install-precommit`) + - `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/TODO.md b/TODO.md new file mode 100644 index 0000000..3e8e557 --- /dev/null +++ b/TODO.md @@ -0,0 +1,145 @@ +# Workflow + +* branch (from `main`) +* do the work in Next Step +* move Next Step to the top of Completed Steps +* move the top item of Future Steps into Next Step +* commit (`TODO.md` changes in the same commit as the work) +* merge to `main` if the branch is not protected, otherwise open a PR +* push + +# Status + +pre-1.0. No git tags. Core resolver work in flight on feature/resolver +(dirty: internal/resolver/resolver_test.go). Local checkout has diverged +from origin: origin/main is 8 commits ahead (watcher orchestrator, +unified TARGETS) and origin/feature/resolver already contains the full +iterative resolver implementation with hermetic mocked tests. + +# Next Step + +Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig, +.dockerignore, and .gitea/workflows/check.yml, and add the missing +fmt-check, docker, and hooks targets to the Makefile. One commit, then +confirm make check still passes. + +# Completed Steps + +- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, + Makefile shims, README Entrypoints section +- 2026-02-20: iterative DNS resolver implemented; tests made hermetic + with mocked DNS (origin/feature/resolver, unmerged) +- 2026-02-20: CI actions and go install refs pinned to commit SHAs; + Gitea Actions workflow for make check (origin/ci/make-check, unmerged) +- 2026-02-20: watcher monitoring orchestrator merged to main (#8) +- 2026-02-20: DOMAINS/HOSTNAMES unified into single TARGETS config (#11) +- 2026-02-19: TCP port connectivity checker, made concurrent with port + validation; gosec G704 SSRF findings fixed without suppression + (feature branches, unmerged) +- 2026-02-19: TLS certificate inspector with no-peer-certificates error + path and IP SANs (feature branch, unmerged) +- 2026-02-19: gosec SSRF and formatting fixes on main +- 2026-02-19: initial scaffold with per-nameserver DNS monitoring model + +# Future Steps + +Compliance: + +- Add README sections required by policy (Description, Getting Started, + Rationale, Design, TODO, License, Author) if any are missing +- Pin Dockerfile base images by sha256 and ensure the Docker build runs + make check + +Branch reconciliation: + +- Sync local checkout with origin: local main is 8 commits behind + origin/main; local feature/resolver has diverged from + origin/feature/resolver, which already implements the resolver +- Merge in-flight branches to main once green: feature/resolver, + ci/make-check, feature/portcheck-implementation, + feature/tlscheck-implementation + +Resolver (plan from untracked TODO.md; largely implemented on +origin/feature/resolver, verify each item before closing): + +- Add github.com/miekg/dns dependency +- roots.go: hardcoded IANA root server list (a through m, IPv4/IPv6), + rootServers() returning ip:53 strings +- query.go: low-level query(ctx, server, name, qtype): UDP with TCP + fallback on truncation, RD=0, context respected, 5s per-query timeout, + returns raw *dns.Msg +- trace.go: iterative delegation chasing from roots: referral detection + (NOERROR, empty answer, NS in authority), glue extraction with + bailiwick check, out-of-bailiwick NS resolved with recursion guard, + delegation depth limit (20), retry across nameservers on failure, do + not chase CNAMEs inside trace +- FindAuthoritativeNameservers: NS set via trace, sorted, FQDN + normalized, trailing dot handled; must pass its 9 tests +- QueryNameserver: resolve NS host to IPs, query A/AAAA/CNAME/MX/TXT/ + SRV/CAA/NS, build NameserverResponse with status mapping (OK, + NXDomain, NoData, Error), documented record formatting, sorted values, + lame delegation detection; must pass its 16 tests +- QueryAllNameservers: find NS set for parent domain (public suffix + list), query all NS in parallel with bounded concurrency, return map + even when all fail, context cancellation; must pass its 4 tests +- LookupNS: thin wrapper over FindAuthoritativeNameservers, sorted, + identical results; must pass its 3 tests +- ResolveIPAddresses: collect A/AAAA from all NS, follow CNAME chains + with MaxCNAMEDepth, dedupe, sort, NXDOMAIN returns empty slice with + nil error; must pass its 9 tests +- All 39 resolver tests pass, make check green, merge to main + +Watcher (internal/watcher/watcher.go): + +- Scheduling loop in Run(ctx): initial check on startup, separate + tickers for DNS/port and TLS intervals, persist state via state.Save() + after each cycle, clean shutdown on context cancel +- Domain check: LookupNS, compare to stored state, store silently on + first run, notify with old/new NS lists on change +- Hostname check: QueryAllNameservers, compare per-NS records; notify on + record changes, NS failure, NS recovery, inconsistency detected, + inconsistency resolved, empty response; store silently on first run +- Port check: ResolveIPAddresses, check ports 80 and 443 per IP, notify + on open/closed transitions, handle new and disappeared IPs +- TLS check: for each open IP:443, CheckCertificate; notify on expiry + warning, certificate change (CN/issuer/SANs), TLS failure/recovery + +Port checker (internal/portcheck/portcheck.go): + +- Tests against known-open ports and RFC documentation IPs +- CheckPort: net.DialTimeout (5s), context respected; (true, nil) open, + (false, nil) closed/timeout/refused, error only for unexpected + failures + +TLS checker (internal/tlscheck/tlscheck.go): + +- Tests against known public HTTPS servers, verify fields populated +- CheckCertificate: tls.Dial to specific IP:443 with hostname as SNI; + extract subject CN, issuer CN and org, NotAfter, SANs; error on + handshake failure + +Notification service (internal/notify/notify.go, Slack/Mattermost/ntfy +backends exist): + +- Structured notification types: DNS change, port change, TLS expiry, + TLS change, NS failure, NS recovery, NS inconsistency +- Per-backend formatting: Slack/Mattermost attachment colors (red + failures/expiry, yellow warnings, green recoveries, blue info); ntfy + priorities (urgent failures, high warnings, default changes, low + recoveries); include hostname, nameserver, old/new values, timestamps + +HTTP API handlers: + +- Wire *state.State and *watcher.Watcher into handler params +- GET /api/v1/status: full state snapshot as JSON +- GET /api/v1/domains: domain states with NS records and last-checked +- GET /api/v1/hostnames: hostname states with per-NS record data + +Infrastructure notes (from untracked TODO.md): + +- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de + remote intentionally; do not "fix" it +- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix +- Resolver tests originally used live DNS against *.dns.sneak.cloud + (required records documented in the test file header); origin now has + mocked hermetic tests, keep them hermetic diff --git a/cmd/dnswatcher/main.go b/cmd/dnswatcher/main.go index 0dd77b9..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( @@ -51,6 +49,20 @@ func main() { handlers.New, server.New, ), + fx.Provide( + func(r *resolver.Resolver) watcher.DNSResolver { + return r + }, + func(p *portcheck.Checker) watcher.PortChecker { + return p + }, + func(t *tlscheck.Checker) watcher.TLSChecker { + return t + }, + func(n *notify.Service) watcher.Notifier { + return n + }, + ), fx.Invoke(func(*server.Server, *watcher.Watcher) {}), ).Run() } diff --git a/go.mod b/go.mod index 41f57c4..53638c9 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,13 @@ 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 ( @@ -36,8 +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/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // 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 1b5631a..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,10 +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/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +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/classify.go b/internal/config/classify.go new file mode 100644 index 0000000..1076215 --- /dev/null +++ b/internal/config/classify.go @@ -0,0 +1,85 @@ +package config + +import ( + "errors" + "fmt" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// DNSNameType indicates whether a DNS name is an apex domain or a hostname. +type DNSNameType int + +const ( + // DNSNameTypeDomain indicates the name is an apex (eTLD+1) domain. + DNSNameTypeDomain DNSNameType = iota + // DNSNameTypeHostname indicates the name is a subdomain/hostname. + DNSNameTypeHostname +) + +// ErrEmptyDNSName is returned when an empty string is passed to ClassifyDNSName. +var ErrEmptyDNSName = errors.New("empty DNS name") + +// String returns the string representation of a DNSNameType. +func (t DNSNameType) String() string { + switch t { + case DNSNameTypeDomain: + return "domain" + case DNSNameTypeHostname: + return "hostname" + default: + return "unknown" + } +} + +// ClassifyDNSName determines whether a DNS name is an apex domain or a +// hostname (subdomain) using the Public Suffix List. It returns an error +// if the input is empty or is itself a public suffix (e.g. "co.uk"). +func ClassifyDNSName(name string) (DNSNameType, error) { + name = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), ".")) + + if name == "" { + return 0, ErrEmptyDNSName + } + + etld1, err := publicsuffix.EffectiveTLDPlusOne(name) + if err != nil { + return 0, fmt.Errorf("invalid DNS name %q: %w", name, err) + } + + if name == etld1 { + return DNSNameTypeDomain, nil + } + + return DNSNameTypeHostname, nil +} + +// ClassifyTargets splits a list of DNS names into apex domains and +// hostnames using the Public Suffix List. It returns an error if any +// name cannot be classified. +func ClassifyTargets(targets []string) ([]string, []string, error) { + var domains, hostnames []string + + for _, t := range targets { + normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(t), ".")) + + if normalized == "" { + continue + } + + typ, classErr := ClassifyDNSName(normalized) + if classErr != nil { + return nil, nil, classErr + } + + switch typ { + case DNSNameTypeDomain: + domains = append(domains, normalized) + case DNSNameTypeHostname: + hostnames = append(hostnames, normalized) + } + } + + return domains, hostnames, nil +} diff --git a/internal/config/classify_test.go b/internal/config/classify_test.go new file mode 100644 index 0000000..fb21bbc --- /dev/null +++ b/internal/config/classify_test.go @@ -0,0 +1,83 @@ +package config_test + +import ( + "testing" + + "sneak.berlin/go/dnswatcher/internal/config" +) + +func TestClassifyDNSName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want config.DNSNameType + wantErr bool + }{ + {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain}, + {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname}, + {name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain}, + {name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname}, + {name: "public suffix itself", input: "co.uk", wantErr: true}, + {name: "empty string", input: "", wantErr: true}, + {name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname}, + {name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain}, + {name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := config.ClassifyDNSName(tt.input) + + if tt.wantErr { + if err == nil { + t.Errorf("ClassifyDNSName(%q) expected error, got %v", tt.input, got) + } + + return + } + + if err != nil { + t.Fatalf("ClassifyDNSName(%q) unexpected error: %v", tt.input, err) + } + + if got != tt.want { + t.Errorf("ClassifyDNSName(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestClassifyTargets(t *testing.T) { + t.Parallel() + + domains, hostnames, err := config.ClassifyTargets([]string{ + "example.com", + "www.example.com", + "example.co.uk", + "api.example.co.uk", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(domains) != 2 { + t.Errorf("expected 2 domains, got %d: %v", len(domains), domains) + } + + if len(hostnames) != 2 { + t.Errorf("expected 2 hostnames, got %d: %v", len(hostnames), hostnames) + } +} + +func TestClassifyTargetsRejectsPublicSuffix(t *testing.T) { + t.Parallel() + + _, _, err := config.ClassifyTargets([]string{"co.uk"}) + if err == nil { + t.Error("expected error for public suffix, got nil") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b43027d..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,9 +94,8 @@ func setupViper(name string) { viper.SetDefault("PORT", defaultPort) viper.SetDefault("DEBUG", false) - viper.SetDefault("DATA_DIR", "./data") - viper.SetDefault("DOMAINS", "") - viper.SetDefault("HOSTNAMES", "") + viper.SetDefault("DATA_DIR", "/var/lib/"+name) + viper.SetDefault("TARGETS", "") viper.SetDefault("SLACK_WEBHOOK", "") viper.SetDefault("MATTERMOST_WEBHOOK", "") viper.SetDefault("NTFY_TOPIC", "") @@ -101,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( @@ -133,29 +139,52 @@ 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: parseCSV(viper.GetString("DOMAINS")), - Hostnames: parseCSV(viper.GetString("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, + 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, nil, fmt.Errorf( + "invalid targets configuration: %w", err, + ) + } + + if len(domains) == 0 && len(hostnames) == 0 { + return nil, nil, ErrNoTargets + } + + return domains, hostnames, nil +} + func parseCSV(input string) []string { if input == "" { return nil 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 08dbe5a..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,8 +8,10 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" + "net/url" "time" "go.uber.org/fx" @@ -33,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 @@ -45,9 +106,15 @@ type Params struct { // Service provides notification functionality. type Service struct { - log *slog.Logger - client *http.Client - config *config.Config + log *slog.Logger + 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. @@ -55,99 +122,193 @@ func New( _ fx.Lifecycle, params Params, ) (*Service, error) { - return &Service{ - log: params.Logger.Get(), - client: &http.Client{ - Timeout: httpClientTimeout, - }, - config: params.Config, - }, nil + svc := &Service{ + log: params.Logger.Get(), + transport: http.DefaultTransport, + config: params.Config, + history: NewAlertHistory(), + } + + if params.Config.NtfyTopic != "" { + u, err := ValidateWebhookURL( + params.Config.NtfyTopic, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid ntfy topic URL: %w", err, + ) + } + + svc.ntfyURL = u + } + + if params.Config.SlackWebhook != "" { + u, err := ValidateWebhookURL( + params.Config.SlackWebhook, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid slack webhook URL: %w", err, + ) + } + + svc.slackWebhookURL = u + } + + if params.Config.MattermostWebhook != "" { + u, err := ValidateWebhookURL( + params.Config.MattermostWebhook, + ) + if err != nil { + return nil, fmt.Errorf( + "invalid mattermost webhook URL: %w", err, + ) + } + + svc.mattermostWebhookURL = u + } + + 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.config.NtfyTopic != "" { - 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.config.NtfyTopic, - 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.config.SlackWebhook != "" { - go func() { - notifyCtx := context.WithoutCancel(ctx) + go func() { + notifyCtx := context.WithoutCancel(ctx) - err := svc.sendSlack( - notifyCtx, - svc.config.SlackWebhook, - 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.config.MattermostWebhook != "" { - go func() { - notifyCtx := context.WithoutCancel(ctx) + go func() { + notifyCtx := context.WithoutCancel(ctx) - err := svc.sendSlack( - notifyCtx, - svc.config.MattermostWebhook, - 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( ctx context.Context, - topic, title, message, priority string, + topicURL *url.URL, + title, message, priority string, ) error { svc.log.Debug( "sending ntfy notification", - "topic", topic, + "topic", topicURL.String(), "title", title, ) - request, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - topic, - 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) + resp, err := svc.transport.RoundTrip(request) if err != nil { return fmt.Errorf("sending ntfy request: %w", err) } @@ -156,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, ) } @@ -193,11 +355,17 @@ type SlackAttachment struct { func (svc *Service) sendSlack( ctx context.Context, - webhookURL, title, message, priority string, + webhookURL *url.URL, + title, message, priority string, ) error { + ctx, cancel := context.WithTimeout( + ctx, httpClientTimeout, + ) + defer cancel() + svc.log.Debug( "sending webhook notification", - "url", webhookURL, + "url", webhookURL.String(), "title", title, ) @@ -213,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, + 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) + 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 index 94bc313..3f203d4 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -4,11 +4,6 @@ import "errors" // Sentinel errors returned by the resolver. var ( - // ErrNotImplemented indicates a method is stubbed out. - ErrNotImplemented = errors.New( - "resolver not yet implemented", - ) - // ErrNoNameservers is returned when no authoritative NS // could be discovered for a domain. ErrNoNameservers = errors.New( 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 b155782..aec9b89 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -4,7 +4,6 @@ package resolver import ( - "context" "log/slog" "go.uber.org/fx" @@ -18,6 +17,7 @@ const ( StatusError = "error" StatusNXDomain = "nxdomain" StatusNoData = "nodata" + StatusTimeout = "timeout" ) // MaxCNAMEDepth is the maximum CNAME chain depth to follow. @@ -40,7 +40,9 @@ type NameserverResponse struct { // Resolver performs iterative DNS resolution from root servers. type Resolver struct { - log *slog.Logger + log *slog.Logger + client DNSClient + tcp DNSClient } // New creates a new Resolver instance for use with uber/fx. @@ -49,66 +51,33 @@ func New( params Params, ) (*Resolver, error) { return &Resolver{ - log: params.Logger.Get(), + log: params.Logger.Get(), + client: &udpClient{timeout: queryTimeoutDuration}, + tcp: &tcpClient{timeout: queryTimeoutDuration}, }, nil } // 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} + return &Resolver{ + log: log, + client: &udpClient{timeout: queryTimeoutDuration}, + tcp: &tcpClient{timeout: queryTimeoutDuration}, + } } -// FindAuthoritativeNameservers traces the delegation chain from -// root servers to discover all authoritative nameservers for the -// given domain. Returns the NS hostnames (e.g. ["ns1.example.com.", -// "ns2.example.com."]). -func (r *Resolver) FindAuthoritativeNameservers( - _ context.Context, - _ 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, + } } -// QueryNameserver queries a specific authoritative nameserver for -// all supported record types (A, AAAA, CNAME, MX, TXT, SRV, CAA, -// NS) for the given hostname. Returns a NameserverResponse with -// per-type record slices and a status indicating success or the -// type of failure. -func (r *Resolver) QueryNameserver( - _ context.Context, - _ string, - _ string, -) (*NameserverResponse, error) { - return nil, ErrNotImplemented -} - -// QueryAllNameservers discovers the authoritative nameservers for -// the hostname's parent domain, then queries each one independently. -// Returns a map from nameserver hostname to its response. -func (r *Resolver) QueryAllNameservers( - _ context.Context, - _ string, -) (map[string]*NameserverResponse, error) { - return nil, ErrNotImplemented -} - -// LookupNS returns the NS record set for a domain by performing -// iterative resolution. This is used for domain (apex) monitoring. -func (r *Resolver) LookupNS( - _ context.Context, - _ string, -) ([]string, error) { - return nil, ErrNotImplemented -} - -// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 -// addresses by querying all authoritative nameservers and following -// CNAME chains up to MaxCNAMEDepth. Returns a deduplicated list -// of IP address strings. -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 index c7c2c99..e68b367 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -1,3 +1,10 @@ +// Package resolver_test verifies the iterative resolver against +// live DNS. Per TESTING.md, all resolver tests use real queries +// against real nameservers — no mocking of the DNS client layer. +// Tests target long-lived public infrastructure (root servers, +// gTLD servers, IANA, Google, Cloudflare, GitHub) to minimize +// fixture churn, plus dedicated fixture records in the +// sneak.cloud zone. package resolver_test import ( @@ -16,43 +23,41 @@ import ( "sneak.berlin/go/dnswatcher/internal/resolver" ) -// Test domain and hostnames hosted on Cloudflare. -// These records must exist in the sneak.cloud Cloudflare zone: +// ---------------------------------------------------------------- +// Test fixtures +// ---------------------------------------------------------------- + +// Fixture hostnames in the sneak.cloud zone (hosted on +// Cloudflare). These records must exist in the sneak.cloud zone: // -// basic.dns.sneak.cloud A 192.0.2.1 -// multi.dns.sneak.cloud A 192.0.2.1 -// multi.dns.sneak.cloud A 192.0.2.2 -// ipv6.dns.sneak.cloud AAAA 2001:db8::1 -// dual.dns.sneak.cloud A 192.0.2.1 -// dual.dns.sneak.cloud AAAA 2001:db8::1 +// basic.dns.sneak.cloud A 192.0.2.1 +// multi.dns.sneak.cloud A 192.0.2.1 +// multi.dns.sneak.cloud A 192.0.2.2 +// ipv6.dns.sneak.cloud AAAA 2001:db8::1 +// dual.dns.sneak.cloud A 192.0.2.1 +// dual.dns.sneak.cloud AAAA 2001:db8::1 // cname-target.dns.sneak.cloud A 198.51.100.1 -// cname.dns.sneak.cloud CNAME cname-target.dns.sneak.cloud -// mx.dns.sneak.cloud MX 10 mail.dns.sneak.cloud -// mail.dns A 192.0.2.10 -// txt.dns.sneak.cloud TXT "v=spf1 -all" +// cname.dns.sneak.cloud CNAME cname-target.dns.sneak.cloud const ( - testDomain = "sneak.cloud" - testHostBasic = "basic.dns.sneak.cloud" - testHostMultiA = "multi.dns.sneak.cloud" - testHostIPv6 = "ipv6.dns.sneak.cloud" - testHostDualStack = "dual.dns.sneak.cloud" - testHostCNAME = "cname.dns.sneak.cloud" - testHostCNAMETarget = "cname-target.dns.sneak.cloud" - testHostMX = "mx.dns.sneak.cloud" - testHostMail = "mail.dns.sneak.cloud" - testHostTXT = "txt.dns.sneak.cloud" - testHostNXDomain = "nxdomain-surely-does-not-exist.dns.sneak.cloud" + testDomain = "sneak.cloud" + testHostBasic = "basic.dns.sneak.cloud" + testHostMultiA = "multi.dns.sneak.cloud" + testHostIPv6 = "ipv6.dns.sneak.cloud" + testHostDualStack = "dual.dns.sneak.cloud" + testHostCNAME = "cname.dns.sneak.cloud" ) -// queryTimeout is the default timeout for test queries. -const queryTimeout = 30 * time.Second +// ---------------------------------------------------------------- +// Test helpers +// ---------------------------------------------------------------- func newTestResolver(t *testing.T) *resolver.Resolver { t.Helper() - log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelDebug, - })) + log := slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )) return resolver.NewFromLogger(log) } @@ -61,14 +66,33 @@ func testContext(t *testing.T) context.Context { t.Helper() ctx, cancel := context.WithTimeout( - context.Background(), queryTimeout, + context.Background(), 60*time.Second, ) t.Cleanup(cancel) return ctx } -// --- FindAuthoritativeNameservers tests --- +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, @@ -79,36 +103,23 @@ func TestFindAuthoritativeNameservers_ValidDomain( ctx := testContext(t) nameservers, err := r.FindAuthoritativeNameservers( - ctx, testDomain, + ctx, "google.com", ) require.NoError(t, err) - require.NotEmpty(t, nameservers, "should find at least one NS") + require.NotEmpty(t, nameservers) - // sneak.cloud is on Cloudflare, NS should contain cloudflare - for _, ns := range nameservers { - t.Logf("discovered NS: %s", ns) - assert.True( - t, - strings.HasSuffix(ns, "."), - "NS should be FQDN with trailing dot: %s", ns, - ) - } - - // Verify at least one is a Cloudflare NS - hasCloudflare := false + hasGoogleNS := false for _, ns := range nameservers { - if strings.Contains(ns, "cloudflare") { - hasCloudflare = true + if strings.Contains(ns, "google") { + hasGoogleNS = true break } } - assert.True( - t, hasCloudflare, - "sneak.cloud should be hosted on Cloudflare, got: %v", - nameservers, + assert.True(t, hasGoogleNS, + "expected google nameservers, got: %v", nameservers, ) } @@ -120,26 +131,11 @@ func TestFindAuthoritativeNameservers_Subdomain( r := newTestResolver(t) ctx := testContext(t) - // Looking up NS for a hostname that isn't a zone should - // return the parent zone's NS records. nameservers, err := r.FindAuthoritativeNameservers( - ctx, testHostBasic, + ctx, "www.google.com", ) require.NoError(t, err) require.NotEmpty(t, nameservers) - - // Should be the same Cloudflare NSes as the parent domain - hasCloudflare := false - - for _, ns := range nameservers { - if strings.Contains(ns, "cloudflare") { - hasCloudflare = true - - break - } - } - - assert.True(t, hasCloudflare) } func TestFindAuthoritativeNameservers_TLD(t *testing.T) { @@ -149,14 +145,26 @@ func TestFindAuthoritativeNameservers_TLD(t *testing.T) { ctx := testContext(t) nameservers, err := r.FindAuthoritativeNameservers( - ctx, "cloud", + ctx, "com", ) require.NoError(t, err) - require.NotEmpty(t, nameservers, "should find TLD nameservers") + require.NotEmpty(t, nameservers, + "should find TLD nameservers", + ) + + hasGTLD := false for _, ns := range nameservers { - t.Logf("TLD NS: %s", ns) + if strings.Contains(ns, "gtld-servers.net") { + hasGTLD = true + + break + } } + + assert.True(t, hasGTLD, + "expected gtld-servers for com, got: %v", nameservers, + ) } func TestFindAuthoritativeNameservers_ReturnsSorted( @@ -168,12 +176,10 @@ func TestFindAuthoritativeNameservers_ReturnsSorted( ctx := testContext(t) nameservers, err := r.FindAuthoritativeNameservers( - ctx, testDomain, + ctx, "google.com", ) require.NoError(t, err) - require.NotEmpty(t, nameservers) - // Results should be sorted for deterministic comparison assert.True( t, sort.StringsAreSorted(nameservers), @@ -190,45 +196,146 @@ func TestFindAuthoritativeNameservers_Deterministic( ctx := testContext(t) first, err := r.FindAuthoritativeNameservers( - ctx, testDomain, + ctx, "google.com", ) require.NoError(t, err) second, err := r.FindAuthoritativeNameservers( - ctx, testDomain, + ctx, "google.com", ) require.NoError(t, err) - assert.Equal( - t, first, second, - "repeated lookups should return same result", - ) + assert.Equal(t, first, second) } -// --- QueryNameserver tests --- +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, + ) + } +} + +func TestFindAuthoritativeNameservers_AllNSRespondAuthoritatively( + t *testing.T, +) { + // Every NS returned by FindAuthoritativeNameservers must + // actually serve the zone. A lame NS would return REFUSED, + // SERVFAIL, or a non-authoritative answer. + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "google.com", + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + for _, ns := range nameservers { + resp, queryErr := r.QueryNameserver( + ctx, ns, "www.google.com", + ) + require.NoError(t, queryErr, + "NS %s should be queryable", ns, + ) + assert.Equal( + t, resolver.StatusOK, resp.Status, + "NS %s should respond authoritatively, "+ + "status=%s error=%s", + ns, resp.Status, resp.Error, + ) + + hasRecords := len(resp.Records["A"]) > 0 || + len(resp.Records["CNAME"]) > 0 + assert.True(t, hasRecords, + "NS %s should return records for www.google.com", + ns, + ) + } +} + +func TestFindAuthoritativeNameservers_IsIterative( + t *testing.T, +) { + // Verify that resolution works for well-known domains, + // proving we trace from root rather than relying on a + // system stub resolver that might not be configured. + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + // Resolve a well-known domain to prove root->TLD->domain + // tracing works. + nameservers, err := r.FindAuthoritativeNameservers( + ctx, "example.com", + ) + require.NoError(t, err) + require.NotEmpty(t, nameservers) + + t.Logf("example.com NS: %v", nameservers) +} + +// ---------------------------------------------------------------- +// QueryNameserver tests +// ---------------------------------------------------------------- func TestQueryNameserver_BasicA(t *testing.T) { t.Parallel() r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostBasic) + 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) - aRecords := resp.Records["A"] - require.NotEmpty(t, aRecords, "basic.dns should have A records") - assert.Contains(t, aRecords, "192.0.2.1") - - t.Logf( - "QueryNameserver(%s, %s) A records: %v", - ns, testHostBasic, aRecords, + hasRecords := len(resp.Records["A"]) > 0 || + len(resp.Records["CNAME"]) > 0 + assert.True(t, hasRecords, + "expected A or CNAME records for www.google.com", ) } @@ -237,8 +344,7 @@ func TestQueryNameserver_MultipleA(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - - ns := findOneNS(t, r, ctx) + ns := findOneNSForDomain(t, r, ctx, testDomain) resp, err := r.QueryNameserver(ctx, ns, testHostMultiA) require.NoError(t, err) @@ -260,20 +366,24 @@ func TestQueryNameserver_AAAA(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "cloudflare.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostIPv6) + resp, err := r.QueryNameserver( + ctx, ns, "cloudflare.com", + ) require.NoError(t, err) - require.NotNil(t, resp) - assert.Equal(t, resolver.StatusOK, resp.Status) aaaaRecords := resp.Records["AAAA"] - require.NotEmpty( - t, aaaaRecords, - "ipv6.dns should have AAAA records", + require.NotEmpty(t, aaaaRecords, + "cloudflare.com should have AAAA records", ) - assert.Contains(t, aaaaRecords, "2001:db8::1") + + for _, ip := range aaaaRecords { + parsed := net.ParseIP(ip) + require.NotNil(t, parsed, + "should be valid IP: %s", ip, + ) + } } func TestQueryNameserver_DualStack(t *testing.T) { @@ -281,8 +391,7 @@ func TestQueryNameserver_DualStack(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - - ns := findOneNS(t, r, ctx) + ns := findOneNSForDomain(t, r, ctx, testDomain) resp, err := r.QueryNameserver(ctx, ns, testHostDualStack) require.NoError(t, err) @@ -298,8 +407,7 @@ func TestQueryNameserver_CNAME(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - - ns := findOneNS(t, r, ctx) + ns := findOneNSForDomain(t, r, ctx, testDomain) resp, err := r.QueryNameserver(ctx, ns, testHostCNAME) require.NoError(t, err) @@ -321,35 +429,16 @@ func TestQueryNameserver_MX(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostMX) + resp, err := r.QueryNameserver( + ctx, ns, "google.com", + ) require.NoError(t, err) - require.NotNil(t, resp) - assert.Equal(t, resolver.StatusOK, resp.Status) mxRecords := resp.Records["MX"] - require.NotEmpty( - t, mxRecords, - "mx.dns should have MX records", - ) - - // MX records are formatted as "priority host" - hasMail := false - - for _, mx := range mxRecords { - if strings.Contains(mx, "mail.dns.sneak.cloud.") { - hasMail = true - - break - } - } - - assert.True( - t, hasMail, - "MX should reference mail.dns.sneak.cloud, got: %v", - mxRecords, + require.NotEmpty(t, mxRecords, + "google.com should have MX records", ) } @@ -358,18 +447,16 @@ func TestQueryNameserver_TXT(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostTXT) + resp, err := r.QueryNameserver( + ctx, ns, "google.com", + ) require.NoError(t, err) - require.NotNil(t, resp) - assert.Equal(t, resolver.StatusOK, resp.Status) txtRecords := resp.Records["TXT"] - require.NotEmpty( - t, txtRecords, - "txt.dns should have TXT records", + require.NotEmpty(t, txtRecords, + "google.com should have TXT records", ) hasSPF := false @@ -382,9 +469,8 @@ func TestQueryNameserver_TXT(t *testing.T) { } } - assert.True( - t, hasSPF, - "TXT should contain SPF record, got: %v", txtRecords, + assert.True(t, hasSPF, + "google.com should have SPF TXT record", ) } @@ -393,17 +479,15 @@ func TestQueryNameserver_NXDomain(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostNXDomain) - require.NoError(t, err) - require.NotNil(t, resp) - - assert.Equal( - t, resolver.StatusNXDomain, resp.Status, - "nonexistent host should return nxdomain status", + 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) { @@ -411,19 +495,18 @@ func TestQueryNameserver_RecordsSorted(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostMultiA) + resp, err := r.QueryNameserver( + ctx, ns, "google.com", + ) require.NoError(t, err) - // Each record type's values should be sorted for determinism for recordType, values := range resp.Records { assert.True( t, sort.StringsAreSorted(values), - "%s records should be sorted, got: %v", - recordType, values, + "%s records should be sorted", recordType, ) } } @@ -435,29 +518,29 @@ func TestQueryNameserver_ResponseIncludesNameserver( r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "cloudflare.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostBasic) + resp, err := r.QueryNameserver( + ctx, ns, "cloudflare.com", + ) require.NoError(t, err) - assert.Equal( - t, ns, resp.Nameserver, - "response should include the queried nameserver", - ) + assert.Equal(t, ns, resp.Nameserver) } -func TestQueryNameserver_EmptyRecordsMapOnNXDomain( +func TestQueryNameserver_EmptyRecordsOnNXDomain( t *testing.T, ) { t.Parallel() r := newTestResolver(t) ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") - ns := findOneNS(t, r, ctx) - - resp, err := r.QueryNameserver(ctx, ns, testHostNXDomain) + resp, err := r.QueryNameserver( + ctx, ns, + "this-surely-does-not-exist-xyz.google.com", + ) require.NoError(t, err) totalRecords := 0 @@ -465,14 +548,142 @@ func TestQueryNameserver_EmptyRecordsMapOnNXDomain( totalRecords += len(values) } - assert.Zero( - t, totalRecords, - "NXDOMAIN should have no records, got: %v", - resp.Records, + 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) +} + +func TestQueryNameserver_NSRecordsForZoneApex( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "cloudflare.com") + + // Querying a zone apex should return NS records. + resp, err := r.QueryNameserver( + ctx, ns, "cloudflare.com", + ) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, resolver.StatusOK, resp.Status) + + nsRecords := resp.Records["NS"] + require.NotEmpty(t, nsRecords, + "zone apex should have NS records", + ) + + hasCloudflare := false + + for _, nsRecord := range nsRecords { + if strings.Contains(nsRecord, "cloudflare") { + hasCloudflare = true + + break + } + } + + assert.True(t, hasCloudflare, + "NS records should include cloudflare, got: %v", + nsRecords, ) } -// --- QueryAllNameservers tests --- +func TestQueryNameserver_NoNSRecordsForLeafHostname( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + ns := findOneNSForDomain(t, r, ctx, "google.com") + + // A leaf hostname (not a zone cut) has no NS records. + resp, err := r.QueryNameserver( + ctx, ns, "www.google.com", + ) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Empty(t, resp.Records["NS"], + "leaf hostname should not have NS records, got: %v", + resp.Records["NS"], + ) +} + +func TestQueryNameserver_LameNameserver(t *testing.T) { + // Querying a nameserver that does not serve a zone should + // return a non-ok status, not silently return empty results + // with status "ok". a.iana-servers.net serves example.com, + // not google.com. + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + resp, err := r.QueryNameserver( + ctx, "a.iana-servers.net.", "www.google.com", + ) + require.NoError(t, err, "should not return Go-level error") + require.NotNil(t, resp) + + assert.NotEqual( + t, resolver.StatusOK, resp.Status, + "lame NS should not return status ok; got status=%s "+ + "records=%v", + resp.Status, resp.Records, + ) +} + +// ---------------------------------------------------------------- +// QueryNameserverIP tests +// ---------------------------------------------------------------- + +func TestQueryNameserverIP_BasicA(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx := testContext(t) + + // a.iana-servers.net (199.43.135.53) is authoritative for + // example.com; query it directly by IP. + resp, err := r.QueryNameserverIP( + ctx, "a.iana-servers.net.", "199.43.135.53", + "example.com", + ) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, resolver.StatusOK, resp.Status) + assert.Equal(t, "a.iana-servers.net.", resp.Nameserver) + assert.NotEmpty(t, resp.Records["A"], + "example.com should have A records", + ) +} + +// ---------------------------------------------------------------- +// QueryAllNameservers tests +// ---------------------------------------------------------------- func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { t.Parallel() @@ -480,26 +691,36 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - results, err := r.QueryAllNameservers(ctx, testHostBasic) + results, err := r.QueryAllNameservers( + ctx, "google.com", + ) require.NoError(t, err) require.NotEmpty(t, results) - // Should have queried each NS independently - t.Logf( - "QueryAllNameservers returned %d nameserver results", - len(results), - ) + assert.GreaterOrEqual(t, len(results), 2) for ns, resp := range results { - t.Logf(" %s: status=%s A=%v", ns, resp.Status, resp.Records["A"]) assert.Equal(t, ns, resp.Nameserver) } +} - // Should have more than one NS for Cloudflare-hosted domain - assert.GreaterOrEqual( - t, len(results), 2, - "should query at least 2 nameservers", +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_Consistent(t *testing.T) { @@ -544,21 +765,22 @@ func TestQueryAllNameservers_NXDomainFromAllNS( ctx := testContext(t) results, err := r.QueryAllNameservers( - ctx, testHostNXDomain, + ctx, + "this-surely-does-not-exist-xyz.google.com", ) require.NoError(t, err) - require.NotEmpty(t, results) for ns, resp := range results { assert.Equal( t, resolver.StatusNXDomain, resp.Status, - "NS %s should return nxdomain for nonexistent host", - ns, + "NS %s should return nxdomain", ns, ) } } -// --- LookupNS tests --- +// ---------------------------------------------------------------- +// LookupNS tests +// ---------------------------------------------------------------- func TestLookupNS_ValidDomain(t *testing.T) { t.Parallel() @@ -566,16 +788,13 @@ func TestLookupNS_ValidDomain(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - nameservers, err := r.LookupNS(ctx, testDomain) + nameservers, err := r.LookupNS(ctx, "google.com") require.NoError(t, err) require.NotEmpty(t, nameservers) for _, ns := range nameservers { - t.Logf("NS record: %s", ns) - assert.True( - t, - strings.HasSuffix(ns, "."), - "NS should be FQDN: %s", ns, + assert.True(t, strings.HasSuffix(ns, "."), + "NS should have trailing dot: %s", ns, ) } } @@ -586,14 +805,10 @@ func TestLookupNS_Sorted(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - nameservers, err := r.LookupNS(ctx, testDomain) + nameservers, err := r.LookupNS(ctx, "google.com") require.NoError(t, err) - assert.True( - t, - sort.StringsAreSorted(nameservers), - "NS records should be sorted, got: %v", nameservers, - ) + assert.True(t, sort.StringsAreSorted(nameservers)) } func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { @@ -602,23 +817,38 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - fromLookup, err := r.LookupNS(ctx, testDomain) + fromLookup, err := r.LookupNS(ctx, "google.com") require.NoError(t, err) fromFind, err := r.FindAuthoritativeNameservers( - ctx, testDomain, + ctx, "google.com", ) require.NoError(t, err) - // Both methods should return the same NS set - assert.Equal( - t, fromFind, fromLookup, - "LookupNS and FindAuthoritativeNameservers "+ - "should return the same set", - ) + assert.Equal(t, fromFind, fromLookup) } -// --- ResolveIPAddresses tests --- +// ---------------------------------------------------------------- +// 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_BasicA(t *testing.T) { t.Parallel() @@ -687,14 +917,11 @@ func TestResolveIPAddresses_FollowsCNAME(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - // cname.dns.sneak.cloud -> cname-target.dns.sneak.cloud -> 198.51.100.1 - ips, err := r.ResolveIPAddresses(ctx, testHostCNAME) + // www.github.com is a long-standing CNAME to github.com; + // resolution must follow the chain to A records. + ips, err := r.ResolveIPAddresses(ctx, "www.github.com") require.NoError(t, err) require.NotEmpty(t, ips) - assert.Contains( - t, ips, "198.51.100.1", - "should follow CNAME to resolve target IP", - ) } func TestResolveIPAddresses_Deduplicated(t *testing.T) { @@ -703,17 +930,13 @@ func TestResolveIPAddresses_Deduplicated(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - ips, err := r.ResolveIPAddresses(ctx, testHostBasic) + ips, err := r.ResolveIPAddresses(ctx, "google.com") require.NoError(t, err) - // Check for duplicates seen := make(map[string]bool) for _, ip := range ips { - assert.False( - t, seen[ip], - "IP %s appears more than once", ip, - ) + assert.False(t, seen[ip], "duplicate IP: %s", ip) seen[ip] = true } } @@ -724,14 +947,10 @@ func TestResolveIPAddresses_Sorted(t *testing.T) { r := newTestResolver(t) ctx := testContext(t) - ips, err := r.ResolveIPAddresses(ctx, testHostDualStack) + ips, err := r.ResolveIPAddresses(ctx, "google.com") require.NoError(t, err) - assert.True( - t, - sort.StringsAreSorted(ips), - "IP addresses should be sorted, got: %v", ips, - ) + assert.True(t, sort.StringsAreSorted(ips)) } func TestResolveIPAddresses_NXDomainReturnsEmpty( @@ -742,14 +961,28 @@ func TestResolveIPAddresses_NXDomainReturnsEmpty( r := newTestResolver(t) ctx := testContext(t) - ips, err := r.ResolveIPAddresses(ctx, testHostNXDomain) - // Should not error — NXDOMAIN is an expected DNS response. - // It just means no IPs to return. + ips, err := r.ResolveIPAddresses( + ctx, + "this-surely-does-not-exist-xyz.google.com", + ) require.NoError(t, err) assert.Empty(t, ips) } -// --- Context cancellation tests --- +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, @@ -757,11 +990,10 @@ func TestFindAuthoritativeNameservers_ContextCanceled( t.Parallel() r := newTestResolver(t) - ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately + cancel() - _, err := r.FindAuthoritativeNameservers(ctx, testDomain) + _, err := r.FindAuthoritativeNameservers(ctx, "google.com") assert.Error(t, err) } @@ -769,12 +1001,11 @@ func TestQueryNameserver_ContextCanceled(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := r.QueryNameserver( - ctx, "ns1.example.com.", testHostBasic, + ctx, "ns1.google.com.", "google.com", ) assert.Error(t, err) } @@ -783,11 +1014,10 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := r.QueryAllNameservers(ctx, testHostBasic) + _, err := r.QueryAllNameservers(ctx, "google.com") assert.Error(t, err) } @@ -795,238 +1025,9 @@ func TestResolveIPAddresses_ContextCanceled(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := r.ResolveIPAddresses(ctx, testHostBasic) + _, err := r.ResolveIPAddresses(ctx, "google.com") assert.Error(t, err) } - -// --- Iterative resolution verification --- - -func TestFindAuthoritativeNameservers_IsIterative( - t *testing.T, -) { - // Verify that resolution works for well-known domains, - // proving we trace from root rather than relying on a - // system stub resolver that might not be configured. - t.Parallel() - - r := newTestResolver(t) - ctx := testContext(t) - - // Resolve a well-known domain to prove root->TLD->domain - // tracing works. - nameservers, err := r.FindAuthoritativeNameservers( - ctx, "example.com", - ) - require.NoError(t, err) - require.NotEmpty(t, nameservers) - - t.Logf("example.com NS: %v", nameservers) -} - -// --- NS record and delegation tests --- - -func TestQueryNameserver_NSRecordsForZoneApex( - t *testing.T, -) { - t.Parallel() - - r := newTestResolver(t) - ctx := testContext(t) - - ns := findOneNS(t, r, ctx) - - // Querying a zone apex should return NS records - resp, err := r.QueryNameserver(ctx, ns, testDomain) - require.NoError(t, err) - require.NotNil(t, resp) - assert.Equal(t, resolver.StatusOK, resp.Status) - - nsRecords := resp.Records["NS"] - require.NotEmpty( - t, nsRecords, - "zone apex should have NS records", - ) - - // Should contain Cloudflare nameservers - hasCloudflare := false - - for _, nsRecord := range nsRecords { - t.Logf("NS record: %s", nsRecord) - - if strings.Contains(nsRecord, "cloudflare") { - hasCloudflare = true - } - } - - assert.True( - t, hasCloudflare, - "NS records should include Cloudflare, got: %v", - nsRecords, - ) -} - -func TestQueryNameserver_NoNSRecordsForLeafHostname( - t *testing.T, -) { - t.Parallel() - - r := newTestResolver(t) - ctx := testContext(t) - - ns := findOneNS(t, r, ctx) - - // A leaf hostname (not a zone cut) should not have NS records - resp, err := r.QueryNameserver(ctx, ns, testHostBasic) - require.NoError(t, err) - require.NotNil(t, resp) - - nsRecords := resp.Records["NS"] - assert.Empty( - t, nsRecords, - "leaf hostname should not have NS records, got: %v", - nsRecords, - ) -} - -func TestFindAuthoritativeNameservers_AllNSRespondAuthoritatively( - t *testing.T, -) { - // Every NS returned by FindAuthoritativeNameservers should - // actually serve the zone. A lame NS would return REFUSED, - // SERVFAIL, or a non-authoritative answer. This test verifies - // that each discovered NS can answer queries authoritatively. - t.Parallel() - - r := newTestResolver(t) - ctx := testContext(t) - - nameservers, err := r.FindAuthoritativeNameservers( - ctx, testDomain, - ) - require.NoError(t, err) - require.NotEmpty(t, nameservers) - - for _, ns := range nameservers { - resp, queryErr := r.QueryNameserver( - ctx, ns, testHostBasic, - ) - require.NoError( - t, queryErr, - "NS %s should be queryable", ns, - ) - assert.Equal( - t, resolver.StatusOK, resp.Status, - "NS %s should respond authoritatively, "+ - "status=%s error=%s", - ns, resp.Status, resp.Error, - ) - assert.NotEmpty( - t, resp.Records["A"], - "NS %s should return A records for %s", - ns, testHostBasic, - ) - } -} - -func TestQueryNameserver_LameNameserver(t *testing.T) { - // Querying a nameserver that does not serve a zone should - // return a non-ok status (error or refused), not silently - // return empty results with status "ok". - t.Parallel() - - r := newTestResolver(t) - ctx := testContext(t) - - // Use a known-good nameserver that does NOT serve sneak.cloud. - // a.iana-servers.net serves example.com, not sneak.cloud. - resp, err := r.QueryNameserver( - ctx, "a.iana-servers.net.", testHostBasic, - ) - require.NoError(t, err, "should not return Go-level error") - require.NotNil(t, resp) - - assert.NotEqual( - t, resolver.StatusOK, resp.Status, - "lame NS should not return status ok; got status=%s "+ - "records=%v", - resp.Status, resp.Records, - ) -} - -// --- Edge cases --- - -func TestQueryNameserver_TrailingDotHandling(t *testing.T) { - t.Parallel() - - r := newTestResolver(t) - ctx := testContext(t) - - ns := findOneNS(t, r, ctx) - - // Both with and without trailing dot should work - resp1, err := r.QueryNameserver( - ctx, ns, "basic.dns.sneak.cloud", - ) - require.NoError(t, err) - - resp2, err := r.QueryNameserver( - ctx, ns, "basic.dns.sneak.cloud.", - ) - require.NoError(t, err) - - assert.Equal( - t, resp1.Records["A"], resp2.Records["A"], - "trailing dot should not affect results", - ) -} - -func TestFindAuthoritativeNameservers_TrailingDot( - t *testing.T, -) { - t.Parallel() - - r := newTestResolver(t) - ctx := testContext(t) - - ns1, err := r.FindAuthoritativeNameservers( - ctx, "sneak.cloud", - ) - require.NoError(t, err) - - ns2, err := r.FindAuthoritativeNameservers( - ctx, "sneak.cloud.", - ) - require.NoError(t, err) - - assert.Equal( - t, ns1, ns2, - "trailing dot should not affect NS lookup", - ) -} - -// --- Helper functions --- - -// findOneNS discovers authoritative nameservers and returns the first -// one, failing the test if none are found. -func findOneNS( - t *testing.T, - r *resolver.Resolver, - ctx context.Context, //nolint:revive // test helper -) string { - t.Helper() - - nameservers, err := r.FindAuthoritativeNameservers( - ctx, testDomain, - ) - require.NoError(t, err) - require.NotEmpty( - t, nameservers, - "should find at least one NS for %s", testDomain, - ) - - return nameservers[0] -} 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 new file mode 100644 index 0000000..7e9aac7 --- /dev/null +++ b/internal/state/state_test_helper.go @@ -0,0 +1,38 @@ +package state + +import ( + "log/slog" + + "sneak.berlin/go/dnswatcher/internal/config" +) + +// NewForTest creates a State for unit testing with no persistence. +func NewForTest() *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: ""}, + } +} + +// 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 new file mode 100644 index 0000000..dd68017 --- /dev/null +++ b/internal/watcher/interfaces.go @@ -0,0 +1,61 @@ +// Package watcher provides the main monitoring orchestrator. +package watcher + +import ( + "context" + + "sneak.berlin/go/dnswatcher/internal/portcheck" + "sneak.berlin/go/dnswatcher/internal/tlscheck" +) + +// DNSResolver performs iterative DNS resolution. +type DNSResolver interface { + // LookupNS discovers authoritative nameservers for a domain. + LookupNS( + ctx context.Context, + domain string, + ) ([]string, error) + + // LookupAllRecords queries all record types for a hostname, + // returning results keyed by nameserver then record type. + LookupAllRecords( + ctx context.Context, + hostname string, + ) (map[string]map[string][]string, error) + + // ResolveIPAddresses resolves a hostname to all IP addresses. + ResolveIPAddresses( + ctx context.Context, + hostname string, + ) ([]string, error) +} + +// PortChecker tests TCP port connectivity. +type PortChecker interface { + // CheckPort tests TCP connectivity to an address and port. + CheckPort( + ctx context.Context, + address string, + port int, + ) (*portcheck.PortResult, error) +} + +// TLSChecker inspects TLS certificates. +type TLSChecker interface { + // CheckCertificate connects via TLS and returns cert info. + CheckCertificate( + ctx context.Context, + ip string, + hostname string, + ) (*tlscheck.CertificateInfo, error) +} + +// Notifier delivers notifications to configured endpoints. +type Notifier interface { + // SendNotification sends a notification with the given + // details. + SendNotification( + ctx context.Context, + title, message, priority string, + ) +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index a9b1caa..bdf4f22 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -1,21 +1,31 @@ -// Package watcher provides the main monitoring orchestrator and scheduler. package watcher import ( "context" + "fmt" "log/slog" + "sort" + "strings" + "sync" + "time" "go.uber.org/fx" "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/logger" - "sneak.berlin/go/dnswatcher/internal/notify" - "sneak.berlin/go/dnswatcher/internal/portcheck" - "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/tlscheck" ) +// monitoredPorts are the TCP ports checked for each IP address. +var monitoredPorts = []int{80, 443} //nolint:gochecknoglobals + +// tlsPort is the port used for TLS certificate checks. +const tlsPort = 443 + +// hoursPerDay converts days to hours for duration calculations. +const hoursPerDay = 24 + // Params contains dependencies for Watcher. type Params struct { fx.In @@ -23,61 +33,92 @@ type Params struct { Logger *logger.Logger Config *config.Config State *state.State - Resolver *resolver.Resolver - PortCheck *portcheck.Checker - TLSCheck *tlscheck.Checker - Notify *notify.Service + Resolver DNSResolver + PortCheck PortChecker + TLSCheck TLSChecker + Notify Notifier } // Watcher orchestrates all monitoring checks on a schedule. type Watcher struct { - log *slog.Logger - config *config.Config - state *state.State - resolver *resolver.Resolver - portCheck *portcheck.Checker - tlsCheck *tlscheck.Checker - notify *notify.Service - cancel context.CancelFunc + 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. +// New creates a new Watcher instance wired into the fx lifecycle. func New( lifecycle fx.Lifecycle, params Params, ) (*Watcher, error) { - watcher := &Watcher{ - log: params.Logger.Get(), - config: params.Config, - state: params.State, - resolver: params.Resolver, - portCheck: params.PortCheck, - tlsCheck: params.TLSCheck, - notify: params.Notify, + 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, + expiryNotified: make(map[string]time.Time), } lifecycle.Append(fx.Hook{ - OnStart: func(startCtx context.Context) error { - ctx, cancel := context.WithCancel(startCtx) - watcher.cancel = cancel + 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 watcher.Run(ctx) + go w.Run(ctx) //nolint:contextcheck // intentionally not derived from startCtx return nil }, OnStop: func(_ context.Context) error { - if watcher.cancel != nil { - watcher.cancel() + if w.cancel != nil { + w.cancel() } return nil }, }) - return watcher, nil + return w, nil } -// Run starts the monitoring loop. +// NewForTest creates a Watcher without fx for unit testing. +func NewForTest( + cfg *config.Config, + st *state.State, + res DNSResolver, + pc PortChecker, + tc TLSChecker, + n Notifier, +) *Watcher { + return &Watcher{ + log: slog.Default(), + config: cfg, + state: st, + resolver: res, + portCheck: pc, + tlsCheck: tc, + notify: n, + firstRun: true, + expiryNotified: make(map[string]time.Time), + } +} + +// Run starts the monitoring loop with periodic scheduling. func (w *Watcher) Run(ctx context.Context) { w.log.Info( "watcher starting", @@ -87,8 +128,812 @@ func (w *Watcher) Run(ctx context.Context) { "tlsInterval", w.config.TLSInterval, ) - // Stub: wait for context cancellation. - // Implementation will add initial check + periodic scheduling. - <-ctx.Done() - w.log.Info("watcher stopped") + w.RunOnce(ctx) + w.maybeSendTestNotification(ctx) + + dnsTicker := time.NewTicker(w.config.DNSInterval) + tlsTicker := time.NewTicker(w.config.TLSInterval) + + defer dnsTicker.Stop() + defer tlsTicker.Stop() + + for { + select { + case <-ctx.Done(): + w.log.Info("watcher stopped") + + return + case <-dnsTicker.C: + 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() + } + } +} + +// 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() + + // 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 +} + +func (w *Watcher) detectFirstRun() { + snap := w.state.GetSnapshot() + hasState := len(snap.Domains) > 0 || + len(snap.Hostnames) > 0 || + len(snap.Ports) > 0 || + len(snap.Certificates) > 0 + + if hasState { + w.firstRun = false + } +} + +// 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) + } + + for _, hostname := range w.config.Hostnames { + w.checkHostname(ctx, hostname) + } +} + +func (w *Watcher) checkDomain( + ctx context.Context, + domain string, +) { + nameservers, err := w.resolver.LookupNS(ctx, domain) + if err != nil { + w.log.Error( + "failed to lookup NS", + "domain", domain, + "error", err, + ) + + return + } + + sort.Strings(nameservers) + + now := time.Now().UTC() + + prev, hasPrev := w.state.GetDomainState(domain) + if hasPrev && !w.firstRun { + w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers) + } + + w.state.SetDomainState(domain, &state.DomainState{ + 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( + ctx context.Context, + domain string, + oldNS, newNS []string, +) { + oldSet := toSet(oldNS) + newSet := toSet(newNS) + + var added, removed []string + + for ns := range newSet { + if !oldSet[ns] { + added = append(added, ns) + } + } + + for ns := range oldSet { + if !newSet[ns] { + removed = append(removed, ns) + } + } + + if len(added) == 0 && len(removed) == 0 { + return + } + + msg := fmt.Sprintf( + "Domain: %s\nAdded: %s\nRemoved: %s", + domain, + strings.Join(added, ", "), + strings.Join(removed, ", "), + ) + + w.notify.SendNotification( + ctx, + "NS Change: "+domain, + msg, + "warning", + ) +} + +func (w *Watcher) checkHostname( + ctx context.Context, + hostname string, +) { + records, err := w.resolver.LookupAllRecords(ctx, hostname) + if err != nil { + w.log.Error( + "failed to lookup records", + "hostname", hostname, + "error", err, + ) + + return + } + + now := time.Now().UTC() + prev, hasPrev := w.state.GetHostnameState(hostname) + + if hasPrev && !w.firstRun { + w.detectHostnameChanges(ctx, hostname, prev, records) + } + + newState := buildHostnameState(records, now) + w.state.SetHostnameState(hostname, newState) +} + +func buildHostnameState( + records map[string]map[string][]string, + now time.Time, +) *state.HostnameState { + hs := &state.HostnameState{ + RecordsByNameserver: make( + map[string]*state.NameserverRecordState, + ), + LastChecked: now, + } + + for ns, recs := range records { + hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ + Records: recs, + Status: "ok", + LastChecked: now, + } + } + + return hs +} + +func (w *Watcher) detectHostnameChanges( + ctx context.Context, + hostname string, + prev *state.HostnameState, + current map[string]map[string][]string, +) { + w.detectRecordChanges(ctx, hostname, prev, current) + w.detectNSDisappearances(ctx, hostname, prev, current) + w.detectInconsistencies(ctx, hostname, current) +} + +func (w *Watcher) detectRecordChanges( + ctx context.Context, + hostname string, + prev *state.HostnameState, + current map[string]map[string][]string, +) { + for ns, recs := range current { + prevNS, ok := prev.RecordsByNameserver[ns] + if !ok { + continue + } + + if recordsEqual(prevNS.Records, recs) { + continue + } + + msg := fmt.Sprintf( + "Hostname: %s\nNameserver: %s\n"+ + "Old: %v\nNew: %v", + hostname, ns, + prevNS.Records, recs, + ) + + w.notify.SendNotification( + ctx, + "Record Change: "+hostname, + msg, + "warning", + ) + } +} + +func (w *Watcher) detectNSDisappearances( + ctx context.Context, + hostname string, + prev *state.HostnameState, + current map[string]map[string][]string, +) { + for ns, prevNS := range prev.RecordsByNameserver { + if _, ok := current[ns]; ok || prevNS.Status != "ok" { + continue + } + + msg := fmt.Sprintf( + "Hostname: %s\nNameserver: %s disappeared", + hostname, ns, + ) + + w.notify.SendNotification( + ctx, + "NS Failure: "+hostname, + msg, + "error", + ) + } + + for ns := range current { + prevNS, ok := prev.RecordsByNameserver[ns] + if !ok || prevNS.Status != "error" { + continue + } + + msg := fmt.Sprintf( + "Hostname: %s\nNameserver: %s recovered", + hostname, ns, + ) + + w.notify.SendNotification( + ctx, + "NS Recovery: "+hostname, + msg, + "success", + ) + } +} + +func (w *Watcher) detectInconsistencies( + ctx context.Context, + hostname string, + current map[string]map[string][]string, +) { + nameservers := make([]string, 0, len(current)) + for ns := range current { + nameservers = append(nameservers, ns) + } + + sort.Strings(nameservers) + + for i := range len(nameservers) - 1 { + ns1 := nameservers[i] + ns2 := nameservers[i+1] + + if recordsEqual(current[ns1], current[ns2]) { + continue + } + + msg := fmt.Sprintf( + "Hostname: %s\n%s: %v\n%s: %v", + hostname, + ns1, current[ns1], + ns2, current[ns2], + ) + + w.notify.SendNotification( + ctx, + "Inconsistency: "+hostname, + msg, + "warning", + ) + } +} + +func (w *Watcher) checkAllPorts(ctx context.Context) { + // 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) + } + + // Phase 3: Remove port state entries that no longer have + // any hostname referencing them. + w.cleanupStalePorts(associations) +} + +// 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) + + 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) + } + } +} + +func (w *Watcher) collectIPs(hostname string) []string { + hs, ok := w.state.GetHostnameState(hostname) + if !ok { + return nil + } + + ipSet := make(map[string]bool) + + for _, nsState := range hs.RecordsByNameserver { + for _, ip := range nsState.Records["A"] { + ipSet[ip] = true + } + + for _, ip := range nsState.Records["AAAA"] { + ipSet[ip] = true + } + } + + result := make([]string, 0, len(ipSet)) + for ip := range ipSet { + result = append(result, ip) + } + + sort.Strings(result) + + return result +} + +func (w *Watcher) checkSinglePort( + ctx context.Context, + ip string, + port int, + hostnames []string, +) { + result, err := w.portCheck.CheckPort(ctx, ip, port) + if err != nil { + w.log.Error( + "port check failed", + "ip", ip, + "port", port, + "error", err, + ) + + return + } + + key := fmt.Sprintf("%s:%d", ip, port) + now := time.Now().UTC() + prev, hasPrev := w.state.GetPortState(key) + + if hasPrev && !w.firstRun && prev.Open != result.Open { + stateStr := "closed" + if result.Open { + stateStr = "open" + } + + msg := fmt.Sprintf( + "Hosts: %s\nAddress: %s\nPort now %s", + strings.Join(hostnames, ", "), key, stateStr, + ) + + w.notify.SendNotification( + ctx, + "Port Change: "+key, + msg, + "warning", + ) + } + + w.state.SetPortState(key, &state.PortState{ + Open: result.Open, + Hostnames: hostnames, + LastChecked: now, + }) +} + +func (w *Watcher) runTLSChecks(ctx context.Context) { + for _, hostname := range w.config.Hostnames { + w.checkTLSForHostname(ctx, hostname) + } + + for _, domain := range w.config.Domains { + w.checkTLSForHostname(ctx, domain) + } +} + +func (w *Watcher) checkTLSForHostname( + ctx context.Context, + hostname string, +) { + ips := w.collectIPs(hostname) + + for _, ip := range ips { + portKey := fmt.Sprintf("%s:%d", ip, tlsPort) + + ps, ok := w.state.GetPortState(portKey) + if !ok || !ps.Open { + continue + } + + w.checkTLSCert(ctx, ip, hostname) + } +} + +func (w *Watcher) checkTLSCert( + ctx context.Context, + ip string, + hostname string, +) { + cert, err := w.tlsCheck.CheckCertificate(ctx, ip, hostname) + certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname) + now := time.Now().UTC() + prev, hasPrev := w.state.GetCertificateState(certKey) + + if err != nil { + w.handleTLSError( + ctx, certKey, hostname, ip, + hasPrev, prev, now, err, + ) + + return + } + + w.handleTLSSuccess( + ctx, certKey, hostname, ip, + hasPrev, prev, now, cert, + ) +} + +func (w *Watcher) handleTLSError( + ctx context.Context, + certKey, hostname, ip string, + hasPrev bool, + prev *state.CertificateState, + now time.Time, + err error, +) { + if hasPrev && !w.firstRun && prev.Status == "ok" { + msg := fmt.Sprintf( + "Host: %s\nIP: %s\nError: %s", + hostname, ip, err, + ) + + w.notify.SendNotification( + ctx, + "TLS Failure: "+hostname, + msg, + "error", + ) + } + + w.state.SetCertificateState( + certKey, &state.CertificateState{ + Status: "error", + Error: err.Error(), + LastChecked: now, + }, + ) +} + +func (w *Watcher) handleTLSSuccess( + ctx context.Context, + certKey, hostname, ip string, + hasPrev bool, + prev *state.CertificateState, + now time.Time, + cert *tlscheck.CertificateInfo, +) { + if hasPrev && !w.firstRun { + w.detectTLSChanges(ctx, hostname, ip, prev, cert) + } + + w.checkTLSExpiry(ctx, hostname, ip, cert) + + w.state.SetCertificateState( + certKey, &state.CertificateState{ + CommonName: cert.CommonName, + Issuer: cert.Issuer, + NotAfter: cert.NotAfter, + SubjectAlternativeNames: cert.SubjectAlternativeNames, + Status: "ok", + LastChecked: now, + }, + ) +} + +func (w *Watcher) detectTLSChanges( + ctx context.Context, + hostname, ip string, + prev *state.CertificateState, + cert *tlscheck.CertificateInfo, +) { + if prev.Status == "error" { + msg := fmt.Sprintf( + "Host: %s\nIP: %s\nTLS recovered", + hostname, ip, + ) + + w.notify.SendNotification( + ctx, + "TLS Recovery: "+hostname, + msg, + "success", + ) + + return + } + + changed := prev.CommonName != cert.CommonName || + prev.Issuer != cert.Issuer || + !sliceEqual( + prev.SubjectAlternativeNames, + cert.SubjectAlternativeNames, + ) + + if !changed { + return + } + + msg := fmt.Sprintf( + "Host: %s\nIP: %s\n"+ + "Old CN: %s, Issuer: %s\n"+ + "New CN: %s, Issuer: %s", + hostname, ip, + prev.CommonName, prev.Issuer, + cert.CommonName, cert.Issuer, + ) + + w.notify.SendNotification( + ctx, + "TLS Certificate Change: "+hostname, + msg, + "warning", + ) +} + +func (w *Watcher) checkTLSExpiry( + ctx context.Context, + hostname, ip string, + cert *tlscheck.CertificateInfo, +) { + daysLeft := time.Until(cert.NotAfter).Hours() / hoursPerDay + warningDays := float64(w.config.TLSExpiryWarning) + + if daysLeft > warningDays { + 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)", + hostname, ip, cert.CommonName, + cert.NotAfter.Format(time.RFC3339), + daysLeft, + ) + + w.notify.SendNotification( + ctx, + "TLS Expiry Warning: "+hostname, + msg, + "warning", + ) +} + +func (w *Watcher) saveState() { + err := w.state.Save() + if err != nil { + w.log.Error("failed to save state", "error", err) + } +} + +// 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 { + set := make(map[string]bool, len(items)) + for _, item := range items { + set[item] = true + } + + return set +} + +func recordsEqual( + a, b map[string][]string, +) bool { + if len(a) != len(b) { + return false + } + + for k, av := range a { + bv, ok := b[k] + if !ok || !sliceEqual(av, bv) { + return false + } + } + + return true +} + +func sliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + aSorted := make([]string, len(a)) + bSorted := make([]string, len(b)) + + copy(aSorted, a) + copy(bSorted, b) + + sort.Strings(aSorted) + sort.Strings(bSorted) + + for i := range aSorted { + if aSorted[i] != bSorted[i] { + return false + } + } + + return true } diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go new file mode 100644 index 0000000..ea6dbef --- /dev/null +++ b/internal/watcher/watcher_test.go @@ -0,0 +1,904 @@ +package watcher_test + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "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" +) + +// errNotFound is returned when mock data is missing. +var errNotFound = errors.New("not found") + +// --- Mock implementations --- + +type mockResolver struct { + mu sync.Mutex + nsRecords map[string][]string + allRecords map[string]map[string]map[string][]string + ipAddresses map[string][]string + lookupNSErr error + allRecordsErr error + resolveIPErr error + lookupNSCalls int + allRecordCalls int +} + +func (m *mockResolver) LookupNS( + _ context.Context, + domain string, +) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.lookupNSCalls++ + + if m.lookupNSErr != nil { + return nil, m.lookupNSErr + } + + ns, ok := m.nsRecords[domain] + if !ok { + return nil, fmt.Errorf( + "%w: NS for %s", errNotFound, domain, + ) + } + + return ns, nil +} + +func (m *mockResolver) LookupAllRecords( + _ context.Context, + hostname string, +) (map[string]map[string][]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.allRecordCalls++ + + if m.allRecordsErr != nil { + return nil, m.allRecordsErr + } + + recs, ok := m.allRecords[hostname] + if !ok { + return nil, fmt.Errorf( + "%w: records for %s", errNotFound, hostname, + ) + } + + return recs, nil +} + +func (m *mockResolver) ResolveIPAddresses( + _ context.Context, + hostname string, +) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.resolveIPErr != nil { + return nil, m.resolveIPErr + } + + ips, ok := m.ipAddresses[hostname] + if !ok { + return nil, fmt.Errorf( + "%w: IPs for %s", errNotFound, hostname, + ) + } + + return ips, nil +} + +type mockPortChecker struct { + mu sync.Mutex + results map[string]bool + err error + calls int +} + +func (m *mockPortChecker) CheckPort( + _ context.Context, + address string, + port int, +) (*portcheck.PortResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.calls++ + + if m.err != nil { + return nil, m.err + } + + key := fmt.Sprintf("%s:%d", address, port) + open := m.results[key] + + return &portcheck.PortResult{Open: open}, nil +} + +type mockTLSChecker struct { + mu sync.Mutex + certs map[string]*tlscheck.CertificateInfo + err error + calls int +} + +func (m *mockTLSChecker) CheckCertificate( + _ context.Context, + ip string, + hostname string, +) (*tlscheck.CertificateInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.calls++ + + if m.err != nil { + return nil, m.err + } + + key := fmt.Sprintf("%s:%s", ip, hostname) + cert, ok := m.certs[key] + + if !ok { + return nil, fmt.Errorf( + "%w: cert for %s", errNotFound, key, + ) + } + + return cert, nil +} + +type notification struct { + Title string + Message string + Priority string +} + +type mockNotifier struct { + mu sync.Mutex + notifications []notification +} + +func (m *mockNotifier) SendNotification( + _ context.Context, + title, message, priority string, +) { + m.mu.Lock() + defer m.mu.Unlock() + + m.notifications = append(m.notifications, notification{ + Title: title, + Message: message, + Priority: priority, + }) +} + +func (m *mockNotifier) getNotifications() []notification { + m.mu.Lock() + defer m.mu.Unlock() + + result := make([]notification, len(m.notifications)) + copy(result, m.notifications) + + return result +} + +// --- Helper to build a Watcher for testing --- + +type testDeps struct { + resolver *mockResolver + portChecker *mockPortChecker + tlsChecker *mockTLSChecker + notifier *mockNotifier + state *state.State + config *config.Config +} + +func newTestWatcher( + t *testing.T, + cfg *config.Config, +) (*watcher.Watcher, *testDeps) { + t.Helper() + + deps := &testDeps{ + resolver: &mockResolver{ + nsRecords: make(map[string][]string), + allRecords: make(map[string]map[string]map[string][]string), + ipAddresses: make(map[string][]string), + }, + portChecker: &mockPortChecker{ + results: make(map[string]bool), + }, + tlsChecker: &mockTLSChecker{ + certs: make(map[string]*tlscheck.CertificateInfo), + }, + notifier: &mockNotifier{}, + config: cfg, + } + + deps.state = state.NewForTest() + + w := watcher.NewForTest( + deps.config, + deps.state, + deps.resolver, + deps.portChecker, + deps.tlsChecker, + deps.notifier, + ) + + return w, deps +} + +func defaultTestConfig(t *testing.T) *config.Config { + t.Helper() + + return &config.Config{ + DNSInterval: time.Hour, + TLSInterval: 12 * time.Hour, + TLSExpiryWarning: 7, + DataDir: t.TempDir(), + } +} + +func TestFirstRunBaseline(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.Hostnames = []string{"www.example.com"} + + w, deps := newTestWatcher(t, cfg) + setupBaselineMocks(deps) + + w.RunOnce(t.Context()) + + assertNoNotifications(t, deps) + assertStatePopulated(t, deps) +} + +func setupBaselineMocks(deps *testDeps) { + deps.resolver.nsRecords["example.com"] = []string{ + "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"}}, + } + deps.resolver.ipAddresses["www.example.com"] = []string{ + "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:www.example.com"] = &tlscheck.CertificateInfo{ + CommonName: "www.example.com", + Issuer: "DigiCert", + NotAfter: time.Now().Add(90 * 24 * time.Hour), + SubjectAlternativeNames: []string{ + "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( + t *testing.T, + deps *testDeps, +) { + t.Helper() + + notifications := deps.notifier.getNotifications() + if len(notifications) != 0 { + t.Errorf( + "expected 0 notifications on first run, got %d", + len(notifications), + ) + } +} + +func assertStatePopulated( + t *testing.T, + deps *testDeps, +) { + t.Helper() + + snap := deps.state.GetSnapshot() + + if len(snap.Domains) != 1 { + t.Errorf( + "expected 1 domain in state, got %d", + len(snap.Domains), + ) + } + + // 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 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() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + + w, deps := newTestWatcher(t, cfg) + + deps.resolver.nsRecords["example.com"] = []string{ + "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) + + deps.resolver.mu.Lock() + deps.resolver.nsRecords["example.com"] = []string{ + "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) + + notifications := deps.notifier.getNotifications() + if len(notifications) == 0 { + t.Error("expected notification for NS change") + } + + found := false + + for _, n := range notifications { + if n.Priority == "warning" { + found = true + } + } + + if !found { + t.Error("expected warning-priority NS change notification") + } +} + +func TestRecordChangeDetection(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{"www.example.com"} + + w, deps := newTestWatcher(t, cfg) + + deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"93.184.216.34"}}, + } + deps.resolver.ipAddresses["www.example.com"] = []string{ + "93.184.216.34", + } + deps.portChecker.results["93.184.216.34:80"] = false + deps.portChecker.results["93.184.216.34:443"] = false + + ctx := t.Context() + w.RunOnce(ctx) + + deps.resolver.mu.Lock() + deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"93.184.216.35"}}, + } + deps.resolver.ipAddresses["www.example.com"] = []string{ + "93.184.216.35", + } + deps.resolver.mu.Unlock() + + deps.portChecker.mu.Lock() + deps.portChecker.results["93.184.216.35:80"] = false + deps.portChecker.results["93.184.216.35:443"] = false + deps.portChecker.mu.Unlock() + + w.RunOnce(ctx) + + notifications := deps.notifier.getNotifications() + if len(notifications) == 0 { + t.Error("expected notification for record change") + } +} + +func TestPortStateChange(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{"www.example.com"} + + 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(90 * 24 * time.Hour), + SubjectAlternativeNames: []string{ + "www.example.com", + }, + } + + ctx := t.Context() + w.RunOnce(ctx) + + deps.portChecker.mu.Lock() + deps.portChecker.results["1.2.3.4:443"] = false + deps.portChecker.mu.Unlock() + + w.RunOnce(ctx) + + notifications := deps.notifier.getNotifications() + if len(notifications) == 0 { + t.Error("expected notification for port state change") + } +} + +func TestTLSExpiryWarning(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{"www.example.com"} + + 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 + w.RunOnce(ctx) + + // Second run should warn about expiry + w.RunOnce(ctx) + + notifications := deps.notifier.getNotifications() + + found := false + + for _, n := range notifications { + if n.Priority == "warning" { + found = true + } + } + + if !found { + t.Errorf( + "expected expiry warning, got: %v", + notifications, + ) + } +} + +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() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{"example.com"} + cfg.DNSInterval = 100 * time.Millisecond + cfg.TLSInterval = 100 * time.Millisecond + + 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": {"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()) + + done := make(chan struct{}) + + go func() { + w.Run(ctx) + close(done) + }() + + time.Sleep(250 * time.Millisecond) + cancel() + + select { + case <-done: + // Shut down cleanly + case <-time.After(5 * time.Second): + t.Error("watcher did not shut down within timeout") + } +} + +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() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{"www.example.com"} + + w, deps := newTestWatcher(t, cfg) + + deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"1.2.3.4"}}, + "ns2.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"] = false + deps.portChecker.results["1.2.3.4:443"] = false + + ctx := t.Context() + + w.RunOnce(ctx) + + deps.resolver.mu.Lock() + deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ + "ns1.example.com.": {"A": {"1.2.3.4"}}, + } + deps.resolver.mu.Unlock() + + w.RunOnce(ctx) + + notifications := deps.notifier.getNotifications() + if len(notifications) == 0 { + t.Error("expected notification for NS disappearance") + } +} diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..ffcb29a --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,82 @@ +#!/bin/sh +# script/bootstrap: install all dependencies needed to build and develop +# this repo. Idempotent: every install is guarded by a check so already +# installed tools are skipped. Base tooling comes from nix, apt, brew, +# or apk (detected in that order); assumes nothing is present. +# golangci-lint and goimports are installed via `go install` at the same +# pinned commits the Dockerfile uses (never "latest"). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Pinned versions, 2026-07-07 (same pins as the Dockerfile) +# golangci-lint v2.10.1 +GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee" +# goimports v0.42.0 +GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" + +PKGMGR="" +SUDO="" +APT_UPDATED="" + +detect_pkgmgr() { + [ -n "$PKGMGR" ] && return 0 + if command -v nix-env >/dev/null 2>&1; then + PKGMGR="nix" + elif command -v apt-get >/dev/null 2>&1; then + PKGMGR="apt" + elif command -v brew >/dev/null 2>&1; then + PKGMGR="brew" + elif command -v apk >/dev/null 2>&1; then + PKGMGR="apk" + else + echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2 + exit 1 + fi + if [ "$PKGMGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + if [ "$(id -u)" != "0" ]; then + SUDO="sudo" + fi + fi +} + +# pkg_install +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) + if [ -z "$APT_UPDATED" ]; then + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update + APT_UPDATED=1 + fi + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" + ;; + brew) brew install "$3" ;; + apk) apk add --no-cache "$4" ;; + esac +} + +missing() { + ! command -v "$1" >/dev/null 2>&1 +} + +main() { + cd "$ROOT" + + if missing git; then pkg_install git git git git; fi + if missing make; then pkg_install gnumake make make make; fi + if missing go; then pkg_install go golang go go; fi + + # Lint/format tools, pinned via go install (installs into + # "$(go env GOPATH)/bin"; ensure that is on your PATH). + if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi + if missing goimports; then go install "$GOIMPORTS_REF"; fi + + go mod download + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..3e1778c --- /dev/null +++ b/script/check @@ -0,0 +1,14 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. Must not modify any files. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/test" + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..966f51d --- /dev/null +++ b/script/cibuild @@ -0,0 +1,13 @@ +#!/bin/sh +# script/cibuild: run the CI build. The Dockerfile runs make check, so +# a successful build implies all checks pass. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..9b9ea86 --- /dev/null +++ b/script/docker @@ -0,0 +1,14 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# Identical in all repos; the tag comes from script/projectname. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..b3235a8 --- /dev/null +++ b/script/fmt @@ -0,0 +1,13 @@ +#!/bin/sh +# script/fmt: format all files (writes). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + gofmt -s -w . + goimports -w . +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..b7438ef --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,18 @@ +#!/bin/sh +# script/fmt-check: check formatting (read-only). Same scope as +# script/fmt, but fails instead of writing. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + files="$(gofmt -l .)" + if [ -n "$files" ]; then + echo "gofmt: files not formatted:" >&2 + echo "$files" >&2 + exit 1 + fi +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..723bae1 --- /dev/null +++ b/script/install-precommit @@ -0,0 +1,16 @@ +#!/bin/sh +# script/install-precommit: install the git pre-commit hook that runs +# script/precommit. Our own extension to scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + hook=".git/hooks/pre-commit" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook" + chmod +x "$hook" + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..8017180 --- /dev/null +++ b/script/lint @@ -0,0 +1,12 @@ +#!/bin/sh +# script/lint: run the linter. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + golangci-lint run --config .golangci.yml ./... +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..ec8a004 --- /dev/null +++ b/script/precommit @@ -0,0 +1,21 @@ +#!/bin/sh +# script/precommit: run by the git pre-commit hook; fails the commit if +# checks fail. Our own extension to scripts-to-rule-them-all. Go extra: +# go mod tidy must be a no-op before the checks run. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + if ! git diff --exit-code -- go.mod go.sum; then + echo "precommit: go mod tidy changed go.mod/go.sum;" \ + "stage the changes and retry" >&2 + exit 1 + fi + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..7a8bc4b --- /dev/null +++ b/script/projectname @@ -0,0 +1,12 @@ +#!/bin/sh +# script/projectname: output the name of this project. Our own +# extension to scripts-to-rule-them-all. Other scripts that need the +# name (e.g. script/docker) call this, so they can stay identical +# across all repos. +set -eu + +main() { + echo "dnswatcher" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..4cc5b6b --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: set up the repo for development after a fresh clone: +# installs dependencies and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..50b1730 --- /dev/null +++ b/script/test @@ -0,0 +1,12 @@ +#!/bin/sh +# script/test: run the test suite. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + go test -v -race -timeout 30s -cover ./... +} + +main "$@" 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