mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
Firewall Mode only permits what ctrld resolved, so an approved service addressed by literal IP - with no DNS lookup to observe - is unreachable, and the only workaround was turning the mode off. The API now sends the effective per-org list in destination_ips of every resolver-config response. Apply it as a set rather than as additions: each refresh replaces the previous snapshot, so an entry added upstream takes effect and one removed upstream stops bypassing enforcement. This happens inside the refresh handler before its early returns, so scheduled and forced refreshes both carry it, and without a ctrld reload. Entries carry no TTL and survive the allowlist flushes that follow a profile or network change. Track what the API asked for separately from what pf/WFP accepted, because mirroring can fail and the next refresh - carrying an identical list - would compute no delta to retry. The applied snapshot advances only on success, and the difference is retried by the next refresh and by a reconcile every 5 minutes, reported meanwhile as allowed_destinations_pending. Enforcement coming up replaces the whole set rather than adding to it: the macOS table is a persist table that can still hold what a previous run put there. Enforcement is versioned by a generation advanced under the same lock the mirror is called with, so a maintenance worker outliving its run cannot reinstall permits into enforcement that is gone. macOS keeps the set in a second pf table, <ctrld_allowed_dst>; Windows in per-entry WFP permit filters in their own map - apart from the DNS-resolved entries so a flush of those leaves them installed. Linux is unchanged, the mode already fails open there, and devices with Firewall Mode off are unaffected. Lookups binary search sorted per-family address ranges, so the per-connection hot path stays flat at ~40ns rather than growing with the list. Addresses are logged at debug level only: the list is organization network topology, and Info-level logs are persisted and travel in support bundles. Indirect the refresh's fetch and split its handler out of the fetch loop so both refresh paths are driven end to end in tests without an API server.
137 lines
4.4 KiB
Go
137 lines
4.4 KiB
Go
package controld
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func Test_parseUID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
uid string
|
|
wantUID string
|
|
wantClientID string
|
|
}{
|
|
{"empty", "", "", ""},
|
|
{"only uid", "abcd1234", "abcd1234", ""},
|
|
{"with client id", "abcd1234/clientID", "abcd1234", "clientID"},
|
|
{"with empty clientID", "abcd1234/", "abcd1234", ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
gotUID, gotClientID := ParseRawUID(tc.uid)
|
|
assert.Equal(t, tc.wantUID, gotUID)
|
|
assert.Equal(t, tc.wantClientID, gotClientID)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAPIErrorRecordsHTTPStatus pins the plumbing the caller's exit decision rests on.
|
|
//
|
|
// cmd/cli treats a 4xx as "this configuration is refused, restarting cannot help" and
|
|
// exits cleanly, while a 5xx keeps the abnormal exit so the service manager retries. Both
|
|
// readings need the status, and it is not in the JSON body - so a decode path that
|
|
// forgets to record it would quietly send every API error down the retry branch,
|
|
// including a deleted device that should self-uninstall and stop.
|
|
func TestAPIErrorRecordsHTTPStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
statusCode int
|
|
body string
|
|
wantCode int
|
|
wantMsg string
|
|
}{
|
|
{
|
|
name: "deleted device",
|
|
statusCode: http.StatusNotFound,
|
|
body: `{"error":{"message":"device does not exist","code":40402}}`,
|
|
wantCode: InvalidConfigCode,
|
|
wantMsg: "device does not exist",
|
|
},
|
|
{
|
|
// A gateway error body carries no error object at all, which decodes
|
|
// cleanly into the zero value - so the status is the only thing that
|
|
// distinguishes it from a real rejection.
|
|
name: "gateway error with an empty body",
|
|
statusCode: http.StatusBadGateway,
|
|
body: `{}`,
|
|
},
|
|
{
|
|
name: "service unavailable",
|
|
statusCode: http.StatusServiceUnavailable,
|
|
body: `{"error":{"message":"try again later","code":0}}`,
|
|
wantMsg: "try again later",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
d := json.NewDecoder(strings.NewReader(tc.body))
|
|
errResp, err := apiErrorFromResponse(tc.statusCode, d)
|
|
if err != nil {
|
|
t.Fatalf("unexpected decode error: %v", err)
|
|
}
|
|
if errResp.StatusCode != tc.statusCode {
|
|
t.Errorf("StatusCode = %d, want %d: the caller cannot tell a permanent rejection from a transient failure without it", errResp.StatusCode, tc.statusCode)
|
|
}
|
|
if errResp.ErrorField.Code != tc.wantCode {
|
|
t.Errorf("code = %d, want %d", errResp.ErrorField.Code, tc.wantCode)
|
|
}
|
|
if errResp.Error() != tc.wantMsg {
|
|
t.Errorf("message = %q, want %q", errResp.Error(), tc.wantMsg)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("an undecodable body is reported as a decode failure", func(t *testing.T) {
|
|
d := json.NewDecoder(strings.NewReader("<html>502 Bad Gateway</html>"))
|
|
if _, err := apiErrorFromResponse(http.StatusBadGateway, d); err == nil {
|
|
t.Error("expected a decode error for a non-JSON body")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestUtilityResponseDecodesDestinationIPs pins the API field that carries the
|
|
// organization's effective Allowed Destination IP list. The list is enforced as a
|
|
// set of Firewall Mode exceptions, so a silent decode change - a renamed field, a
|
|
// nesting change - would leave endpoints blocking destinations the organization
|
|
// approved, with nothing in the logs to say why.
|
|
func TestUtilityResponseDecodesDestinationIPs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "addresses and CIDRs of both families",
|
|
body: `{"body":{"resolver":{"doh":"https://dns.controld.dev/abc","destination_ips":["203.0.113.10","198.51.100.0/24","2606:1a40::1","2001:db8::/48"]}},"success":true}`,
|
|
want: []string{"203.0.113.10", "198.51.100.0/24", "2606:1a40::1", "2001:db8::/48"},
|
|
},
|
|
{
|
|
name: "empty list - the API always sends the field",
|
|
body: `{"body":{"resolver":{"doh":"https://dns.controld.dev/abc","destination_ips":[]}},"success":true}`,
|
|
want: []string{},
|
|
},
|
|
{
|
|
name: "field absent",
|
|
body: `{"body":{"resolver":{"doh":"https://dns.controld.dev/abc"}},"success":true}`,
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ur := &utilityResponse{}
|
|
require.NoError(t, json.Unmarshal([]byte(tc.body), ur))
|
|
assert.Equal(t, tc.want, ur.Body.Resolver.DestinationIPs)
|
|
})
|
|
}
|
|
}
|