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>
1082 lines
31 KiB
Go
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
|
|
}
|