mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Internal Domains now support three modes. "os" (network default) is unchanged. "resolvers" becomes explicit resolver with network fallback, the default explicit selection: the configured resolvers are tried first, and a timeout, unreachable resolver, SERVFAIL, NXDOMAIN, REFUSED or NOTIMP hands the query to the network resolvers - matching VPN DNS servers, then domain-less VPN DNS servers, then the OS resolver's LAN nameservers. A valid empty answer stays final. "resolvers_only" keeps the previous strict behavior. The OS step uses a new LanOnlyQueryCtx: the OS resolver then asks only its LAN-classified nameservers (private, loopback, link-local, CGNAT) and sends nothing when it has none, so the private name never reaches a public nameserver, whether DHCP supplied it or it is ctrld's own public fallback. LanQueryCtx only dropped the latter. LAN-only queries get their own singleflight and hot-cache key, so they are never answered with an ordinary query's public answer. In fallback mode a configured resolver that the upstream monitor has marked down is skipped instead of waited on, so an endpoint off the organization network reaches the fallback without paying the resolver's 2s timeout on every query. Nothing else marks a generated resolver up again, so skipping one re-checks it in the background, at most once every 30s; an answer marks it up. Strict mode never skips. An absent mode with resolvers gets the fallback mode. An unrecognized mode with resolvers fails closed to explicit resolver only, so a wire value this build does not know never sends the domain to the Control D upstream; without resolvers it is dropped. Switching between the explicit modes is detected on refresh. Generated upstreams are now identified by an UpstreamConfig.InternalDomain marker that records their mode and that no configuration file can set, instead of by the internal_ key prefix. An upstream a local ctrld.toml or custom config defines as internal_foo, or as a copy of a generated one, is an ordinary upstream again: it keeps the OS-resolver catch-all, recovery and query-health grading. The "resolvers_only" wire value is provisional until the API agrees on it; the dashboard does not offer strict mode yet.
494 lines
16 KiB
Go
494 lines
16 KiB
Go
package controld
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"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"`
|
|
SplitDNS []SplitDNS `json:"split_dns"`
|
|
UID string `json:"uid"`
|
|
DeactivationPin *int64 `json:"deactivation_pin,omitempty"`
|
|
}
|
|
|
|
// Internal Domain resolution modes. Mode is what the administrator selected;
|
|
// Resolvers is only meaningful under SplitDNSModeResolvers and
|
|
// SplitDNSModeResolversOnly.
|
|
const (
|
|
// SplitDNSModeOS resolves the domain through the endpoint's own OS/default
|
|
// resolver, so it follows DHCP and VPN resolver changes.
|
|
SplitDNSModeOS = "os"
|
|
// SplitDNSModeResolvers resolves the domain through the addresses in
|
|
// Resolvers first. When none of them answers, or they answer SERVFAIL or
|
|
// NXDOMAIN, the query falls back to the resolvers of the endpoint's active
|
|
// network and VPN. This is the default explicit selection.
|
|
SplitDNSModeResolvers = "resolvers"
|
|
// SplitDNSModeResolversOnly resolves the domain through the addresses in
|
|
// Resolvers and nothing else: their answer, or their failure, is final.
|
|
SplitDNSModeResolversOnly = "resolvers_only"
|
|
)
|
|
|
|
// SplitDNS is one organization Internal Domain: a domain suffix, the resolution
|
|
// mode the organization administrator selected for it, and the resolver
|
|
// addresses that mode may need.
|
|
//
|
|
// Mode decides the routing. Resolvers is read only under the explicit modes,
|
|
// so addresses left behind by an earlier selection cannot resurrect themselves
|
|
// once the administrator switches back to the OS resolver. An empty Mode is a
|
|
// deployment that predates the field; the caller infers the mode from
|
|
// Resolvers in that case.
|
|
type SplitDNS struct {
|
|
Domain string `json:"domain"`
|
|
Mode string `json:"mode"`
|
|
Resolvers []string `json:"resolvers"`
|
|
}
|
|
|
|
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
|
|
}
|
|
body, size, getBody, cleanup, err := spoolLogBody(lr.Data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", apiUrl, body)
|
|
if err != nil {
|
|
return fmt.Errorf("http.NewRequest: %w", err)
|
|
}
|
|
req.ContentLength = size
|
|
req.GetBody = getBody
|
|
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
|
|
// The first attempt consumed the body. A request that can give its body
|
|
// again sends the whole body a second time.
|
|
if req.GetBody != nil {
|
|
body, bodyErr := req.GetBody()
|
|
if bodyErr != nil {
|
|
return nil, fmt.Errorf("request failed: %w; fallback to direct ip %s failed: %w", err, apiIp, bodyErr)
|
|
}
|
|
ipReq.Body = body
|
|
}
|
|
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
|
|
}
|
|
|
|
// spoolLogBody copies an upload to a temporary file, so the request can go
|
|
// out a second time to the direct IP of the API. A log reader yields its
|
|
// bytes once, and the first attempt consumes them. The caller runs cleanup
|
|
// after the response arrived.
|
|
func spoolLogBody(data io.Reader) (body *os.File, size int64, getBody func() (io.ReadCloser, error), cleanup func(), err error) {
|
|
f, err := os.CreateTemp("", "ctrld-log-upload-*")
|
|
if err != nil {
|
|
return nil, 0, nil, nil, fmt.Errorf("creating the upload spool: %w", err)
|
|
}
|
|
cleanup = func() {
|
|
_ = f.Close()
|
|
_ = os.Remove(f.Name())
|
|
}
|
|
size, err = io.Copy(f, data)
|
|
if err != nil {
|
|
cleanup()
|
|
return nil, 0, nil, nil, fmt.Errorf("writing the upload spool: %w", err)
|
|
}
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
cleanup()
|
|
return nil, 0, nil, nil, fmt.Errorf("rewinding the upload spool: %w", err)
|
|
}
|
|
// The transport closes the body of a failed attempt, so the second body
|
|
// is a new handle on the same file.
|
|
getBody = func() (io.ReadCloser, error) { return os.Open(f.Name()) }
|
|
return f, size, getBody, cleanup, 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)
|
|
}
|