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"` } func (u ErrorResponse) Error() string { return u.ErrorField.Message } 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") req, err := http.NewRequest("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 := &ErrorResponse{} if err := d.Decode(errResp); 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.NewRequest("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 := &ErrorResponse{} if err := d.Decode(errResp); 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: // // - // - / 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()} } 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 }