Skip to content

Commit 5d2f88d

Browse files
authored
feat(egress): add patch policy updates and smoke coverage (opensandbox-group#392)
* feat(egress): add patch policy updates and smoke coverage * feat(egress): add patch override ordering and docs
1 parent 380bc99 commit 5d2f88d

4 files changed

Lines changed: 261 additions & 18 deletions

File tree

components/egress/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22

33
The **Egress Sidecar** is a core component of OpenSandbox that provides **FQDN-based egress control**. It runs alongside the sandbox application container (sharing the same network namespace) and enforces declared network policies.
44

5-
> **Status**: Implementing. Currently supports Layer 1 (DNS Proxy). Layer 2 (Network Filter) is on the roadmap.
6-
> See [OSEP-0001: FQDN-based Egress Control](../../oseps/0001-fqdn-based-egress-control.md) for the detailed design.
7-
85
## Features
96

107
- **FQDN-based Allowlist**: Control outbound traffic by domain name (e.g., `api.github.com`).
@@ -59,8 +56,9 @@ The egress control is implemented as a **Sidecar** that shares the network names
5956

6057
- Default listen address: `:18080` (override with `OPENSANDBOX_EGRESS_HTTP_ADDR`).
6158
- Endpoints:
62-
- `GET /policy` — returns the current policy.
63-
- `POST /policy` — replaces the policy. Empty/whitespace/`{}`/`null` resets to default deny-all.
59+
- `GET /policy` — returns the current policy.
60+
- `POST /policy` — replaces the policy. Empty/whitespace/`{}`/`null` resets to default deny-all.
61+
- `PATCH /policy` — merge/append rules at runtime. Body **must** be a JSON array of egress rules (not wrapped in an object). New rules are placed before existing ones (same target overrides), so a later PATCH can override prior wildcard denies with a more specific allow, and vice versa.
6462

6563
Examples:
6664

@@ -84,6 +82,16 @@ Examples:
8482
curl -XPOST http://127.0.0.1:18080/policy \
8583
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.example.com"},{"action":"allow","target":"203.0.113.0/24"},{"action":"deny","target":"*.bad.com"}]}'
8684
```
85+
- Merge-only PATCH (override wildcard deny with a specific allow):
86+
```bash
87+
# baseline: deny *.cloudflare.com
88+
curl -XPOST http://127.0.0.1:18080/policy \
89+
-d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.cloudflare.com"}]}'
90+
91+
# allow a specific host; PATCH rules are prepended, so this wins
92+
curl -XPATCH http://127.0.0.1:18080/policy \
93+
-d '[{"action":"allow","target":"www.cloudflare.com"}]'
94+
```
8795

8896
## Build & Run
8997

components/egress/policy_server.go

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"net/http"
2525
"net/netip"
2626
"strings"
27+
"sync"
2728
"time"
2829

2930
"github.com/alibaba/opensandbox/egress/pkg/constants"
@@ -104,6 +105,15 @@ type policyServer struct {
104105
token string
105106
enforcementMode string
106107
nameserverIPs []netip.Addr
108+
mu sync.Mutex // serializes read-merge-apply to avoid lost updates across POST/PATCH
109+
}
110+
111+
type policyStatusResponse struct {
112+
Status string `json:"status,omitempty"`
113+
Mode string `json:"mode,omitempty"`
114+
EnforcementMode string `json:"enforcementMode,omitempty"`
115+
Reason string `json:"reason,omitempty"`
116+
Policy any `json:"policy,omitempty"`
107117
}
108118

109119
func (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
@@ -116,24 +126,29 @@ func (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
116126
s.handleGet(w)
117127
case http.MethodPost, http.MethodPut:
118128
s.handlePost(w, r)
129+
case http.MethodPatch:
130+
s.handlePatch(w, r)
119131
default:
120-
w.Header().Set("Allow", "GET, POST, PUT")
132+
w.Header().Set("Allow", "GET, POST, PUT, PATCH")
121133
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
122134
}
123135
}
124136

125137
func (s *policyServer) handleGet(w http.ResponseWriter) {
126138
current := s.proxy.CurrentPolicy()
127139
mode := modeFromPolicy(current)
128-
writeJSON(w, http.StatusOK, map[string]any{
129-
"mode": mode,
130-
"enforcementMode": s.enforcementMode,
131-
"policy": current,
140+
writeJSON(w, http.StatusOK, policyStatusResponse{
141+
Status: "ok",
142+
Mode: mode,
143+
EnforcementMode: s.enforcementMode,
144+
Policy: current,
132145
})
133146
}
134147

135148
func (s *policyServer) handlePost(w http.ResponseWriter, r *http.Request) {
136149
defer r.Body.Close()
150+
s.mu.Lock()
151+
defer s.mu.Unlock()
137152

138153
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
139154
if err != nil {
@@ -154,10 +169,10 @@ func (s *policyServer) handlePost(w http.ResponseWriter, r *http.Request) {
154169
}
155170
s.proxy.UpdatePolicy(def)
156171
log.Infof("policy API: proxy and nftables updated to deny_all")
157-
writeJSON(w, http.StatusOK, map[string]any{
158-
"status": "ok",
159-
"mode": "deny_all",
160-
"reason": "policy reset to default deny-all",
172+
writeJSON(w, http.StatusOK, policyStatusResponse{
173+
Status: "ok",
174+
Mode: "deny_all",
175+
Reason: "policy reset to default deny-all",
161176
})
162177
return
163178
}
@@ -179,10 +194,78 @@ func (s *policyServer) handlePost(w http.ResponseWriter, r *http.Request) {
179194
}
180195
s.proxy.UpdatePolicy(pol)
181196
log.Infof("policy API: proxy and nftables updated successfully")
182-
writeJSON(w, http.StatusOK, map[string]any{
183-
"status": "ok",
184-
"mode": mode,
185-
"enforcementMode": s.enforcementMode,
197+
writeJSON(w, http.StatusOK, policyStatusResponse{
198+
Status: "ok",
199+
Mode: mode,
200+
EnforcementMode: s.enforcementMode,
201+
})
202+
}
203+
204+
// handlePatch adds or replaces egress rules by merging with the current policy.
205+
// It is a convenience wrapper over the full replace flow: we still read -> merge -> apply.
206+
// Request body supports {"egress":[{"action":"allow","target":"example.com"}, ...]}.
207+
func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) {
208+
defer r.Body.Close()
209+
s.mu.Lock()
210+
defer s.mu.Unlock()
211+
212+
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
213+
if err != nil {
214+
http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest)
215+
return
216+
}
217+
raw := strings.TrimSpace(string(body))
218+
if raw == "" {
219+
http.Error(w, "patch body cannot be empty", http.StatusBadRequest)
220+
return
221+
}
222+
223+
var patchRules []policy.EgressRule
224+
if err = json.Unmarshal([]byte(raw), &patchRules); err != nil {
225+
http.Error(w, fmt.Sprintf("invalid patch rules: %v", err), http.StatusBadRequest)
226+
return
227+
}
228+
if len(patchRules) == 0 {
229+
http.Error(w, "patch must include at least one egress rule", http.StatusBadRequest)
230+
return
231+
}
232+
233+
base := s.proxy.CurrentPolicy()
234+
if base == nil {
235+
base = policy.DefaultDenyPolicy()
236+
}
237+
baseCopy := *base
238+
baseCopy.Egress = append([]policy.EgressRule(nil), base.Egress...)
239+
240+
merged := mergeEgressRules(baseCopy.Egress, patchRules)
241+
242+
// Reuse parser to normalize targets/actions.
243+
rawMerged, _ := json.Marshal(policy.NetworkPolicy{
244+
DefaultAction: baseCopy.DefaultAction,
245+
Egress: merged,
246+
})
247+
newPolicy, err := policy.ParsePolicy(string(rawMerged))
248+
if err != nil {
249+
http.Error(w, fmt.Sprintf("invalid merged policy: %v", err), http.StatusBadRequest)
250+
return
251+
}
252+
253+
mode := modeFromPolicy(newPolicy)
254+
log.Infof("policy API: patching policy with %d new rule(s), mode=%s, enforcement=%s", len(patchRules), mode, s.enforcementMode)
255+
if s.nft != nil {
256+
polWithNS := newPolicy.WithExtraAllowIPs(s.nameserverIPs)
257+
if err := s.nft.ApplyStatic(r.Context(), polWithNS); err != nil {
258+
log.Errorf("policy API: nftables apply failed on patch: %v", err)
259+
http.Error(w, fmt.Sprintf("failed to apply nftables policy: %v", err), http.StatusInternalServerError)
260+
return
261+
}
262+
}
263+
s.proxy.UpdatePolicy(newPolicy)
264+
log.Infof("policy API: patch applied successfully")
265+
writeJSON(w, http.StatusOK, policyStatusResponse{
266+
Status: "ok",
267+
Mode: mode,
268+
EnforcementMode: s.enforcementMode,
186269
})
187270
}
188271

@@ -218,3 +301,40 @@ func modeFromPolicy(p *policy.NetworkPolicy) string {
218301

219302
return "enforcing"
220303
}
304+
305+
// mergeEgressRules joins base rules and additions, deduping by target (last writer wins).
306+
func mergeEgressRules(base, additions []policy.EgressRule) []policy.EgressRule {
307+
if len(additions) == 0 {
308+
return base
309+
}
310+
out := make([]policy.EgressRule, 0, len(base)+len(additions))
311+
seen := make(map[string]struct{})
312+
313+
// Priority: additions first; base rules only if target not overridden.
314+
for _, r := range additions {
315+
key := mergeKey(r)
316+
if _, ok := seen[key]; ok {
317+
continue
318+
}
319+
seen[key] = struct{}{}
320+
out = append(out, r)
321+
}
322+
for _, r := range base {
323+
key := mergeKey(r)
324+
if _, ok := seen[key]; ok {
325+
continue
326+
}
327+
seen[key] = struct{}{}
328+
out = append(out, r)
329+
}
330+
return out
331+
}
332+
333+
// mergeKey normalizes domain targets to lowercase for dedupe;
334+
// IP/CIDR targets are kept as-is.
335+
func mergeKey(r policy.EgressRule) string {
336+
if r.Target == "" {
337+
return r.Target
338+
}
339+
return strings.ToLower(r.Target)
340+
}

components/egress/policy_server_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,80 @@ func TestHandleGet_ReturnsEnforcementMode(t *testing.T) {
125125
t.Fatalf("expected enforcementMode dns in response, got: %s", string(body))
126126
}
127127
}
128+
129+
func TestHandlePatch_MergesAndApplies(t *testing.T) {
130+
initial := &policy.NetworkPolicy{
131+
DefaultAction: policy.ActionDeny,
132+
Egress: []policy.EgressRule{
133+
{Action: policy.ActionAllow, Target: "example.com"},
134+
{Action: policy.ActionDeny, Target: "*.example.com"},
135+
},
136+
}
137+
proxy := &stubProxy{updated: initial}
138+
nft := &stubNft{}
139+
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}
140+
141+
body := `[{"action":"deny","target":"blocked.com"},{"action":"allow","target":"example.com"}]`
142+
req := httptest.NewRequest(http.MethodPatch, "/policy", strings.NewReader(body))
143+
w := httptest.NewRecorder()
144+
145+
srv.handlePolicy(w, req)
146+
147+
resp := w.Result()
148+
if resp.StatusCode != http.StatusOK {
149+
t.Fatalf("expected 200, got %d", resp.StatusCode)
150+
}
151+
if nft.calls != 1 {
152+
t.Fatalf("expected nft ApplyStatic called once, got %d", nft.calls)
153+
}
154+
if proxy.updated == nil {
155+
t.Fatalf("expected proxy policy to be updated")
156+
}
157+
if proxy.updated.DefaultAction != policy.ActionDeny {
158+
t.Fatalf("default action should be preserved, got %s", proxy.updated.DefaultAction)
159+
}
160+
if len(proxy.updated.Egress) != 3 {
161+
t.Fatalf("expected 3 egress rules, got %d", len(proxy.updated.Egress))
162+
}
163+
if proxy.updated.Egress[0].Target != "blocked.com" || proxy.updated.Egress[0].Action != policy.ActionDeny {
164+
t.Fatalf("expected first rule to be patched blocked.com deny, got %+v", proxy.updated.Egress[0])
165+
}
166+
if proxy.updated.Egress[1].Target != "example.com" || proxy.updated.Egress[1].Action != policy.ActionAllow {
167+
t.Fatalf("expected second rule to be patched example.com allow, got %+v", proxy.updated.Egress[1])
168+
}
169+
if proxy.updated.Egress[2].Target != "*.example.com" || proxy.updated.Egress[2].Action != policy.ActionDeny {
170+
t.Fatalf("expected base wildcard rule to remain last, got %+v", proxy.updated.Egress[2])
171+
}
172+
}
173+
174+
func TestHandlePatch_DomainCaseOverride(t *testing.T) {
175+
initial := &policy.NetworkPolicy{
176+
DefaultAction: policy.ActionDeny,
177+
Egress: []policy.EgressRule{
178+
{Action: policy.ActionDeny, Target: "Example.COM"},
179+
},
180+
}
181+
proxy := &stubProxy{updated: initial}
182+
nft := &stubNft{}
183+
srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"}
184+
185+
body := `[{"action":"allow","target":"example.com"}]`
186+
req := httptest.NewRequest(http.MethodPatch, "/policy", strings.NewReader(body))
187+
w := httptest.NewRecorder()
188+
189+
srv.handlePolicy(w, req)
190+
191+
resp := w.Result()
192+
if resp.StatusCode != http.StatusOK {
193+
t.Fatalf("expected 200, got %d", resp.StatusCode)
194+
}
195+
if proxy.updated == nil {
196+
t.Fatalf("expected proxy policy to be updated")
197+
}
198+
if len(proxy.updated.Egress) != 1 {
199+
t.Fatalf("expected deduped rule count 1, got %d", len(proxy.updated.Egress))
200+
}
201+
if proxy.updated.Egress[0].Action != policy.ActionAllow || proxy.updated.Egress[0].Target != "example.com" {
202+
t.Fatalf("expected allow example.com to override, got %+v", proxy.updated.Egress[0])
203+
}
204+
}

components/egress/tests/smoke-nft.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,42 @@ else
9494
pass "DoT 853 blocked"
9595
fi
9696

97+
info "Rules update: wildcard deny -> patch allow specific (dns+nft)"
98+
curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \
99+
-d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.cloudflare.com"}]}'
100+
101+
info "Test: www.cloudflare.com should be blocked initially (deny via wildcard)"
102+
if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then
103+
fail "www.cloudflare.com should be blocked before patch"
104+
else
105+
pass "www.cloudflare.com blocked before patch"
106+
fi
107+
108+
info "Patching allow for www.cloudflare.com (specific should override earlier deny)"
109+
curl -sSf -XPATCH "http://127.0.0.1:${POLICY_PORT}/policy" \
110+
-d '[{"action":"allow","target":"www.cloudflare.com"}]'
111+
112+
info "Test: www.cloudflare.com should be allowed after patch"
113+
run_in_app -I https://www.cloudflare.com --max-time 10 >/dev/null 2>&1 || fail "www.cloudflare.com should succeed after patch"
114+
pass "www.cloudflare.com allowed after patch"
115+
116+
info "Rules update: wildcard allow -> patch deny specific (dns+nft)"
117+
curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \
118+
-d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.mozilla.org"}]}'
119+
120+
info "Test: www.mozilla.org should be allowed initially (allow via wildcard)"
121+
run_in_app -I https://www.mozilla.org --max-time 10 >/dev/null 2>&1 || fail "www.mozilla.org should succeed before patch"
122+
pass "www.mozilla.org allowed before patch"
123+
124+
info "Patching deny for www.mozilla.org (specific should override earlier allow)"
125+
curl -sSf -XPATCH "http://127.0.0.1:${POLICY_PORT}/policy" \
126+
-d '[{"action":"deny","target":"www.mozilla.org"}]'
127+
128+
info "Test: www.mozilla.org should be blocked after patch"
129+
if run_in_app -I https://www.mozilla.org --max-time 8 >/dev/null 2>&1; then
130+
fail "www.mozilla.org should be blocked after patch"
131+
else
132+
pass "www.mozilla.org blocked after patch"
133+
fi
134+
97135
info "All smoke tests passed."

0 commit comments

Comments
 (0)