Initial scaffold with per-nameserver DNS monitoring model
Full project structure following upaas conventions: uber/fx DI, go-chi routing, slog logging, Viper config. State persisted as JSON file with per-nameserver record tracking for inconsistency detection. Stub implementations for resolver, portcheck, tlscheck, and watcher.
This commit is contained in:
187
internal/config/config.go
Normal file
187
internal/config/config.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package config provides application configuration via Viper.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// Default configuration values.
|
||||
const (
|
||||
defaultPort = 8080
|
||||
defaultDNSInterval = 1 * time.Hour
|
||||
defaultTLSInterval = 12 * time.Hour
|
||||
defaultTLSExpiryWarning = 7
|
||||
)
|
||||
|
||||
// Params contains dependencies for Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// New creates a new Config instance from environment and config files.
|
||||
func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
||||
log := params.Logger.Get()
|
||||
|
||||
name := params.Globals.Appname
|
||||
if name == "" {
|
||||
name = "dnswatcher"
|
||||
}
|
||||
|
||||
setupViper(name)
|
||||
|
||||
cfg, err := buildConfig(log, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configureDebugLogging(cfg, params)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func setupViper(name string) {
|
||||
viper.SetConfigName(name)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath("/etc/" + name)
|
||||
viper.AddConfigPath("$HOME/.config/" + name)
|
||||
viper.AddConfigPath(".")
|
||||
|
||||
viper.SetEnvPrefix("DNSWATCHER")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// PORT is not prefixed for compatibility
|
||||
_ = viper.BindEnv("PORT", "PORT")
|
||||
|
||||
viper.SetDefault("PORT", defaultPort)
|
||||
viper.SetDefault("DEBUG", false)
|
||||
viper.SetDefault("DATA_DIR", "./data")
|
||||
viper.SetDefault("DOMAINS", "")
|
||||
viper.SetDefault("HOSTNAMES", "")
|
||||
viper.SetDefault("SLACK_WEBHOOK", "")
|
||||
viper.SetDefault("MATTERMOST_WEBHOOK", "")
|
||||
viper.SetDefault("NTFY_TOPIC", "")
|
||||
viper.SetDefault("DNS_INTERVAL", defaultDNSInterval.String())
|
||||
viper.SetDefault("TLS_INTERVAL", defaultTLSInterval.String())
|
||||
viper.SetDefault("TLS_EXPIRY_WARNING", defaultTLSExpiryWarning)
|
||||
viper.SetDefault("SENTRY_DSN", "")
|
||||
viper.SetDefault("MAINTENANCE_MODE", false)
|
||||
viper.SetDefault("METRICS_USERNAME", "")
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
}
|
||||
|
||||
func buildConfig(
|
||||
log *slog.Logger,
|
||||
params *Params,
|
||||
) (*Config, error) {
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
log.Error("config file malformed", "error", err)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"config file malformed: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dnsInterval, err := time.ParseDuration(
|
||||
viper.GetString("DNS_INTERVAL"),
|
||||
)
|
||||
if err != nil {
|
||||
dnsInterval = defaultDNSInterval
|
||||
}
|
||||
|
||||
tlsInterval, err := time.ParseDuration(
|
||||
viper.GetString("TLS_INTERVAL"),
|
||||
)
|
||||
if err != nil {
|
||||
tlsInterval = defaultTLSInterval
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseCSV(input string) []string {
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(input, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func configureDebugLogging(cfg *Config, params Params) {
|
||||
if cfg.Debug {
|
||||
params.Logger.EnableDebugLogging()
|
||||
cfg.log = params.Logger.Get()
|
||||
}
|
||||
}
|
||||
|
||||
// StatePath returns the full path to the state JSON file.
|
||||
func (c *Config) StatePath() string {
|
||||
return c.DataDir + "/state.json"
|
||||
}
|
||||
62
internal/globals/globals.go
Normal file
62
internal/globals/globals.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package globals provides build-time variables and application-wide constants.
|
||||
package globals
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Package-level variables set from main via ldflags.
|
||||
// These are intentionally global to allow build-time injection using -ldflags.
|
||||
//
|
||||
//nolint:gochecknoglobals // Required for ldflags injection at build time
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
appname string
|
||||
version string
|
||||
buildarch string
|
||||
)
|
||||
|
||||
// Globals holds build-time variables for dependency injection.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
}
|
||||
|
||||
// New creates a new Globals instance from package-level variables.
|
||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
|
||||
return &Globals{
|
||||
Appname: appname,
|
||||
Version: version,
|
||||
Buildarch: buildarch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetAppname sets the application name.
|
||||
func SetAppname(name string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
appname = name
|
||||
}
|
||||
|
||||
// SetVersion sets the version.
|
||||
func SetVersion(ver string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
version = ver
|
||||
}
|
||||
|
||||
// SetBuildarch sets the build architecture.
|
||||
func SetBuildarch(arch string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
buildarch = arch
|
||||
}
|
||||
58
internal/handlers/handlers.go
Normal file
58
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package handlers provides HTTP request handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Handlers.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
}
|
||||
|
||||
// Handlers provides HTTP request handlers.
|
||||
type Handlers struct {
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
globals *globals.Globals
|
||||
hc *healthcheck.Healthcheck
|
||||
}
|
||||
|
||||
// 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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handlers) respondJSON(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
data any,
|
||||
status int,
|
||||
) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
|
||||
if data != nil {
|
||||
err := json.NewEncoder(writer).Encode(data)
|
||||
if err != nil {
|
||||
h.log.Error("json encode error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
internal/handlers/healthcheck.go
Normal file
17
internal/handlers/healthcheck.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleHealthCheck returns the health check handler.
|
||||
func (h *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
h.respondJSON(
|
||||
writer, request, h.hc.Check(), http.StatusOK,
|
||||
)
|
||||
}
|
||||
}
|
||||
23
internal/handlers/status.go
Normal file
23
internal/handlers/status.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// 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,
|
||||
) {
|
||||
h.respondJSON(
|
||||
writer, request,
|
||||
&response{Status: "ok"},
|
||||
http.StatusOK,
|
||||
)
|
||||
}
|
||||
}
|
||||
79
internal/healthcheck/healthcheck.go
Normal file
79
internal/healthcheck/healthcheck.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package healthcheck provides application health status.
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Healthcheck.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Healthcheck provides health status information.
|
||||
type Healthcheck struct {
|
||||
StartupTime time.Time
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
}
|
||||
|
||||
// Response is the health check response structure.
|
||||
type Response struct {
|
||||
Status string `json:"status"`
|
||||
Now string `json:"now"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
UptimeHuman string `json:"uptimeHuman"`
|
||||
Version string `json:"version"`
|
||||
Appname string `json:"appname"`
|
||||
Maintenance bool `json:"maintenanceMode"`
|
||||
}
|
||||
|
||||
// New creates a new Healthcheck instance.
|
||||
func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Healthcheck, error) {
|
||||
healthcheck := &Healthcheck{
|
||||
log: params.Logger.Get(),
|
||||
params: ¶ms,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
healthcheck.StartupTime = time.Now()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return healthcheck, nil
|
||||
}
|
||||
|
||||
// Check returns the current health status.
|
||||
func (h *Healthcheck) Check() *Response {
|
||||
return &Response{
|
||||
Status: "ok",
|
||||
Now: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
UptimeSeconds: int64(h.uptime().Seconds()),
|
||||
UptimeHuman: h.uptime().String(),
|
||||
Appname: h.params.Globals.Appname,
|
||||
Version: h.params.Globals.Version,
|
||||
Maintenance: h.params.Config.MaintenanceMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Healthcheck) uptime() time.Duration {
|
||||
return time.Since(h.StartupTime)
|
||||
}
|
||||
83
internal/logger/logger.go
Normal file
83
internal/logger/logger.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package logger provides structured logging with slog.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Logger.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
// Logger wraps slog.Logger with level control.
|
||||
type Logger struct {
|
||||
log *slog.Logger
|
||||
level *slog.LevelVar
|
||||
params Params
|
||||
}
|
||||
|
||||
// New creates a new Logger with TTY detection for output format.
|
||||
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
|
||||
loggerInstance := &Logger{
|
||||
level: new(slog.LevelVar),
|
||||
params: params,
|
||||
}
|
||||
loggerInstance.level.Set(slog.LevelInfo)
|
||||
|
||||
isTTY := detectTTY()
|
||||
|
||||
var handler slog.Handler
|
||||
|
||||
if isTTY {
|
||||
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: loggerInstance.level,
|
||||
AddSource: true,
|
||||
})
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: loggerInstance.level,
|
||||
AddSource: true,
|
||||
})
|
||||
}
|
||||
|
||||
loggerInstance.log = slog.New(handler)
|
||||
|
||||
return loggerInstance, nil
|
||||
}
|
||||
|
||||
func detectTTY() bool {
|
||||
fileInfo, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// Get returns the underlying slog.Logger.
|
||||
func (l *Logger) Get() *slog.Logger {
|
||||
return l.log
|
||||
}
|
||||
|
||||
// EnableDebugLogging sets the log level to debug.
|
||||
func (l *Logger) EnableDebugLogging() {
|
||||
l.level.Set(slog.LevelDebug)
|
||||
l.log.Debug("debug logging enabled", "debug", true)
|
||||
}
|
||||
|
||||
// Identify logs application startup information.
|
||||
func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
"buildarch", l.params.Globals.Buildarch,
|
||||
)
|
||||
}
|
||||
205
internal/middleware/middleware.go
Normal file
205
internal/middleware/middleware.go
Normal file
@@ -0,0 +1,205 @@
|
||||
// Package middleware provides HTTP middleware.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// corsMaxAge is the maximum age for CORS preflight responses.
|
||||
const corsMaxAge = 300
|
||||
|
||||
// Params contains dependencies for Middleware.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Middleware provides HTTP middleware.
|
||||
type Middleware struct {
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
}
|
||||
|
||||
// New creates a new Middleware instance.
|
||||
func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Middleware, error) {
|
||||
return &Middleware{
|
||||
log: params.Logger.Get(),
|
||||
params: ¶ms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loggingResponseWriter wraps http.ResponseWriter to capture status.
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func newLoggingResponseWriter(
|
||||
writer http.ResponseWriter,
|
||||
) *loggingResponseWriter {
|
||||
return &loggingResponseWriter{writer, http.StatusOK}
|
||||
}
|
||||
|
||||
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lrw.statusCode = code
|
||||
lrw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Logging returns a request logging middleware.
|
||||
func (m *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
start := time.Now()
|
||||
lrw := newLoggingResponseWriter(writer)
|
||||
ctx := request.Context()
|
||||
|
||||
defer func() {
|
||||
latency := time.Since(start)
|
||||
reqID := middleware.GetReqID(ctx)
|
||||
m.log.InfoContext(ctx, "request",
|
||||
"request_start", start,
|
||||
"method", request.Method,
|
||||
"url", request.URL.String(),
|
||||
"useragent", request.UserAgent(),
|
||||
"request_id", reqID,
|
||||
"referer", request.Referer(),
|
||||
"proto", request.Proto,
|
||||
"remoteIP", realIP(request),
|
||||
"status", lrw.statusCode,
|
||||
"latency_ms", latency.Milliseconds(),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(lrw, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ipFromHostPort(hostPort string) string {
|
||||
host, _, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return hostPort
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
// trustedProxyNets are RFC1918 and loopback CIDRs.
|
||||
//
|
||||
//nolint:gochecknoglobals // package-level constant nets parsed once
|
||||
var trustedProxyNets = func() []*net.IPNet {
|
||||
cidrs := []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.0/8",
|
||||
"::1/128",
|
||||
"fc00::/7",
|
||||
}
|
||||
|
||||
nets := make([]*net.IPNet, 0, len(cidrs))
|
||||
|
||||
for _, cidr := range cidrs {
|
||||
_, n, _ := net.ParseCIDR(cidr)
|
||||
nets = append(nets, n)
|
||||
}
|
||||
|
||||
return nets
|
||||
}()
|
||||
|
||||
func isTrustedProxy(ip net.IP) bool {
|
||||
for _, n := range trustedProxyNets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// realIP extracts the client's real IP address from the request.
|
||||
// Proxy headers are only trusted from RFC1918/loopback addresses.
|
||||
func realIP(r *http.Request) string {
|
||||
addr := ipFromHostPort(r.RemoteAddr)
|
||||
remoteIP := net.ParseIP(addr)
|
||||
|
||||
if remoteIP == nil || !isTrustedProxy(remoteIP) {
|
||||
return addr
|
||||
}
|
||||
|
||||
if ip := strings.TrimSpace(
|
||||
r.Header.Get("X-Real-IP"),
|
||||
); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if parts := strings.SplitN(
|
||||
xff, ",", 2, //nolint:mnd
|
||||
); len(parts) > 0 {
|
||||
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
// CORS returns CORS middleware.
|
||||
func (m *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: corsMaxAge,
|
||||
})
|
||||
}
|
||||
|
||||
// MetricsAuth returns basic auth middleware for /metrics.
|
||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
if m.params.Config.MetricsUsername == "" {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
return basicauth.New(
|
||||
"metrics",
|
||||
map[string][]string{
|
||||
m.params.Config.MetricsUsername: {
|
||||
m.params.Config.MetricsPassword,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
261
internal/notify/notify.go
Normal file
261
internal/notify/notify.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Package notify provides notification delivery to Slack, Mattermost, and ntfy.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// HTTP client timeout.
|
||||
const httpClientTimeout = 10 * time.Second
|
||||
|
||||
// HTTP status code thresholds.
|
||||
const httpStatusClientError = 400
|
||||
|
||||
// Sentinel errors for notification failures.
|
||||
var (
|
||||
// ErrNtfyFailed indicates the ntfy request failed.
|
||||
ErrNtfyFailed = errors.New("ntfy notification failed")
|
||||
// ErrSlackFailed indicates the Slack request failed.
|
||||
ErrSlackFailed = errors.New("slack notification failed")
|
||||
// ErrMattermostFailed indicates the Mattermost request failed.
|
||||
ErrMattermostFailed = errors.New(
|
||||
"mattermost notification failed",
|
||||
)
|
||||
)
|
||||
|
||||
// Params contains dependencies for Service.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Service provides notification functionality.
|
||||
type Service struct {
|
||||
log *slog.Logger
|
||||
client *http.Client
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new notify Service.
|
||||
func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
log: params.Logger.Get(),
|
||||
client: &http.Client{
|
||||
Timeout: httpClientTimeout,
|
||||
},
|
||||
config: params.Config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendNotification sends a notification to all configured endpoints.
|
||||
func (svc *Service) SendNotification(
|
||||
ctx context.Context,
|
||||
title, message, priority string,
|
||||
) {
|
||||
if svc.config.NtfyTopic != "" {
|
||||
go func() {
|
||||
notifyCtx := context.WithoutCancel(ctx)
|
||||
|
||||
err := svc.sendNtfy(
|
||||
notifyCtx,
|
||||
svc.config.NtfyTopic,
|
||||
title, message, priority,
|
||||
)
|
||||
if err != nil {
|
||||
svc.log.Error(
|
||||
"failed to send ntfy notification",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if svc.config.SlackWebhook != "" {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if svc.config.MattermostWebhook != "" {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *Service) sendNtfy(
|
||||
ctx context.Context,
|
||||
topic, title, message, priority string,
|
||||
) error {
|
||||
svc.log.Debug(
|
||||
"sending ntfy notification",
|
||||
"topic", topic,
|
||||
"title", title,
|
||||
)
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
topic,
|
||||
bytes.NewBufferString(message),
|
||||
)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending ntfy request: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= httpStatusClientError {
|
||||
return fmt.Errorf(
|
||||
"%w: status %d", ErrNtfyFailed, resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ntfyPriority(priority string) string {
|
||||
switch priority {
|
||||
case "error":
|
||||
return "urgent"
|
||||
case "warning":
|
||||
return "high"
|
||||
case "success":
|
||||
return "default"
|
||||
case "info":
|
||||
return "low"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
// SlackPayload represents a Slack/Mattermost webhook payload.
|
||||
type SlackPayload struct {
|
||||
Text string `json:"text"`
|
||||
Attachments []SlackAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// SlackAttachment represents a Slack/Mattermost attachment.
|
||||
type SlackAttachment struct {
|
||||
Color string `json:"color"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (svc *Service) sendSlack(
|
||||
ctx context.Context,
|
||||
webhookURL, title, message, priority string,
|
||||
) error {
|
||||
svc.log.Debug(
|
||||
"sending webhook notification",
|
||||
"url", webhookURL,
|
||||
"title", title,
|
||||
)
|
||||
|
||||
payload := SlackPayload{
|
||||
Attachments: []SlackAttachment{
|
||||
{
|
||||
Color: slackColor(priority),
|
||||
Title: title,
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling webhook payload: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending webhook request: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= httpStatusClientError {
|
||||
return fmt.Errorf(
|
||||
"%w: status %d",
|
||||
ErrSlackFailed, resp.StatusCode,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func slackColor(priority string) string {
|
||||
switch priority {
|
||||
case "error":
|
||||
return "#dc3545"
|
||||
case "warning":
|
||||
return "#ffc107"
|
||||
case "success":
|
||||
return "#28a745"
|
||||
case "info":
|
||||
return "#17a2b8"
|
||||
default:
|
||||
return "#6c757d"
|
||||
}
|
||||
}
|
||||
48
internal/portcheck/portcheck.go
Normal file
48
internal/portcheck/portcheck.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package portcheck provides TCP port connectivity checking.
|
||||
package portcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// ErrNotImplemented indicates the port checker is not yet implemented.
|
||||
var ErrNotImplemented = errors.New(
|
||||
"port checker not yet implemented",
|
||||
)
|
||||
|
||||
// Params contains dependencies for Checker.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Checker performs TCP port connectivity checks.
|
||||
type Checker struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new port Checker instance.
|
||||
func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Checker, error) {
|
||||
return &Checker{
|
||||
log: params.Logger.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckPort tests TCP connectivity to the given address and port.
|
||||
func (c *Checker) CheckPort(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
_ int,
|
||||
) (bool, error) {
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
64
internal/resolver/resolver.go
Normal file
64
internal/resolver/resolver.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Package resolver provides iterative DNS resolution from root nameservers.
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// ErrNotImplemented indicates the resolver is not yet implemented.
|
||||
var ErrNotImplemented = errors.New("resolver not yet implemented")
|
||||
|
||||
// Params contains dependencies for Resolver.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Resolver performs iterative DNS resolution from root servers.
|
||||
type Resolver struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Resolver instance.
|
||||
func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Resolver, error) {
|
||||
return &Resolver{
|
||||
log: params.Logger.Get(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LookupNS performs iterative resolution to find authoritative
|
||||
// nameservers for the given domain.
|
||||
func (r *Resolver) LookupNS(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
) ([]string, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// LookupAllRecords performs iterative resolution to find all DNS
|
||||
// records for the given hostname.
|
||||
func (r *Resolver) LookupAllRecords(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
) (map[string][]string, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
|
||||
// addresses, following CNAME chains.
|
||||
func (r *Resolver) ResolveIPAddresses(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
) ([]string, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
43
internal/server/routes.go
Normal file
43
internal/server/routes.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// requestTimeout is the maximum duration for handling a request.
|
||||
const requestTimeout = 60 * time.Second
|
||||
|
||||
// SetupRoutes configures all HTTP routes.
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.router = chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
s.router.Use(chimw.Recoverer)
|
||||
s.router.Use(chimw.RequestID)
|
||||
s.router.Use(s.mw.Logging())
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(chimw.Timeout(requestTimeout))
|
||||
|
||||
// Health check
|
||||
s.router.Get("/health", s.handlers.HandleHealthCheck())
|
||||
|
||||
// API v1 routes
|
||||
s.router.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/status", s.handlers.HandleStatus())
|
||||
})
|
||||
|
||||
// Metrics endpoint (optional, with basic auth)
|
||||
if s.params.Config.MetricsUsername != "" {
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.MetricsAuth())
|
||||
r.Get(
|
||||
"/metrics",
|
||||
promhttp.Handler().ServeHTTP,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
129
internal/server/server.go
Normal file
129
internal/server/server.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Package server provides the HTTP server.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
"sneak.berlin/go/dnswatcher/internal/middleware"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Server.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Config *config.Config
|
||||
Middleware *middleware.Middleware
|
||||
Handlers *handlers.Handlers
|
||||
}
|
||||
|
||||
// shutdownTimeout is how long to wait for graceful shutdown.
|
||||
const shutdownTimeout = 30 * time.Second
|
||||
|
||||
// readHeaderTimeout is the max duration for reading request headers.
|
||||
const readHeaderTimeout = 10 * time.Second
|
||||
|
||||
// Server is the HTTP server.
|
||||
type Server struct {
|
||||
startupTime time.Time
|
||||
port int
|
||||
log *slog.Logger
|
||||
router *chi.Mux
|
||||
httpServer *http.Server
|
||||
params Params
|
||||
mw *middleware.Middleware
|
||||
handlers *handlers.Handlers
|
||||
}
|
||||
|
||||
// New creates a new Server instance.
|
||||
func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Server, error) {
|
||||
srv := &Server{
|
||||
port: params.Config.Port,
|
||||
log: params.Logger.Get(),
|
||||
params: params,
|
||||
mw: params.Middleware,
|
||||
handlers: params.Handlers,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
srv.startupTime = time.Now()
|
||||
go srv.Run()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return srv.Shutdown(ctx)
|
||||
},
|
||||
})
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server.
|
||||
func (s *Server) Run() {
|
||||
s.SetupRoutes()
|
||||
|
||||
listenAddr := fmt.Sprintf(":%d", s.port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: s,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
}
|
||||
|
||||
s.log.Info("http server starting", "addr", listenAddr)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("http server error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s.httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.log.Info("shutting down http server")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(
|
||||
ctx, shutdownTimeout,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err := s.httpServer.Shutdown(shutdownCtx)
|
||||
if err != nil {
|
||||
s.log.Error("http server shutdown error", "error", err)
|
||||
|
||||
return fmt.Errorf("shutting down http server: %w", err)
|
||||
}
|
||||
|
||||
s.log.Info("http server stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (s *Server) ServeHTTP(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
s.router.ServeHTTP(writer, request)
|
||||
}
|
||||
287
internal/state/state.go
Normal file
287
internal/state/state.go
Normal file
@@ -0,0 +1,287 @@
|
||||
// Package state provides JSON file-based state persistence.
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// filePermissions for the state file.
|
||||
const filePermissions = 0o600
|
||||
|
||||
// dirPermissions for the data directory.
|
||||
const dirPermissions = 0o700
|
||||
|
||||
// stateVersion is the current state file format version.
|
||||
const stateVersion = 1
|
||||
|
||||
// Params contains dependencies for State.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// DomainState holds the monitoring state for an apex domain.
|
||||
type DomainState struct {
|
||||
Nameservers []string `json:"nameservers"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// NameserverRecordState holds one NS's response for a hostname.
|
||||
type NameserverRecordState struct {
|
||||
Records map[string][]string `json:"records"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// HostnameState holds per-nameserver monitoring state for a hostname.
|
||||
type HostnameState struct {
|
||||
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// PortState holds the monitoring state for a port.
|
||||
type PortState struct {
|
||||
Open bool `json:"open"`
|
||||
Hostname string `json:"hostname"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// CertificateState holds TLS certificate monitoring state.
|
||||
type CertificateState struct {
|
||||
CommonName string `json:"commonName"`
|
||||
Issuer string `json:"issuer"`
|
||||
NotAfter time.Time `json:"notAfter"`
|
||||
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
// Snapshot is the complete monitoring state persisted to disk.
|
||||
type Snapshot struct {
|
||||
Version int `json:"version"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
Domains map[string]*DomainState `json:"domains"`
|
||||
Hostnames map[string]*HostnameState `json:"hostnames"`
|
||||
Ports map[string]*PortState `json:"ports"`
|
||||
Certificates map[string]*CertificateState `json:"certificates"`
|
||||
}
|
||||
|
||||
// State manages the monitoring state with file persistence.
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
snapshot *Snapshot
|
||||
log *slog.Logger
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// New creates a new State instance and loads existing state from disk.
|
||||
func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) (*State, error) {
|
||||
state := &State{
|
||||
log: params.Logger.Get(),
|
||||
config: params.Config,
|
||||
snapshot: &Snapshot{
|
||||
Version: stateVersion,
|
||||
Domains: make(map[string]*DomainState),
|
||||
Hostnames: make(map[string]*HostnameState),
|
||||
Ports: make(map[string]*PortState),
|
||||
Certificates: make(map[string]*CertificateState),
|
||||
},
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
return state.Load()
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
return state.Save()
|
||||
},
|
||||
})
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// Load reads the state from disk.
|
||||
func (s *State) Load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.config.StatePath()
|
||||
|
||||
//nolint:gosec // path is from trusted config
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.log.Info(
|
||||
"no existing state file, starting fresh",
|
||||
"path", path,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("reading state file: %w", err)
|
||||
}
|
||||
|
||||
var snapshot Snapshot
|
||||
|
||||
err = json.Unmarshal(data, &snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing state file: %w", err)
|
||||
}
|
||||
|
||||
s.snapshot = &snapshot
|
||||
s.log.Info("loaded state from disk", "path", path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save writes the current state to disk atomically.
|
||||
func (s *State) Save() error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
s.snapshot.LastUpdated = time.Now().UTC()
|
||||
|
||||
data, err := json.MarshalIndent(s.snapshot, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling state: %w", err)
|
||||
}
|
||||
|
||||
path := s.config.StatePath()
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(path), dirPermissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
tmpPath := path + ".tmp"
|
||||
|
||||
err = os.WriteFile(tmpPath, data, filePermissions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing temp state file: %w", err)
|
||||
}
|
||||
|
||||
err = os.Rename(tmpPath, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renaming state file: %w", err)
|
||||
}
|
||||
|
||||
s.log.Debug("state saved to disk", "path", path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSnapshot returns a copy of the current snapshot.
|
||||
func (s *State) GetSnapshot() Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return *s.snapshot
|
||||
}
|
||||
|
||||
// SetDomainState updates the state for a domain.
|
||||
func (s *State) SetDomainState(
|
||||
domain string,
|
||||
ds *DomainState,
|
||||
) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.snapshot.Domains[domain] = ds
|
||||
}
|
||||
|
||||
// GetDomainState returns the state for a domain.
|
||||
func (s *State) GetDomainState(
|
||||
domain string,
|
||||
) (*DomainState, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
ds, ok := s.snapshot.Domains[domain]
|
||||
|
||||
return ds, ok
|
||||
}
|
||||
|
||||
// SetHostnameState updates the state for a hostname.
|
||||
func (s *State) SetHostnameState(
|
||||
hostname string,
|
||||
hs *HostnameState,
|
||||
) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.snapshot.Hostnames[hostname] = hs
|
||||
}
|
||||
|
||||
// GetHostnameState returns the state for a hostname.
|
||||
func (s *State) GetHostnameState(
|
||||
hostname string,
|
||||
) (*HostnameState, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
hs, ok := s.snapshot.Hostnames[hostname]
|
||||
|
||||
return hs, ok
|
||||
}
|
||||
|
||||
// SetPortState updates the state for a port.
|
||||
func (s *State) SetPortState(key string, ps *PortState) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.snapshot.Ports[key] = ps
|
||||
}
|
||||
|
||||
// GetPortState returns the state for a port.
|
||||
func (s *State) GetPortState(key string) (*PortState, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
ps, ok := s.snapshot.Ports[key]
|
||||
|
||||
return ps, ok
|
||||
}
|
||||
|
||||
// SetCertificateState updates the state for a certificate.
|
||||
func (s *State) SetCertificateState(
|
||||
key string,
|
||||
cs *CertificateState,
|
||||
) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.snapshot.Certificates[key] = cs
|
||||
}
|
||||
|
||||
// GetCertificateState returns the state for a certificate.
|
||||
func (s *State) GetCertificateState(
|
||||
key string,
|
||||
) (*CertificateState, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cs, ok := s.snapshot.Certificates[key]
|
||||
|
||||
return cs, ok
|
||||
}
|
||||
58
internal/tlscheck/tlscheck.go
Normal file
58
internal/tlscheck/tlscheck.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package tlscheck provides TLS certificate inspection.
|
||||
package tlscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// ErrNotImplemented indicates the TLS checker is not yet implemented.
|
||||
var ErrNotImplemented = errors.New(
|
||||
"tls checker not yet implemented",
|
||||
)
|
||||
|
||||
// Params contains dependencies for Checker.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// New creates a new TLS Checker instance.
|
||||
func New(
|
||||
_ fx.Lifecycle,
|
||||
params Params,
|
||||
) (*Checker, error) {
|
||||
return &Checker{
|
||||
log: params.Logger.Get(),
|
||||
}, 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
|
||||
}
|
||||
94
internal/watcher/watcher.go
Normal file
94
internal/watcher/watcher.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Package watcher provides the main monitoring orchestrator and scheduler.
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Watcher.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
State *state.State
|
||||
Resolver *resolver.Resolver
|
||||
PortCheck *portcheck.Checker
|
||||
TLSCheck *tlscheck.Checker
|
||||
Notify *notify.Service
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// New creates a new Watcher instance.
|
||||
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,
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(startCtx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(startCtx)
|
||||
watcher.cancel = cancel
|
||||
|
||||
go watcher.Run(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
if watcher.cancel != nil {
|
||||
watcher.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return watcher, nil
|
||||
}
|
||||
|
||||
// Run starts the monitoring loop.
|
||||
func (w *Watcher) Run(ctx context.Context) {
|
||||
w.log.Info(
|
||||
"watcher starting",
|
||||
"domains", len(w.config.Domains),
|
||||
"hostnames", len(w.config.Hostnames),
|
||||
"dnsInterval", w.config.DNSInterval,
|
||||
"tlsInterval", w.config.TLSInterval,
|
||||
)
|
||||
|
||||
// Stub: wait for context cancellation.
|
||||
// Implementation will add initial check + periodic scheduling.
|
||||
<-ctx.Done()
|
||||
w.log.Info("watcher stopped")
|
||||
}
|
||||
Reference in New Issue
Block a user