首次推送
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user