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