Compare commits
4 Commits
a66d17ad1b
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dede6af9d | |||
| b84c2f3b34 | |||
| a65a14b8ec | |||
| b7b9629bc3 |
@@ -1,6 +1,6 @@
|
||||
.git/
|
||||
bin/
|
||||
*.md
|
||||
LICENSE
|
||||
.editorconfig
|
||||
.gitignore
|
||||
.git
|
||||
bin
|
||||
data
|
||||
.env
|
||||
.DS_Store
|
||||
*.exe
|
||||
|
||||
@@ -8,5 +8,8 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
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
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
@@ -18,32 +18,25 @@ fmt:
|
||||
goimports -w .
|
||||
|
||||
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:
|
||||
go test -v -race -timeout 30s -cover ./...
|
||||
go test -v -race -cover -timeout 30s ./...
|
||||
|
||||
# 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 -timeout 30s ./...
|
||||
check: fmt-check lint test
|
||||
@echo "==> Building..."
|
||||
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
|
||||
@echo "==> All checks passed!"
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
docker:
|
||||
docker build .
|
||||
|
||||
hooks:
|
||||
@echo '#!/bin/sh' > .git/hooks/pre-commit
|
||||
@echo 'make check' >> .git/hooks/pre-commit
|
||||
@printf '#!/bin/sh\nset -e\nmake check\n' > .git/hooks/pre-commit
|
||||
@chmod +x .git/hooks/pre-commit
|
||||
@echo "Pre-commit hook installed."
|
||||
|
||||
docker:
|
||||
docker build .
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
17
README.md
17
README.md
@@ -1,10 +1,9 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
notifications via Slack, Mattermost, and/or ntfy webhooks.
|
||||
|
||||
@@ -328,10 +327,13 @@ tracks reachability:
|
||||
|
||||
```sh
|
||||
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 fmt # Format code
|
||||
make check # Run all checks (format, lint, test, build)
|
||||
make fmt # Format code (writes)
|
||||
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
|
||||
```
|
||||
|
||||
@@ -395,8 +397,7 @@ Viper for configuration.
|
||||
|
||||
## License
|
||||
|
||||
License has not yet been chosen for this project. Pending decision by the
|
||||
author (MIT, GPL, or WTFPL).
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"sort"
|
||||
"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 {
|
||||
err := ctx.Err()
|
||||
if err != nil {
|
||||
@@ -227,7 +244,7 @@ func (r *Resolver) followDelegation(
|
||||
|
||||
authNS := extractNSSet(resp.Ns)
|
||||
if len(authNS) == 0 {
|
||||
return r.resolveNSIterative(ctx, domain)
|
||||
return r.resolveNSRecursive(ctx, domain)
|
||||
}
|
||||
|
||||
glue := extractGlue(resp.Extra)
|
||||
@@ -291,84 +308,60 @@ func (r *Resolver) resolveNSIPs(
|
||||
return ips
|
||||
}
|
||||
|
||||
// resolveNSIterative queries for NS records using iterative
|
||||
// resolution as a fallback when followDelegation finds no
|
||||
// authoritative answer in the delegation chain.
|
||||
func (r *Resolver) resolveNSIterative(
|
||||
// resolveNSRecursive queries for NS records using recursive
|
||||
// resolution as a fallback for intercepted environments.
|
||||
func (r *Resolver) resolveNSRecursive(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
) ([]string, error) {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
resp, err := r.queryServers(
|
||||
ctx, servers, domain, dns.TypeNS,
|
||||
)
|
||||
addr := net.JoinHostPort(ip, "53")
|
||||
|
||||
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
continue
|
||||
}
|
||||
|
||||
nsNames := extractNSSet(resp.Answer)
|
||||
if len(nsNames) > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// resolveARecord resolves a hostname to IPv4 addresses using
|
||||
// iterative resolution through the delegation chain.
|
||||
// resolveARecord resolves a hostname to IPv4 addresses.
|
||||
func (r *Resolver) resolveARecord(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
) ([]string, error) {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
resp, err := r.queryServers(
|
||||
ctx, servers, hostname, dns.TypeA,
|
||||
)
|
||||
addr := net.JoinHostPort(ip, "53")
|
||||
|
||||
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"resolving %s: %w", hostname, err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for A records in the answer section.
|
||||
var ips []string
|
||||
|
||||
for _, rr := range resp.Answer {
|
||||
@@ -380,24 +373,6 @@ func (r *Resolver) resolveARecord(
|
||||
if len(ips) > 0 {
|
||||
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(
|
||||
@@ -427,7 +402,7 @@ func (r *Resolver) FindAuthoritativeNameservers(
|
||||
candidate := strings.Join(labels[i:], ".") + "."
|
||||
|
||||
nsNames, err := r.followDelegation(
|
||||
ctx, candidate, rootServerList(),
|
||||
ctx, candidate, randomRootServers(),
|
||||
)
|
||||
if err == nil && len(nsNames) > 0 {
|
||||
sort.Strings(nsNames)
|
||||
|
||||
Reference in New Issue
Block a user