From 90c55d27782c634681250145f0fdaa740346f466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=84=B6?= Date: Sat, 28 Mar 2026 21:16:09 +0800 Subject: [PATCH] feat(egress): add transparent HTTP proxy --- components/egress/README.md | 13 ++ components/egress/main.go | 12 ++ .../egress/pkg/constants/configuration.go | 8 + components/egress/pkg/dialer/mark_linux.go | 44 ++++++ components/egress/pkg/dialer/mark_other.go | 27 ++++ components/egress/pkg/dnsproxy/proxy_linux.go | 18 +-- components/egress/pkg/httpproxy/config.go | 120 +++++++++++++++ .../egress/pkg/httpproxy/config_test.go | 90 +++++++++++ .../egress/pkg/httpproxy/origdst_linux.go | 80 ++++++++++ .../egress/pkg/httpproxy/server_linux.go | 145 ++++++++++++++++++ .../egress/pkg/httpproxy/server_linux_test.go | 79 ++++++++++ .../egress/pkg/httpproxy/server_other.go | 30 ++++ .../egress/pkg/iptables/http_redirect.go | 48 ++++++ 13 files changed, 698 insertions(+), 16 deletions(-) create mode 100644 components/egress/pkg/dialer/mark_linux.go create mode 100644 components/egress/pkg/dialer/mark_other.go create mode 100644 components/egress/pkg/httpproxy/config.go create mode 100644 components/egress/pkg/httpproxy/config_test.go create mode 100644 components/egress/pkg/httpproxy/origdst_linux.go create mode 100644 components/egress/pkg/httpproxy/server_linux.go create mode 100644 components/egress/pkg/httpproxy/server_linux_test.go create mode 100644 components/egress/pkg/httpproxy/server_other.go create mode 100644 components/egress/pkg/iptables/http_redirect.go diff --git a/components/egress/README.md b/components/egress/README.md index 4202f300f..f9208bec4 100644 --- a/components/egress/README.md +++ b/components/egress/README.md @@ -10,6 +10,7 @@ The **Egress Sidecar** is a core component of OpenSandbox that provides **FQDN-b - **Dynamic DNS (dns+nft mode)**: When a domain is allowed and the proxy resolves it, the resolved A/AAAA IPs are added to nftables with TTL so that default-deny + domain-allow is enforced at the network layer. - **Privilege Isolation**: Requires `CAP_NET_ADMIN` only for the sidecar; the application container runs unprivileged. - **Graceful Degradation**: If `CAP_NET_ADMIN` is missing, it warns and disables enforcement instead of crashing. +- **Optional cleartext HTTP transparent proxy** (Linux): When enabled, `iptables` redirects outbound **TCP port 80** to a local listener that forwards HTTP to the original destination and can inject custom request headers from a small text file at startup (see [Configuration](#configuration)). Does **not** intercept HTTPS (`tcp/443`); use a separate MITM solution if TLS termination is required. ## Architecture @@ -25,6 +26,9 @@ The egress control is implemented as a **Sidecar** that shares the network names - Uses `nftables` to enforce IP-level allow/deny. Resolved IPs for allowed domains are added to dynamic allow sets with TTL (dynamic DNS). - At startup, the sidecar whitelists **127.0.0.1** (redirect target for the proxy) and **nameserver IPs** from `/etc/resolv.conf` so DNS resolution and proxy upstream work (including private DNS). Nameserver count is capped and invalid IPs are filtered; see [Configuration](#configuration). +3. **Cleartext HTTP (optional)**: + - When `OPENSANDBOX_EGRESS_HTTP_TRANSPARENT` is enabled, outbound **TCP/80** is redirected to `127.0.0.1` on a configurable port (default `18081`). A small proxy reads `SO_ORIGINAL_DST`, dials the real destination with `SO_MARK` (same mark as the DNS proxy) so iptables/nft bypass rules apply, and injects optional headers from the file pointed to by `OPENSANDBOX_EGRESS_HTTP_HEADERS_FILE` (read once at startup; format below). + ## Requirements - **Runtime**: Docker or Kubernetes. @@ -45,6 +49,11 @@ The egress control is implemented as a **Sidecar** that shares the network names - Mode (`OPENSANDBOX_EGRESS_MODE`, default `dns`): - `dns`: DNS proxy only, no nftables (IP/CIDR rules have no effect at L2). - `dns+nft`: enable nftables; if nft apply fails, fallback to `dns`. IP/CIDR enforcement and DoH/DoT blocking require this mode. +- **Cleartext HTTP transparent proxy (optional, Linux)** + - `OPENSANDBOX_EGRESS_HTTP_TRANSPARENT`: set to `true`/`1`/… to redirect **outbound TCP port 80** to the local HTTP forwarder and install matching `iptables` rules. + - `OPENSANDBOX_EGRESS_HTTP_PROXY_LISTEN` (default `127.0.0.1:18081`): bind address for the forwarder; must match the redirect target port. + - `OPENSANDBOX_EGRESS_HTTP_HEADERS_FILE` (optional): path to a text file, read **once at process startup**. Format: line-oriented `KEY=VALUE` (one header per line). Empty lines and lines starting with `#` are ignored; the first `=` on each line separates the header name from the value (additional `=` characters belong to the value). Optional double quotes around the value are stripped. If the file is missing or has no valid `KEY=VALUE` lines, injection is skipped (and invalid lines log a warning). + - **Scope:** **HTTP only** (port 80); **HTTPS is not modified.** Requires the same `CAP_NET_ADMIN` as DNS redirection. In `dns+nft` mode, upstream connections use the same packet mark as the DNS proxy so nft `meta mark` rules allow egress. - **Nameserver exempt** Set `OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT` to a comma-separated list of **nameserver IPs** (e.g. `26.26.26.26` or `26.26.26.26,100.100.2.116`). Only single IPs are supported; CIDR entries are ignored. Traffic to these IPs on port 53 is not redirected to the proxy (iptables RETURN). In `dns+nft` mode, these IPs are also merged into the nft allow set so proxy upstream traffic to them (sent without SO_MARK) is accepted. Use when the upstream is reachable only via a specific route (e.g. tunnel) and SO_MARK would send proxy traffic elsewhere. - **DNS and nft mode (nameserver whitelist)** @@ -166,6 +175,8 @@ To test the sidecar with a sandbox application: - `pkg/iptables`: `iptables` rule management. - `pkg/nftables`: nftables static/dynamic rules and DNS-resolved IP sets. - `pkg/policy`: Policy parsing and definition. + - `pkg/httpproxy`: optional cleartext HTTP transparent proxy (header injection). + - `pkg/dialer`: shared `SO_MARK` dial helper for proxy upstream traffic. - **Main (egress)**: - `nameserver.go`: Builds the list of IPs to whitelist for DNS in nft mode (127.0.0.1 + validated/capped nameservers from resolv.conf). @@ -174,6 +185,8 @@ To test the sidecar with a sandbox application: go test ./... ``` +`pkg/httpproxy/server_linux_test.go` is **Linux-only** (`GOOS=linux`): it checks that injected headers reach the upstream HTTP handler (without iptables). On macOS those files are skipped; run `go test ./...` on Linux CI or in a Linux container to execute them. + ### E2E benchmark: dns vs dns+nft (sync dynamic IP write) An end-to-end benchmark compares **dns** (pass-through, no nft write) and **dns+nft** (sync `AddResolvedIPs` before each DNS reply) under real conditions: sidecar in Docker, iptables redirect, real DNS + HTTPS from a client container. diff --git a/components/egress/main.go b/components/egress/main.go index a45ef9b66..b3e5e87de 100644 --- a/components/egress/main.go +++ b/components/egress/main.go @@ -25,6 +25,7 @@ import ( "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" "github.com/alibaba/opensandbox/egress/pkg/events" + "github.com/alibaba/opensandbox/egress/pkg/httpproxy" "github.com/alibaba/opensandbox/egress/pkg/iptables" "github.com/alibaba/opensandbox/egress/pkg/log" "github.com/alibaba/opensandbox/egress/pkg/policy" @@ -85,6 +86,17 @@ func main() { setupNft(ctx, nftMgr, initialRules, proxy, allowIPs) + httpTransparentCfg := httpproxy.LoadConfigFromEnv() + if httpTransparentCfg.Enabled { + go func() { + if err := httpproxy.Start(ctx, httpTransparentCfg); err != nil { + log.Fatalf("http transparent proxy: %v", err) + } + }() + log.Infof("cleartext HTTP transparent proxy enabled (%s, %s, optional %s)", + constants.EnvHTTPTransparent, constants.EnvHTTPProxyListen, constants.EnvHTTPHeadersFile) + } + // start policy server httpAddr := envOrDefault(constants.EnvEgressHTTPAddr, constants.DefaultEgressServerAddr) if err = startPolicyServer(ctx, proxy, nftMgr, mode, httpAddr, os.Getenv(constants.EnvEgressToken), allowIPs, os.Getenv(constants.EnvEgressPolicyFile)); err != nil { diff --git a/components/egress/pkg/constants/configuration.go b/components/egress/pkg/constants/configuration.go index ac3e785e2..8dd7fa233 100644 --- a/components/egress/pkg/constants/configuration.go +++ b/components/egress/pkg/constants/configuration.go @@ -30,6 +30,13 @@ const ( // EnvNameserverExempt comma-separated IPs; proxy upstream to these is not marked and is allowed in nft allow set EnvNameserverExempt = "OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT" + + // EnvHTTPTransparent enables cleartext HTTP transparent proxy (iptables redirect tcp/80 -> local listener). + EnvHTTPTransparent = "OPENSANDBOX_EGRESS_HTTP_TRANSPARENT" + // EnvHTTPProxyListen bind address for the HTTP transparent proxy (default 127.0.0.1:18081). + EnvHTTPProxyListen = "OPENSANDBOX_EGRESS_HTTP_PROXY_LISTEN" + // EnvHTTPHeadersFile path to a header injection file (see README); read once at startup; optional. + EnvHTTPHeadersFile = "OPENSANDBOX_EGRESS_HTTP_HEADERS_FILE" ) const ( @@ -39,6 +46,7 @@ const ( const ( DefaultEgressServerAddr = ":18080" + DefaultHTTPProxyListen = "127.0.0.1:18081" DefaultMaxNameservers = 3 DefaultMaxEgressRules = 4096 ) diff --git a/components/egress/pkg/dialer/mark_linux.go b/components/egress/pkg/dialer/mark_linux.go new file mode 100644 index 000000000..ad6e49238 --- /dev/null +++ b/components/egress/pkg/dialer/mark_linux.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package dialer + +import ( + "net" + "syscall" + "time" + + "golang.org/x/sys/unix" + + "github.com/alibaba/opensandbox/egress/pkg/constants" +) + +// Marked returns a dialer that sets SO_MARK on sockets so iptables/nft can treat +// proxy upstream traffic differently from client-originated flows. +func Marked(timeout time.Duration) *net.Dialer { + return &net.Dialer{ + Timeout: timeout, + Control: func(network, address string, c syscall.RawConn) error { + var opErr error + if err := c.Control(func(fd uintptr) { + opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.MarkValue) + }); err != nil { + return err + } + return opErr + }, + } +} diff --git a/components/egress/pkg/dialer/mark_other.go b/components/egress/pkg/dialer/mark_other.go new file mode 100644 index 000000000..c15c21d2d --- /dev/null +++ b/components/egress/pkg/dialer/mark_other.go @@ -0,0 +1,27 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package dialer + +import ( + "net" + "time" +) + +// Marked returns a plain dialer on non-Linux platforms. +func Marked(timeout time.Duration) *net.Dialer { + return &net.Dialer{Timeout: timeout} +} diff --git a/components/egress/pkg/dnsproxy/proxy_linux.go b/components/egress/pkg/dnsproxy/proxy_linux.go index c86ff9e38..142b3d906 100644 --- a/components/egress/pkg/dnsproxy/proxy_linux.go +++ b/components/egress/pkg/dnsproxy/proxy_linux.go @@ -19,12 +19,9 @@ package dnsproxy import ( "net" "sync" - "syscall" "time" - "golang.org/x/sys/unix" - - "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/dialer" "github.com/alibaba/opensandbox/egress/pkg/log" ) @@ -42,16 +39,5 @@ func (p *Proxy) dialerWithMark() *net.Dialer { return &net.Dialer{Timeout: 5 * time.Second} } - return &net.Dialer{ - Timeout: 5 * time.Second, - Control: func(network, address string, c syscall.RawConn) error { - var opErr error - if err := c.Control(func(fd uintptr) { - opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.MarkValue) - }); err != nil { - return err - } - return opErr - }, - } + return dialer.Marked(5 * time.Second) } diff --git a/components/egress/pkg/httpproxy/config.go b/components/egress/pkg/httpproxy/config.go new file mode 100644 index 000000000..081346287 --- /dev/null +++ b/components/egress/pkg/httpproxy/config.go @@ -0,0 +1,120 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpproxy + +import ( + "bytes" + "net" + "os" + "strconv" + "strings" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +// Config controls the optional cleartext HTTP transparent proxy. +type Config struct { + Enabled bool + ListenAddr string + Inject map[string]string +} + +// ListenPort returns the TCP port from ListenAddr (host:port). +func (c Config) ListenPort() (int, error) { + _, portStr, err := net.SplitHostPort(c.ListenAddr) + if err != nil { + return 0, err + } + return strconv.Atoi(portStr) +} + +// LoadConfigFromEnv parses OPENSANDBOX_EGRESS_HTTP_* variables and loads optional inject headers from file once. +func LoadConfigFromEnv() Config { + cfg := Config{ + ListenAddr: envOrDefault(constants.EnvHTTPProxyListen, constants.DefaultHTTPProxyListen), + } + switch strings.ToLower(strings.TrimSpace(os.Getenv(constants.EnvHTTPTransparent))) { + case "1", "true", "yes", "y", "on": + cfg.Enabled = true + } + cfg.Inject = loadHeadersFromFile(os.Getenv(constants.EnvHTTPHeadersFile)) + return cfg +} + +func loadHeadersFromFile(path string) map[string]string { + path = strings.TrimSpace(path) + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + log.Warnf("[http] inject headers file %q: read: %v", path, err) + return nil + } + m := parseHeadersFileContent(data) + if len(m) == 0 { + return nil + } + return m +} + +// parseHeadersFileContent parses line-oriented KEY=VALUE: one pair per line, optional # comments, empty lines ignored. +// The first '=' on each line separates header name from value; later '=' characters belong to the value. +// If the value is wrapped in a single pair of double quotes, they are stripped. +func parseHeadersFileContent(data []byte) map[string]string { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil + } + return parseKVHeaders(data) +} + +func parseKVHeaders(data []byte) map[string]string { + m := make(map[string]string) + lines := strings.Split(string(data), "\n") + for n, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx <= 0 { + log.Warnf("[http] inject headers file: line %d: expected KEY=VALUE, skipping %q", n+1, line) + continue + } + k := strings.TrimSpace(line[:idx]) + v := strings.TrimSpace(line[idx+1:]) + if k == "" { + log.Warnf("[http] inject headers file: line %d: empty key, skipping", n+1) + continue + } + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + v = v[1 : len(v)-1] + } + m[k] = v + } + if len(m) == 0 { + return nil + } + return m +} + +func envOrDefault(key, defaultVal string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return defaultVal +} diff --git a/components/egress/pkg/httpproxy/config_test.go b/components/egress/pkg/httpproxy/config_test.go new file mode 100644 index 000000000..87dfb1632 --- /dev/null +++ b/components/egress/pkg/httpproxy/config_test.go @@ -0,0 +1,90 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpproxy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/egress/pkg/constants" +) + +func TestLoadConfigFromEnv_Disabled(t *testing.T) { + t.Setenv(constants.EnvHTTPTransparent, "") + t.Setenv(constants.EnvHTTPProxyListen, "") + t.Setenv(constants.EnvHTTPHeadersFile, "") + cfg := LoadConfigFromEnv() + require.False(t, cfg.Enabled) + require.Equal(t, constants.DefaultHTTPProxyListen, cfg.ListenAddr) + require.Nil(t, cfg.Inject) +} + +func TestLoadConfigFromEnv_EnabledAndInjectFromKVFile(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "headers.env") + content := ` +# tenant +X-Test=v + +X-Other = a=b +` + require.NoError(t, os.WriteFile(p, []byte(content), 0o600)) + + t.Setenv(constants.EnvHTTPTransparent, "true") + t.Setenv(constants.EnvHTTPProxyListen, "127.0.0.1:9999") + t.Setenv(constants.EnvHTTPHeadersFile, p) + + cfg := LoadConfigFromEnv() + require.True(t, cfg.Enabled) + require.Equal(t, "127.0.0.1:9999", cfg.ListenAddr) + require.Equal(t, map[string]string{"X-Test": "v", "X-Other": "a=b"}, cfg.Inject) + port, err := cfg.ListenPort() + require.NoError(t, err) + require.Equal(t, 9999, port) +} + +func TestLoadConfigFromEnv_JSONStyleFileNoHeaders(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "old.json") + require.NoError(t, os.WriteFile(p, []byte(`{"X-Test":"v"}`), 0o600)) + + t.Setenv(constants.EnvHTTPTransparent, "true") + t.Setenv(constants.EnvHTTPHeadersFile, p) + + cfg := LoadConfigFromEnv() + require.True(t, cfg.Enabled) + require.Nil(t, cfg.Inject) +} + +func TestLoadConfigFromEnv_MissingFileIgnored(t *testing.T) { + t.Setenv(constants.EnvHTTPTransparent, "1") + t.Setenv(constants.EnvHTTPHeadersFile, filepath.Join(t.TempDir(), "nope.env")) + cfg := LoadConfigFromEnv() + require.True(t, cfg.Enabled) + require.Nil(t, cfg.Inject) +} + +func TestParseHeadersFileContent_KVQuoted(t *testing.T) { + m := parseHeadersFileContent([]byte(`H="quoted value"`)) + require.Equal(t, map[string]string{"H": "quoted value"}, m) +} + +func TestParseHeadersFileContent_OnlyComments(t *testing.T) { + m := parseHeadersFileContent([]byte("# only\n\n# x")) + require.Nil(t, m) +} diff --git a/components/egress/pkg/httpproxy/origdst_linux.go b/components/egress/pkg/httpproxy/origdst_linux.go new file mode 100644 index 000000000..0f2805469 --- /dev/null +++ b/components/egress/pkg/httpproxy/origdst_linux.go @@ -0,0 +1,80 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package httpproxy + +import ( + "encoding/binary" + "errors" + "fmt" + "net" + "net/netip" + "unsafe" + + "golang.org/x/sys/unix" +) + +// Linux nf netfilter: IPv6 original destination uses the same option number as IPv4 SO_ORIGINAL_DST. +const ip6OriginalDst = unix.SO_ORIGINAL_DST + +func getOriginalDst(conn net.Conn) (netip.AddrPort, error) { + tcp, ok := conn.(*net.TCPConn) + if !ok { + return netip.AddrPort{}, errors.New("connection is not TCP") + } + raw, err := tcp.SyscallConn() + if err != nil { + return netip.AddrPort{}, err + } + var ap netip.AddrPort + var opErr error + if err := raw.Control(func(fd uintptr) { + ap, opErr = originalDstFromFD(fd) + }); err != nil { + return netip.AddrPort{}, err + } + return ap, opErr +} + +func originalDstFromFD(fd uintptr) (netip.AddrPort, error) { + var sa4 unix.RawSockaddrInet4 + l4 := uint32(unsafe.Sizeof(sa4)) + _, _, errno := unix.Syscall6(unix.SYS_GETSOCKOPT, fd, unix.SOL_IP, unix.SO_ORIGINAL_DST, + uintptr(unsafe.Pointer(&sa4)), uintptr(unsafe.Pointer(&l4)), 0) + if errno == 0 { + return sockaddrInet4ToAddrPort(&sa4) + } + var sa6 unix.RawSockaddrInet6 + l6 := uint32(unsafe.Sizeof(sa6)) + _, _, errno6 := unix.Syscall6(unix.SYS_GETSOCKOPT, fd, unix.SOL_IPV6, ip6OriginalDst, + uintptr(unsafe.Pointer(&sa6)), uintptr(unsafe.Pointer(&l6)), 0) + if errno6 == 0 { + return sockaddrInet6ToAddrPort(&sa6) + } + return netip.AddrPort{}, fmt.Errorf("SO_ORIGINAL_DST: %v %v", errno, errno6) +} + +func sockaddrInet4ToAddrPort(sa *unix.RawSockaddrInet4) (netip.AddrPort, error) { + port := binary.BigEndian.Uint16(unsafe.Slice((*byte)(unsafe.Pointer(&sa.Port)), 2)) + ip := netip.AddrFrom4([4]byte(sa.Addr)) + return netip.AddrPortFrom(ip, port), nil +} + +func sockaddrInet6ToAddrPort(sa *unix.RawSockaddrInet6) (netip.AddrPort, error) { + port := binary.BigEndian.Uint16(unsafe.Slice((*byte)(unsafe.Pointer(&sa.Port)), 2)) + ip := netip.AddrFrom16(sa.Addr) + return netip.AddrPortFrom(ip, port), nil +} diff --git a/components/egress/pkg/httpproxy/server_linux.go b/components/egress/pkg/httpproxy/server_linux.go new file mode 100644 index 000000000..7d4d1b2ff --- /dev/null +++ b/components/egress/pkg/httpproxy/server_linux.go @@ -0,0 +1,145 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package httpproxy + +import ( + "bufio" + "context" + "io" + "net" + "net/http" + "net/netip" + "strconv" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/dialer" + "github.com/alibaba/opensandbox/egress/pkg/iptables" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +// Start runs the cleartext HTTP transparent proxy until ctx is cancelled. +func Start(ctx context.Context, cfg Config) error { + if !cfg.Enabled { + return nil + } + ln, err := net.Listen("tcp", cfg.ListenAddr) + if err != nil { + return err + } + proxyPort, err := cfg.ListenPort() + if err != nil { + _ = ln.Close() + return err + } + if err := iptables.SetupHTTPRedirect(proxyPort); err != nil { + _ = ln.Close() + return err + } + go func() { + <-ctx.Done() + _ = ln.Close() + }() + + srv := &server{ + inject: cfg.Inject, + dialer: dialer.Marked(60 * time.Second), + } + + log.Infof("[http] transparent proxy listening on %s (iptables redirect tcp/80 -> local)", cfg.ListenAddr) + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + go srv.handleConn(conn) + } +} + +type server struct { + inject map[string]string + dialer *net.Dialer +} + +func (s *server) handleConn(client net.Conn) { + defer client.Close() + + orig, err := getOriginalDst(client) + if err != nil { + log.Warnf("[http] original dst: %v", err) + return + } + s.forwardHTTP(client, orig) +} + +// forwardHTTP proxies cleartext HTTP from client to orig (host:port), injecting s.inject headers on each request. +func (s *server) forwardHTTP(client net.Conn, orig netip.AddrPort) { + upstreamAddr := net.JoinHostPort(orig.Addr().String(), strconv.Itoa(int(orig.Port()))) + + up, err := s.dialer.Dial("tcp", upstreamAddr) + if err != nil { + log.Warnf("[http] dial upstream %s: %v", upstreamAddr, err) + return + } + defer up.Close() + + brClient := bufio.NewReader(client) + brUp := bufio.NewReader(up) + + for { + req, err := http.ReadRequest(brClient) + if err != nil { + if err != io.EOF { + log.Warnf("[http] read request: %v", err) + } + return + } + for k, v := range s.inject { + req.Header.Set(k, v) + } + req.RequestURI = "" + + if err := req.Write(up); err != nil { + log.Warnf("[http] write request to upstream: %v", err) + return + } + if req.Body != nil { + if _, err := io.Copy(up, req.Body); err != nil { + return + } + if err := req.Body.Close(); err != nil { + return + } + } + + resp, err := http.ReadResponse(brUp, req) + if err != nil { + log.Warnf("[http] read response: %v", err) + return + } + if err := resp.Write(client); err != nil { + log.Warnf("[http] write response to client: %v", err) + return + } + + if resp.Close || req.Close { + return + } + } +} diff --git a/components/egress/pkg/httpproxy/server_linux_test.go b/components/egress/pkg/httpproxy/server_linux_test.go new file mode 100644 index 000000000..b86038ab9 --- /dev/null +++ b/components/egress/pkg/httpproxy/server_linux_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package httpproxy + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestForwardHTTP_UpstreamReceivesInjectedHeaders verifies that headers from the inject map are +// present on the request seen by the upstream HTTP server (simulating the “peer” service). +// This does not use iptables or SO_ORIGINAL_DST; it calls forwardHTTP with an explicit +// netip.AddrPort. End-to-end transparent redirect is covered by deployment tests / manual runs. +func TestForwardHTTP_UpstreamReceivesInjectedHeaders(t *testing.T) { + const wantHeader = "X-Egress-Injected" + const wantValue = "expected-header-value" + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, r.Header.Get(wantHeader)) + })) + defer upstream.Close() + + tcpAddr := upstream.Listener.Addr().(*net.TCPAddr) + orig, err := netip.ParseAddrPort(net.JoinHostPort(tcpAddr.IP.String(), strconv.Itoa(tcpAddr.Port))) + require.NoError(t, err) + + srv := &server{ + inject: map[string]string{wantHeader: wantValue}, + dialer: &net.Dialer{Timeout: 5 * time.Second}, + } + + cProxy, cApp := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + srv.forwardHTTP(cProxy, orig) + }() + + // Client side of the proxied connection: plain HTTP as an app would send. + _, err = fmt.Fprintf(cApp, "GET / HTTP/1.1\r\nHost: test.local\r\nConnection: close\r\n\r\n") + require.NoError(t, err) + + resp, err := http.ReadResponse(bufio.NewReader(cApp), nil) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, wantValue, string(body)) + + require.NoError(t, cApp.Close()) + <-done +} diff --git a/components/egress/pkg/httpproxy/server_other.go b/components/egress/pkg/httpproxy/server_other.go new file mode 100644 index 000000000..736450e55 --- /dev/null +++ b/components/egress/pkg/httpproxy/server_other.go @@ -0,0 +1,30 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package httpproxy + +import ( + "context" + "fmt" +) + +// Start is a no-op on non-Linux platforms unless enabled, in which case it returns an error. +func Start(_ context.Context, cfg Config) error { + if cfg.Enabled { + return fmt.Errorf("HTTP transparent proxy requires Linux") + } + return nil +} diff --git a/components/egress/pkg/iptables/http_redirect.go b/components/egress/pkg/iptables/http_redirect.go new file mode 100644 index 000000000..1843c429f --- /dev/null +++ b/components/egress/pkg/iptables/http_redirect.go @@ -0,0 +1,48 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iptables + +import ( + "fmt" + "os/exec" + "strconv" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +// SetupHTTPRedirect installs OUTPUT nat rules: tcp dport 80 -> local proxyPort, with mark bypass +// and loopback destination exemption. Requires CAP_NET_ADMIN. +func SetupHTTPRedirect(proxyPort int) error { + targetPort := strconv.Itoa(proxyPort) + log.Infof("installing iptables HTTP redirect: OUTPUT tcp dport 80 -> %s (mark %s bypass)", targetPort, constants.MarkHex) + + // Avoid redirecting traffic already destined to loopback (prevents loops on local :80). + rules := [][]string{ + {"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "-d", "127.0.0.0/8", "--dport", "80", "-j", "RETURN"}, + {"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "80", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"}, + {"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", targetPort}, + {"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "-d", "::1", "--dport", "80", "-j", "RETURN"}, + {"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "80", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"}, + {"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "80", "-j", "REDIRECT", "--to-port", targetPort}, + } + for _, args := range rules { + if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + return fmt.Errorf("iptables http redirect: %v (output: %s)", err, output) + } + } + log.Infof("iptables HTTP redirect installed successfully") + return nil +}