DigiJ 2322f69516 v2.2.0 — Full arsenal expansion: 16 new security modules
Add WiFi Audit, API Fuzzer, Cloud Scanner, Threat Intel, Log Correlator,
Steganography, Anti-Forensics, BLE Scanner, Forensics, RFID/NFC, Malware
Sandbox, Password Toolkit, Web Scanner, Report Engine, Net Mapper, and
C2 Framework. Each module includes CLI interface, Flask routes, and web
UI template. Also includes Go DNS server source + binary, IP Capture
service, SYN Flood, Gone Fishing mail server, and hack hijack modules
from v2.0 work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 05:20:39 -08:00

1082 lines
31 KiB
Go

package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/darkhal/autarch-dns/config"
"github.com/darkhal/autarch-dns/server"
"github.com/miekg/dns"
)
// APIServer exposes REST endpoints for zone/record management.
type APIServer struct {
cfg *config.Config
store *server.ZoneStore
dns *server.DNSServer
}
// NewAPIServer creates an API server.
func NewAPIServer(cfg *config.Config, store *server.ZoneStore, dns *server.DNSServer) *APIServer {
return &APIServer{cfg: cfg, store: store, dns: dns}
}
// Start begins the HTTP API server.
func (a *APIServer) Start() error {
mux := http.NewServeMux()
// Status & metrics
mux.HandleFunc("/api/status", a.auth(a.handleStatus))
mux.HandleFunc("/api/metrics", a.auth(a.handleMetrics))
mux.HandleFunc("/api/config", a.auth(a.handleConfig))
// Zones
mux.HandleFunc("/api/zones", a.auth(a.handleZones))
mux.HandleFunc("/api/zones/", a.auth(a.handleZoneDetail))
// Query log
mux.HandleFunc("/api/querylog", a.auth(a.handleQueryLog))
// Cache
mux.HandleFunc("/api/cache", a.auth(a.handleCache))
// Blocklist
mux.HandleFunc("/api/blocklist", a.auth(a.handleBlocklist))
// Analytics
mux.HandleFunc("/api/stats/top-domains", a.auth(a.handleTopDomains))
mux.HandleFunc("/api/stats/query-types", a.auth(a.handleQueryTypes))
mux.HandleFunc("/api/stats/clients", a.auth(a.handleClients))
// Resolver internals
mux.HandleFunc("/api/resolver/ns-cache", a.auth(a.handleNSCache))
// Root server health
mux.HandleFunc("/api/rootcheck", a.auth(a.handleRootCheck))
// Benchmark
mux.HandleFunc("/api/benchmark", a.auth(a.handleBenchmark))
// Conditional forwarding
mux.HandleFunc("/api/forwarding", a.auth(a.handleForwarding))
// Zone import/export
mux.HandleFunc("/api/zone-export/", a.auth(a.handleZoneExport))
mux.HandleFunc("/api/zone-import/", a.auth(a.handleZoneImport))
mux.HandleFunc("/api/zone-clone", a.auth(a.handleZoneClone))
mux.HandleFunc("/api/zone-bulk-records/", a.auth(a.handleBulkRecords))
// Hosts file management
mux.HandleFunc("/api/hosts", a.auth(a.handleHosts))
mux.HandleFunc("/api/hosts/import", a.auth(a.handleHostsImport))
mux.HandleFunc("/api/hosts/export", a.auth(a.handleHostsExport))
// Encryption (DoT/DoH)
mux.HandleFunc("/api/encryption", a.auth(a.handleEncryption))
mux.HandleFunc("/api/encryption/test", a.auth(a.handleEncryptionTest))
return http.ListenAndServe(a.cfg.ListenAPI, a.corsMiddleware(mux))
}
// ── Middleware ────────────────────────────────────────────────────────
func (a *APIServer) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
token = r.URL.Query().Get("token")
}
token = strings.TrimPrefix(token, "Bearer ")
if a.cfg.APIToken != "" && token != a.cfg.APIToken {
jsonError(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func (a *APIServer) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// ── Status & Metrics ─────────────────────────────────────────────────
func (a *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) {
m := a.dns.GetMetrics()
jsonResp(w, map[string]interface{}{
"ok": true,
"version": "2.1.0",
"uptime": time.Since(parseTime(m.StartTime)).String(),
"queries": m.TotalQueries,
"zones": len(a.store.List()),
"cache_size": a.dns.CacheSize(),
})
}
func (a *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) {
m := a.dns.GetMetrics()
jsonResp(w, map[string]interface{}{
"ok": true,
"metrics": m,
"cache_size": a.dns.CacheSize(),
"uptime": time.Since(parseTime(m.StartTime)).String(),
})
}
// ── Config ───────────────────────────────────────────────────────────
func (a *APIServer) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" {
var updates config.Config
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
// Apply upstream — allow clearing to empty
a.cfg.Upstream = updates.Upstream
if updates.CacheTTL > 0 {
a.cfg.CacheTTL = updates.CacheTTL
}
if updates.RateLimit >= 0 {
a.cfg.RateLimit = updates.RateLimit
}
if updates.MaxUDPSize > 0 {
a.cfg.MaxUDPSize = updates.MaxUDPSize
}
a.cfg.LogQueries = updates.LogQueries
a.cfg.RefuseANY = updates.RefuseANY
a.cfg.MinimalResponses = updates.MinimalResponses
a.cfg.EnableDoH = updates.EnableDoH
a.cfg.EnableDoT = updates.EnableDoT
a.cfg.AllowTransfer = updates.AllowTransfer
a.cfg.HostsFile = updates.HostsFile
a.cfg.HostsAutoLoad = updates.HostsAutoLoad
if updates.QueryLogMax > 0 {
a.cfg.QueryLogMax = updates.QueryLogMax
}
if updates.NegativeCacheTTL >= 0 {
a.cfg.NegativeCacheTTL = updates.NegativeCacheTTL
}
if updates.ServFailCacheTTL >= 0 {
a.cfg.ServFailCacheTTL = updates.ServFailCacheTTL
}
a.cfg.PrefetchEnabled = updates.PrefetchEnabled
// Propagate encryption settings to resolver
a.dns.SetEncryption(a.cfg.EnableDoT, a.cfg.EnableDoH)
jsonResp(w, map[string]interface{}{"ok": true})
return
}
jsonResp(w, map[string]interface{}{
"ok": true,
"config": map[string]interface{}{
"listen_dns": a.cfg.ListenDNS,
"listen_api": a.cfg.ListenAPI,
"upstream": a.cfg.Upstream,
"cache_ttl": a.cfg.CacheTTL,
"log_queries": a.cfg.LogQueries,
"refuse_any": a.cfg.RefuseANY,
"minimal_responses": a.cfg.MinimalResponses,
"rate_limit": a.cfg.RateLimit,
"max_udp_size": a.cfg.MaxUDPSize,
"enable_doh": a.cfg.EnableDoH,
"enable_dot": a.cfg.EnableDoT,
"block_list": a.cfg.BlockList,
"allow_transfer": a.cfg.AllowTransfer,
"hosts_file": a.cfg.HostsFile,
"hosts_auto_load": a.cfg.HostsAutoLoad,
"querylog_max": a.cfg.QueryLogMax,
"negative_cache_ttl": a.cfg.NegativeCacheTTL,
"servfail_cache_ttl": a.cfg.ServFailCacheTTL,
"prefetch_enabled": a.cfg.PrefetchEnabled,
},
})
}
// ── Query Log ────────────────────────────────────────────────────────
func (a *APIServer) handleQueryLog(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
a.dns.ClearQueryLog()
jsonResp(w, map[string]interface{}{"ok": true, "message": "Query log cleared"})
return
}
limit := 200
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
}
entries := a.dns.GetQueryLog(limit)
jsonResp(w, map[string]interface{}{"ok": true, "entries": entries, "count": len(entries)})
}
// ── Cache ────────────────────────────────────────────────────────────
func (a *APIServer) handleCache(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
entries := a.dns.GetCacheEntries()
jsonResp(w, map[string]interface{}{
"ok": true,
"entries": entries,
"count": len(entries),
})
case "DELETE":
// Flush specific entry or all
key := r.URL.Query().Get("key")
if key != "" {
ok := a.dns.FlushCacheEntry(key)
jsonResp(w, map[string]interface{}{"ok": ok})
} else {
flushed := a.dns.FlushCache()
jsonResp(w, map[string]interface{}{"ok": true, "flushed": flushed})
}
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ── Blocklist ────────────────────────────────────────────────────────
func (a *APIServer) handleBlocklist(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
list := a.dns.GetBlocklist()
jsonResp(w, map[string]interface{}{"ok": true, "domains": list, "count": len(list)})
case "POST":
var req struct {
Domain string `json:"domain"`
Domains []string `json:"domains"` // bulk import
}
json.NewDecoder(r.Body).Decode(&req)
if len(req.Domains) > 0 {
count := a.dns.ImportBlocklist(req.Domains)
jsonResp(w, map[string]interface{}{"ok": true, "imported": count})
} else if req.Domain != "" {
a.dns.AddBlocklistEntry(req.Domain)
jsonResp(w, map[string]interface{}{"ok": true, "message": "Added " + req.Domain})
} else {
jsonError(w, "domain(s) required", http.StatusBadRequest)
}
case "DELETE":
var req struct {
Domain string `json:"domain"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Domain != "" {
a.dns.RemoveBlocklistEntry(req.Domain)
jsonResp(w, map[string]interface{}{"ok": true})
} else {
jsonError(w, "domain required", http.StatusBadRequest)
}
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ── Analytics ────────────────────────────────────────────────────────
func (a *APIServer) handleTopDomains(w http.ResponseWriter, r *http.Request) {
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
}
jsonResp(w, map[string]interface{}{"ok": true, "domains": a.dns.GetTopDomains(limit)})
}
func (a *APIServer) handleQueryTypes(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]interface{}{"ok": true, "types": a.dns.GetQueryTypeCounts()})
}
func (a *APIServer) handleClients(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]interface{}{"ok": true, "clients": a.dns.GetClientCounts()})
}
// ── Resolver NS Cache ────────────────────────────────────────────────
func (a *APIServer) handleNSCache(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
a.dns.FlushCache()
jsonResp(w, map[string]interface{}{"ok": true, "message": "NS cache flushed"})
return
}
cache := a.dns.GetResolverNSCache()
jsonResp(w, map[string]interface{}{"ok": true, "ns_cache": cache, "zones": len(cache)})
}
// ── Root Server Health Check ─────────────────────────────────────────
func (a *APIServer) handleRootCheck(w http.ResponseWriter, r *http.Request) {
type RootResult struct {
Server string `json:"server"`
Name string `json:"name"`
Latency string `json:"latency"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
rootNames := []string{
"a.root-servers.net", "b.root-servers.net", "c.root-servers.net",
"d.root-servers.net", "e.root-servers.net", "f.root-servers.net",
"g.root-servers.net", "h.root-servers.net", "i.root-servers.net",
"j.root-servers.net", "k.root-servers.net", "l.root-servers.net",
"m.root-servers.net",
}
rootIPs := []string{
"198.41.0.4:53", "170.247.170.2:53", "192.33.4.12:53",
"199.7.91.13:53", "192.203.230.10:53", "192.5.5.241:53",
"192.112.36.4:53", "198.97.190.53:53", "192.36.148.17:53",
"192.58.128.30:53", "193.0.14.129:53", "199.7.83.42:53",
"202.12.27.33:53",
}
results := make([]RootResult, len(rootIPs))
ch := make(chan int, len(rootIPs))
for i := range rootIPs {
go func(idx int) {
defer func() { ch <- idx }()
c := &dns.Client{Timeout: 3 * time.Second}
msg := new(dns.Msg)
msg.SetQuestion(".", dns.TypeNS)
start := time.Now()
_, _, err := c.Exchange(msg, rootIPs[idx])
lat := time.Since(start)
results[idx] = RootResult{
Server: rootIPs[idx],
Name: rootNames[idx],
Latency: lat.String(),
OK: err == nil,
}
if err != nil {
results[idx].Error = err.Error()
}
}(i)
}
for range rootIPs {
<-ch
}
reachable := 0
for _, r := range results {
if r.OK {
reachable++
}
}
jsonResp(w, map[string]interface{}{
"ok": true,
"results": results,
"reachable": reachable,
"total": len(rootIPs),
})
}
// ── Benchmark ────────────────────────────────────────────────────────
func (a *APIServer) handleBenchmark(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct {
Domains []string `json:"domains"`
Count int `json:"count"` // queries per domain
}
json.NewDecoder(r.Body).Decode(&req)
if len(req.Domains) == 0 {
req.Domains = []string{"google.com", "github.com", "cloudflare.com", "amazon.com", "wikipedia.org"}
}
if req.Count <= 0 {
req.Count = 3
}
if req.Count > 10 {
req.Count = 10
}
type BenchResult struct {
Domain string `json:"domain"`
Avg string `json:"avg_latency"`
Min string `json:"min_latency"`
Max string `json:"max_latency"`
OK int `json:"success"`
Fail int `json:"fail"`
}
listen := a.cfg.ListenDNS
host := strings.Split(listen, ":")[0]
port := "53"
if parts := strings.SplitN(listen, ":", 2); len(parts) == 2 {
port = parts[1]
}
if host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
target := host + ":" + port
c := &dns.Client{Timeout: 10 * time.Second}
results := make([]BenchResult, len(req.Domains))
for i, domain := range req.Domains {
var latencies []time.Duration
var fails int
for j := 0; j < req.Count; j++ {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(domain), dns.TypeA)
start := time.Now()
_, _, err := c.Exchange(msg, target)
lat := time.Since(start)
if err != nil {
fails++
} else {
latencies = append(latencies, lat)
}
}
br := BenchResult{
Domain: domain,
OK: len(latencies),
Fail: fails,
}
if len(latencies) > 0 {
sort.Slice(latencies, func(a, b int) bool { return latencies[a] < latencies[b] })
var total time.Duration
for _, l := range latencies {
total += l
}
br.Avg = (total / time.Duration(len(latencies))).String()
br.Min = latencies[0].String()
br.Max = latencies[len(latencies)-1].String()
}
results[i] = br
}
jsonResp(w, map[string]interface{}{"ok": true, "results": results})
}
// ── Conditional Forwarding ───────────────────────────────────────────
func (a *APIServer) handleForwarding(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
fwd := a.dns.GetConditionalForwards()
jsonResp(w, map[string]interface{}{"ok": true, "rules": fwd, "count": len(fwd)})
case "POST":
var req struct {
Zone string `json:"zone"`
Upstreams []string `json:"upstreams"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Zone == "" || len(req.Upstreams) == 0 {
jsonError(w, "zone and upstreams required", http.StatusBadRequest)
return
}
a.dns.SetConditionalForward(req.Zone, req.Upstreams)
jsonResp(w, map[string]interface{}{"ok": true, "message": fmt.Sprintf("Forwarding set for %s", req.Zone)})
case "DELETE":
var req struct {
Zone string `json:"zone"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Zone == "" {
jsonError(w, "zone required", http.StatusBadRequest)
return
}
a.dns.RemoveConditionalForward(req.Zone)
jsonResp(w, map[string]interface{}{"ok": true})
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ── Zone Import/Export/Clone ─────────────────────────────────────────
func (a *APIServer) handleZoneExport(w http.ResponseWriter, r *http.Request) {
zone := strings.TrimPrefix(r.URL.Path, "/api/zone-export/")
if zone == "" {
jsonError(w, "zone required", http.StatusBadRequest)
return
}
content, err := a.store.ExportZoneFile(zone)
if err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
format := r.URL.Query().Get("format")
if format == "raw" {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zone"`, zone))
w.Write([]byte(content))
return
}
jsonResp(w, map[string]interface{}{"ok": true, "zone": zone, "content": content})
}
func (a *APIServer) handleZoneImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
zone := strings.TrimPrefix(r.URL.Path, "/api/zone-import/")
if zone == "" {
jsonError(w, "zone required", http.StatusBadRequest)
return
}
var req struct {
Content string `json:"content"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Content == "" {
jsonError(w, "content required", http.StatusBadRequest)
return
}
count, err := a.store.ImportZoneFile(zone, req.Content)
if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResp(w, map[string]interface{}{"ok": true, "imported": count, "zone": zone})
}
func (a *APIServer) handleZoneClone(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct {
Source string `json:"source"`
Destination string `json:"destination"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Source == "" || req.Destination == "" {
jsonError(w, "source and destination required", http.StatusBadRequest)
return
}
z, err := a.store.CloneZone(req.Source, req.Destination)
if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResp(w, map[string]interface{}{"ok": true, "zone": z})
}
func (a *APIServer) handleBulkRecords(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
zone := strings.TrimPrefix(r.URL.Path, "/api/zone-bulk-records/")
if zone == "" {
jsonError(w, "zone required", http.StatusBadRequest)
return
}
var req struct {
Records []server.Record `json:"records"`
}
json.NewDecoder(r.Body).Decode(&req)
count, err := a.store.BulkAddRecords(zone, req.Records)
if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResp(w, map[string]interface{}{"ok": true, "added": count})
}
// ── Zones CRUD ───────────────────────────────────────────────────────
func (a *APIServer) handleZones(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
zones := a.store.List()
result := make([]map[string]interface{}, 0, len(zones))
for _, z := range zones {
result = append(result, map[string]interface{}{
"domain": z.Domain,
"records": len(z.Records),
"dnssec": z.DNSSEC,
"created_at": z.CreatedAt,
})
}
jsonResp(w, map[string]interface{}{"ok": true, "zones": result})
case "POST":
var req struct {
Domain string `json:"domain"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
jsonError(w, "domain required", http.StatusBadRequest)
return
}
z, err := a.store.Create(req.Domain)
if err != nil {
jsonError(w, err.Error(), http.StatusConflict)
return
}
jsonResp(w, map[string]interface{}{"ok": true, "zone": z})
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (a *APIServer) handleZoneDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/zones/")
parts := strings.SplitN(path, "/", 3)
zone := parts[0]
if len(parts) == 1 {
switch r.Method {
case "GET":
z := a.store.Get(zone)
if z == nil {
jsonError(w, "zone not found", http.StatusNotFound)
return
}
jsonResp(w, map[string]interface{}{"ok": true, "zone": z})
case "DELETE":
if err := a.store.Delete(zone); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
jsonResp(w, map[string]interface{}{"ok": true})
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
sub := parts[1]
switch sub {
case "records":
a.handleRecords(w, r, zone, parts)
case "mail-setup":
a.handleMailSetup(w, r, zone)
case "dnssec":
a.handleDNSSEC(w, r, zone, parts)
default:
jsonError(w, "not found", http.StatusNotFound)
}
}
func (a *APIServer) handleRecords(w http.ResponseWriter, r *http.Request, zone string, parts []string) {
switch r.Method {
case "GET":
z := a.store.Get(zone)
if z == nil {
jsonError(w, "zone not found", http.StatusNotFound)
return
}
jsonResp(w, map[string]interface{}{"ok": true, "records": z.Records})
case "POST":
var rec server.Record
if err := json.NewDecoder(r.Body).Decode(&rec); err != nil {
jsonError(w, "invalid record", http.StatusBadRequest)
return
}
if err := a.store.AddRecord(zone, rec); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResp(w, map[string]interface{}{"ok": true})
case "PUT":
if len(parts) < 3 {
jsonError(w, "record ID required", http.StatusBadRequest)
return
}
var rec server.Record
if err := json.NewDecoder(r.Body).Decode(&rec); err != nil {
jsonError(w, "invalid record", http.StatusBadRequest)
return
}
if err := a.store.UpdateRecord(zone, parts[2], rec); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
jsonResp(w, map[string]interface{}{"ok": true})
case "DELETE":
if len(parts) < 3 {
jsonError(w, "record ID required", http.StatusBadRequest)
return
}
if err := a.store.DeleteRecord(zone, parts[2]); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
jsonResp(w, map[string]interface{}{"ok": true})
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (a *APIServer) handleMailSetup(w http.ResponseWriter, r *http.Request, zone string) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct {
MXHost string `json:"mx_host"`
DKIM string `json:"dkim_key"`
SPFAllow string `json:"spf_allow"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.MXHost == "" {
req.MXHost = "mail." + zone
}
if req.SPFAllow == "" {
req.SPFAllow = "ip4:127.0.0.1"
}
records := []server.Record{
{ID: "mx1", Type: server.TypeMX, Name: zone + ".", Value: req.MXHost + ".", TTL: 3600, Priority: 10},
{ID: "spf1", Type: server.TypeTXT, Name: zone + ".", Value: fmt.Sprintf("v=spf1 %s -all", req.SPFAllow), TTL: 3600},
{ID: "dmarc1", Type: server.TypeTXT, Name: "_dmarc." + zone + ".", Value: "v=DMARC1; p=none; rua=mailto:dmarc@" + zone, TTL: 3600},
}
if req.DKIM != "" {
records = append(records, server.Record{
ID: "dkim1", Type: server.TypeTXT, Name: "default._domainkey." + zone + ".",
Value: fmt.Sprintf("v=DKIM1; k=rsa; p=%s", req.DKIM), TTL: 3600,
})
}
var added int
for _, rec := range records {
if err := a.store.AddRecord(zone, rec); err != nil {
log.Printf("mail-setup: %v", err)
} else {
added++
}
}
jsonResp(w, map[string]interface{}{
"ok": true,
"message": fmt.Sprintf("Added %d mail records for %s", added, zone),
"records": records,
})
}
func (a *APIServer) handleDNSSEC(w http.ResponseWriter, r *http.Request, zone string, parts []string) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
action := ""
if len(parts) >= 3 {
action = parts[2]
}
z := a.store.Get(zone)
if z == nil {
jsonError(w, "zone not found", http.StatusNotFound)
return
}
switch action {
case "enable":
z.DNSSEC = true
a.store.Save(z)
jsonResp(w, map[string]interface{}{
"ok": true,
"message": fmt.Sprintf("DNSSEC enabled for %s (zone signing keys generated)", zone),
})
case "disable":
z.DNSSEC = false
a.store.Save(z)
jsonResp(w, map[string]interface{}{"ok": true, "message": "DNSSEC disabled for " + zone})
default:
jsonError(w, "use /dnssec/enable or /dnssec/disable", http.StatusBadRequest)
}
}
// ── Hosts File Management ────────────────────────────────────────────
func (a *APIServer) handleHosts(w http.ResponseWriter, r *http.Request) {
hosts := a.dns.GetHosts()
switch r.Method {
case "GET":
entries := hosts.List()
jsonResp(w, map[string]interface{}{
"ok": true,
"entries": entries,
"count": len(entries),
"path": a.cfg.HostsFile,
})
case "POST":
var req struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
Aliases []string `json:"aliases"`
Comment string `json:"comment"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if err := hosts.Add(req.IP, req.Hostname, req.Aliases, req.Comment); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResp(w, map[string]interface{}{"ok": true, "message": fmt.Sprintf("Added %s -> %s", req.Hostname, req.IP)})
case "DELETE":
var req struct {
Hostname string `json:"hostname"`
All bool `json:"all"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.All {
n := hosts.Clear()
jsonResp(w, map[string]interface{}{"ok": true, "cleared": n})
return
}
if req.Hostname == "" {
jsonError(w, "hostname required", http.StatusBadRequest)
return
}
if hosts.Remove(req.Hostname) {
jsonResp(w, map[string]interface{}{"ok": true})
} else {
jsonError(w, "host not found", http.StatusNotFound)
}
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (a *APIServer) handleHostsImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct {
Content string `json:"content"` // hosts-file format text
Path string `json:"path"` // or load from file path
Clear bool `json:"clear"` // clear existing before import
}
json.NewDecoder(r.Body).Decode(&req)
hosts := a.dns.GetHosts()
if req.Clear {
hosts.Clear()
}
if req.Path != "" {
if err := hosts.LoadFile(req.Path); err != nil {
jsonError(w, fmt.Sprintf("failed to load %s: %v", req.Path, err), http.StatusBadRequest)
return
}
a.cfg.HostsFile = req.Path
jsonResp(w, map[string]interface{}{
"ok": true,
"message": fmt.Sprintf("Loaded hosts from %s", req.Path),
"count": hosts.Count(),
})
return
}
if req.Content != "" {
count := hosts.LoadFromText(req.Content)
jsonResp(w, map[string]interface{}{
"ok": true,
"imported": count,
"total": hosts.Count(),
})
return
}
jsonError(w, "content or path required", http.StatusBadRequest)
}
func (a *APIServer) handleHostsExport(w http.ResponseWriter, r *http.Request) {
hosts := a.dns.GetHosts()
content := hosts.Export()
format := r.URL.Query().Get("format")
if format == "raw" {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", `attachment; filename="hosts"`)
w.Write([]byte(content))
return
}
jsonResp(w, map[string]interface{}{
"ok": true,
"content": content,
"count": hosts.Count(),
})
}
// ── Encryption (DoT / DoH) ──────────────────────────────────────────
func (a *APIServer) handleEncryption(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
status := a.dns.GetEncryptionStatus()
status["ok"] = true
jsonResp(w, status)
case "PUT", "POST":
var req struct {
EnableDoT *bool `json:"enable_dot"`
EnableDoH *bool `json:"enable_doh"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
dot := a.cfg.EnableDoT
doh := a.cfg.EnableDoH
if req.EnableDoT != nil {
dot = *req.EnableDoT
}
if req.EnableDoH != nil {
doh = *req.EnableDoH
}
a.dns.SetEncryption(dot, doh)
jsonResp(w, map[string]interface{}{
"ok": true,
"message": fmt.Sprintf("Encryption updated: DoT=%v DoH=%v", dot, doh),
})
default:
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (a *APIServer) handleEncryptionTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var req struct {
Server string `json:"server"` // IP or IP:port
Mode string `json:"mode"` // "dot", "doh", or "plain"
Domain string `json:"domain"` // test domain (default: google.com)
}
json.NewDecoder(r.Body).Decode(&req)
if req.Server == "" {
req.Server = "8.8.8.8:53"
}
if req.Domain == "" {
req.Domain = "google.com"
}
if req.Mode == "" {
req.Mode = "dot"
}
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(req.Domain), dns.TypeA)
msg.RecursionDesired = true
start := time.Now()
var resp *dns.Msg
var testErr error
var method string
switch req.Mode {
case "doh":
resp, testErr = a.dns.GetResolver().QueryUpstreamDoH(msg, req.Server)
method = "DNS-over-HTTPS"
case "dot":
resp, testErr = a.dns.GetResolver().QueryUpstreamDoT(msg, req.Server)
method = "DNS-over-TLS"
default:
c := &dns.Client{Timeout: 5 * time.Second}
resp, _, testErr = c.Exchange(msg, req.Server)
method = "Plain DNS"
}
latency := time.Since(start)
result := map[string]interface{}{
"ok": testErr == nil,
"method": method,
"server": req.Server,
"domain": req.Domain,
"latency": latency.String(),
}
if testErr != nil {
result["error"] = testErr.Error()
}
if resp != nil {
result["rcode"] = dns.RcodeToString[resp.Rcode]
result["answers"] = len(resp.Answer)
var ips []string
for _, ans := range resp.Answer {
if a, ok := ans.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
result["ips"] = ips
}
jsonResp(w, result)
}
// ── Helpers ──────────────────────────────────────────────────────────
func jsonResp(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": msg})
}
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}