Compare commits
2 Commits
agent/dock
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7379d6e6e7 | ||
|
|
ae0604ab68 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
compose.yaml
|
||||
*_test.go
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY main.go ./
|
||||
COPY web ./web
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /dns-orbit .
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /dns-orbit /dns-orbit
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/dns-orbit"]
|
||||
@ -1,2 +1,9 @@
|
||||
# dnsquery
|
||||
# DNS Orbit
|
||||
|
||||
面向全球公共 DNS 节点的实时解析观测工具。支持 A、AAAA、CNAME、MX、NS、TXT、SOA、CAA,查询结果通过 SSE 流式返回。
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
打开 `http://localhost:8080`。健康检查:`GET /api/health`。
|
||||
|
||||
13
compose.yaml
Normal file
13
compose.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
services:
|
||||
dns-orbit:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
PORT: "8080"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "/dns-orbit", "--healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
278
main.go
Normal file
278
main.go
Normal file
@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
|
||||
type resolver struct{ Name, Country, City, Region, URL string }
|
||||
type record struct {
|
||||
Value string `json:"value"`
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
type result struct {
|
||||
Resolver string `json:"resolver"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Status string `json:"status"`
|
||||
Records []record `json:"records"`
|
||||
ResponseTime int64 `json:"responseTime"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
type query struct {
|
||||
ID, Domain, Type string
|
||||
Created time.Time
|
||||
Results chan result
|
||||
}
|
||||
type app struct {
|
||||
mu sync.RWMutex
|
||||
queries map[string]*query
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var resolvers = []resolver{
|
||||
{"Cloudflare", "全球", "Anycast", "全球", "https://cloudflare-dns.com/dns-query"},
|
||||
{"Google", "全球", "Anycast", "全球", "https://dns.google/resolve"},
|
||||
{"Quad9", "瑞士", "Zurich", "欧洲", "https://dns.quad9.net/dns-query"},
|
||||
{"AdGuard", "塞浦路斯", "Limassol", "欧洲", "https://dns.adguard-dns.com/resolve"},
|
||||
{"AliDNS", "中国", "Hangzhou", "亚洲", "https://dns.alidns.com/resolve"},
|
||||
{"DNSPod", "中国", "Shanghai", "亚洲", "https://doh.pub/dns-query"},
|
||||
{"360 Secure DNS", "中国", "Beijing", "亚洲", "https://doh.360.cn/dns-query"},
|
||||
{"Tencent EdgeOne", "新加坡", "Singapore", "亚洲", "https://public.dns.iij.jp/dns-query"},
|
||||
{"IIJ", "日本", "Tokyo", "亚洲", "https://public.dns.iij.jp/dns-query"},
|
||||
{"Mullvad", "瑞典", "Gothenburg", "欧洲", "https://doh.mullvad.net/dns-query"},
|
||||
{"Control D", "加拿大", "Toronto", "北美", "https://freedns.controld.com/p0"},
|
||||
{"CleanBrowsing", "美国", "Virginia", "北美", "https://doh.cleanbrowsing.org/doh/security-filter/"},
|
||||
{"OpenDNS", "美国", "San Francisco", "北美", "https://doh.opendns.com/dns-query"},
|
||||
{"NextDNS", "美国", "Anycast", "北美", "https://dns.nextdns.io"},
|
||||
{"LibreDNS", "德国", "Frankfurt", "欧洲", "https://doh.libredns.gr/dns-query"},
|
||||
{"Digitale Gesellschaft", "瑞士", "Zurich", "欧洲", "https://dns.digitale-gesellschaft.ch/dns-query"},
|
||||
{"Foundation RESTENA", "卢森堡", "Luxembourg", "欧洲", "https://kaitain.restena.lu/dns-query"},
|
||||
{"CIRA Canadian Shield", "加拿大", "Ottawa", "北美", "https://private.canadianshield.cira.ca/dns-query"},
|
||||
{"Australian DNS", "澳大利亚", "Sydney", "大洋洲", "https://dns0.eu/dns-query"},
|
||||
{"DNS0.eu", "法国", "Paris", "欧洲", "https://dns0.eu/dns-query"},
|
||||
}
|
||||
|
||||
var allowed = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "MX": true, "NS": true, "TXT": true, "SOA": true, "CAA": true}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "--healthcheck" {
|
||||
resp, err := (&http.Client{Timeout: 2 * time.Second}).Get("http://127.0.0.1:" + env("PORT", "8080") + "/api/health")
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
a := &app{queries: map[string]*query{}, client: &http.Client{Timeout: 6 * time.Second}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/dns/query", a.start)
|
||||
mux.HandleFunc("GET /api/dns/query/{id}/events", a.events)
|
||||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
mux.Handle("/web/", http.StripPrefix("/web", staticHandler()))
|
||||
mux.Handle("/", staticHandler())
|
||||
addr := ":" + env("PORT", "8080")
|
||||
log.Printf("DNS Orbit listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, securityHeaders(mux)))
|
||||
}
|
||||
|
||||
func staticHandler() http.Handler {
|
||||
assets, err := fs.Sub(webFS, "web")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.FileServer(http.FS(assets))
|
||||
}
|
||||
|
||||
func env(k, v string) string {
|
||||
if x := os.Getenv(k); x != "" {
|
||||
return x
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func securityHeaders(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", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeDomain(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", errors.New("请输入域名")
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "", errors.New("域名格式无效")
|
||||
}
|
||||
d := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
|
||||
if len(d) > 253 || strings.Contains(d, "_") || net.ParseIP(d) != nil {
|
||||
return "", errors.New("仅支持公共域名")
|
||||
}
|
||||
parts := strings.Split(d, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", errors.New("请输入完整公共域名")
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" || len(p) > 63 || p[0] == '-' || p[len(p)-1] == '-' {
|
||||
return "", errors.New("域名格式无效")
|
||||
}
|
||||
for _, c := range p {
|
||||
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
|
||||
return "", errors.New("域名格式无效")
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (a *app) start(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Domain, Type string }
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&in) != nil {
|
||||
http.Error(w, "invalid json", 400)
|
||||
return
|
||||
}
|
||||
d, err := normalizeDomain(in.Domain)
|
||||
t := strings.ToUpper(in.Type)
|
||||
if err != nil || !allowed[t] {
|
||||
if err == nil {
|
||||
err = errors.New("不支持该记录类型")
|
||||
}
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
b := make([]byte, 6)
|
||||
rand.Read(b)
|
||||
q := &query{ID: "dns_" + hex.EncodeToString(b), Domain: d, Type: t, Created: time.Now(), Results: make(chan result, len(resolvers))}
|
||||
a.mu.Lock()
|
||||
a.queries[q.ID] = q
|
||||
a.mu.Unlock()
|
||||
go a.run(q)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"queryId": q.ID, "domain": d, "type": t, "status": "running"})
|
||||
}
|
||||
|
||||
func (a *app) run(q *query) {
|
||||
var wg sync.WaitGroup
|
||||
for _, res := range resolvers {
|
||||
wg.Add(1)
|
||||
go func(rs resolver) { defer wg.Done(); q.Results <- a.lookup(q, rs) }(res)
|
||||
}
|
||||
wg.Wait()
|
||||
close(q.Results)
|
||||
time.AfterFunc(5*time.Minute, func() { a.mu.Lock(); delete(a.queries, q.ID); a.mu.Unlock() })
|
||||
}
|
||||
|
||||
func (a *app) lookup(q *query, rs resolver) result {
|
||||
started := time.Now()
|
||||
out := result{Resolver: rs.Name, Country: rs.Country, City: rs.City, Region: rs.Region, Status: "error", Records: []record{}}
|
||||
u, err := url.Parse(rs.URL)
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
return out
|
||||
}
|
||||
params := u.Query()
|
||||
params.Set("name", q.Domain)
|
||||
params.Set("type", q.Type)
|
||||
u.RawQuery = params.Encode()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
req.Header.Set("Accept", "application/dns-json")
|
||||
resp, err := a.client.Do(req)
|
||||
out.ResponseTime = time.Since(started).Milliseconds()
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
out.Status = "timeout"
|
||||
}
|
||||
out.Error = "查询失败"
|
||||
return out
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
out.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
return out
|
||||
}
|
||||
var body struct {
|
||||
Status int `json:"Status"`
|
||||
Answer []struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
TTL int `json:"TTL"`
|
||||
Data string `json:"data"`
|
||||
} `json:"Answer"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
|
||||
out.Error = "响应格式无效"
|
||||
return out
|
||||
}
|
||||
if body.Status != 0 {
|
||||
out.Status = "error"
|
||||
out.Error = fmt.Sprintf("DNS RCODE %d", body.Status)
|
||||
return out
|
||||
}
|
||||
if len(body.Answer) == 0 {
|
||||
out.Status = "empty"
|
||||
return out
|
||||
}
|
||||
for _, v := range body.Answer {
|
||||
out.Records = append(out.Records, record{strings.Trim(v.Data, "\""), v.TTL})
|
||||
}
|
||||
sort.Slice(out.Records, func(i, j int) bool { return out.Records[i].Value < out.Records[j].Value })
|
||||
out.Status = "success"
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *app) events(w http.ResponseWriter, r *http.Request) {
|
||||
a.mu.RLock()
|
||||
q := a.queries[r.PathValue("id")]
|
||||
a.mu.RUnlock()
|
||||
if q == nil {
|
||||
http.Error(w, "query not found", 404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "stream unsupported", 500)
|
||||
return
|
||||
}
|
||||
for x := range q.Results {
|
||||
b, _ := json.Marshal(x)
|
||||
fmt.Fprintf(w, "event: result\ndata: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
}
|
||||
fmt.Fprint(w, "event: complete\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
36
main_test.go
Normal file
36
main_test.go
Normal file
@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeDomain(t *testing.T) {
|
||||
cases := map[string]string{"example.com": "example.com", "https://WWW.Example.com/a": "www.example.com", "example.com/": "example.com"}
|
||||
for in, want := range cases {
|
||||
got, err := normalizeDomain(in)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("%q: got %q, %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"localhost", "127.0.0.1", "bad_domain.com", "-bad.com"} {
|
||||
if _, err := normalizeDomain(in); err == nil {
|
||||
t.Fatalf("expected %q invalid", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebAssetRoutes(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/web/", http.StripPrefix("/web", staticHandler()))
|
||||
mux.Handle("/", staticHandler())
|
||||
|
||||
for _, path := range []string{"/", "/web/app.js", "/web/style.css"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("GET %s returned %d, want 200", path, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
6
web/app.js
Normal file
6
web/app.js
Normal file
@ -0,0 +1,6 @@
|
||||
const $=s=>document.querySelector(s), state={results:[],filter:'all',domain:'',type:''};
|
||||
$('#theme').onclick=()=>{document.body.classList.toggle('light');localStorage.theme=document.body.classList.contains('light')?'light':'dark'};if(localStorage.theme==='light')document.body.classList.add('light');
|
||||
$('#form').onsubmit=async e=>{e.preventDefault();$('#error').textContent='';$('#submit').disabled=true;state.results=[];render();try{const res=await fetch('/api/dns/query',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:$('#domain').value,type:$('#type').value})});if(!res.ok)throw new Error(await res.text());const q=await res.json();state.domain=q.domain;state.type=q.type;$('#queryTitle').textContent=`${q.domain} / ${q.type}`;$('#dashboard').hidden=false;$('#empty').hidden=true;const es=new EventSource(`/api/dns/query/${q.queryId}/events`);es.addEventListener('result',x=>{state.results.push(JSON.parse(x.data));render()});es.addEventListener('complete',()=>{es.close();$('#submit').disabled=false;$('#finished').textContent='COMPLETED '+new Date().toLocaleTimeString()})}catch(err){$('#error').textContent=err.message.trim();$('#submit').disabled=false}};
|
||||
function render(){const rs=state.results,ok=rs.filter(x=>x.status==='success'),bad=rs.filter(x=>x.status!=='success');$('#progress').textContent=`${rs.length} / 20`;$('#bar').style.width=`${rs.length*5}%`;$('#success').textContent=ok.length;$('#failed').textContent=bad.length;const signatures=new Set(ok.map(x=>x.records.map(r=>r.value).sort().join('|')));$('#consistent').textContent=!ok.length?'分析中':signatures.size===1?'一致':'不一致';$('#consistent').style.color=signatures.size>1?'var(--amber)':'var(--green)';let shown=rs.filter(x=>state.filter==='all'||state.filter==='success'&&x.status==='success'||state.filter==='issue'&&x.status!=='success');$('#rows').innerHTML=shown.map((x,i)=>`<tr><td><b>${esc(x.country)} · ${esc(x.city)}</b><small>${esc(x.region)} / ${esc(x.resolver)}</small></td><td><span class="status ${x.status==='error'?'error-s':x.status+'-s'}">${label(x.status)}</span></td><td>${x.records.length?x.records.map(r=>`<code>${esc(r.value)}</code>`).join('<br>'):'<span style="color:var(--muted)">—</span>'}</td><td>${x.records[0]?.ttl??'—'}</td><td>${x.responseTime?x.responseTime+' ms':'—'}</td><td><button class="copy" data-i="${state.results.indexOf(x)}">⧉</button></td></tr>`).join('');document.querySelectorAll('.copy').forEach(b=>b.onclick=()=>copyResult(state.results[+b.dataset.i]))}
|
||||
function label(s){return({success:'成功',timeout:'超时',empty:'无记录',error:'失败'})[s]||s}function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function copyResult(x){navigator.clipboard.writeText(`${x.resolver}\t${x.status}\t${x.records.map(r=>r.value).join(', ')}`)}
|
||||
$('#filters').onclick=e=>{if(!e.target.dataset.filter)return;document.querySelectorAll('#filters button').forEach(x=>x.classList.remove('active'));e.target.classList.add('active');state.filter=e.target.dataset.filter;render()};$('#copyAll').onclick=()=>navigator.clipboard.writeText(state.results.map(x=>`${x.country}/${x.city}\t${x.resolver}\t${label(x.status)}\t${x.records.map(r=>r.value).join(', ')}\t${x.responseTime}ms`).join('\n'));
|
||||
10
web/index.html
Normal file
10
web/index.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="DNS Orbit 全球公共 DNS 解析传播检测"><title>DNS Orbit · 全球解析观测站</title><link rel="stylesheet" href="/web/style.css"></head><body>
|
||||
<div class="grid"></div><header><a class="brand" href="/"><span class="logo">◈</span><span>DNS ORBIT<small>GLOBAL RESOLUTION GRID</small></span></a><div class="live"><i></i> 20 NODES ONLINE</div><button id="theme" aria-label="切换主题">◐</button></header>
|
||||
<main><section class="hero"><div class="eyebrow">/ GLOBAL DNS INTELLIGENCE</div><h1>看见域名解析<br><em>穿越全球的轨迹</em></h1><p>连接 20 个公共 DNS 解析节点,实时检测记录传播、一致性与响应延迟。</p>
|
||||
<form id="form"><div class="input"><span>⌁</span><input id="domain" required autocomplete="off" placeholder="输入域名,例如 example.com"><select id="type" aria-label="记录类型"><option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>NS</option><option>TXT</option><option>SOA</option><option>CAA</option></select></div><button id="submit">启动全球扫描 <b>→</b></button></form><div id="error" class="error"></div></section>
|
||||
<section id="dashboard" hidden><div class="query-head"><div><span class="eyebrow">/ LIVE TELEMETRY</span><h2 id="queryTitle"></h2></div><button id="copyAll">复制全部结果</button></div>
|
||||
<div class="stats"><article><label>扫描进度</label><strong id="progress">0 / 20</strong><div class="bar"><i id="bar"></i></div></article><article><label>成功节点</label><strong id="success">0</strong><small>RESOLVED</small></article><article><label>异常节点</label><strong id="failed">0</strong><small>ISSUES</small></article><article><label>一致性</label><strong id="consistent">分析中</strong><small>CONSENSUS</small></article></div>
|
||||
<div class="toolbar"><div id="filters"><button class="active" data-filter="all">全部</button><button data-filter="success">成功</button><button data-filter="issue">异常</button></div><span id="finished"></span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>区域 / 节点</th><th>状态</th><th>DNS 结果</th><th>TTL</th><th>响应时间</th><th></th></tr></thead><tbody id="rows"></tbody></table></div></section>
|
||||
<section id="empty" class="empty"><div class="radar"><i></i><i></i><i></i><b>◎</b></div><p>等待扫描指令</p><small>AWAITING TARGET DOMAIN</small></section></main>
|
||||
<footer><span>DNS ORBIT / 2026</span><span>NO LOGS · NO TRACKING · OPEN ACCESS</span></footer><script src="/web/app.js"></script></body></html>
|
||||
1
web/style.css
Normal file
1
web/style.css
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user