首次推送

This commit is contained in:
sansen
2026-07-20 19:01:03 +08:00
parent ea01b9cf99
commit ef1bf61f9f
4484 changed files with 937163 additions and 1 deletions
@@ -0,0 +1,295 @@
package discovery
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
var (
errEmptyAddress = errors.New("empty address")
errNicknameNotFound = errors.New("nickname not found")
errEmptyResponseBody = errors.New("empty response")
errTSDNSNotFound = errors.New("not found")
)
const (
TsDnsDefaultPort = "41144"
NicknameLookup = "https://named.myteamspeak.com/lookup"
CacheTTL = 10 * time.Minute
)
// ResolvedAddr is one resolved host:port and how it was obtained.
type ResolvedAddr struct {
Expiry time.Time
Addr string
Source string
}
// Resolver resolves TeamSpeak-style addresses (nickname, SRV, TSDNS) with TTL cache.
type Resolver struct {
log *slog.Logger
cache map[string][]ResolvedAddr
mu sync.RWMutex
}
// NewResolver returns a Resolver using log for debug tracing (nil → slog.Default).
func NewResolver(log *slog.Logger) *Resolver {
if log == nil {
log = slog.Default()
}
return &Resolver{
log: log,
cache: make(map[string][]ResolvedAddr),
}
}
// Resolve tries, in order: MyTeamSpeak nickname, _ts3._udp SRV, TSDNS via SRV and :41144, then plain DNS.
func (r *Resolver) Resolve(ctx context.Context, inputAddr string) ([]ResolvedAddr, error) {
if inputAddr == "" {
return nil, errEmptyAddress
}
if cached, ok := r.getValidCache(inputAddr); ok {
return cached, nil
}
host, port := splitHostPortOrDefault(inputAddr)
if ip := net.ParseIP(host); ip != nil {
return []ResolvedAddr{{Addr: net.JoinHostPort(host, port), Source: "Direct"}}, nil
}
if !strings.Contains(host, ".") && host != "localhost" {
if nickAddr, ok := r.resolveNicknameAddr(ctx, host); ok {
return r.Resolve(ctx, nickAddr)
}
}
if results, ok := r.resolveSRV(ctx, host); ok {
return r.setCache(inputAddr, results), nil
}
domainList := getDomainList(host)
if tsdnsAddr, ok := r.resolveTSDNSSRV(ctx, domainList, host); ok {
results := []ResolvedAddr{{Addr: tsdnsAddr, Source: "TSDNS-SRV"}}
return r.setCache(inputAddr, results), nil
}
if tsdnsAddr, ok := r.resolveTSDNSDirect(ctx, domainList, host); ok {
results := []ResolvedAddr{{Addr: tsdnsAddr, Source: "TSDNS-Direct"}}
return r.setCache(inputAddr, results), nil
}
r.log.Debug("falling back to direct dns", slog.String("host", host), slog.String("port", port))
results := []ResolvedAddr{{
Addr: net.JoinHostPort(host, port),
Source: "Direct",
}}
return r.setCache(inputAddr, results), nil
}
func (r *Resolver) getValidCache(inputAddr string) ([]ResolvedAddr, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
cached, ok := r.cache[inputAddr]
if !ok || len(cached) == 0 || time.Now().After(cached[0].Expiry) {
return nil, false
}
r.log.Debug("cache hit", slog.String("addr", inputAddr), slog.String("source", cached[0].Source))
return cached, true
}
func splitHostPortOrDefault(inputAddr string) (string, string) {
host, port, err := net.SplitHostPort(inputAddr)
if err != nil {
return inputAddr, "9987"
}
return host, port
}
func (r *Resolver) resolveNicknameAddr(ctx context.Context, host string) (string, bool) {
r.log.Debug("trying nickname resolution", slog.String("nickname", host))
nickAddr, err := resolveNickname(ctx, host)
if err != nil || nickAddr == "" {
r.log.Debug("nickname resolution failed", slog.String("nickname", host), slog.Any("error", err))
return "", false
}
r.log.Debug("nickname resolved", slog.String("nickname", host), slog.String("result", nickAddr))
return nickAddr, true
}
func (r *Resolver) resolveSRV(ctx context.Context, host string) ([]ResolvedAddr, bool) {
r.log.Debug("trying dns srv", slog.String("host", host))
_, srvs, err := net.DefaultResolver.LookupSRV(ctx, "ts3", "udp", host)
if err != nil || len(srvs) == 0 {
r.log.Debug("dns srv failed", slog.String("host", host), slog.Any("error", err))
return nil, false
}
results := make([]ResolvedAddr, 0, len(srvs))
for _, srv := range srvs {
target := strings.TrimSuffix(srv.Target, ".")
results = append(results, ResolvedAddr{
Addr: net.JoinHostPort(target, strconv.FormatUint(uint64(srv.Port), 10)),
Source: "SRV",
})
}
r.log.Debug("dns srv succeeded", slog.String("host", host), slog.String("result", results[0].Addr))
return results, true
}
func (r *Resolver) resolveTSDNSSRV(ctx context.Context, domains []string, queryHost string) (string, bool) {
for _, domain := range domains {
r.log.Debug("trying tsdns srv", slog.String("domain", domain))
_, srvs, err := net.DefaultResolver.LookupSRV(ctx, "tsdns", "tcp", domain)
if err != nil || len(srvs) == 0 {
r.log.Debug("tsdns srv failed", slog.String("domain", domain))
continue
}
for _, srv := range srvs {
target := strings.TrimSuffix(srv.Target, ".")
tsdnsAddr, queryErr := queryTSDNS(
ctx, net.JoinHostPort(target, strconv.FormatUint(uint64(srv.Port), 10)), queryHost,
)
if queryErr == nil && tsdnsAddr != "" {
r.log.Debug("tsdns srv succeeded", slog.String("domain", domain), slog.String("result", tsdnsAddr))
return tsdnsAddr, true
}
}
}
return "", false
}
func (r *Resolver) resolveTSDNSDirect(ctx context.Context, domains []string, queryHost string) (string, bool) {
for _, domain := range domains {
r.log.Debug("trying tsdns direct", slog.String("domain", domain))
tsdnsAddr, err := queryTSDNS(ctx, net.JoinHostPort(domain, TsDnsDefaultPort), queryHost)
if err == nil && tsdnsAddr != "" {
r.log.Debug("tsdns direct succeeded", slog.String("domain", domain), slog.String("result", tsdnsAddr))
return tsdnsAddr, true
}
r.log.Debug("tsdns direct failed", slog.String("domain", domain))
}
return "", false
}
func (r *Resolver) setCache(key string, results []ResolvedAddr) []ResolvedAddr {
expiry := time.Now().Add(CacheTTL)
for i := range results {
results[i].Expiry = expiry
}
r.mu.Lock()
r.cache[key] = results
r.mu.Unlock()
return results
}
// getDomainList returns host suffixes ordered longest-first (for TSDNS), capped at three.
func getDomainList(host string) []string {
parts := strings.Split(host, ".")
list := make([]string, 0, len(parts)-1)
for i := range len(parts) - 1 {
list = append(list, strings.Join(parts[i:], "."))
}
if len(list) > 3 {
return list[:3]
}
return list
}
func resolveNickname(ctx context.Context, nickname string) (string, error) {
lookupURL, err := url.Parse(NicknameLookup)
if err != nil {
return "", err
}
query := lookupURL.Query()
query.Set("name", nickname)
lookupURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL.String(), nil)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", errNicknameNotFound
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
lines := strings.Split(string(body), "\n")
if len(lines) > 0 && lines[0] != "" {
return strings.TrimSpace(lines[0]), nil
}
return "", errEmptyResponseBody
}
func queryTSDNS(ctx context.Context, tsdnsFullAddr, queryHost string) (string, error) {
d := net.Dialer{Timeout: 2 * time.Second}
conn, err := d.DialContext(ctx, "tcp", tsdnsFullAddr)
if err != nil {
return "", err
}
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
_, err = fmt.Fprintf(conn, "%s\n", queryHost)
if err != nil {
return "", err
}
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" || line == "404" || line == "errors" {
return "", errTSDNSNotFound
}
return line, nil
}