mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
415 lines
13 KiB
Go
415 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"
|
|
"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
|
|
)
|
|
|
|
// Provisioning-token rejection reasons the API sends in error.metadata.reason
|
|
// (HTTP 400, code 40003). This list can grow; a value outside it is not an
|
|
// error, just one cmd/cli does not classify yet.
|
|
const (
|
|
ReasonTokenInvalid = "token_invalid"
|
|
ReasonTokenExpired = "token_expired"
|
|
ReasonTokenLimitReached = "token_limit_reached"
|
|
ReasonTokenDisabled = "token_disabled"
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// errorMetadata carries additive, optional detail on top of Code/Message.
|
|
// Older API deployments omit it, so it must decode to its zero value rather
|
|
// than fail the whole response. Its custom UnmarshalJSON gives the same
|
|
// tolerance to a malformed value: a metadata that is not an object, or a
|
|
// Reason that is not a string (a number, an object, or null), degrades to
|
|
// the zero value rather than failing the response that contains it.
|
|
type errorMetadata struct {
|
|
// Reason is a machine-readable rejection reason sent on provisioning-token
|
|
// errors (HTTP 400, code 40003): token_invalid, token_expired,
|
|
// token_limit_reached, or token_disabled. Empty when absent or malformed;
|
|
// callers must treat any other value as unknown rather than reject the
|
|
// response.
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func (m *errorMetadata) UnmarshalJSON(data []byte) error {
|
|
var raw struct {
|
|
Reason json.RawMessage `json:"reason"`
|
|
}
|
|
// Best-effort: a metadata that is not an object, or a reason that is not
|
|
// a string (number, object, null), leaves the zero value instead of
|
|
// failing this decode. Code and Message still classify the failure.
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
*m = errorMetadata{}
|
|
return nil
|
|
}
|
|
_ = json.Unmarshal(raw.Reason, &m.Reason)
|
|
return nil
|
|
}
|
|
|
|
type ErrorResponse struct {
|
|
ErrorField struct {
|
|
Message string `json:"message"`
|
|
Code int `json:"code"`
|
|
Metadata errorMetadata `json:"metadata"`
|
|
} `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
|
|
}
|
|
|
|
// 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.
|
|
func doWithFallback(client *http.Client, req *http.Request, apiIp string) (*http.Response, error) {
|
|
resp, err := client.Do(req)
|
|
if err == nil {
|
|
return resp, 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, 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(client *http.Client, req *http.Request, apiIp string) (*http.Response, error) {
|
|
return doWithFallback(client, req, apiIp)
|
|
}
|