Skip to content

Commit 1bb5724

Browse files
Pangjipingclaude
andcommitted
feat(egress): add DELETE /policy endpoint for removing egress rules by target
Add DELETE handler that accepts a JSON array of target strings, removes matching rules case-insensitively, and commits the updated policy. Targets not found are silently ignored (idempotent). Spec and README docs updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 395b5aa commit 1bb5724

6 files changed

Lines changed: 297 additions & 1 deletion

File tree

components/egress/policy_server.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,10 @@ func (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
147147
s.handlePost(w, r)
148148
case http.MethodPatch:
149149
s.handlePatch(w, r)
150+
case http.MethodDelete:
151+
s.handleDelete(w, r)
150152
default:
151-
w.Header().Set("Allow", "GET, POST, PUT, PATCH")
153+
w.Header().Set("Allow", "GET, POST, PUT, PATCH, DELETE")
152154
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
153155
}
154156
}
@@ -268,6 +270,84 @@ func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) {
268270
})
269271
}
270272

273+
func (s *policyServer) handleDelete(w http.ResponseWriter, r *http.Request) {
274+
defer r.Body.Close()
275+
s.mu.Lock()
276+
defer s.mu.Unlock()
277+
278+
raw, err := readPolicyRequestBody(r)
279+
if err != nil || raw == "" {
280+
if err != nil {
281+
logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err))
282+
} else {
283+
logEgressUpdateFailedWarn("empty delete body")
284+
}
285+
http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest)
286+
return
287+
}
288+
289+
var targets []string
290+
if err := json.Unmarshal([]byte(raw), &targets); err != nil {
291+
logEgressUpdateFailedWarn(fmt.Sprintf("invalid delete targets: %v", err))
292+
http.Error(w, fmt.Sprintf("invalid delete targets: %v", err), http.StatusBadRequest)
293+
return
294+
}
295+
if len(targets) == 0 {
296+
logEgressUpdateFailedWarn("empty delete targets array")
297+
http.Error(w, "invalid delete targets: empty array", http.StatusBadRequest)
298+
return
299+
}
300+
301+
base := s.proxy.CurrentPolicy()
302+
if base == nil {
303+
base = policy.DefaultDenyPolicy()
304+
}
305+
oldCount := len(base.Egress)
306+
newEgress, removedRules := removeRulesByTarget(base.Egress, targets)
307+
removed := oldCount - len(newEgress)
308+
309+
if removed == 0 {
310+
mode := modeFromPolicy(base)
311+
writeJSON(w, http.StatusOK, policyStatusResponse{
312+
Status: "ok",
313+
Mode: mode,
314+
EnforcementMode: s.enforcementMode,
315+
Reason: "no matching targets found",
316+
Policy: base,
317+
})
318+
return
319+
}
320+
321+
rawMerged, err := json.Marshal(policy.NetworkPolicy{
322+
DefaultAction: base.DefaultAction,
323+
Egress: newEgress,
324+
})
325+
if err != nil {
326+
logEgressUpdateFailedWarn(fmt.Sprintf("failed to marshal updated policy: %v", err))
327+
http.Error(w, fmt.Sprintf("internal error: %v", err), http.StatusInternalServerError)
328+
return
329+
}
330+
newPolicy, err := policy.ParsePolicy(string(rawMerged))
331+
if err != nil {
332+
logEgressUpdateFailedWarn(fmt.Sprintf("invalid policy after delete: %v", err))
333+
http.Error(w, fmt.Sprintf("invalid policy after delete: %v", err), http.StatusBadRequest)
334+
return
335+
}
336+
337+
mode := modeFromPolicy(newPolicy)
338+
log.Infof("policy API: deleting %d egress rule(s) by target, removed=%d, mode=%s, enforcement=%s", len(targets), removed, mode, s.enforcementMode)
339+
if !s.commitPolicy(r.Context(), w, newPolicy, "delete") {
340+
return
341+
}
342+
logEgressUpdated(newPolicy.DefaultAction, removedRules)
343+
log.Infof("policy API: delete applied successfully")
344+
writeJSON(w, http.StatusOK, policyStatusResponse{
345+
Status: "ok",
346+
Mode: mode,
347+
EnforcementMode: s.enforcementMode,
348+
})
349+
}
350+
271351
// commitPolicy applies one logical change: optional disk persist → merge always file rules → nft
272352
// static (with nameserver allow-IPs) → then update in-memory user policy (POST/PATCH/GET view).
273353
func (s *policyServer) commitPolicy(ctx context.Context, w http.ResponseWriter, pol *policy.NetworkPolicy, op string) bool {

components/egress/policy_server_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,150 @@ func TestHandlePatch_RejectsWhenOverMaxEgressRules(t *testing.T) {
245245
require.Len(t, proxy.updated.Egress, 2, "policy should be unchanged")
246246
}
247247

248+
func TestHandleDelete_RemovesMatchingTargets(t *testing.T) {
249+
initial := &policy.NetworkPolicy{
250+
DefaultAction: policy.ActionDeny,
251+
Egress: []policy.EgressRule{
252+
{Action: policy.ActionAllow, Target: "example.com"},
253+
{Action: policy.ActionDeny, Target: "blocked.com"},
254+
{Action: policy.ActionAllow, Target: "keep.com"},
255+
},
256+
}
257+
proxy := &stubProxy{updated: initial}
258+
nft := &stubNft{}
259+
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}
260+
261+
body := `["blocked.com","nonexistent.com"]`
262+
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
263+
w := httptest.NewRecorder()
264+
265+
srv.handlePolicy(w, req)
266+
267+
resp := w.Result()
268+
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK")
269+
require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once")
270+
require.NotNil(t, proxy.updated, "expected proxy policy updated")
271+
require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "defaultAction should be preserved")
272+
require.Len(t, proxy.updated.Egress, 2, "expected 2 rules remaining after delete")
273+
require.Equal(t, policy.ActionAllow, proxy.updated.Egress[0].Action)
274+
require.Equal(t, "example.com", proxy.updated.Egress[0].Target)
275+
require.Equal(t, policy.ActionAllow, proxy.updated.Egress[1].Action)
276+
require.Equal(t, "keep.com", proxy.updated.Egress[1].Target)
277+
}
278+
279+
func TestHandleDelete_CaseInsensitiveMatch(t *testing.T) {
280+
initial := &policy.NetworkPolicy{
281+
DefaultAction: policy.ActionDeny,
282+
Egress: []policy.EgressRule{
283+
{Action: policy.ActionAllow, Target: "Example.COM"},
284+
{Action: policy.ActionDeny, Target: "Blocked.COM"},
285+
},
286+
}
287+
proxy := &stubProxy{updated: initial}
288+
nft := &stubNft{}
289+
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}
290+
291+
body := `["example.com"]`
292+
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
293+
w := httptest.NewRecorder()
294+
295+
srv.handlePolicy(w, req)
296+
297+
resp := w.Result()
298+
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK")
299+
require.NotNil(t, proxy.updated)
300+
require.Len(t, proxy.updated.Egress, 1, "expected 1 rule remaining")
301+
require.Equal(t, "Blocked.COM", proxy.updated.Egress[0].Target, "unmatched rule should remain")
302+
}
303+
304+
func TestHandleDelete_NoMatchReturns200(t *testing.T) {
305+
initial := &policy.NetworkPolicy{
306+
DefaultAction: policy.ActionDeny,
307+
Egress: []policy.EgressRule{
308+
{Action: policy.ActionAllow, Target: "keep.com"},
309+
},
310+
}
311+
proxy := &stubProxy{updated: initial}
312+
nft := &stubNft{}
313+
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}
314+
315+
body := `["nonexistent.com"]`
316+
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
317+
w := httptest.NewRecorder()
318+
319+
srv.handlePolicy(w, req)
320+
321+
resp := w.Result()
322+
require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK even when no targets match")
323+
require.Equal(t, 0, nft.calls, "nft should not be called when nothing changes")
324+
require.Len(t, proxy.updated.Egress, 1, "policy should be unchanged")
325+
}
326+
327+
func TestHandleDelete_EmptyBodyReturns400(t *testing.T) {
328+
proxy := &stubProxy{updated: policy.DefaultDenyPolicy()}
329+
srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"}
330+
331+
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(""))
332+
w := httptest.NewRecorder()
333+
334+
srv.handlePolicy(w, req)
335+
336+
resp := w.Result()
337+
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty body")
338+
}
339+
340+
func TestHandleDelete_EmptyArrayReturns400(t *testing.T) {
341+
proxy := &stubProxy{updated: policy.DefaultDenyPolicy()}
342+
srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"}
343+
344+
body := `[]`
345+
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
346+
w := httptest.NewRecorder()
347+
348+
srv.handlePolicy(w, req)
349+
350+
resp := w.Result()
351+
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty array")
352+
}
353+
354+
func TestHandleDelete_InvalidJSONReturns400(t *testing.T) {
355+
proxy := &stubProxy{updated: policy.DefaultDenyPolicy()}
356+
srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"}
357+
358+
body := `not-json`
359+
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
360+
w := httptest.NewRecorder()
361+
362+
srv.handlePolicy(w, req)
363+
364+
resp := w.Result()
365+
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for invalid JSON")
366+
}
367+
368+
func TestHandleDelete_NftFailureReturns500(t *testing.T) {
369+
initial := &policy.NetworkPolicy{
370+
DefaultAction: policy.ActionDeny,
371+
Egress: []policy.EgressRule{
372+
{Action: policy.ActionAllow, Target: "example.com"},
373+
},
374+
}
375+
proxy := &stubProxy{updated: initial}
376+
nft := &stubNft{err: errors.New("nft apply failed")}
377+
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}
378+
379+
body := `["example.com"]`
380+
req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body))
381+
w := httptest.NewRecorder()
382+
383+
srv.handlePolicy(w, req)
384+
385+
resp := w.Result()
386+
require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected 500 on nft failure")
387+
require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once")
388+
require.Len(t, proxy.updated.Egress, 1, "proxy should not be updated on nft failure")
389+
require.Equal(t, "example.com", proxy.updated.Egress[0].Target, "original rule should remain")
390+
}
391+
248392
func TestHandlePost_RejectsWhenOverMaxEgressRules(t *testing.T) {
249393
proxy := &stubProxy{}
250394
nft := &stubNft{}

components/egress/policy_utils.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ func mergeEgressRules(base, additions []policy.EgressRule) []policy.EgressRule {
8383
return out
8484
}
8585

86+
// removeRulesByTarget returns a new slice with rules matching targets removed,
87+
// plus the removed rules. Domain targets are matched case-insensitively.
88+
// Targets not found are silently ignored.
89+
func removeRulesByTarget(rules []policy.EgressRule, targets []string) (kept, removed []policy.EgressRule) {
90+
if len(targets) == 0 || len(rules) == 0 {
91+
return rules, nil
92+
}
93+
removeSet := make(map[string]struct{}, len(targets))
94+
for _, t := range targets {
95+
removeSet[strings.ToLower(strings.TrimSpace(t))] = struct{}{}
96+
}
97+
kept = make([]policy.EgressRule, 0, len(rules))
98+
for _, r := range rules {
99+
if _, ok := removeSet[strings.ToLower(r.Target)]; ok {
100+
removed = append(removed, r)
101+
} else {
102+
kept = append(kept, r)
103+
}
104+
}
105+
return kept, removed
106+
}
107+
86108
// mergeKey: domain targets lowercased for dedupe; IP/CIDR left as-is.
87109
func mergeKey(r policy.EgressRule) string {
88110
if r.Target == "" {

specs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ the sandbox endpoint for the egress port and then calling the sidecar endpoint d
122122
**Main Endpoints:**
123123
- `GET /policy` - Get the current egress policy
124124
- `PATCH /policy` - Merge new egress rules into the current policy
125+
- `DELETE /policy` - Remove specific egress rules from the current policy by target
125126

126127
## Technical Features
127128

specs/README_zh.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
**主要端点:**
122122
- `GET /policy` - 获取当前 egress 策略
123123
- `PATCH /policy` - 将新的 egress 规则合并到当前策略
124+
- `DELETE /policy` - 按 target 删除当前策略中的指定 egress 规则
124125

125126
## 技术特性
126127

specs/egress-api.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,54 @@ paths:
109109
$ref: '#/components/responses/Unauthorized'
110110
'500':
111111
$ref: '#/components/responses/InternalServerError'
112+
delete:
113+
tags: [Policy]
114+
summary: Delete egress rules
115+
description: |
116+
Remove specific egress rules from the currently enforced policy by target.
117+
118+
- Accepts a list of target strings (FQDNs or wildcard domains).
119+
- Matching rules are removed; targets not found in the current policy
120+
are silently ignored (idempotent).
121+
- Returns the resulting policy after deletion.
122+
requestBody:
123+
required: true
124+
content:
125+
application/json:
126+
schema:
127+
type: array
128+
minItems: 1
129+
items:
130+
type: string
131+
description: FQDN or wildcard domain to remove from the policy.
132+
example:
133+
- bad.example.com
134+
- "*.blocked.org"
135+
responses:
136+
'200':
137+
description: Rules removed successfully.
138+
content:
139+
application/json:
140+
schema:
141+
$ref: '#/components/schemas/PolicyStatusResponse'
142+
examples:
143+
removed:
144+
summary: Rules removed
145+
value:
146+
status: ok
147+
mode: deny_all
148+
enforcementMode: dns
149+
policy:
150+
defaultAction: deny
151+
egress:
152+
- action: allow
153+
target: pypi.org
154+
'400':
155+
$ref: '#/components/responses/BadRequest'
156+
'401':
157+
$ref: '#/components/responses/Unauthorized'
158+
'500':
159+
$ref: '#/components/responses/InternalServerError'
112160
components:
113161
responses:
114162
BadRequest:

0 commit comments

Comments
 (0)