Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 88 additions & 7 deletions components/egress/policy_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ func (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
s.handlePost(w, r)
case http.MethodPatch:
s.handlePatch(w, r)
case http.MethodDelete:
s.handleDelete(w, r)
default:
w.Header().Set("Allow", "GET, POST, PUT, PATCH")
w.Header().Set("Allow", "GET, POST, PUT, PATCH, DELETE")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
Expand Down Expand Up @@ -222,15 +224,16 @@ func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) {
defer s.mu.Unlock()

raw, err := readPolicyRequestBody(r)
if err != nil || raw == "" {
if err != nil {
logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err))
} else {
logEgressUpdateFailedWarn("empty patch body")
}
if err != nil {
logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err))
http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest)
return
}
if raw == "" {
logEgressUpdateFailedWarn("empty patch body")
http.Error(w, "empty body", http.StatusBadRequest)
return
}

var patchRules []policy.EgressRule
if err := json.Unmarshal([]byte(raw), &patchRules); err != nil {
Expand Down Expand Up @@ -268,6 +271,84 @@ func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) {
})
}

func (s *policyServer) handleDelete(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
s.mu.Lock()
defer s.mu.Unlock()

raw, err := readPolicyRequestBody(r)
if err != nil {
logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err))
http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest)
return
}
if raw == "" {
logEgressUpdateFailedWarn("empty delete body")
http.Error(w, "empty body", http.StatusBadRequest)
return
}

var targets []string
if err := json.Unmarshal([]byte(raw), &targets); err != nil {
logEgressUpdateFailedWarn(fmt.Sprintf("invalid delete targets: %v", err))
http.Error(w, fmt.Sprintf("invalid delete targets: %v", err), http.StatusBadRequest)
return
}
if len(targets) == 0 {
logEgressUpdateFailedWarn("empty delete targets array")
http.Error(w, "invalid delete targets: empty array", http.StatusBadRequest)
return
}

base := s.proxy.CurrentPolicy()
if base == nil {
base = policy.DefaultDenyPolicy()
}
oldCount := len(base.Egress)
newEgress, removedRules := removeRulesByTarget(base.Egress, targets)
removed := oldCount - len(newEgress)

if removed == 0 {
mode := modeFromPolicy(base)
writeJSON(w, http.StatusOK, policyStatusResponse{
Status: "ok",
Mode: mode,
EnforcementMode: s.enforcementMode,
Reason: "no matching targets found",
})
return
}

rawMerged, err := json.Marshal(policy.NetworkPolicy{
DefaultAction: base.DefaultAction,
Egress: newEgress,
})
if err != nil {
logEgressUpdateFailedError(fmt.Sprintf("failed to marshal updated policy: %v", err))
http.Error(w, fmt.Sprintf("internal error: %v", err), http.StatusInternalServerError)
return
}
newPolicy, err := policy.ParsePolicy(string(rawMerged))
if err != nil {
logEgressUpdateFailedError(fmt.Sprintf("invalid policy after delete: %v", err))
http.Error(w, fmt.Sprintf("internal error: %v", err), http.StatusInternalServerError)
return
}

mode := modeFromPolicy(newPolicy)
log.Infof("policy API: deleting %d egress rule(s) by target, removed=%d, mode=%s, enforcement=%s", len(targets), removed, mode, s.enforcementMode)
if !s.commitPolicy(r.Context(), w, newPolicy, "delete") {
return
}
logEgressUpdated(newPolicy.DefaultAction, removedRules)
log.Infof("policy API: delete applied successfully")
writeJSON(w, http.StatusOK, policyStatusResponse{
Status: "ok",
Mode: mode,
EnforcementMode: s.enforcementMode,
})
}

// commitPolicy applies one logical change: optional disk persist → merge always file rules → nft
// static (with nameserver allow-IPs) → then update in-memory user policy (POST/PATCH/GET view).
func (s *policyServer) commitPolicy(ctx context.Context, w http.ResponseWriter, pol *policy.NetworkPolicy, op string) bool {
Expand Down
144 changes: 144 additions & 0 deletions components/egress/policy_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,150 @@ func TestHandlePatch_RejectsWhenOverMaxEgressRules(t *testing.T) {
require.Len(t, proxy.updated.Egress, 2, "policy should be unchanged")
}

func TestHandleDelete_RemovesMatchingTargets(t *testing.T) {
initial := &policy.NetworkPolicy{
DefaultAction: policy.ActionDeny,
Egress: []policy.EgressRule{
{Action: policy.ActionAllow, Target: "example.com"},
{Action: policy.ActionDeny, Target: "blocked.com"},
{Action: policy.ActionAllow, Target: "keep.com"},
},
}
proxy := &stubProxy{updated: initial}
nft := &stubNft{}
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}

body := `["blocked.com","nonexistent.com"]`
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
w := httptest.NewRecorder()

srv.handlePolicy(w, req)

resp := w.Result()
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK")
require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once")
require.NotNil(t, proxy.updated, "expected proxy policy updated")
require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "defaultAction should be preserved")
require.Len(t, proxy.updated.Egress, 2, "expected 2 rules remaining after delete")
require.Equal(t, policy.ActionAllow, proxy.updated.Egress[0].Action)
require.Equal(t, "example.com", proxy.updated.Egress[0].Target)
require.Equal(t, policy.ActionAllow, proxy.updated.Egress[1].Action)
require.Equal(t, "keep.com", proxy.updated.Egress[1].Target)
}

func TestHandleDelete_CaseInsensitiveMatch(t *testing.T) {
initial := &policy.NetworkPolicy{
DefaultAction: policy.ActionDeny,
Egress: []policy.EgressRule{
{Action: policy.ActionAllow, Target: "Example.COM"},
{Action: policy.ActionDeny, Target: "Blocked.COM"},
},
}
proxy := &stubProxy{updated: initial}
nft := &stubNft{}
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}

body := `["example.com"]`
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
w := httptest.NewRecorder()

srv.handlePolicy(w, req)

resp := w.Result()
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK")
require.NotNil(t, proxy.updated)
require.Len(t, proxy.updated.Egress, 1, "expected 1 rule remaining")
require.Equal(t, "Blocked.COM", proxy.updated.Egress[0].Target, "unmatched rule should remain")
}

func TestHandleDelete_NoMatchReturns200(t *testing.T) {
initial := &policy.NetworkPolicy{
DefaultAction: policy.ActionDeny,
Egress: []policy.EgressRule{
{Action: policy.ActionAllow, Target: "keep.com"},
},
}
proxy := &stubProxy{updated: initial}
nft := &stubNft{}
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}

body := `["nonexistent.com"]`
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
w := httptest.NewRecorder()

srv.handlePolicy(w, req)

resp := w.Result()
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK even when no targets match")
require.Equal(t, 0, nft.calls, "nft should not be called when nothing changes")
require.Len(t, proxy.updated.Egress, 1, "policy should be unchanged")
}

func TestHandleDelete_EmptyBodyReturns400(t *testing.T) {
proxy := &stubProxy{updated: policy.DefaultDenyPolicy()}
srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"}

req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(""))
w := httptest.NewRecorder()

srv.handlePolicy(w, req)

resp := w.Result()
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty body")
}

func TestHandleDelete_EmptyArrayReturns400(t *testing.T) {
proxy := &stubProxy{updated: policy.DefaultDenyPolicy()}
srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"}

body := `[]`
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
w := httptest.NewRecorder()

srv.handlePolicy(w, req)

resp := w.Result()
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty array")
}

func TestHandleDelete_InvalidJSONReturns400(t *testing.T) {
proxy := &stubProxy{updated: policy.DefaultDenyPolicy()}
srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"}

body := `not-json`
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
w := httptest.NewRecorder()

srv.handlePolicy(w, req)

resp := w.Result()
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for invalid JSON")
}

func TestHandleDelete_NftFailureReturns500(t *testing.T) {
initial := &policy.NetworkPolicy{
DefaultAction: policy.ActionDeny,
Egress: []policy.EgressRule{
{Action: policy.ActionAllow, Target: "example.com"},
},
}
proxy := &stubProxy{updated: initial}
nft := &stubNft{err: errors.New("nft apply failed")}
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}

body := `["example.com"]`
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
w := httptest.NewRecorder()

srv.handlePolicy(w, req)

resp := w.Result()
require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected 500 on nft failure")
require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once")
require.Len(t, proxy.updated.Egress, 1, "proxy should not be updated on nft failure")
require.Equal(t, "example.com", proxy.updated.Egress[0].Target, "original rule should remain")
}

func TestHandlePost_RejectsWhenOverMaxEgressRules(t *testing.T) {
proxy := &stubProxy{}
nft := &stubNft{}
Expand Down
26 changes: 26 additions & 0 deletions components/egress/policy_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ func mergeEgressRules(base, additions []policy.EgressRule) []policy.EgressRule {
return out
}

// removeRulesByTarget returns a new slice with rules matching targets removed,
// plus the removed rules. Domain targets are matched case-insensitively.
// Targets not found are silently ignored.
func removeRulesByTarget(rules []policy.EgressRule, targets []string) (kept, removed []policy.EgressRule) {
if len(targets) == 0 || len(rules) == 0 {
return rules, nil
}
removeSet := make(map[string]struct{}, len(targets))
for _, t := range targets {
key := strings.ToLower(strings.TrimSpace(t))
if key == "" {
continue
}
removeSet[key] = struct{}{}
}
kept = make([]policy.EgressRule, 0, len(rules))
for _, r := range rules {
if _, ok := removeSet[strings.ToLower(r.Target)]; ok {
removed = append(removed, r)
} else {
kept = append(kept, r)
}
}
return kept, removed
}

// mergeKey: domain targets lowercased for dedupe; IP/CIDR left as-is.
func mergeKey(r policy.EgressRule) string {
if r.Target == "" {
Expand Down
41 changes: 41 additions & 0 deletions components/egress/tests/smoke-nft.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,47 @@ else
pass "www.mozilla.org blocked after patch"
fi

info "DELETE: deny two hosts, then delete one rule"
curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \
-d '{"defaultAction":"allow","egress":[{"action":"deny","target":"api.github.com"},{"action":"deny","target":"www.cloudflare.com"}]}'

info "Test: both hosts should be blocked before delete"
if run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then
fail "api.github.com should be blocked before delete"
fi
if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then
fail "www.cloudflare.com should be blocked before delete"
fi
pass "both hosts blocked before delete"

info "Deleting api.github.com rule"
curl -sSf -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" \
-d '["api.github.com"]'

info "Test: api.github.com allowed, www.cloudflare.com still blocked after delete"
run_in_app -I https://api.github.com --max-time 20 >/dev/null 2>&1 || fail "api.github.com should be allowed after delete"
pass "api.github.com allowed after delete"
if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then
fail "www.cloudflare.com should remain blocked after delete"
fi
pass "www.cloudflare.com still blocked"

info "Deleting non-existent target (idempotent)"
resp="$(curl -sSf -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" -d '["nonexistent.com"]')"
if echo "${resp}" | grep -q '"no matching targets found"'; then
pass "idempotent delete returns no matching targets found"
else
fail "expected no matching targets found, got: ${resp}"
fi

info "Deleting with empty body (expect 400)"
http_code="$(curl -s -o /dev/null -w '%{http_code}' -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" -d '')"
if [ "${http_code}" = "400" ]; then
pass "empty body returns 400"
else
fail "empty body should return 400, got ${http_code}"
fi

info "Always-rule dynamic check (single transition)"
curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"api.github.com"}]}'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ public async Task PatchRulesAsync(
await _client.PatchAsync("/policy", normalizedRules, cancellationToken).ConfigureAwait(false);
}

public async Task DeleteRulesAsync(
IReadOnlyList<string> targets,
CancellationToken cancellationToken = default)
{
await _client.DeleteAsync("/policy", targets.ToList(), cancellationToken).ConfigureAwait(false);
}

private static NetworkPolicy ParseNetworkPolicy(JsonElement element)
{
var policy = new NetworkPolicy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,23 @@ public async Task DeleteAsync(
await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
}

public async Task DeleteAsync(
string path,
object body,
CancellationToken cancellationToken)
{
var url = BuildUrl(path);
_logger.LogDebug("HTTP DELETE {Url}", url);
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
ApplyDefaultHeaders(request);

var json = JsonSerializer.Serialize(body, JsonOptions);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");

using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
}

public async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken = default)
Expand Down
Loading
Loading