Compare commits
4 Commits
27d2a69026
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dede6af9d | |||
| b84c2f3b34 | |||
| a65a14b8ec | |||
| b7b9629bc3 |
@@ -1,6 +1,6 @@
|
|||||||
.git/
|
.git
|
||||||
bin/
|
bin
|
||||||
*.md
|
data
|
||||||
LICENSE
|
.env
|
||||||
.editorconfig
|
.DS_Store
|
||||||
.gitignore
|
*.exe
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ charset = utf-8
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
[Makefile]
|
[Makefile]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 sneak
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
25
Makefile
25
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean hooks docker
|
.PHONY: all build lint fmt fmt-check test check clean docker hooks
|
||||||
|
|
||||||
BINARY := dnswatcher
|
BINARY := dnswatcher
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
@@ -18,32 +18,25 @@ fmt:
|
|||||||
goimports -w .
|
goimports -w .
|
||||||
|
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@test -z "$$(gofmt -l .)" || (echo "gofmt: files not formatted:" && gofmt -l . && exit 1)
|
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v -race -timeout 30s -cover ./...
|
go test -v -race -cover -timeout 30s ./...
|
||||||
|
|
||||||
# Check runs all validation without making changes
|
# Check runs all validation without making changes
|
||||||
# Used by CI and Docker build - fails if anything is wrong
|
# Used by CI and Docker build - fails if anything is wrong
|
||||||
check:
|
check: fmt-check lint test
|
||||||
@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 -timeout 30s ./...
|
|
||||||
@echo "==> Building..."
|
@echo "==> Building..."
|
||||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
|
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
|
||||||
@echo "==> All checks passed!"
|
@echo "==> All checks passed!"
|
||||||
|
|
||||||
clean:
|
docker:
|
||||||
rm -rf bin/
|
docker build .
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@echo '#!/bin/sh' > .git/hooks/pre-commit
|
@printf '#!/bin/sh\nset -e\nmake check\n' > .git/hooks/pre-commit
|
||||||
@echo 'make check' >> .git/hooks/pre-commit
|
|
||||||
@chmod +x .git/hooks/pre-commit
|
@chmod +x .git/hooks/pre-commit
|
||||||
@echo "Pre-commit hook installed."
|
@echo "Pre-commit hook installed."
|
||||||
|
|
||||||
docker:
|
clean:
|
||||||
docker build .
|
rm -rf bin/
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,10 +1,9 @@
|
|||||||
# dnswatcher
|
# dnswatcher
|
||||||
|
|
||||||
dnswatcher is a pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks.
|
|
||||||
|
|
||||||
> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice.
|
> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice.
|
||||||
|
|
||||||
dnswatcher watches configured DNS domains and hostnames for changes, monitors TCP
|
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
|
port availability, tracks TLS certificate expiry, and delivers real-time
|
||||||
notifications via Slack, Mattermost, and/or ntfy webhooks.
|
notifications via Slack, Mattermost, and/or ntfy webhooks.
|
||||||
|
|
||||||
@@ -328,10 +327,13 @@ tracks reachability:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # Build binary to bin/dnswatcher
|
make build # Build binary to bin/dnswatcher
|
||||||
make test # Run tests with race detector
|
make test # Run tests with race detector and 30s timeout
|
||||||
make lint # Run golangci-lint
|
make lint # Run golangci-lint
|
||||||
make fmt # Format code
|
make fmt # Format code (writes)
|
||||||
make check # Run all checks (format, lint, test, build)
|
make fmt-check # Read-only format check
|
||||||
|
make check # Run all checks (fmt-check, lint, test, build)
|
||||||
|
make docker # Build Docker image
|
||||||
|
make hooks # Install pre-commit hook
|
||||||
make clean # Remove build artifacts
|
make clean # Remove build artifacts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -395,8 +397,7 @@ Viper for configuration.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
License has not yet been chosen for this project. Pending decision by the
|
MIT — see [LICENSE](LICENSE).
|
||||||
author (MIT, GPL, or WTFPL).
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -41,6 +42,22 @@ func rootServerList() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxRootServers = 3
|
||||||
|
|
||||||
|
// randomRootServers returns a shuffled subset of root servers.
|
||||||
|
func randomRootServers() []string {
|
||||||
|
all := rootServerList()
|
||||||
|
rand.Shuffle(len(all), func(i, j int) {
|
||||||
|
all[i], all[j] = all[j], all[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(all) > maxRootServers {
|
||||||
|
return all[:maxRootServers]
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
func checkCtx(ctx context.Context) error {
|
func checkCtx(ctx context.Context) error {
|
||||||
err := ctx.Err()
|
err := ctx.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -227,7 +244,7 @@ func (r *Resolver) followDelegation(
|
|||||||
|
|
||||||
authNS := extractNSSet(resp.Ns)
|
authNS := extractNSSet(resp.Ns)
|
||||||
if len(authNS) == 0 {
|
if len(authNS) == 0 {
|
||||||
return r.resolveNSIterative(ctx, domain)
|
return r.resolveNSRecursive(ctx, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
glue := extractGlue(resp.Extra)
|
glue := extractGlue(resp.Extra)
|
||||||
@@ -291,84 +308,60 @@ func (r *Resolver) resolveNSIPs(
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveNSIterative queries for NS records using iterative
|
// resolveNSRecursive queries for NS records using recursive
|
||||||
// resolution as a fallback when followDelegation finds no
|
// resolution as a fallback for intercepted environments.
|
||||||
// authoritative answer in the delegation chain.
|
func (r *Resolver) resolveNSRecursive(
|
||||||
func (r *Resolver) resolveNSIterative(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
if checkCtx(ctx) != nil {
|
|
||||||
return nil, ErrContextCanceled
|
|
||||||
}
|
|
||||||
|
|
||||||
domain = dns.Fqdn(domain)
|
domain = dns.Fqdn(domain)
|
||||||
servers := rootServerList()
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion(domain, dns.TypeNS)
|
||||||
|
msg.RecursionDesired = true
|
||||||
|
|
||||||
for range maxDelegation {
|
for _, ip := range randomRootServers() {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.queryServers(
|
addr := net.JoinHostPort(ip, "53")
|
||||||
ctx, servers, domain, dns.TypeNS,
|
|
||||||
)
|
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
nsNames := extractNSSet(resp.Answer)
|
nsNames := extractNSSet(resp.Answer)
|
||||||
if len(nsNames) > 0 {
|
if len(nsNames) > 0 {
|
||||||
return nsNames, nil
|
return nsNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow delegation.
|
|
||||||
authNS := extractNSSet(resp.Ns)
|
|
||||||
if len(authNS) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
glue := extractGlue(resp.Extra)
|
|
||||||
nextServers := glueIPs(authNS, glue)
|
|
||||||
|
|
||||||
if len(nextServers) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
servers = nextServers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrNoNameservers
|
return nil, ErrNoNameservers
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveARecord resolves a hostname to IPv4 addresses using
|
// resolveARecord resolves a hostname to IPv4 addresses.
|
||||||
// iterative resolution through the delegation chain.
|
|
||||||
func (r *Resolver) resolveARecord(
|
func (r *Resolver) resolveARecord(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
if checkCtx(ctx) != nil {
|
|
||||||
return nil, ErrContextCanceled
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname = dns.Fqdn(hostname)
|
hostname = dns.Fqdn(hostname)
|
||||||
servers := rootServerList()
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion(hostname, dns.TypeA)
|
||||||
|
msg.RecursionDesired = true
|
||||||
|
|
||||||
for range maxDelegation {
|
for _, ip := range randomRootServers() {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.queryServers(
|
addr := net.JoinHostPort(ip, "53")
|
||||||
ctx, servers, hostname, dns.TypeA,
|
|
||||||
)
|
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
continue
|
||||||
"resolving %s: %w", hostname, err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for A records in the answer section.
|
|
||||||
var ips []string
|
var ips []string
|
||||||
|
|
||||||
for _, rr := range resp.Answer {
|
for _, rr := range resp.Answer {
|
||||||
@@ -380,24 +373,6 @@ func (r *Resolver) resolveARecord(
|
|||||||
if len(ips) > 0 {
|
if len(ips) > 0 {
|
||||||
return ips, nil
|
return ips, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow delegation if present.
|
|
||||||
authNS := extractNSSet(resp.Ns)
|
|
||||||
if len(authNS) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
glue := extractGlue(resp.Extra)
|
|
||||||
nextServers := glueIPs(authNS, glue)
|
|
||||||
|
|
||||||
if len(nextServers) == 0 {
|
|
||||||
// Resolve NS IPs iteratively — but guard
|
|
||||||
// against infinite recursion by using only
|
|
||||||
// already-resolved servers.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
servers = nextServers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -427,7 +402,7 @@ func (r *Resolver) FindAuthoritativeNameservers(
|
|||||||
candidate := strings.Join(labels[i:], ".") + "."
|
candidate := strings.Join(labels[i:], ".") + "."
|
||||||
|
|
||||||
nsNames, err := r.followDelegation(
|
nsNames, err := r.followDelegation(
|
||||||
ctx, candidate, rootServerList(),
|
ctx, candidate, randomRootServers(),
|
||||||
)
|
)
|
||||||
if err == nil && len(nsNames) > 0 {
|
if err == nil && len(nsNames) > 0 {
|
||||||
sort.Strings(nsNames)
|
sort.Strings(nsNames)
|
||||||
|
|||||||
@@ -72,15 +72,13 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(startCtx context.Context) error {
|
||||||
// Use context.Background() — the fx startup context
|
ctx, cancel := context.WithCancel(
|
||||||
// expires after startup completes, so deriving from it
|
context.WithoutCancel(startCtx),
|
||||||
// would cancel the watcher immediately. The watcher's
|
)
|
||||||
// lifetime is controlled by w.cancel in OnStop.
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
w.cancel = cancel
|
w.cancel = cancel
|
||||||
|
|
||||||
go w.Run(ctx) //nolint:contextcheck // intentionally not derived from startCtx
|
go w.Run(ctx)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user