mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
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 the service reported itself stopped, doing work on behalf of a service the OS considers stopped. 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 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. Bind the two API requests themselves as well, so a stop does not have to wait out an in-flight request. Without this the loop honours a stop only between attempts, which leaves up to defaultTimeout (20s) of a request the service is no longer interested in - the same "still working after Service stopped" the loop change exists to end, one layer down. Doing so means a context parameter on FetchResolverConfig, FetchResolverUID, UpdateCustomLastFailed and SendLogs, since all four reach a request builder. The callers that have no context pass context.Background(), which is what master effectively does at those sites: its loggerCtx carries a logger, not cancellation. doWithFallback needs no parameter, because it clones the request with req.Context() and so inherits the binding. This also repairs internal/controld/controld_test.go, which is behind //go:build controld and had already been written against the context-taking signature, so it could not compile. Cover the cancellation paths; removing either check makes the tests hang until timeout. 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.
354 lines
11 KiB
Go
354 lines
11 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"
|
|
"github.com/Control-D-Inc/ctrld/internal/router"
|
|
"github.com/Control-D-Inc/ctrld/internal/router/ddwrt"
|
|
)
|
|
|
|
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 given uid.
|
|
func FetchResolverConfig(ctx context.Context, req *ResolverConfigRequest, cdDev bool) (*ResolverConfig, error) {
|
|
uid, clientID := ParseRawUID(req.RawUID)
|
|
uReq := utilityRequest{
|
|
UID: uid,
|
|
Metadata: req.Metadata,
|
|
}
|
|
if clientID != "" {
|
|
uReq.ClientID = clientID
|
|
}
|
|
body, _ := json.Marshal(uReq)
|
|
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) {
|
|
if req == nil {
|
|
return nil, errors.New("invalid request")
|
|
}
|
|
if req.Hostname == "" {
|
|
hostname, _ := preferredHostname()
|
|
ctrld.ProxyLogger.Load().Debug().Msgf("Using system hostname: %s", hostname)
|
|
req.Hostname = 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.ProxyLogger.Load().Debug().Msgf("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, true, bytes.NewReader(body))
|
|
}
|
|
|
|
func postUtilityAPI(ctx context.Context, version string, cdDev, lastUpdatedFailed bool, body io.Reader) (*ResolverConfig, error) {
|
|
apiUrl := resolverDataURLCom
|
|
if cdDev {
|
|
apiUrl = resolverDataURLDev
|
|
}
|
|
// 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 {
|
|
return nil, fmt.Errorf("http.NewRequest: %w", err)
|
|
}
|
|
q := req.URL.Query()
|
|
q.Set("platform", "ctrld")
|
|
q.Set("version", version)
|
|
if lastUpdatedFailed {
|
|
q.Set("custom_last_failed", "1")
|
|
}
|
|
req.URL.RawQuery = q.Encode()
|
|
req.Header.Add("Content-Type", "application/json")
|
|
transport := apiTransport(cdDev)
|
|
client := &http.Client{
|
|
Timeout: defaultTimeout,
|
|
Transport: transport,
|
|
}
|
|
resp, err := doWithFallback(client, req, apiServerIP(cdDev))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("postUtilityAPI client.Do: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
d := json.NewDecoder(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
errResp, err := apiErrorFromResponse(resp.StatusCode, d)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, errResp
|
|
}
|
|
|
|
ur := &utilityResponse{}
|
|
if err := d.Decode(ur); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ur.Body.Resolver, nil
|
|
}
|
|
|
|
// SendLogs sends runtime log to ControlD API.
|
|
func SendLogs(ctx context.Context, lr *LogsRequest, cdDev bool) error {
|
|
defer lr.Data.Close()
|
|
apiUrl := logURLCom
|
|
if cdDev {
|
|
apiUrl = logURLDev
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "POST", apiUrl, lr.Data)
|
|
if err != nil {
|
|
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")
|
|
transport := apiTransport(cdDev)
|
|
client := &http.Client{
|
|
Timeout: sendLogTimeout,
|
|
Transport: transport,
|
|
}
|
|
resp, err := doWithFallback(client, req, apiServerIP(cdDev))
|
|
if err != nil {
|
|
return fmt.Errorf("SendLogs client.Do: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
d := json.NewDecoder(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
errResp, err := apiErrorFromResponse(resp.StatusCode, d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return errResp
|
|
}
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
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(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(apiDomain)
|
|
if len(ips) == 0 {
|
|
ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, use direct IPs: %v", apiDomain, apiIPs)
|
|
ips = apiIPs
|
|
}
|
|
|
|
// Separate IPv4 and IPv6 addresses
|
|
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{}
|
|
return d.DialContext(ctx, network, addrs, ctrld.ProxyLogger.Load())
|
|
}
|
|
_, 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 router.Name() == ddwrt.Name || 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(client *http.Client, req *http.Request, apiIp string) (*http.Response, error) {
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
ctrld.ProxyLogger.Load().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
|
|
}
|