Files
rmm-openwrt/agent/go/cmd/rmm-agent/direct_dns.go
2026-07-21 04:03:49 +03:00

151 lines
4.3 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
var nonPublicAddressPrefixes = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("2001:db8::/32"),
}
type directDNSUpdateState struct {
nextAttempt time.Time
}
func (s directDNSUpdateState) due(now time.Time) bool {
return s.nextAttempt.IsZero() || !now.Before(s.nextAttempt)
}
func (s *directDNSUpdateState) schedule(now time.Time, interval time.Duration, updateErr error) {
if interval < time.Minute {
interval = 5 * time.Minute
}
if updateErr != nil && interval > time.Minute {
interval = time.Minute
}
s.nextAttempt = now.Add(interval)
}
func updateDirectDNS(ctx context.Context, client *http.Client, cfg config) error {
discoveryContext, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
type result struct {
address string
ipv4 bool
err error
}
results := make(chan result, 2)
requests := 0
for _, request := range []struct {
endpoint string
ipv4 bool
}{{cfg.PublicIPv4URL, true}, {cfg.PublicIPv6URL, false}} {
if strings.TrimSpace(request.endpoint) == "" {
continue
}
requests++
go func(endpoint string, ipv4 bool) {
address, err := fetchPublicIPAddress(discoveryContext, client, endpoint, ipv4)
results <- result{address: address, ipv4: ipv4, err: err}
}(request.endpoint, request.ipv4)
}
var ipv4, ipv6 string
var discoveryErrors []string
for range requests {
result := <-results
if result.err != nil {
discoveryErrors = append(discoveryErrors, result.err.Error())
continue
}
if result.ipv4 {
ipv4 = result.address
} else {
ipv6 = result.address
}
}
if ipv4 == "" && ipv6 == "" {
if len(discoveryErrors) == 0 {
return errors.New("no public IP discovery service is configured")
}
return fmt.Errorf("public IP discovery failed: %s", strings.Join(discoveryErrors, "; "))
}
body := map[string]string{
"device_id": cfg.DeviceID,
"ipv4": ipv4,
"ipv6": ipv6,
}
return postJSON(ctx, client, cfg.ServerURL+"/api/agent/dns/update", cfg.DeviceToken, body, nil)
}
func fetchPublicIPAddress(ctx context.Context, client *http.Client, endpoint string, wantIPv4 bool) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return "", errors.New("public IP discovery URL must use HTTPS without credentials")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return "", err
}
request.Header.Set("Accept", "text/plain")
discoveryClient := *client
discoveryClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
response, err := discoveryClient.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return "", fmt.Errorf("public IP discovery returned HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, 129))
if err != nil {
return "", err
}
if len(data) > 128 {
return "", errors.New("public IP discovery response is too large")
}
address, err := normalizePublicAddress(string(data), wantIPv4)
if err != nil {
return "", err
}
return address, nil
}
func normalizePublicAddress(raw string, wantIPv4 bool) (string, error) {
address, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return "", errors.New("public IP discovery returned an invalid address")
}
address = address.Unmap()
if address.Is4() != wantIPv4 || !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsMulticast() || address.IsUnspecified() {
return "", errors.New("public IP discovery returned a non-public address")
}
for _, prefix := range nonPublicAddressPrefixes {
if prefix.Contains(address) {
return "", errors.New("public IP discovery returned a reserved address")
}
}
return address.String(), nil
}