mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
326 lines
9.8 KiB
Go
326 lines
9.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// One budget covers the entire native evidence transaction, including
|
|
// both identity/static rechecks; it is not renewed for each subprocess.
|
|
nativeTargetReadBudget = 2 * time.Second
|
|
nativeTargetMaxOutput = 64 * 1024
|
|
)
|
|
|
|
var errNativeTargetOutputLimit = errors.New("native DNS evidence output exceeds limit")
|
|
|
|
type nativeTargetOutput struct{ buf bytes.Buffer }
|
|
|
|
func (w *nativeTargetOutput) Write(p []byte) (int, error) {
|
|
if len(p) > nativeTargetMaxOutput-w.buf.Len() {
|
|
return 0, errNativeTargetOutputLimit
|
|
}
|
|
return w.buf.Write(p)
|
|
}
|
|
|
|
func runNativeTargetCommand(ctx context.Context, input, path string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, path, args...)
|
|
cmd.Stdin = strings.NewReader(input)
|
|
var out nativeTargetOutput
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = io.Discard
|
|
// Also bound waiting for inherited pipes if a child outlives the command.
|
|
cmd.WaitDelay = 100 * time.Millisecond
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.buf.Bytes(), nil
|
|
}
|
|
|
|
type nativeTargetService struct {
|
|
ID, Name, Device string
|
|
}
|
|
|
|
type nativeTargetReader struct {
|
|
run func(context.Context, string, string, ...string) ([]byte, error)
|
|
interfaceByName func(string) (*net.Interface, error)
|
|
interfaceAddrs func(*net.Interface) ([]net.Addr, error)
|
|
}
|
|
|
|
func (r nativeTargetReader) store(ctx context.Context, key string) (map[string]string, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := r.run(ctx, "show "+key+"\nquit\n", "/usr/sbin/scutil")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseNativeIPv4Store(string(out))
|
|
}
|
|
|
|
// The service root contains only the configured name. Reject multiline or
|
|
// unfamiliar layouts rather than searching arbitrary DHCP/SSID/packet text.
|
|
// The UUID in the key comes exclusively from the validated Global dictionary.
|
|
func parseNativeServiceName(out string) (string, error) {
|
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
if len(lines) != 3 || lines[0] != "<dictionary> {" || lines[2] != "}" {
|
|
return "", errors.New("invalid native service dictionary")
|
|
}
|
|
name, ok := strings.CutPrefix(strings.TrimLeft(lines[1], " \t"), "UserDefinedName : ")
|
|
if !ok || name == "" || strings.ContainsAny(name, "\r\n\x00/{}") {
|
|
return "", errors.New("invalid native service name")
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// defaultService returns the actual primary service, not the first service
|
|
// networksetup happens to list for the device. Unknown or changing state is
|
|
// never absence evidence. The caller must carry and recheck the whole tuple.
|
|
func (r nativeTargetReader) defaultService(ctx context.Context, device string) (nativeTargetService, error) {
|
|
unknown := nativeTargetService{}
|
|
before, err := r.store(ctx, "State:/Network/Global/IPv4")
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
id := before["PrimaryService"]
|
|
if !nativeServiceID.MatchString(id) || before["PrimaryInterface"] != device {
|
|
return unknown, nil
|
|
}
|
|
state, err := r.store(ctx, "State:/Network/Service/"+id+"/IPv4")
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
if state["CLAT46"] != "TRUE" || state["InterfaceName"] != device || state["Router"] != "192.0.0.1" {
|
|
return unknown, nil
|
|
}
|
|
iface, err := r.interfaceByName(device)
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
if iface == nil || iface.Name != device || iface.Flags&net.FlagUp == 0 {
|
|
return unknown, nil
|
|
}
|
|
addrs, err := r.interfaceAddrs(iface)
|
|
if err != nil || !nativeCLATAddresses(addrs) {
|
|
return unknown, err
|
|
}
|
|
published := strings.Fields(state["AddressList"])
|
|
if len(published) != 1 {
|
|
return unknown, nil
|
|
}
|
|
matched := false
|
|
for _, addr := range addrs {
|
|
ip, _, parseErr := net.ParseCIDR(addr.String())
|
|
if parseErr == nil && ip.String() == published[0] {
|
|
matched = true
|
|
}
|
|
}
|
|
if !matched {
|
|
return unknown, nil
|
|
}
|
|
out, err := r.run(ctx, "show Setup:/Network/Service/"+id+"\nquit\n", "/usr/sbin/scutil")
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
_, err = parseNativeServiceName(string(out))
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
out, err = r.run(ctx, "", "/usr/sbin/networksetup", "-listnetworkserviceorder")
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
name, err := targetNetworkServiceName(string(out), device)
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
after, err := r.store(ctx, "State:/Network/Global/IPv4")
|
|
if err != nil {
|
|
return unknown, err
|
|
}
|
|
if after["PrimaryService"] != id || after["PrimaryInterface"] != device {
|
|
return unknown, errors.New("default service changed during CLAT discovery")
|
|
}
|
|
return nativeTargetService{ID: id, Name: name, Device: device}, nil
|
|
}
|
|
|
|
// saveTargetStaticDNSSnapshot saves only the already validated/rechecked
|
|
// snapshot. Never re-read via the legacy permissive static DNS reader, and
|
|
// never replace the original backup with ctrld's own (possibly non-loopback)
|
|
// target when a listener changes after restart.
|
|
func saveTargetStaticDNSSnapshot(path string, snapshot []string, owned string) error {
|
|
if isInterceptDNSTargetOnly(snapshot, owned) {
|
|
// Missing means the original service used automatic DNS. A present
|
|
// but unreadable/corrupt backup must not authorize a listener change.
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
for _, s := range strings.Split(string(data), ",") {
|
|
if net.ParseIP(s) == nil || s == owned {
|
|
return errors.New("invalid retained static DNS backup")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
var dns []string
|
|
for _, s := range snapshot {
|
|
ip := net.ParseIP(s)
|
|
if ip == nil {
|
|
return errors.New("invalid static DNS snapshot")
|
|
}
|
|
if s == owned {
|
|
continue
|
|
}
|
|
// The restore reader filters loopbacks. Refuse an unrestorable
|
|
// user snapshot rather than silently discarding a local resolver.
|
|
if ip.IsLoopback() {
|
|
return errors.New("non-owned loopback in static DNS snapshot")
|
|
}
|
|
dns = append(dns, ip.String())
|
|
}
|
|
if len(dns) == 0 {
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
return os.WriteFile(path, []byte(strings.Join(dns, ",")), 0600)
|
|
}
|
|
|
|
var nativeServiceID = regexp.MustCompile(`^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$`)
|
|
|
|
// parseNativeIPv4Store reads only the two native IPv4 dictionaries, never
|
|
// ipconfig getsummary (which embeds unescaped, network-controlled DHCP text).
|
|
// Unknown layouts fail closed. These fields are generated by configd from
|
|
// interface names, service UUIDs, booleans and binary IP addresses.
|
|
func parseNativeIPv4Store(out string) (map[string]string, error) {
|
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
if len(lines) < 2 || lines[0] != "<dictionary> {" || lines[len(lines)-1] != "}" {
|
|
return nil, errors.New("invalid native IPv4 dictionary")
|
|
}
|
|
values := make(map[string]string)
|
|
depth := 1
|
|
arrayKey := ""
|
|
for _, line := range lines[1 : len(lines)-1] {
|
|
s := strings.TrimSpace(line)
|
|
if s == "}" {
|
|
depth--
|
|
if depth < 1 {
|
|
return nil, errors.New("unbalanced native IPv4 dictionary")
|
|
}
|
|
continue
|
|
}
|
|
key, value, ok := strings.Cut(s, " : ")
|
|
if !ok {
|
|
return nil, errors.New("invalid native IPv4 field")
|
|
}
|
|
if depth == 1 {
|
|
if _, exists := values[key]; exists {
|
|
return nil, errors.New("duplicate native IPv4 field")
|
|
}
|
|
switch key {
|
|
case "PrimaryService":
|
|
if !nativeServiceID.MatchString(value) {
|
|
return nil, errors.New("invalid service ID")
|
|
}
|
|
case "PrimaryInterface", "InterfaceName":
|
|
if value == "" || strings.ContainsAny(value, " \t\r\n{}") {
|
|
return nil, errors.New("invalid interface")
|
|
}
|
|
case "CLAT46":
|
|
if value != "TRUE" && value != "FALSE" {
|
|
return nil, errors.New("invalid CLAT flag")
|
|
}
|
|
case "Router":
|
|
if net.ParseIP(value).To4() == nil {
|
|
return nil, errors.New("invalid router")
|
|
}
|
|
case "Addresses", "SubnetMasks", "DestinationAddresses", "AdditionalRoutes", "ExcludedRoutes":
|
|
if value != "<array> {" {
|
|
return nil, errors.New("invalid IPv4 array")
|
|
}
|
|
default:
|
|
return nil, errors.New("unknown native IPv4 field")
|
|
}
|
|
values[key] = value
|
|
arrayKey = key
|
|
} else if depth == 2 && arrayKey == "Addresses" {
|
|
ip := net.ParseIP(value)
|
|
if ip.To4() == nil {
|
|
return nil, errors.New("invalid native IPv4 address")
|
|
}
|
|
values["AddressList"] += ip.String() + " "
|
|
}
|
|
if strings.HasSuffix(value, "{") {
|
|
depth++
|
|
}
|
|
}
|
|
if depth != 1 {
|
|
return nil, errors.New("unbalanced native IPv4 dictionary")
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// A successful networksetup exit alone is not absence evidence: reject all
|
|
// text except IP literals and its exact no-static-DNS response.
|
|
func parseTargetStaticDNS(out, service string) ([]string, error) {
|
|
out = strings.TrimSpace(out)
|
|
if out == "There aren't any DNS Servers set on "+service+"." {
|
|
return nil, nil
|
|
}
|
|
if out == "" {
|
|
return nil, errors.New("empty static DNS response")
|
|
}
|
|
var dns []string
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
for scanner.Scan() {
|
|
ip := net.ParseIP(strings.TrimSpace(scanner.Text()))
|
|
if ip == nil {
|
|
return nil, errors.New("invalid static DNS response")
|
|
}
|
|
dns = append(dns, ip.String())
|
|
}
|
|
return dns, scanner.Err()
|
|
}
|
|
|
|
func nativeCLATAddresses(addrs []net.Addr) bool {
|
|
clat, ipv6 := false, false
|
|
for _, addr := range addrs {
|
|
ip, _, err := net.ParseCIDR(addr.String())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if v4 := ip.To4(); v4 != nil {
|
|
// Apple allocates one of these four CLAT addresses, not only 192.0.0.2.
|
|
if v4[0] != 192 || v4[1] != 0 || v4[2] != 0 || v4[3] < 2 || v4[3] > 5 {
|
|
return false
|
|
}
|
|
clat = true
|
|
} else if ip.IsGlobalUnicast() {
|
|
// ULA is legitimate IPv6 connectivity. Addresses alone never
|
|
// authorize a target: configd must independently publish CLAT.
|
|
ipv6 = true
|
|
}
|
|
}
|
|
return clat && ipv6
|
|
}
|