151 lines
4.3 KiB
Go
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
|
|
}
|