mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-24 14:08:27 +02:00
When a discovered VPN resolver is Tailscale's quad-100, read the Split DNS and App Connector routes from Tailscale's LocalAPI and route those suffixes to quad-100 instead of Control D. Master adaptation: newVPNDNSManager keeps master's logger argument and Refresh keeps its context argument. tailscaleRoutesFor takes the refresh context, and a retry builds its context from the manager's logger (backgroundCtx). setDNS closes the previous manager and wires the gated retry the same way as on v1.0.
156 lines
4.9 KiB
Go
156 lines
4.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// tailscaleLocalAPITimeout bounds one LocalAPI read. Discovery runs on network
|
|
// change paths, so it must never stall them.
|
|
const tailscaleLocalAPITimeout = 2 * time.Second
|
|
|
|
// tailscaleLocalAPIHost is the Host header Tailscale's LocalAPI requires.
|
|
const tailscaleLocalAPIHost = "local-tailscaled.sock"
|
|
|
|
// tailscaleDNSConfigPath returns the network's DNS config, including Split DNS
|
|
// and App Connector routes. Tailscale added it in v1.98.0.
|
|
const tailscaleDNSConfigPath = "/localapi/v0/dns-config"
|
|
|
|
// maxTailscaleDNSConfigBytes bounds the response body ctrld reads.
|
|
const maxTailscaleDNSConfigBytes = 4 << 20
|
|
|
|
var errTailscaleLocalAPIUnavailable = errors.New("tailscale LocalAPI not found")
|
|
|
|
// tailscaleLocalAPIEndpoint is one way to reach a running Tailscale LocalAPI.
|
|
type tailscaleLocalAPIEndpoint struct {
|
|
name string
|
|
dial func(ctx context.Context) (net.Conn, error)
|
|
token string // HTTP Basic-Auth password; empty for the tailscaled socket
|
|
}
|
|
|
|
// tailscaleAppStoreProc is the exact command name of the Mac App Store
|
|
// Tailscale network extension.
|
|
const tailscaleAppStoreProc = "IPNExtension"
|
|
|
|
// sameUserProofPath matches the sameuserproof file of the Mac App Store
|
|
// Tailscale extension: <home>/Library/Group Containers/<group>.tailscale.ipn.macos/
|
|
// sameuserproof-<port>-<token>. Paths anywhere else are not accepted, because
|
|
// any local user can create a file with that name under /tmp.
|
|
var sameUserProofPath = regexp.MustCompile(`^/Users/[^/]+/Library/Group Containers/[^/]+\.tailscale\.ipn\.macos/sameuserproof-([0-9]+)-([^/]+)$`)
|
|
|
|
// macOSSameUserProof is one App Store LocalAPI candidate found by lsof.
|
|
type macOSSameUserProof struct {
|
|
pid int
|
|
port int
|
|
token string
|
|
}
|
|
|
|
// parseMacOSSameUserProofs extracts App Store LocalAPI candidates from
|
|
// `lsof -F pcn` output. A candidate needs a process whose command is exactly
|
|
// IPNExtension (lsof -c matches a prefix only) holding open a sameuserproof
|
|
// file in a user's Tailscale group container. The caller still has to verify
|
|
// the process code signature, because a command name is not proof.
|
|
func parseMacOSSameUserProofs(out []byte) []macOSSameUserProof {
|
|
var proofs []macOSSameUserProof
|
|
pid, command := 0, ""
|
|
sc := bufio.NewScanner(bytes.NewReader(out))
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
value := line[1:]
|
|
switch line[0] {
|
|
case 'p':
|
|
pid, command = 0, ""
|
|
if p, err := strconv.Atoi(value); err == nil && p > 0 {
|
|
pid = p
|
|
}
|
|
case 'c':
|
|
command = value
|
|
case 'n':
|
|
if pid == 0 || command != tailscaleAppStoreProc {
|
|
continue
|
|
}
|
|
m := sameUserProofPath.FindStringSubmatch(value)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
port, err := strconv.Atoi(m[1])
|
|
if err != nil || port <= 0 || port > 65535 {
|
|
continue
|
|
}
|
|
proofs = append(proofs, macOSSameUserProof{pid: pid, port: port, token: m[2]})
|
|
}
|
|
}
|
|
return proofs
|
|
}
|
|
|
|
// fetchTailscaleDNSConfig reads the dns-config body from one endpoint.
|
|
func fetchTailscaleDNSConfig(ctx context.Context, ep tailscaleLocalAPIEndpoint) ([]byte, error) {
|
|
tr := &http.Transport{
|
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return ep.dial(ctx) },
|
|
DisableKeepAlives: true,
|
|
Proxy: nil,
|
|
}
|
|
defer tr.CloseIdleConnections()
|
|
client := &http.Client{Transport: tr}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+tailscaleLocalAPIHost+tailscaleDNSConfigPath, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ep.token != "" {
|
|
req.SetBasicAuth("", ep.token)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", ep.name, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTailscaleDNSConfigBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", ep.name, err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
// Older Tailscale has no dns-config endpoint and answers 404.
|
|
return nil, fmt.Errorf("%s: dns-config returned HTTP %d", ep.name, resp.StatusCode)
|
|
}
|
|
if len(body) > maxTailscaleDNSConfigBytes {
|
|
return nil, fmt.Errorf("%s: dns-config response exceeds %d bytes", ep.name, maxTailscaleDNSConfigBytes)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// discoverTailscaleRoutesFrom tries each endpoint in order and returns the
|
|
// routes of the first that answers. The error never contains the token.
|
|
func discoverTailscaleRoutesFrom(ctx context.Context, endpoints []tailscaleLocalAPIEndpoint) ([]tailscaleRoute, error) {
|
|
if len(endpoints) == 0 {
|
|
return nil, errTailscaleLocalAPIUnavailable
|
|
}
|
|
var errs []error
|
|
for _, ep := range endpoints {
|
|
body, err := fetchTailscaleDNSConfig(ctx, ep)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
continue
|
|
}
|
|
routes, err := parseTailscaleRoutes(body)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: decode dns-config: %w", ep.name, err))
|
|
continue
|
|
}
|
|
return routes, nil
|
|
}
|
|
return nil, errors.Join(errs...)
|
|
}
|