dnsquery/cmd/server/main.go
Ubuntu ec23258cc9 feat: launch DNS query MVP
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 17:42:44 +00:00

105 lines
9.1 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"time"
)
var allowedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "MX": true, "NS": true, "TXT": true, "SOA": true, "CAA": true}
type resolver struct{ Name, Region, Country, City, URL string }
type record struct { Value string `json:"value"`; TTL uint32 `json:"ttl"` }
type result struct { Node resolverView `json:"node"`; Status string `json:"status"`; Records []record `json:"records"`; ResponseTime int64 `json:"responseTime"`; Error string `json:"error,omitempty"` }
type resolverView struct { Name string `json:"name"`; Region string `json:"region"`; Country string `json:"country"`; City string `json:"city"` }
type query struct { ID string `json:"queryId"`; Domain string `json:"domain"`; Type string `json:"type"`; Status string `json:"status"`; CreatedAt time.Time `json:"createdAt"`; Results []result `json:"results,omitempty"`; Subscribers map[chan result]struct{} `json:"-"`; mu sync.RWMutex }
type store struct { mu sync.RWMutex; queries map[string]*query }
var nodes = []resolver{
{"Cloudflare", "North America", "United States", "Global Anycast", "https://cloudflare-dns.com/dns-query"},
{"Google", "North America", "United States", "Global Anycast", "https://dns.google/resolve"},
{"AdGuard", "Europe", "Cyprus", "Global Anycast", "https://dns.adguard-dns.com/resolve"},
{"AliDNS", "Asia", "China", "Global Anycast", "https://dns.alidns.com/resolve"},
}
func main() {
s := &store{queries: make(map[string]*query)}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json"); io.WriteString(w, `{"status":"ok"}`) })
mux.HandleFunc("POST /api/dns/query", s.create)
mux.HandleFunc("GET /api/dns/query/{id}", s.get)
mux.HandleFunc("GET /api/dns/query/{id}/events", s.events)
server := &http.Server{Addr: env("ADDR", ":8080"), Handler: security(rateLimit(mux)), ReadHeaderTimeout: 5 * time.Second}
go s.cleanup()
go func() { slog.Info("server started", "addr", server.Addr); if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { slog.Error("server stopped", "error", err); os.Exit(1) } }()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM); defer stop(); <-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second); defer cancel(); _ = server.Shutdown(shutdown)
}
func (s *store) create(w http.ResponseWriter, r *http.Request) {
var body struct { Domain string `json:"domain"`; Type string `json:"type"` }
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { problem(w, 400, "请求格式无效"); return }
domain, err := normalizeDomain(body.Domain); kind := strings.ToUpper(strings.TrimSpace(body.Type))
if err != nil { problem(w, 400, err.Error()); return }; if !allowedTypes[kind] { problem(w, 400, "不支持的记录类型"); return }
q := &query{ID: newID(), Domain: domain, Type: kind, Status: "running", CreatedAt: time.Now().UTC(), Subscribers: make(map[chan result]struct{})}
s.mu.Lock(); s.queries[q.ID] = q; s.mu.Unlock(); go s.run(q)
writeJSON(w, 202, q)
}
func (s *store) get(w http.ResponseWriter, r *http.Request) { q := s.find(r.PathValue("id")); if q == nil { problem(w, 404, "查询不存在或已过期"); return }; q.mu.RLock(); defer q.mu.RUnlock(); writeJSON(w, 200, q) }
func (s *store) events(w http.ResponseWriter, r *http.Request) {
q := s.find(r.PathValue("id")); if q == nil { problem(w, 404, "查询不存在或已过期"); return }
flusher, ok := w.(http.Flusher); if !ok { problem(w, 500, "不支持事件流"); return }
w.Header().Set("Content-Type", "text/event-stream"); w.Header().Set("Cache-Control", "no-cache"); w.Header().Set("X-Accel-Buffering", "no")
ch := make(chan result, len(nodes)); q.mu.Lock(); existing := append([]result(nil), q.Results...); done := q.Status == "completed"; if !done { q.Subscribers[ch] = struct{}{} }; q.mu.Unlock()
defer func() { q.mu.Lock(); delete(q.Subscribers, ch); q.mu.Unlock() }()
for _, item := range existing { sendEvent(w, "result", item) }; if done { sendEvent(w, "complete", map[string]any{"count": len(existing)}); flusher.Flush(); return }; flusher.Flush()
for { select { case item, open := <-ch: if !open { sendEvent(w, "complete", map[string]any{"count": len(nodes)}); flusher.Flush(); return }; sendEvent(w, "result", item); flusher.Flush(); case <-r.Context().Done(): return } }
}
func (s *store) run(q *query) { var wg sync.WaitGroup; for _, n := range nodes { wg.Add(1); go func() { defer wg.Done(); item := resolve(q.Domain, q.Type, n); q.mu.Lock(); q.Results = append(q.Results, item); for ch := range q.Subscribers { ch <- item }; q.mu.Unlock() }() }; wg.Wait(); q.mu.Lock(); q.Status = "completed"; for ch := range q.Subscribers { close(ch); delete(q.Subscribers, ch) }; q.mu.Unlock() }
func resolve(domain, kind string, n resolver) result {
view := resolverView{n.Name, n.Region, n.Country, n.City}; started := time.Now(); ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second); defer cancel()
u, _ := url.Parse(n.URL); params := u.Query(); params.Set("name", domain); params.Set("type", kind); u.RawQuery = params.Encode()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil); req.Header.Set("Accept", "application/dns-json"); resp, err := http.DefaultClient.Do(req); elapsed := time.Since(started).Milliseconds()
if err != nil { status := "failed"; if errors.Is(ctx.Err(), context.DeadlineExceeded) { status = "timeout" }; return result{Node: view, Status: status, ResponseTime: elapsed, Error: "解析器请求失败"} }; defer resp.Body.Close()
if resp.StatusCode != 200 { return result{Node: view, Status: "failed", ResponseTime: elapsed, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)} }
var payload struct { Status int `json:"Status"`; Answer []struct { Data string `json:"data"`; TTL uint32 `json:"TTL"` } `json:"Answer"` }
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload) != nil || payload.Status != 0 { return result{Node: view, Status: "failed", ResponseTime: elapsed, Error: fmt.Sprintf("DNS 状态 %d", payload.Status)} }
if len(payload.Answer) == 0 { return result{Node: view, Status: "no_record", ResponseTime: elapsed, Records: []record{}} }
records := make([]record, 0, len(payload.Answer)); for _, a := range payload.Answer { records = append(records, record{strings.TrimSuffix(a.Data, "."), a.TTL}) }; sort.Slice(records, func(i,j int) bool{return records[i].Value<records[j].Value})
return result{Node: view, Status: "success", Records: records, ResponseTime: elapsed}
}
func normalizeDomain(input string) (string, error) { input = strings.TrimSpace(input); if !strings.Contains(input, "://") { input = "//" + input }; u, err := url.Parse(input); if err != nil || u.Hostname() == "" { return "", errors.New("请输入合法域名") }; host := strings.TrimSuffix(strings.ToLower(u.Hostname()), "."); if len(host)>253 || net.ParseIP(host)!=nil || host=="localhost" || !validLabels(host) { return "", errors.New("请输入合法的公网域名") }; return host,nil }
func validLabels(host string) bool { labels:=strings.Split(host,"."); if len(labels)<2{return false}; for _,l:=range labels { if len(l)<1||len(l)>63||l[0]=='-'||l[len(l)-1]=='-'{return false}; for _,c:=range l {if !(c>='a'&&c<='z'||c>='0'&&c<='9'||c=='-'){return false}} }; return true }
func (s *store) find(id string)*query{s.mu.RLock();defer s.mu.RUnlock();return s.queries[id]}
func (s *store) cleanup(){ticker:=time.NewTicker(time.Minute);defer ticker.Stop();for range ticker.C{s.mu.Lock();for id,q:=range s.queries{if time.Since(q.CreatedAt)>5*time.Minute{delete(s.queries,id)}};s.mu.Unlock()}}
func sendEvent(w io.Writer,name string,data any){b,_:=json.Marshal(data);fmt.Fprintf(w,"event: %s\ndata: %s\n\n",name,b)}
func writeJSON(w http.ResponseWriter,status int,v any){w.Header().Set("Content-Type","application/json");w.WriteHeader(status);_ = json.NewEncoder(w).Encode(v)}
func problem(w http.ResponseWriter,status int,msg string){writeJSON(w,status,map[string]string{"error":msg})}
func newID()string{b:=make([]byte,6);_,_=rand.Read(b);return "dns_"+hex.EncodeToString(b)}
func env(k,d string)string{if v:=os.Getenv(k);v!=""{return v};return d}
func security(next http.Handler) http.Handler{return http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){w.Header().Set("X-Content-Type-Options","nosniff");w.Header().Set("X-Frame-Options","DENY");w.Header().Set("Referrer-Policy","same-origin");next.ServeHTTP(w,r)})}
var visitors sync.Map
func rateLimit(next http.Handler) http.Handler{return http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){if r.Method=="POST" {ip,_,_:=net.SplitHostPort(r.RemoteAddr);now:=time.Now();raw,_:=visitors.LoadOrStore(ip,&struct{sync.Mutex;start time.Time;n int}{start:now});v:=raw.(*struct{sync.Mutex;start time.Time;n int});v.Lock();if now.Sub(v.start)>time.Minute{v.start=now;v.n=0};v.n++;blocked:=v.n>30;v.Unlock();if blocked{problem(w,429,"请求过于频繁,请稍后重试");return}};next.ServeHTTP(w,r)})}