255 lines
7.2 KiB
Go

package report
import (
"context"
"math"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// collectDNS gathers CoreDNS, Caddy, and DNS resolution health information.
// Only called when /etc/coredns exists.
func collectDNS() *DNSReport {
r := &DNSReport{}
// Set TLS days to -1 by default (failure state).
r.BaseTLSDaysLeft = -1
r.WildTLSDaysLeft = -1
// 1. CoreDNSActive: systemctl is-active coredns
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "systemctl", "is-active", "coredns"); err == nil {
r.CoreDNSActive = strings.TrimSpace(out) == "active"
}
}
// 2. CaddyActive: systemctl is-active caddy
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "systemctl", "is-active", "caddy"); err == nil {
r.CaddyActive = strings.TrimSpace(out) == "active"
}
}
// 3. Port53Bound: check :53 in ss -ulnp
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "ss", "-ulnp"); err == nil {
r.Port53Bound = strings.Contains(out, ":53 ") || strings.Contains(out, ":53\t")
}
}
// 4. Port80Bound and Port443Bound: check in ss -tlnp
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "ss", "-tlnp"); err == nil {
r.Port80Bound = strings.Contains(out, ":80 ") || strings.Contains(out, ":80\t")
r.Port443Bound = strings.Contains(out, ":443 ") || strings.Contains(out, ":443\t")
}
}
// 5. CoreDNSMemMB: ps -C coredns -o rss=
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "ps", "-C", "coredns", "-o", "rss=", "--no-headers"); err == nil {
line := strings.TrimSpace(out)
if line != "" {
first := strings.Fields(line)[0]
if kb, err := strconv.Atoi(first); err == nil {
r.CoreDNSMemMB = kb / 1024
}
}
}
}
// 6. CoreDNSRestarts: systemctl show coredns --property=NRestarts
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "systemctl", "show", "coredns", "--property=NRestarts"); err == nil {
props := parseProperties(out)
r.CoreDNSRestarts = parseInt(props["NRestarts"])
}
}
// 7. LogErrors: grep errors from coredns journal (last 5 min)
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "bash", "-c",
`journalctl -u coredns --no-pager -n 100 --since "5 min ago" 2>/dev/null | grep -ciE "(error|ERR)" || echo 0`); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(out)); err == nil {
r.LogErrors = n
}
}
}
// 8. CorefileExists: check /etc/coredns/Corefile
if _, err := os.Stat("/etc/coredns/Corefile"); err == nil {
r.CorefileExists = true
}
// Parse domain from Corefile for DNS resolution tests.
domain := parseDomain()
if domain == "" {
return r
}
// 9. SOAResolves: dig SOA
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "SOA", domain, "+short", "+time=2"); err == nil {
r.SOAResolves = strings.TrimSpace(out) != ""
}
}
// 10. NSResolves and NSRecordCount: dig NS
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "NS", domain, "+short", "+time=2"); err == nil {
out = strings.TrimSpace(out)
if out != "" {
r.NSResolves = true
lines := strings.Split(out, "\n")
count := 0
for _, l := range lines {
if strings.TrimSpace(l) != "" {
count++
}
}
r.NSRecordCount = count
}
}
}
// 11. WildcardResolves: dig A test.<domain>
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "A", "test."+domain, "+short", "+time=2"); err == nil {
r.WildcardResolves = strings.TrimSpace(out) != ""
}
}
// 12. BaseAResolves: dig A <domain>
{
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "A", domain, "+short", "+time=2"); err == nil {
r.BaseAResolves = strings.TrimSpace(out) != ""
}
}
// 13. BaseTLSDaysLeft: check TLS cert expiry for base domain
r.BaseTLSDaysLeft = checkTLSDaysLeft(domain, domain)
// 14. WildTLSDaysLeft: check TLS cert expiry for wildcard
r.WildTLSDaysLeft = checkTLSDaysLeft("*."+domain, domain)
return r
}
// parseDomain reads /etc/coredns/Corefile and extracts the base domain.
// It looks for zone block declarations like "example.com {" or "*.example.com {"
// and returns the base domain (without wildcard prefix).
func parseDomain() string {
data, err := os.ReadFile("/etc/coredns/Corefile")
if err != nil {
return ""
}
content := string(data)
// Look for domain patterns in the Corefile.
// Common patterns:
// example.com {
// *.example.com {
// example.com:53 {
// We want to find a real domain, not "." (root zone).
domainRe := regexp.MustCompile(`(?m)^\s*\*?\.?([a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z])(?::\d+)?\s*\{`)
matches := domainRe.FindStringSubmatch(content)
if len(matches) >= 2 {
return matches[1]
}
// Fallback: look for any line that looks like a domain block declaration.
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Strip trailing "{" and port suffix.
line = strings.TrimSuffix(line, "{")
line = strings.TrimSpace(line)
// Remove port if present.
if idx := strings.LastIndex(line, ":"); idx > 0 {
if _, err := strconv.Atoi(line[idx+1:]); err == nil {
line = line[:idx]
}
}
// Strip wildcard prefix.
line = strings.TrimPrefix(line, "*.")
// Check if it looks like a domain (has at least one dot and no spaces).
if strings.Contains(line, ".") && !strings.Contains(line, " ") && line != "." {
return strings.TrimSpace(line)
}
}
return ""
}
// checkTLSDaysLeft uses openssl to check the TLS certificate expiry date
// for a given servername connecting to localhost:443.
// Returns days until expiry, or -1 on any failure.
func checkTLSDaysLeft(servername, domain string) int {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
cmd := `echo | openssl s_client -servername ` + servername + ` -connect localhost:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null`
out, err := runCmd(ctx, "bash", "-c", cmd)
if err != nil {
return -1
}
// Output looks like: "notAfter=Mar 15 12:00:00 2025 GMT"
out = strings.TrimSpace(out)
if !strings.HasPrefix(out, "notAfter=") {
return -1
}
dateStr := strings.TrimPrefix(out, "notAfter=")
dateStr = strings.TrimSpace(dateStr)
// Parse the date. OpenSSL uses the format: "Jan 2 15:04:05 2006 GMT"
layouts := []string{
"Jan 2 15:04:05 2006 GMT",
"Jan 2 15:04:05 2006 GMT",
"Jan 02 15:04:05 2006 GMT",
}
for _, layout := range layouts {
t, err := time.Parse(layout, dateStr)
if err == nil {
days := int(math.Floor(time.Until(t).Hours() / 24))
return days
}
}
return -1
}