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:
commit
144a2df665
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
bin/
|
||||
vendor/
|
||||
data/
|
||||
.env
|
||||
*.exe
|
||||
/dnswatcher
|
||||
32
.golangci.yml
Normal file
32
.golangci.yml
Normal 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
68
CLAUDE.md
Normal 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
1225
CONVENTIONS.md
Normal file
File diff suppressed because it is too large
Load Diff
38
Dockerfile
Normal file
38
Dockerfile
Normal 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
37
Makefile
Normal 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
385
README.md
Normal 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
56
cmd/dnswatcher/main.go
Normal 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
39
go.mod
Normal 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
87
go.sum
Normal 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
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")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user