Files
ctrld/internal/controld/config.go
T
Cuong Manh Le 052b057756 all: keep the first-attempt error when the direct-ip fallback also fails
Both API requests and binary downloads retry against a hard-coded IP when the
attempt via hostname fails. Both then overwrote the first error with the
fallback's, so only the last failure was reported.

That discarded the diagnosis. During the Firewall Mode incident the hostname
attempt was denied locally - WSAEACCES, "An attempt was made to access a socket
in a way forbidden by its access permissions", which means the host is blocking
ctrld - while the direct-ip fallback failed with an unreachable IPv6 route. What
surfaced to the operator was "dial tcp6: no route to host", pointing at a
routing problem that did not exist, while the WSAEACCES that named the real
cause was visible only in debug logs.

Report both failures instead, keeping the error chain intact so errors.Is still
matches either one. Also switch the final wrap in doWithRetry from %v to %w,
which had been flattening the chain even when a single error was reported.

Add coverage for both paths, including that the fallback is still attempted and
that a successful fallback returns no error.

This also changes retry classification, which is worth stating explicitly because
it is not obvious from "report both errors". processCDFlags decides whether to
keep backing off with errUrlNetworkError, which uses errors.As - and errors.As
returns the first match in the tree. Wrapping the hostname attempt first
therefore hands the predicate that attempt's failure, where previously only the
fallback's error survived to be classified.

The effect is intended. A locally denied socket (WSAEACCES) is not a transient
network error, so preflight now fails fast and reports instead of retrying
against a firewall that is not going to clear on its own - the incident logged
256 retry cycles doing exactly that. The case that justifies retrying forever, a
network unreachable on both attempts at boot, is unchanged. Both classifications
are pinned by tests, along with the wrap order they depend on at each composition
site, so reversing it fails loudly rather than silently restoring the old
behaviour.
2026-08-14 15:29:32 +07:00

434 lines
14 KiB
Go

package controld
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"runtime"
"strings"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/certs"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
const (
apiDomainCom = "api.controld.com"
apiDomainComIPv4 = "147.185.34.1"
apiDomainComIPv6 = "2606:1a40:3::1"
apiDomainDev = "api.controld.dev"
apiDomainDevIPv4 = "23.171.240.84"
apiURLCom = "https://api.controld.com"
apiURLDev = "https://api.controld.dev"
resolverDataURLCom = apiURLCom + "/utility"
resolverDataURLDev = apiURLDev + "/utility"
logURLCom = apiURLCom + "/logs"
logURLDev = apiURLDev + "/logs"
InvalidConfigCode = 40402
defaultTimeout = 20 * time.Second
sendLogTimeout = 300 * time.Second
)
// ResolverConfig represents Control D resolver data.
type ResolverConfig struct {
DOH string `json:"doh"`
Ctrld struct {
CustomConfig string `json:"custom_config"`
CustomLastUpdate int64 `json:"custom_last_update"`
VersionTarget string `json:"version_target"`
} `json:"ctrld"`
Exclude []string `json:"exclude"`
UID string `json:"uid"`
DeactivationPin *int64 `json:"deactivation_pin,omitempty"`
}
type utilityResponse struct {
Success bool `json:"success"`
Body struct {
Resolver ResolverConfig `json:"resolver"`
} `json:"body"`
}
type ErrorResponse struct {
ErrorField struct {
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
// StatusCode is the HTTP status the API answered with. It is not part of the JSON
// body: this type is built for *any* non-200 whose body decodes, so the body alone
// cannot tell a permanent rejection of the request from a transient server-side
// failure, and callers that act differently on the two need the status to tell them
// apart. Zero means the status was not recorded.
StatusCode int `json:"-"`
}
func (u ErrorResponse) Error() string {
return u.ErrorField.Message
}
// apiErrorFromResponse builds the error for a non-200 API answer, recording the HTTP
// status alongside the decoded body.
//
// The status is what tells a caller whether the answer will change on a retry: this type
// is built for every non-200 whose body decodes, so a 502 from a load balancer and a 404
// for a deleted device are otherwise indistinguishable. Both response paths go through
// here so neither can decode a body and forget to record it.
func apiErrorFromResponse(statusCode int, d *json.Decoder) (*ErrorResponse, error) {
errResp := &ErrorResponse{StatusCode: statusCode}
if err := d.Decode(errResp); err != nil {
return nil, err
}
// Decode fills exported fields from the body; StatusCode is json:"-", so it survives.
errResp.StatusCode = statusCode
return errResp, nil
}
type utilityRequest struct {
UID string `json:"uid"`
ClientID string `json:"client_id,omitempty"`
Metadata map[string]string `json:"metadata"`
}
// UtilityOrgRequest contains request data for calling Org API.
type UtilityOrgRequest struct {
ProvToken string `json:"prov_token"`
Hostname string `json:"hostname"`
Metadata map[string]string `json:"metadata"`
}
// ResolverConfigRequest contains request data for fetching resolver config.
type ResolverConfigRequest struct {
RawUID string
Version string
Metadata map[string]string
}
// LogsRequest contains request data for sending runtime logs to API.
type LogsRequest struct {
UID string `json:"uid"`
Data io.ReadCloser `json:"-"`
}
// FetchResolverConfig fetch Control D config for a given request.
func FetchResolverConfig(ctx context.Context, req *ResolverConfigRequest, cdDev bool) (*ResolverConfig, error) {
logger := ctrld.LoggerFromCtx(ctx)
ctrld.Log(ctx, logger.Debug(), "Fetching ControlD resolver configuration")
uid, clientID := ParseRawUID(req.RawUID)
ctrld.Log(ctx, logger.Debug(), "Parsed UID: %s, ClientID: %s", uid, clientID)
uReq := utilityRequest{
UID: uid,
Metadata: req.Metadata,
}
if clientID != "" {
uReq.ClientID = clientID
ctrld.Log(ctx, logger.Debug(), "Including client ID in request")
}
body, _ := json.Marshal(uReq)
ctrld.Log(ctx, logger.Debug(), "Sending resolver config request to ControlD API")
return postUtilityAPI(ctx, req.Version, cdDev, false, bytes.NewReader(body))
}
// FetchResolverUID fetch resolver uid from a given request.
func FetchResolverUID(ctx context.Context, req *UtilityOrgRequest, version string, cdDev bool) (*ResolverConfig, error) {
logger := ctrld.LoggerFromCtx(ctx)
ctrld.Log(ctx, logger.Debug(), "Fetching resolver UID from provision token")
if req == nil {
ctrld.Log(ctx, logger.Error(), "Invalid request: request is nil")
return nil, errors.New("invalid request")
}
hostname := req.Hostname
if req.Hostname == "" {
hostname, _ = preferredHostname()
ctrld.Log(ctx, logger.Debug(), "Using system hostname: %s", hostname)
req.Hostname = hostname
} else {
ctrld.Log(ctx, logger.Debug(), "Using provided hostname: %s", hostname)
}
// Include all hostname sources in metadata so the API can pick the
// best one if the primary looks generic (e.g., "Mac", "Mac.lan").
if req.Metadata == nil {
req.Metadata = make(map[string]string)
}
for k, v := range hostnameHints() {
req.Metadata["hostname_"+k] = v
}
ctrld.Log(ctx, logger.Debug(), "Sending UID request to ControlD API")
body, _ := json.Marshal(req)
return postUtilityAPI(ctx, version, cdDev, false, bytes.NewReader(body))
}
// UpdateCustomLastFailed calls API to mark custom config is bad.
func UpdateCustomLastFailed(ctx context.Context, rawUID, version string, cdDev, lastUpdatedFailed bool) (*ResolverConfig, error) {
uid, clientID := ParseRawUID(rawUID)
req := utilityRequest{UID: uid}
if clientID != "" {
req.ClientID = clientID
}
body, _ := json.Marshal(req)
return postUtilityAPI(ctx, version, cdDev, lastUpdatedFailed, bytes.NewReader(body))
}
func postUtilityAPI(ctx context.Context, version string, cdDev, lastUpdatedFailed bool, body io.Reader) (*ResolverConfig, error) {
logger := ctrld.LoggerFromCtx(ctx)
ctrld.Log(ctx, logger.Debug(), "Posting utility API request")
apiUrl := resolverDataURLCom
if cdDev {
apiUrl = resolverDataURLDev
ctrld.Log(ctx, logger.Debug(), "Using development API URL: %s", apiUrl)
} else {
ctrld.Log(ctx, logger.Debug(), "Using production API URL: %s", apiUrl)
}
ctrld.Log(ctx, logger.Debug(), "Creating HTTP request")
// Context-bound so an in-flight request is abandoned when the caller is
// cancelled - a service stop during API preflight must not wait out the
// request timeout, let alone keep retrying.
req, err := http.NewRequestWithContext(ctx, "POST", apiUrl, body)
if err != nil {
ctrld.Log(ctx, logger.Error(), "Failed to create HTTP request: %v", err)
return nil, fmt.Errorf("http.NewRequest: %w", err)
}
ctrld.Log(ctx, logger.Debug(), "Setting request parameters")
q := req.URL.Query()
q.Set("platform", "ctrld")
q.Set("version", version)
if lastUpdatedFailed {
q.Set("custom_last_failed", "1")
ctrld.Log(ctx, logger.Debug(), "Marking custom config as failed")
}
req.URL.RawQuery = q.Encode()
req.Header.Add("Content-Type", "application/json")
ctrld.Log(ctx, logger.Debug(), "Setting up API transport")
transport := apiTransport(ctx, cdDev)
client := &http.Client{
Timeout: defaultTimeout,
Transport: transport,
}
ctrld.Log(ctx, logger.Debug(), "Sending request to ControlD API")
resp, err := doWithFallback(ctx, client, req, apiServerIP(cdDev))
if err != nil {
ctrld.Log(ctx, logger.Error(), "Failed to send request to ControlD API: %v", err)
return nil, fmt.Errorf("postUtilityAPI client.Do: %w", err)
}
defer resp.Body.Close()
ctrld.Log(ctx, logger.Debug(), "Processing API response")
d := json.NewDecoder(resp.Body)
if resp.StatusCode != http.StatusOK {
errResp, err := apiErrorFromResponse(resp.StatusCode, d)
if err != nil {
ctrld.Log(ctx, logger.Error(), "Failed to decode error response: %v", err)
return nil, err
}
ctrld.Log(ctx, logger.Error(), "ControlD API returned error: %s", errResp.Error())
return nil, errResp
}
ur := &utilityResponse{}
if err := d.Decode(ur); err != nil {
ctrld.Log(ctx, logger.Error(), "Failed to decode utility response: %v", err)
return nil, err
}
ctrld.Log(ctx, logger.Debug(), "Successfully received resolver configuration")
return &ur.Body.Resolver, nil
}
// SendLogs sends runtime log to ControlD API.
func SendLogs(ctx context.Context, lr *LogsRequest, cdDev bool) error {
logger := ctrld.LoggerFromCtx(ctx)
ctrld.Log(ctx, logger.Debug(), "Sending runtime logs to ControlD API")
defer lr.Data.Close()
apiUrl := logURLCom
if cdDev {
apiUrl = logURLDev
}
ctrld.Log(ctx, logger.Debug(), "Creating HTTP request for log upload")
req, err := http.NewRequestWithContext(ctx, "POST", apiUrl, lr.Data)
if err != nil {
ctrld.Log(ctx, logger.Error(), "Failed to create HTTP request: %v", err)
return fmt.Errorf("http.NewRequest: %w", err)
}
q := req.URL.Query()
q.Set("uid", lr.UID)
req.URL.RawQuery = q.Encode()
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
ctrld.Log(ctx, logger.Debug(), "Setting up API transport")
transport := apiTransport(ctx, cdDev)
client := &http.Client{
Timeout: sendLogTimeout,
Transport: transport,
}
ctrld.Log(ctx, logger.Debug(), "Sending log data to ControlD API")
resp, err := doWithFallback(ctx, client, req, apiServerIP(cdDev))
if err != nil {
ctrld.Log(ctx, logger.Error(), "Failed to send logs to ControlD API: %v", err)
return fmt.Errorf("SendLogs client.Do: %w", err)
}
defer resp.Body.Close()
ctrld.Log(ctx, logger.Debug(), "Processing API response")
d := json.NewDecoder(resp.Body)
if resp.StatusCode != http.StatusOK {
errResp, err := apiErrorFromResponse(resp.StatusCode, d)
if err != nil {
ctrld.Log(ctx, logger.Error(), "Failed to decode error response: %v", err)
return err
}
ctrld.Log(ctx, logger.Error(), "ControlD API returned error: %s", errResp.Error())
return errResp
}
_, _ = io.Copy(io.Discard, resp.Body)
ctrld.Log(ctx, logger.Debug(), "Runtime logs sent successfully to ControlD API")
return nil
}
// ParseRawUID parse the input raw UID, returning real UID and ClientID.
// The raw UID can have 2 forms:
//
// - <uid>
// - <uid>/<client_id>
func ParseRawUID(rawUID string) (string, string) {
uid, clientID, _ := strings.Cut(rawUID, "/")
return uid, clientID
}
// apiTransport returns an HTTP transport for connecting to ControlD API endpoint.
func apiTransport(loggerCtx context.Context, cdDev bool) *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
apiDomain := apiDomainCom
apiIpsV4 := []string{apiDomainComIPv4}
apiIpsV6 := []string{apiDomainComIPv6}
apiIPs := []string{apiDomainComIPv4, apiDomainComIPv6}
if cdDev {
apiDomain = apiDomainDev
apiIpsV4 = []string{apiDomainDevIPv4}
apiIpsV6 = []string{}
apiIPs = []string{apiDomainDevIPv4}
}
ips := ctrld.LookupIP(loggerCtx, apiDomain)
if len(ips) == 0 {
logger := ctrld.LoggerFromCtx(loggerCtx)
logger.Warn().Msgf("No ips found for %s, use direct ips: %v", apiDomain, apiIPs)
ips = apiIPs
}
// Separate IPv4 and IPv6 addresses
// This separation is needed because different network stacks may have different
// connectivity to IPv4 vs IPv6, so we try them separately for better reliability
var ipv4s, ipv6s []string
for _, ip := range ips {
if strings.Contains(ip, ":") {
ipv6s = append(ipv6s, ip)
} else {
ipv4s = append(ipv4s, ip)
}
}
dial := func(ctx context.Context, network string, addrs []string) (net.Conn, error) {
d := &ctrldnet.ParallelDialer{}
logger := ctrld.LoggerFromCtx(loggerCtx)
return d.DialContext(ctx, network, addrs, logger.Logger)
}
_, port, _ := net.SplitHostPort(addr)
// Try IPv4 first
if len(ipv4s) > 0 {
if conn, err := dial(ctx, "tcp4", addrsFromPort(ipv4s, port)); err == nil {
return conn, nil
}
}
// Fallback to direct IPv4
if conn, err := dial(ctx, "tcp4", addrsFromPort(apiIpsV4, port)); err == nil {
return conn, nil
}
// Fallback to IPv6 if available
if len(ipv6s) > 0 {
if conn, err := dial(ctx, "tcp6", addrsFromPort(ipv6s, port)); err == nil {
return conn, nil
}
}
// Fallback to direct IPv6
return dial(ctx, "tcp6", addrsFromPort(apiIpsV6, port))
}
if runtime.GOOS == "android" {
transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool(), MinVersion: tls.VersionTLS12}
}
return transport
}
func addrsFromPort(ips []string, port string) []string {
addrs := make([]string, len(ips))
for i, ip := range ips {
addrs[i] = net.JoinHostPort(ip, port)
}
return addrs
}
// doWithFallback sends req, retrying against apiIp directly if the first attempt
// fails (typically because DNS is not usable yet).
//
// Both failures are reported. The first attempt carries the diagnosis - on Windows
// a local firewall denying the socket surfaces there as WSAEACCES ("An attempt was
// made to access a socket in a way forbidden by its access permissions"), which
// says the host is blocking ctrld rather than that the network is down. Returning
// only the fallback error hid that behind a bare "no route to host" from the IPv6
// attempt and sent the Firewall Mode incident investigation after a routing
// problem that did not exist.
func doWithFallback(ctx context.Context, client *http.Client, req *http.Request, apiIp string) (*http.Response, error) {
resp, err := client.Do(req)
if err == nil {
return resp, nil
}
logger := ctrld.LoggerFromCtx(ctx)
logger.Warn().Err(err).Msgf("Failed to send request, fallback to direct ip: %s", apiIp)
ipReq := req.Clone(req.Context())
ipReq.Host = apiIp
ipReq.URL.Host = apiIp
resp, fallbackErr := client.Do(ipReq)
if fallbackErr != nil {
return nil, fmt.Errorf("request failed: %w; fallback to direct ip %s failed: %w", err, apiIp, fallbackErr)
}
return resp, nil
}
// apiServerIP returns the direct IP to connect to API server.
func apiServerIP(cdDev bool) string {
if cdDev {
return apiDomainDevIPv4
}
return apiDomainComIPv4
}
// DoWithFallbackForTest exposes doWithFallback so tests outside this package can drive
// the real two-attempt composition through the real retry predicate, rather than
// asserting a copy of this error shape against another copy of it.
func DoWithFallbackForTest(ctx context.Context, client *http.Client, req *http.Request, apiIp string) (*http.Response, error) {
return doWithFallback(ctx, client, req, apiIp)
}