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:
Jeffrey Paul 2026-02-19 21:05:39 +01:00
commit 144a2df665
26 changed files with 3671 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
bin/
vendor/
data/
.env
*.exe
/dnswatcher

32
.golangci.yml Normal file
View File

@ -0,0 +1,32 @@
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

68
CLAUDE.md Normal file
View File

@ -0,0 +1,68 @@
# 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.

1225
CONVENTIONS.md Normal file

File diff suppressed because it is too large Load Diff

38
Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# Build stage
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make gcc musl-dev
# 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
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run all checks - build fails if any check fails
RUN make check
# Build the binary
RUN make build
# Runtime stage
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /src/bin/dnswatcher /app/dnswatcher
# Create data directory
RUN mkdir -p /var/lib/dnswatcher
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
EXPOSE 8080
ENTRYPOINT ["/app/dnswatcher"]

37
Makefile Normal file
View File

@ -0,0 +1,37 @@
.PHONY: all build lint fmt test check clean
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)
all: check build
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher
lint:
golangci-lint run --config .golangci.yml ./...
fmt:
gofmt -s -w .
goimports -w .
test:
go test -v -race -cover ./...
# 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!"
clean:
rm -rf bin/

385
README.md Normal file
View File

@ -0,0 +1,385 @@
# dnswatcher
dnswatcher is a production DNS and infrastructure monitoring daemon written in
Go. It 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.
It performs all DNS resolution itself via iterative (non-recursive) queries,
tracing from root nameservers to authoritative servers directly—never relying
on upstream recursive resolvers.
State is persisted to a local JSON file so that monitoring survives restarts
without requiring an external database.
---
## Features
### DNS Domain Monitoring (Apex Domains)
- Accepts a list of DNS domain names (apex domains, identified via the
[Public Suffix List](https://publicsuffix.org/)).
- Every **1 hour**, performs a full iterative trace from root servers to
discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently.
- Stores the NS record set as observed by the delegation chain.
- Any change triggers a notification:
- NS added to or removed from the delegation.
- NS IP address changed (glue record change).
### DNS Hostname Monitoring (Subdomains)
- Accepts a list of DNS hostnames (subdomains, distinguished from apex
domains via the Public Suffix List).
- Every **1 hour**, performs a full iterative trace to discover the
authoritative nameservers for the hostname's parent domain.
- Queries **each** authoritative nameserver independently for **all**
record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Stores results **per nameserver**. The state for a hostname is not a
merged view — it is a map from nameserver to record set.
- Any observable change in any nameserver's response triggers a
notification. This includes:
- **Record change**: A nameserver returns different records than it
did on the previous check (additions, removals, value changes).
- **NS query failure**: A nameserver that previously responded
becomes unreachable (timeout, SERVFAIL, REFUSED, network error).
This is distinct from "responded with no records."
- **NS recovery**: A previously-unreachable nameserver starts
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
- For every configured domain and hostname, constructs a deduplicated list
of all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain
resolution across all authoritative nameservers.
- Checks TCP connectivity on ports **80** and **443** for each IP address.
- Every **1 hour**, re-checks all ports.
- Any change in port availability triggers a notification:
- Port transitioned from open to closed (or vice versa).
- New IP appeared (from DNS change) and its port state was recorded.
- IP disappeared (from DNS change) — noted in the DNS change
notification; port state for that IP is removed.
### TLS Certificate Monitoring
- Every **12 hours**, for each IP address listening on port 443, connects
via TLS using the correct SNI hostname.
- Records the certificate's Subject CN, SANs, issuer, and expiry date.
- Any change triggers a notification:
- Certificate is expiring within **7 days** (warning, repeated each
check until renewed or expired).
- Certificate CN, issuer, or SANs changed (replacement detected,
reports old and new values).
- TLS connection failure to a previously-reachable IP:443 (handshake
error, timeout, connection refused after previously succeeding).
- TLS recovery: a previously-failing IP:443 now completes a
handshake again.
### Notifications
**Every observable state change produces a notification.** dnswatcher is
designed as a real-time change feed — degradations, failures, recoveries,
and routine changes are all reported equally.
Supported notification backends:
| Backend | Configuration | Payload Format |
|----------------|--------------------------|------------------------------|
| **Slack** | Incoming Webhook URL | Attachments with color |
| **Mattermost** | Incoming Webhook URL | Slack-compatible attachments |
| **ntfy** | Topic URL (e.g. `https://ntfy.sh/mytopic`) | Title + body + priority |
All configured endpoints receive every notification. Notification content
includes:
- **DNS record changes**: Which hostname, which nameserver, what record
type, old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added/removed.
- **NS query failures**: Which nameserver failed, error type (timeout,
SERVFAIL, REFUSED, network error), which hostname/domain affected.
- **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.
- **TLS expiry warnings**: Which certificate, days remaining, CN,
issuer, associated hostname and IP.
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated
hostname and IP.
- **TLS connection failures/recoveries**: Which IP:port, error details,
associated hostname.
### State Management
- All monitoring state is kept in memory and persisted to a JSON file on
disk (`DATA_DIR/state.json`).
- State is loaded on startup to resume monitoring without triggering
false-positive change notifications.
- State is written atomically (write to temp file, then rename) to prevent
corruption.
### HTTP API
dnswatcher exposes a lightweight HTTP API for operational visibility:
| Endpoint | Description |
|---------------------------------------|--------------------------------|
| `GET /health` | Health check (JSON) |
| `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) |
---
## Architecture
```
cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
internal/
config/config.go Viper-based configuration
globals/globals.go Build-time variables (version, arch)
logger/logger.go slog structured logging (TTY detection)
healthcheck/healthcheck.go Health check service
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
handlers/handlers.go HTTP request handlers
server/
server.go HTTP server lifecycle
routes.go Route definitions
state/state.go JSON file state persistence
resolver/resolver.go Iterative DNS resolution engine
portcheck/portcheck.go TCP port connectivity checker
tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy)
watcher/watcher.go Main monitoring orchestrator and scheduler
```
### Design Principles
- **No recursive resolvers**: All DNS resolution is performed iteratively,
tracing from root nameservers through the delegation chain to
authoritative servers.
- **No external database**: State is persisted as a single JSON file.
- **Dependency injection**: All components are wired via
[uber/fx](https://github.com/uber-go/fx).
- **Structured logging**: All logs use `log/slog` with JSON output in
production (TTY detection for development).
- **Graceful shutdown**: All background goroutines respect context
cancellation and the fx lifecycle.
---
## Configuration
Configuration is loaded via [Viper](https://github.com/spf13/viper) with
the following precedence (highest to lowest):
1. Environment variables (prefixed with `DNSWATCHER_`)
2. `.env` file (loaded via godotenv)
3. Config file: `/etc/dnswatcher/dnswatcher.yaml`,
`~/.config/dnswatcher/dnswatcher.yaml`, or `./dnswatcher.yaml`
4. Defaults
### Environment Variables
| Variable | Description | Default |
|---------------------------------|--------------------------------------------|-------------|
| `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_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
| `DNSWATCHER_DNS_INTERVAL` | DNS check interval | `1h` |
| `DNSWATCHER_TLS_INTERVAL` | TLS check interval | `12h` |
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
### 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_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
```
---
## DNS Resolution Strategy
dnswatcher never uses the system's configured recursive resolver. Instead,
it performs full iterative resolution:
1. **Root servers**: Starts from the IANA root nameserver list (hardcoded,
with periodic refresh).
2. **TLD delegation**: Queries root servers for the TLD NS records.
3. **Domain delegation**: Queries TLD nameservers for the domain's NS
records.
4. **Authoritative query**: Queries all discovered authoritative
nameservers directly for the requested records.
This approach ensures:
- Independence from any upstream resolver's cache or filtering.
- Ability to detect split-horizon or inconsistent responses across
authoritative servers.
- Visibility into the full delegation chain.
For hostname monitoring, the resolver follows CNAME chains (with a
depth limit to prevent loops) before collecting terminal A/AAAA records.
---
## State File Format
The state file (`DATA_DIR/state.json`) contains the complete monitoring
snapshot. Hostname records are stored **per authoritative nameserver**,
not as a merged view, to enable inconsistency detection.
```json
{
"version": 1,
"lastUpdated": "2026-02-19T12:00:00Z",
"domains": {
"example.com": {
"nameservers": ["ns1.example.com.", "ns2.example.com."],
"lastChecked": "2026-02-19T12:00:00Z"
}
},
"hostnames": {
"www.example.com": {
"recordsByNameserver": {
"ns1.example.com.": {
"records": {
"A": ["93.184.216.34"],
"AAAA": ["2606:2800:220:1:248:1893:25c8:1946"]
},
"status": "ok",
"lastChecked": "2026-02-19T12:00:00Z"
},
"ns2.example.com.": {
"records": {
"A": ["93.184.216.34"],
"AAAA": ["2606:2800:220:1:248:1893:25c8:1946"]
},
"status": "ok",
"lastChecked": "2026-02-19T12:00:00Z"
}
},
"lastChecked": "2026-02-19T12:00:00Z"
}
},
"ports": {
"93.184.216.34:80": {
"open": true,
"hostname": "www.example.com",
"lastChecked": "2026-02-19T12:00:00Z"
},
"93.184.216.34:443": {
"open": true,
"hostname": "www.example.com",
"lastChecked": "2026-02-19T12:00:00Z"
}
},
"certificates": {
"93.184.216.34:443:www.example.com": {
"commonName": "www.example.com",
"issuer": "DigiCert TLS RSA SHA256 2020 CA1",
"notAfter": "2027-01-15T23:59:59Z",
"subjectAlternativeNames": ["www.example.com"],
"status": "ok",
"lastChecked": "2026-02-19T06:00:00Z"
}
}
}
```
The `status` field for each per-nameserver entry and certificate entry
tracks reachability:
| Status | Meaning |
|-------------|-------------------------------------------------|
| `ok` | Query succeeded, records are current |
| `error` | Query failed (timeout, SERVFAIL, network error) |
| `nxdomain` | Authoritative NXDOMAIN response |
| `nodata` | Authoritative empty response (NODATA) |
---
## Building
```sh
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 clean # Remove build artifacts
```
### Build-Time Variables
Version and architecture are injected via `-ldflags`:
```sh
go build -ldflags "-X main.Version=$(git describe --tags --always) \
-X main.Buildarch=$(go env GOARCH)" ./cmd/dnswatcher
```
---
## Docker
```sh
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_NTFY_TOPIC=https://ntfy.sh/my-alerts \
dnswatcher
```
---
## Monitoring Lifecycle
1. **Startup**: Load state from disk. If no state file exists, start
with empty state (first check will establish baseline without
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).
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
notifications, stop gracefully.
---
## Project Structure
Follows the conventions defined in `CONVENTIONS.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.

56
cmd/dnswatcher/main.go Normal file
View File

@ -0,0 +1,56 @@
// Package main is the entry point for dnswatcher.
package main
import (
"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/healthcheck"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/middleware"
"sneak.berlin/go/dnswatcher/internal/notify"
"sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/server"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/tlscheck"
"sneak.berlin/go/dnswatcher/internal/watcher"
_ "github.com/joho/godotenv/autoload"
)
// Build-time variables injected by linker flags (-ldflags).
//
//nolint:gochecknoglobals // build-time variables
var (
Appname = "dnswatcher"
Version string
Buildarch string
)
func main() {
globals.SetAppname(Appname)
globals.SetVersion(Version)
globals.SetBuildarch(Buildarch)
fx.New(
fx.Provide(
globals.New,
logger.New,
config.New,
state.New,
healthcheck.New,
resolver.New,
portcheck.New,
tlscheck.New,
notify.New,
watcher.New,
middleware.New,
handlers.New,
server.New,
),
fx.Invoke(func(*server.Server, *watcher.Watcher) {}),
).Run()
}

39
go.mod Normal file
View File

@ -0,0 +1,39 @@
module sneak.berlin/go/dnswatcher
go 1.25.5
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
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/prometheus/client_golang v1.23.2
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
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
google.golang.org/protobuf v1.36.8 // indirect
)

87
go.sum Normal file
View File

@ -0,0 +1,87 @@
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/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=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
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=
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=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

187
internal/config/config.go Normal file
View 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, &params)
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, &notFound) {
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"
}

View 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
}

View 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: &params,
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)
}
}
}

View 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,
)
}
}

View 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,
)
}
}

View 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: &params,
}
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
View 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,
)
}

View 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: &params,
}, 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
View 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"
}
}

View 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
}

View 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
View 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
View 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
View 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
}

View 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
}

View 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")
}