Skip to content

Commit 2fabb9d

Browse files
authored
Merge pull request #78 from Pangjiping/feat/components/egress-part1
FEATURE: simple MVP for opensandbox egress
2 parents ad63c43 + 1e730f1 commit 2fabb9d

File tree

13 files changed

+576
-2
lines changed

13 files changed

+576
-2
lines changed

.github/workflows/manual-docker-publish.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
options:
1111
- execd
1212
- code-interpreter
13+
- egress
1314
default: 'execd'
1415
image_tag:
1516
description: 'Docker image tag'
@@ -19,6 +20,7 @@ on:
1920
tags:
2021
- 'docker/execd/**'
2122
- 'docker/code-interpreter/**'
23+
- 'docker/egress/**'
2224

2325
jobs:
2426
publish:
@@ -77,6 +79,8 @@ jobs:
7779
7880
if [ "$COMPONENT" == "execd" ]; then
7981
cd components/execd
82+
elif [ "$COMPONENT" == "egress" ]; then
83+
cd components/egress
8084
else
8185
cd sandboxes/$COMPONENT
8286
fi

components/egress/Dockerfile

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright 2026 Alibaba Group Holding Ltd.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
FROM golang:1.24-bookworm AS builder
16+
17+
WORKDIR /app
18+
19+
COPY go.mod go.sum ./
20+
21+
# Static-ish build (no cgo) to simplify runtime deps
22+
ENV CGO_ENABLED=0
23+
RUN go mod download
24+
25+
COPY . .
26+
RUN go build -o /out/egress .
27+
28+
FROM debian:bookworm-slim
29+
30+
# iptables is needed for DNS REDIRECT; ca-certificates for TLS to upstream resolvers
31+
RUN apt-get update \
32+
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
33+
iptables \
34+
ca-certificates \
35+
curl \
36+
wget \
37+
net-tools \
38+
dnsutils \
39+
netcat-openbsd \
40+
iputils-ping \
41+
traceroute \
42+
telnet \
43+
tcpdump \
44+
nmap \
45+
htop \
46+
procps \
47+
strace \
48+
lsof \
49+
&& rm -rf /var/lib/apt/lists/*
50+
51+
COPY --from=builder /out/egress /egress
52+
53+
# Default entrypoint; expects OPENSANDBOX_NETWORK_POLICY env at runtime.
54+
ENTRYPOINT ["/egress"]

components/egress/TODO.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Egress Sidecar TODO (Linux MVP → Full OSEP-0001)
2+
3+
## Gaps vs OSEP-0001
4+
- No Layer 2 yet: no nftables full isolation, no DoH/DoT blocking, no IP/CIDR rules.
5+
- Policy surface is minimal: only domain allow + default_action; missing deny rules, IP/CIDR, `require_full_isolation`.
6+
- Observability missing: no enforcement mode/status exposure, no violation logs.
7+
- Capability probing missing: no CAP_NET_ADMIN/nftables detection; no hostNetwork rejection.
8+
- Platform integration missing: server/SDK/spec not updated; sidecar not wired into server flow.
9+
- No IPv6; startup ordering not enforced (relies on container start order).
10+
11+
## Short-term priorities (suggested order)
12+
1) Capability probing & mode exposure
13+
- Detect CAP_NET_ADMIN and nftables; set `dns-only` vs `dns+nftables`; surface in logs/status.
14+
- Fast-fail on hostNetwork.
15+
2) Layer 2 via nftables
16+
- Allow-set + default DROP; add DNS-learned IPs dynamically.
17+
- Static IP/CIDR rules; block DoH/DoT ports.
18+
3) Policy expansion
19+
- Support deny rules, IP/CIDR, `require_full_isolation`.
20+
- Validation and clear errors.
21+
4) Observability & logging
22+
- Violation logs (domain/action/upstream IP); expose current enforcement mode.
23+
- Optional lightweight health/status endpoint.
24+
5) Platform & SDK alignment
25+
- Update `specs/sandbox-lifecycle.yml`; add `network_policy` to Python/Kotlin SDKs.
26+
- Server (Docker/K8s) integrates sidecar injection; NET_ADMIN only on sidecar.
27+
6) Security hardening
28+
- Whitelist/validate upstream DNS to avoid arbitrary 53 egress abuse.
29+
- Document bypass/limits (dns-only can be bypassed via direct IP/DoH).
30+
7) IPv6 & tests
31+
- Handle IPv6 support or explicit non-support.
32+
- Unit/integration tests: interception, graceful degrade, nftables, DoH blocking, hostNetwork rejection.
33+
34+
## Dev notes
35+
- Current behavior: SO_MARK=0x1 bypass for proxy’s own upstream DNS; iptables only redirects port 53, no other DROP rules.
36+
- Runtime deps: Linux, `CAP_NET_ADMIN`, `iptables` binary; upstream DNS must be reachable and recursive.
37+

components/egress/build.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/bin/bash
2+
# Copyright 2026 Alibaba Group Holding Ltd.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
set -ex
17+
18+
TAG=${TAG:-latest}
19+
20+
docker buildx rm egress-builder || true
21+
22+
docker buildx create --use --name egress-builder
23+
24+
docker buildx inspect --bootstrap
25+
26+
docker buildx ls
27+
28+
docker buildx build \
29+
-t opensandbox/egress:${TAG} \
30+
-t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:${TAG} \
31+
--platform linux/amd64,linux/arm64 \
32+
--push \
33+
.

components/egress/go.mod

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module github.com/alibaba/opensandbox/egress
2+
3+
go 1.24.0
4+
5+
require github.com/miekg/dns v1.1.61
6+
7+
require (
8+
golang.org/x/mod v0.18.0 // indirect
9+
golang.org/x/net v0.26.0 // indirect
10+
golang.org/x/sync v0.7.0 // indirect
11+
golang.org/x/sys v0.21.0 // indirect
12+
golang.org/x/tools v0.22.0 // indirect
13+
)

components/egress/go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
2+
github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
3+
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
4+
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
5+
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
6+
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
7+
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
8+
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
9+
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
10+
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
11+
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
12+
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=

components/egress/main.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2026 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
"log"
20+
"os"
21+
"os/signal"
22+
"syscall"
23+
24+
"github.com/alibaba/opensandbox/egress/pkg/dnsproxy"
25+
"github.com/alibaba/opensandbox/egress/pkg/iptables"
26+
)
27+
28+
// Linux MVP: DNS proxy + iptables REDIRECT. No nftables/full isolation yet.
29+
func main() {
30+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
31+
defer cancel()
32+
33+
policy, err := dnsproxy.LoadPolicyFromEnv()
34+
if err != nil {
35+
log.Fatalf("failed to parse network policy: %v", err)
36+
}
37+
if policy == nil {
38+
log.Println("OPENSANDBOX_NETWORK_POLICY empty; skip egress control")
39+
// Block here to avoid infinite container restart loop in Kubernetes
40+
// when restartPolicy is Always. As a sidecar, we should keep running.
41+
<-ctx.Done()
42+
return
43+
}
44+
45+
proxy, err := dnsproxy.New(policy, "")
46+
if err != nil {
47+
log.Fatalf("failed to init dns proxy: %v", err)
48+
}
49+
if err := proxy.Start(ctx); err != nil {
50+
log.Fatalf("failed to start dns proxy: %v", err)
51+
}
52+
log.Println("dns proxy started on 127.0.0.1:15353")
53+
54+
if err := iptables.SetupRedirect(15353); err != nil {
55+
log.Fatalf("failed to install iptables redirect: %v", err)
56+
}
57+
log.Printf("iptables redirect configured (OUTPUT 53 -> 15353) with SO_MARK bypass for proxy upstream traffic")
58+
59+
<-ctx.Done()
60+
log.Println("received shutdown signal; exiting")
61+
_ = os.Stderr.Sync()
62+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright 2026 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package dnsproxy
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"log"
21+
"net"
22+
"os"
23+
"time"
24+
25+
"github.com/miekg/dns"
26+
27+
"github.com/alibaba/opensandbox/egress/pkg/policy"
28+
)
29+
30+
const defaultListenAddr = "127.0.0.1:15353"
31+
32+
type Proxy struct {
33+
policy *policy.NetworkPolicy
34+
listenAddr string
35+
upstream string // single upstream for MVP
36+
servers []*dns.Server
37+
}
38+
39+
// New builds a proxy with resolved upstream; listenAddr can be empty for default.
40+
func New(p *policy.NetworkPolicy, listenAddr string) (*Proxy, error) {
41+
if listenAddr == "" {
42+
listenAddr = defaultListenAddr
43+
}
44+
upstream, err := discoverUpstream()
45+
if err != nil {
46+
return nil, err
47+
}
48+
return &Proxy{
49+
policy: p,
50+
listenAddr: listenAddr,
51+
upstream: upstream,
52+
}, nil
53+
}
54+
55+
func (p *Proxy) Start(ctx context.Context) error {
56+
handler := dns.HandlerFunc(p.serveDNS)
57+
58+
udpServer := &dns.Server{Addr: p.listenAddr, Net: "udp", Handler: handler}
59+
tcpServer := &dns.Server{Addr: p.listenAddr, Net: "tcp", Handler: handler}
60+
p.servers = []*dns.Server{udpServer, tcpServer}
61+
62+
errCh := make(chan error, len(p.servers))
63+
for _, srv := range p.servers {
64+
s := srv
65+
go func() {
66+
if err := s.ListenAndServe(); err != nil {
67+
errCh <- err
68+
}
69+
}()
70+
}
71+
72+
// Shutdown on context done
73+
go func() {
74+
<-ctx.Done()
75+
for _, srv := range p.servers {
76+
_ = srv.Shutdown()
77+
}
78+
}()
79+
80+
select {
81+
case err := <-errCh:
82+
return fmt.Errorf("dns proxy failed: %w", err)
83+
case <-time.After(200 * time.Millisecond):
84+
// small grace window; running fine
85+
return nil
86+
}
87+
}
88+
89+
func (p *Proxy) serveDNS(w dns.ResponseWriter, r *dns.Msg) {
90+
if len(r.Question) == 0 {
91+
_ = w.WriteMsg(new(dns.Msg)) // empty response
92+
return
93+
}
94+
q := r.Question[0]
95+
domain := q.Name
96+
97+
if p.policy != nil && p.policy.Evaluate(domain) == policy.ActionDeny {
98+
resp := new(dns.Msg)
99+
resp.SetRcode(r, dns.RcodeNameError)
100+
_ = w.WriteMsg(resp)
101+
return
102+
}
103+
104+
resp, err := p.forward(r)
105+
if err != nil {
106+
log.Printf("[dns] forward error for %s: %v", domain, err)
107+
fail := new(dns.Msg)
108+
fail.SetRcode(r, dns.RcodeServerFailure)
109+
_ = w.WriteMsg(fail)
110+
return
111+
}
112+
_ = w.WriteMsg(resp)
113+
}
114+
115+
func (p *Proxy) forward(r *dns.Msg) (*dns.Msg, error) {
116+
c := &dns.Client{
117+
Timeout: 5 * time.Second,
118+
Dialer: p.dialerWithMark(),
119+
}
120+
resp, _, err := c.Exchange(r, p.upstream)
121+
return resp, err
122+
}
123+
124+
// UpstreamHost returns the host part of the upstream resolver, empty on parse error.
125+
func (p *Proxy) UpstreamHost() string {
126+
host, _, err := net.SplitHostPort(p.upstream)
127+
if err != nil {
128+
return ""
129+
}
130+
return host
131+
}
132+
133+
func discoverUpstream() (string, error) {
134+
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
135+
if err == nil && len(cfg.Servers) > 0 {
136+
return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil
137+
}
138+
// fallback to public resolver; comment to explain deterministic behavior
139+
log.Printf("[dns] fallback upstream resolver due to error: %v", err)
140+
return "8.8.8.8:53", nil
141+
}
142+
143+
// LoadPolicyFromEnv reads OPENSANDBOX_NETWORK_POLICY and parses it.
144+
func LoadPolicyFromEnv() (*policy.NetworkPolicy, error) {
145+
raw := os.Getenv("OPENSANDBOX_NETWORK_POLICY")
146+
if raw == "" {
147+
return nil, nil
148+
}
149+
return policy.ParsePolicy(raw)
150+
}

0 commit comments

Comments
 (0)