mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
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. When the hostname attempt is denied locally - WSAEACCES on Windows, "An attempt was made to access a socket in a way forbidden by its access permissions", which means the host is blocking ctrld - and the direct-ip fallback fails with an unreachable IPv6 route, what surfaces to the operator is "dial tcp6: no route to host": a routing problem that does not exist, while the error naming the real cause is 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. 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 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.
107 lines
3.2 KiB
Go
107 lines
3.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// AppCallback provides hooks for injecting certain functionalities
|
|
// from mobile platforms to main ctrld cli.
|
|
type AppCallback struct {
|
|
HostName func() string
|
|
LanIp func() string
|
|
MacAddress func() string
|
|
Exit func(error string)
|
|
}
|
|
|
|
// AppConfig allows overwriting ctrld cli flags from mobile platforms.
|
|
type AppConfig struct {
|
|
CdUID string
|
|
ProvisionID string
|
|
CustomHostname string
|
|
HomeDir string
|
|
UpstreamProto string
|
|
Verbose int
|
|
LogPath string
|
|
}
|
|
|
|
const (
|
|
defaultHTTPTimeout = 30 * time.Second
|
|
defaultMaxRetries = 3
|
|
downloadServerIp = "23.171.240.151"
|
|
)
|
|
|
|
// httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback
|
|
func httpClientWithFallback(timeout time.Duration) *http.Client {
|
|
return &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &http.Transport{
|
|
// Prefer IPv4 over IPv6
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 10 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
FallbackDelay: 1 * time.Millisecond, // Very small delay to prefer IPv4
|
|
}).DialContext,
|
|
},
|
|
}
|
|
}
|
|
|
|
// doWithRetry performs an HTTP request with retries
|
|
func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) {
|
|
return doWithRetryClient(httpClientWithFallback(defaultHTTPTimeout), req, maxRetries, ip)
|
|
}
|
|
|
|
// doWithRetryClient is doWithRetry with an injectable client, so the retry and
|
|
// error-composition behaviour can be tested without real network access.
|
|
func doWithRetryClient(client *http.Client, req *http.Request, maxRetries int, ip string) (*http.Response, error) {
|
|
var lastErr error
|
|
var ipReq *http.Request
|
|
if ip != "" {
|
|
ipReq = req.Clone(req.Context())
|
|
ipReq.Host = ip
|
|
ipReq.URL.Host = ip
|
|
}
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err == nil {
|
|
return resp, nil
|
|
}
|
|
// Keep the hostname attempt's error: it carries the diagnosis (on Windows,
|
|
// a local firewall denying the socket shows up here as WSAEACCES), while the
|
|
// direct-IP fallback often fails for an unrelated reason such as an
|
|
// unreachable IPv6 route.
|
|
attemptErr := err
|
|
if ipReq != nil {
|
|
mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host)
|
|
mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip)
|
|
resp, fallbackErr := client.Do(ipReq)
|
|
if fallbackErr == nil {
|
|
return resp, nil
|
|
}
|
|
attemptErr = fmt.Errorf("%w; fallback to direct ip %s failed: %w", attemptErr, ip, fallbackErr)
|
|
}
|
|
|
|
lastErr = attemptErr
|
|
mainLog.Load().Debug().Err(attemptErr).
|
|
Str("method", req.Method).
|
|
Str("url", req.URL.String()).
|
|
Msgf("HTTP request attempt %d/%d failed", attempt+1, maxRetries)
|
|
}
|
|
return nil, fmt.Errorf("failed after %d attempts to %s %s: %w", maxRetries, req.Method, req.URL, lastErr)
|
|
}
|
|
|
|
// Helper for making GET requests with retries
|
|
func getWithRetry(url string, ip string) (*http.Response, error) {
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return doWithRetry(req, defaultMaxRetries, ip)
|
|
}
|