Files
ctrld/internal/controld/config.go
T
Cuong Manh Le 52b7aaab87 cmd/cli, internal/controld: bound API preflight by service lifetime
processCDFlags retries the resolver-config fetch indefinitely by design: a
device that has no working network at boot must eventually come up. The loop
had no cancellation, so a stop request arriving while the API is unreachable
was ignored - the process kept retrying long after "Service stopped" was
logged, doing work on behalf of a service the OS considers stopped. The
Windows Firewall Mode incident showed this concretely: API retries continued
15 seconds after the stop completed, so stopping the service could not release
what the process was still holding.

Thread a context through processCDFlags and derive it from p.stopCh, in both
the startup preflight and the config-reload path. The loop now returns as soon
as the context is cancelled, checked both before a retry and after backoff
returns (backoff can wake up on cancellation).

A stop during preflight now exits the way a normal stop does, without Fatal,
so the service manager does not treat it as a failed start and apply its
restart policy to a service the operator just asked to stop.

Also bind the two API requests in internal/controld to the caller's context.
They were built with http.NewRequest, so an in-flight request ignored
cancellation and waited out its own timeout instead.

Covered by tests that assert what the incident needed: a stop request during
preflight ends the retry/backoff loop and reports cancellation rather than
continuing to retry after the service reports itself stopped, an
already-cancelled context makes at most one attempt, and contextFromStopCh
handles its three cases (cancelled by stopCh, released by cancel, usable with
no stop channel). Removing either cancellation check makes these tests hang
until the test timeout. The tests are also what exercise fetchResolverConfig,
the seam this commit introduces.

Sampling the stop state is the whole point of runAPIPreflight rather than doing
this inline. A stop and a failure need opposite handling - one exits quietly, the
other self-uninstalls a deleted device, surfaces the error to a mobile app, and
reports a failed start - so the two must not be confused. Reading it from the
context after cancelling would report stopped for every failure, since
CancelFunc sets ctx.Err() regardless of whether anyone asked to stop; the stop
channel is read directly instead, which also does not depend on the context
watcher goroutine having been scheduled.
2026-08-14 15:27:54 +07:00

413 lines
13 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
}
func doWithFallback(ctx context.Context, client *http.Client, req *http.Request, apiIp string) (*http.Response, error) {
resp, err := client.Do(req)
if err != 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, err = client.Do(ipReq)
}
return resp, err
}
// apiServerIP returns the direct IP to connect to API server.
func apiServerIP(cdDev bool) string {
if cdDev {
return apiDomainDevIPv4
}
return apiDomainComIPv4
}