Files
ctrld/internal/controld/api_dial_test.go
T
Cuong Manh Le f96868c266 all: permit ctrld's own endpoints in Firewall Mode
Firewall Mode permits only what ctrld resolved through its own listener.
The API transport resolves api.controld.com through the OS nameservers and
falls back to hardcoded addresses, so nothing ever teaches the allowlist
about it and ctrld's own block-all filters deny its control-plane socket.
The upgrade download server has the same shape: performUpgrade spawns a
detached child process, and the WFP filters carry no process condition, so
the service blocks its own upgrade.

Permit both permanently, at startup and on reload. For the API that means
the resolved addresses and the transport's direct fallbacks - the fallbacks
are what it dials when DNS is unusable, which is the state a blocked ctrld
is in. For the download server only the fallback IP is needed, since its
hostname lookup does go through the listener and is learned.
APIDomain/APIEndpointIPs are exported so the permitted set and the dialed
set cannot drift apart.

Call initPlatformFirewall on reload even when enforcement is already up.
AddPermanent fires no change callback, so an address permitted by a reload
reached memory only while the platform never heard about it. Each
platform's re-entry is a refresh: Windows reinstalls the permanent filters
it is missing, macOS returns early.

Also keep every dial attempt in the transport's error. It returned only the
last stage, an unroutable IPv6 address reporting "no route to host", hiding
the IPv4 WSAEACCES that named the real cause. The direct IPs are still
always dialed, so the API stays reachable without DNS; only a duplicate
dial of an address the resolver already returned is dropped.
2026-08-28 14:02:24 +07:00

179 lines
6.4 KiB
Go

package controld
import (
"errors"
"net"
"slices"
"strings"
"syscall"
"testing"
)
// TestJoinAttemptErrorsKeepsEveryAttempt pins the diagnosis the incident lost.
//
// The transport dials several address families in turn. The IPv4 attempt is the
// one that says "the host is blocking ctrld"; the last attempt is usually an IPv6
// address that is simply unroutable and reports "no route to host". Returning only
// the last error is what turned a self-inflicted block into a phantom routing
// problem in the logs, and sent the investigation after a network fault that did
// not exist.
func TestJoinAttemptErrorsKeepsEveryAttempt(t *testing.T) {
blocked := &net.OpError{Op: "dial", Net: "tcp4", Err: wsaEACCES}
unroutable := &net.OpError{Op: "dial", Net: "tcp6", Err: syscall.EHOSTUNREACH}
err := joinAttemptErrors([]error{
wrapAttempt("resolved ipv4", blocked),
wrapAttempt("direct ipv6", unroutable),
})
if err == nil {
t.Fatal("joinAttemptErrors() = nil for two failed attempts")
}
msg := err.Error()
for _, want := range []string{"resolved ipv4", "direct ipv6"} {
if !strings.Contains(msg, want) {
t.Errorf("error text does not name the %q attempt: %s", want, msg)
}
}
if !errors.Is(err, wsaEACCES) {
t.Errorf("the IPv4 socket denial did not survive; a caller can no longer tell a local block from a routing failure: %s", msg)
}
if !errors.Is(err, syscall.EHOSTUNREACH) {
t.Errorf("the last attempt's error did not survive: %s", msg)
}
if strings.Contains(msg, "\n") {
t.Errorf("the joined error spans lines, which breaks one-record-per-failure logging: %q", msg)
}
}
// TestJoinAttemptErrorsSingleAndEmpty covers the degenerate inputs: one attempt is
// returned untouched, and no attempt at all still has to be an error rather than a
// nil the dialer would hand back as a successful connection.
func TestJoinAttemptErrorsSingleAndEmpty(t *testing.T) {
only := errors.New("only attempt")
if got := joinAttemptErrors([]error{only}); !errors.Is(got, only) {
t.Errorf("joinAttemptErrors() = %v, want the single attempt unwrapped", got)
}
if got := joinAttemptErrors(nil); got == nil {
t.Error("joinAttemptErrors(nil) = nil; the dialer would report success with no connection")
}
}
// TestAPIDialStagesAlwaysDialTheDirectIPs is the guarantee the direct addresses
// exist for: when DNS is unusable, ctrld must still reach the API.
//
// Whatever resolution returns - nothing, stale addresses, one family only - every
// direct address is dialed. The only thing the duplicate trim removes is a second
// dial of an address an earlier stage already covers.
func TestAPIDialStagesAlwaysDialTheDirectIPs(t *testing.T) {
const (
directV4 = apiDomainComIPv4
directV6 = apiDomainComIPv6
)
v4, v6 := []string{directV4}, []string{directV6}
tests := []struct {
name string
resolved []string
}{
{"resolution returned nothing", nil},
{"resolution returned the direct ips", []string{directV4, directV6}},
{"resolution returned stale ips", []string{"203.0.113.10", "2001:db8::1"}},
{"resolution returned ipv4 only", []string{"203.0.113.10"}},
{"resolution returned ipv6 only", []string{"2001:db8::1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stages := apiDialStages(tt.resolved, v4, v6)
var dialed []string
for _, stage := range stages {
if len(stage.ips) == 0 {
t.Errorf("stage %q has no address; it would dial nothing", stage.what)
}
dialed = append(dialed, stage.ips...)
}
for _, direct := range []string{directV4, directV6} {
if !slices.Contains(dialed, direct) {
t.Errorf("the direct address %s is never dialed; the API is unreachable without DNS", direct)
}
if n := count(dialed, direct); n != 1 {
t.Errorf("the direct address %s is dialed %d times, want exactly 1", direct, n)
}
}
for _, ip := range tt.resolved {
if !slices.Contains(dialed, ip) {
t.Errorf("the resolved address %s is never dialed", ip)
}
}
})
}
}
// TestAPIDialStagesTryIPv4First pins the order: the IPv4 stages come before the
// IPv6 ones. IPv6 at these hosts is commonly unroutable, and its "no route to
// host" is what used to be the only error a failure reported.
func TestAPIDialStagesTryIPv4First(t *testing.T) {
stages := apiDialStages([]string{"203.0.113.10", "2001:db8::1"},
[]string{apiDomainComIPv4}, []string{apiDomainComIPv6})
var order []string
for _, stage := range stages {
order = append(order, stage.network)
}
want := []string{"tcp4", "tcp4", "tcp6", "tcp6"}
if len(order) != len(want) {
t.Fatalf("stage networks = %v, want %v", order, want)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("stage networks = %v, want %v", order, want)
}
}
}
func count(haystack []string, needle string) int {
var n int
for _, s := range haystack {
if s == needle {
n++
}
}
return n
}
// TestNotInSkipsAlreadyDialedAddresses covers the duplicate-dial trim: LookupIP
// normally answers with the direct addresses, so dialing both lists doubles every
// failure for no added chance of success.
func TestNotInSkipsAlreadyDialedAddresses(t *testing.T) {
if got := notIn([]string{apiDomainComIPv4}, []string{apiDomainComIPv4}); len(got) != 0 {
t.Errorf("notIn() = %v, want empty: the address was already dialed", got)
}
if got := notIn([]string{apiDomainComIPv4}, []string{"203.0.113.10"}); len(got) != 1 {
t.Errorf("notIn() = %v, want the direct address kept when it was not dialed", got)
}
if got := notIn([]string{apiDomainComIPv4}, nil); len(got) != 1 {
t.Errorf("notIn() = %v, want the direct address kept when nothing resolved", got)
}
}
// TestAPIEndpointIPsCoverEveryDialedAddress ties the Firewall Mode allowlist to the
// transport. Firewall Mode permits APIEndpointIPs; the transport dials
// apiDirectIPs. If one grows an address the other does not, ctrld starts blocking
// its own control plane again, which is precisely the 38-hour outage.
func TestAPIEndpointIPsCoverEveryDialedAddress(t *testing.T) {
for _, dev := range []bool{false, true} {
permitted := APIEndpointIPs(dev)
v4, v6 := apiDirectIPs(dev)
for _, ip := range append(append([]string{}, v4...), v6...) {
if !slices.Contains(permitted, ip) {
t.Errorf("cdDev=%v: the transport dials %s but APIEndpointIPs does not report it, so Firewall Mode will not permit it", dev, ip)
}
}
if len(permitted) != len(v4)+len(v6) {
t.Errorf("cdDev=%v: APIEndpointIPs = %v, but the split halves are %v/%v", dev, permitted, v4, v6)
}
}
}