Skip to content

Commit 9b63f65

Browse files
committed
initial tunnel
Signed-off-by: npolshakova <nina.polshakova@solo.io>
1 parent 4bbd39f commit 9b63f65

17 files changed

Lines changed: 2224 additions & 11 deletions

File tree

cmd/atecontroller/internal/controllers/workerpool_apply.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@
1515
package controllers
1616

1717
import (
18+
"os"
19+
"strconv"
20+
1821
corev1 "k8s.io/api/core/v1"
1922
appsv1ac "k8s.io/client-go/applyconfigurations/apps/v1"
2023
corev1ac "k8s.io/client-go/applyconfigurations/core/v1"
2124
metav1ac "k8s.io/client-go/applyconfigurations/meta/v1"
2225

2326
"github.com/agent-substrate/substrate/internal/ateompath"
27+
"github.com/agent-substrate/substrate/internal/egresscapture"
2428
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
2529
)
2630

@@ -44,7 +48,9 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool) *appsv1ac.Deployment
4448
WithValueFrom(corev1ac.EnvVarSource().
4549
WithFieldRef(corev1ac.ObjectFieldSelector().
4650
WithFieldPath("metadata.uid"))),
47-
).
51+
)
52+
containerAC.WithEnv(egressCaptureEnvFromController()...)
53+
containerAC.
4854
WithVolumeMounts(corev1ac.VolumeMount().
4955
WithName("run-ateom").
5056
WithMountPath(ateompath.BasePath))
@@ -82,6 +88,37 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool) *appsv1ac.Deployment
8288
WithSpec(podSpecAC)))
8389
}
8490

91+
func egressCaptureEnvFromController() []*corev1ac.EnvVarApplyConfiguration {
92+
enabled, _ := strconv.ParseBool(os.Getenv(egresscapture.EnvCaptureEnabled))
93+
if !enabled {
94+
return nil
95+
}
96+
97+
env := []*corev1ac.EnvVarApplyConfiguration{
98+
corev1ac.EnvVar().
99+
WithName(egresscapture.EnvCaptureEnabled).
100+
WithValue("true"),
101+
}
102+
if v := os.Getenv(egresscapture.EnvPEPAddress); v != "" {
103+
env = append(env, corev1ac.EnvVar().
104+
WithName(egresscapture.EnvPEPAddress).
105+
WithValue(v))
106+
}
107+
if v := os.Getenv(egresscapture.EnvTunnelProtocol); v != "" {
108+
env = append(env, corev1ac.EnvVar().
109+
WithName(egresscapture.EnvTunnelProtocol).
110+
WithValue(v))
111+
}
112+
for _, name := range egresscapture.OptionalEnvNames {
113+
if v := os.Getenv(name); v != "" {
114+
env = append(env, corev1ac.EnvVar().
115+
WithName(name).
116+
WithValue(v))
117+
}
118+
}
119+
return env
120+
}
121+
85122
// maybeApplyMicroVMPodShape adds the /dev/kvm device and node placement a
86123
// micro-VM (kata + cloud-hypervisor) worker pool needs, on top of any
87124
// pod-template settings. No-op unless sandboxClass is the micro-VM class.

cmd/atecontroller/internal/controllers/workerpool_apply_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
metav1ac "k8s.io/client-go/applyconfigurations/meta/v1"
2727

2828
"github.com/agent-substrate/substrate/internal/ateompath"
29+
"github.com/agent-substrate/substrate/internal/egresscapture"
2930
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
3031
)
3132

@@ -261,6 +262,43 @@ func TestMicroVMPodShape(t *testing.T) {
261262
}
262263
}
263264

265+
func TestWorkerPoolEgressCaptureEnvPropagation(t *testing.T) {
266+
t.Setenv(egresscapture.EnvCaptureEnabled, "1")
267+
t.Setenv(egresscapture.EnvPEPAddress, "ate-egress.agentgateway-system.svc.cluster.local:15008")
268+
t.Setenv(egresscapture.EnvTunnelProtocol, egresscapture.TunnelProtocolConnectTLS)
269+
t.Setenv(egresscapture.EnvConnectTLSServerName, "ate-egress.agentgateway-system.svc.cluster.local")
270+
t.Setenv(egresscapture.EnvConnectTLSCAFile, "/run/egress-ca/ca.crt")
271+
t.Setenv(egresscapture.EnvConnectTLSInsecureSkipVerify, "true")
272+
273+
wp := testWorkerPoolApplyConfig(nil)
274+
deployment := buildDeploymentApplyConfig(wp)
275+
containers := deployment.Spec.Template.Spec.Containers
276+
if len(containers) != 1 {
277+
t.Fatalf("containers length = %d, want 1", len(containers))
278+
}
279+
280+
got := map[string]string{}
281+
for _, env := range containers[0].Env {
282+
if env.Name != nil && env.Value != nil {
283+
got[*env.Name] = *env.Value
284+
}
285+
}
286+
287+
want := map[string]string{
288+
egresscapture.EnvCaptureEnabled: "true",
289+
egresscapture.EnvPEPAddress: "ate-egress.agentgateway-system.svc.cluster.local:15008",
290+
egresscapture.EnvTunnelProtocol: egresscapture.TunnelProtocolConnectTLS,
291+
egresscapture.EnvConnectTLSServerName: "ate-egress.agentgateway-system.svc.cluster.local",
292+
egresscapture.EnvConnectTLSCAFile: "/run/egress-ca/ca.crt",
293+
egresscapture.EnvConnectTLSInsecureSkipVerify: "true",
294+
}
295+
for name, value := range want {
296+
if got[name] != value {
297+
t.Errorf("env %s = %q, want %q", name, got[name], value)
298+
}
299+
}
300+
}
301+
264302
func testWorkerPoolApplyConfig(tmpl *atev1alpha1.WorkerPoolPodTemplate) *atev1alpha1.WorkerPool {
265303
return &atev1alpha1.WorkerPool{
266304
ObjectMeta: metav1.ObjectMeta{Name: "pool", Namespace: "default", UID: "uid"},

cmd/ateom-gvisor/egress_proxy.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//go:build linux
2+
3+
// Copyright 2026 Google LLC
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"encoding/binary"
22+
"fmt"
23+
"net"
24+
"syscall"
25+
"unsafe"
26+
27+
"github.com/agent-substrate/substrate/internal/egresscapture"
28+
"github.com/google/nftables"
29+
"github.com/google/nftables/binaryutil"
30+
"github.com/google/nftables/expr"
31+
"golang.org/x/sys/unix"
32+
)
33+
34+
const (
35+
egressCapturePort = uint16(15001)
36+
egressOriginalHTTPPort = uint16(80)
37+
egressOriginalHTTPSPort = uint16(443)
38+
)
39+
40+
var defaultEgressCaptureRedirects = []struct {
41+
originalPort uint16
42+
capturePort uint16
43+
}{
44+
{originalPort: egressOriginalHTTPPort, capturePort: egressCapturePort},
45+
{originalPort: egressOriginalHTTPSPort, capturePort: egressCapturePort},
46+
}
47+
48+
var defaultEgressCaptureListeners = []egresscapture.Listener{
49+
{Port: egressCapturePort},
50+
}
51+
52+
func (s *AteomService) startEgressCaptureIfEnabled(ctx context.Context, identity egresscapture.ActorIdentity) error {
53+
if !egresscapture.EnabledFromEnv() {
54+
return nil
55+
}
56+
cfg, err := egresscapture.ConfigFromEnv(defaultEgressCaptureListeners)
57+
if err != nil {
58+
return err
59+
}
60+
capture, err := egresscapture.Start(ctx, identity, cfg, originalDestination)
61+
if err != nil {
62+
return fmt.Errorf("while starting actor egress capture: %w", err)
63+
}
64+
s.egressCapture = capture
65+
return nil
66+
}
67+
68+
func addEgressCaptureRedirectRules(c *nftables.Conn, table *nftables.Table, prerouting *nftables.Chain, sourceIP string) {
69+
for _, redirect := range defaultEgressCaptureRedirects {
70+
c.AddRule(&nftables.Rule{
71+
Table: table,
72+
Chain: prerouting,
73+
Exprs: tcpRedirectExprs(sourceIP, redirect.originalPort, redirect.capturePort),
74+
})
75+
}
76+
}
77+
78+
func tcpRedirectExprs(sourceIP string, originalPort, capturePort uint16) []expr.Any {
79+
exprs := append(ipSourceEqual(sourceIP), tcpDestinationPortEqual(originalPort)...)
80+
exprs = append(exprs,
81+
&expr.Immediate{
82+
Register: 1,
83+
Data: binaryutil.BigEndian.PutUint16(capturePort),
84+
},
85+
&expr.Redir{
86+
RegisterProtoMin: 1,
87+
},
88+
)
89+
return exprs
90+
}
91+
92+
func originalDestination(conn net.Conn) (net.Addr, error) {
93+
tcpConn, ok := conn.(*net.TCPConn)
94+
if !ok {
95+
return nil, fmt.Errorf("captured connection is %T, not *net.TCPConn", conn)
96+
}
97+
98+
rawConn, err := tcpConn.SyscallConn()
99+
if err != nil {
100+
return nil, err
101+
}
102+
103+
var addr *net.TCPAddr
104+
var controlErr error
105+
if err := rawConn.Control(func(fd uintptr) {
106+
addr, controlErr = originalDstFromFD(int(fd))
107+
}); err != nil {
108+
return nil, err
109+
}
110+
if controlErr != nil {
111+
return nil, controlErr
112+
}
113+
return addr, nil
114+
}
115+
116+
func originalDstFromFD(fd int) (*net.TCPAddr, error) {
117+
var raw unix.RawSockaddrInet4
118+
size := uint32(unsafe.Sizeof(raw))
119+
_, _, errno := unix.Syscall6(
120+
unix.SYS_GETSOCKOPT,
121+
uintptr(fd),
122+
uintptr(unix.SOL_IP),
123+
uintptr(unix.SO_ORIGINAL_DST),
124+
uintptr(unsafe.Pointer(&raw)),
125+
uintptr(unsafe.Pointer(&size)),
126+
0,
127+
)
128+
if errno != 0 {
129+
return nil, errno
130+
}
131+
if raw.Family != syscall.AF_INET {
132+
return nil, fmt.Errorf("SO_ORIGINAL_DST returned address family %d", raw.Family)
133+
}
134+
return &net.TCPAddr{
135+
IP: net.IPv4(raw.Addr[0], raw.Addr[1], raw.Addr[2], raw.Addr[3]),
136+
Port: int(binary.BigEndian.Uint16((*[2]byte)(unsafe.Pointer(&raw.Port))[:])),
137+
}, nil
138+
}

cmd/ateom-gvisor/main.go

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"github.com/agent-substrate/substrate/internal/ateinterceptors"
3333
"github.com/agent-substrate/substrate/internal/ateompath"
3434
"github.com/agent-substrate/substrate/internal/contextlogging"
35+
"github.com/agent-substrate/substrate/internal/egresscapture"
3536
"github.com/agent-substrate/substrate/internal/proto/ateompb"
3637
"github.com/agent-substrate/substrate/internal/serverboot"
3738
"github.com/agent-substrate/substrate/internal/version"
@@ -163,6 +164,7 @@ type AteomService struct {
163164

164165
interiorNetNS netns.NsHandle
165166
actorLogger *actorlog.ActorLogger
167+
egressCapture *egresscapture.Capture
166168
}
167169

168170
var _ ateompb.AteomServer = (*AteomService)(nil)
@@ -187,7 +189,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload
187189
// * Correct runsc version is downloaded and placed on disk.
188190
// * All OCI bundles are set up, including for "pause" container.
189191

190-
if err := s.setupActorNetwork(ctx); err != nil {
192+
if err := s.setupActorNetwork(ctx, actorIdentityFromRun(req)); err != nil {
191193
return nil, fmt.Errorf("while setting up actor network: %w", err)
192194
}
193195
defer func() {
@@ -341,7 +343,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore
341343
// * All OCI bundles are set up, including for "pause" container.
342344
// * Checkpoint downloaded and placed on disk
343345

344-
if err := s.setupActorNetwork(ctx); err != nil {
346+
if err := s.setupActorNetwork(ctx, actorIdentityFromRestore(req)); err != nil {
345347
return nil, fmt.Errorf("while setting up actor network: %w", err)
346348
}
347349
defer func() {
@@ -388,7 +390,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore
388390
return &ateompb.RestoreWorkloadResponse{}, nil
389391
}
390392

391-
func (s *AteomService) setupActorNetwork(ctx context.Context) (retErr error) {
393+
func (s *AteomService) setupActorNetwork(ctx context.Context, identity egresscapture.ActorIdentity) (retErr error) {
392394
// Build a fresh point-to-point network between the worker pod netns and the
393395
// gVisor interior netns. The worker side keeps the pod's real eth0, creates
394396
// ateom0 as the gateway, and moves only the veth peer into the actor netns.
@@ -456,7 +458,11 @@ func (s *AteomService) setupActorNetwork(ctx context.Context) (retErr error) {
456458
if err := enableIPv4Forwarding(); err != nil {
457459
return err
458460
}
459-
if err := installActorNftablesRules(podIP); err != nil {
461+
if err := s.startEgressCaptureIfEnabled(ctx, identity); err != nil {
462+
return err
463+
}
464+
465+
if err := installActorNftablesRules(podIP, s.egressCapture != nil); err != nil {
460466
return err
461467
}
462468

@@ -540,6 +546,12 @@ func (s *AteomService) cleanupActorNetwork(ctx context.Context) error {
540546
if err := removeActorNftablesRules(); err != nil {
541547
return err
542548
}
549+
if s.egressCapture != nil {
550+
if err := s.egressCapture.Close(); err != nil {
551+
slog.WarnContext(ctx, "Failed to close actor egress capture", "err", err)
552+
}
553+
s.egressCapture = nil
554+
}
543555

544556
var cleanupErr error
545557
if link, err := netlink.LinkByName(hostVethName); err == nil {
@@ -621,7 +633,7 @@ func enableIPv4Forwarding() error {
621633
return nil
622634
}
623635

624-
func installActorNftablesRules(podIP net.IP) error {
636+
func installActorNftablesRules(podIP net.IP, egressCapture bool) error {
625637
// Install a dedicated nftables table for the active actor. Keeping all
626638
// rules in an ateom-owned table makes cleanup simple and avoids mutating
627639
// Kubernetes or CNI-managed chains directly.
@@ -659,6 +671,13 @@ func installActorNftablesRules(podIP net.IP) error {
659671
Hooknum: nftables.ChainHookPrerouting,
660672
Priority: nftables.ChainPriorityNATDest,
661673
})
674+
if egressCapture {
675+
addEgressCaptureRedirectRules(c, table, prerouting, actorVethIP)
676+
}
677+
// TODO: Support optional DNS capture for hostname recovery for non-SNI,
678+
// non-HTTP, or DNS-policy egress. The current HTTP/HTTPS path derives
679+
// authority from TLS SNI or HTTP Host, so redirecting UDP/TCP 53 would
680+
// add potential DNS proxy/cache/TTL/search-domain failures
662681
// TODO: Support inbound UDP DNAT for actors that expose UDP protocols such
663682
// as QUIC.
664683
// TODO: Replace the hard-coded HTTP port with the actor's configured
@@ -790,6 +809,22 @@ func tcpDestinationPortEqual(port uint16) []expr.Any {
790809
}
791810
}
792811

812+
func actorIdentityFromRun(req *ateompb.RunWorkloadRequest) egresscapture.ActorIdentity {
813+
return egresscapture.ActorIdentity{
814+
Namespace: req.GetActorTemplateNamespace(),
815+
Template: req.GetActorTemplateName(),
816+
ActorID: req.GetActorId(),
817+
}
818+
}
819+
820+
func actorIdentityFromRestore(req *ateompb.RestoreWorkloadRequest) egresscapture.ActorIdentity {
821+
return egresscapture.ActorIdentity{
822+
Namespace: req.GetActorTemplateNamespace(),
823+
Template: req.GetActorTemplateName(),
824+
ActorID: req.GetActorId(),
825+
}
826+
}
827+
793828
func createNetNSWithoutSwitching(ctx context.Context, name string) (netns.NsHandle, error) {
794829
runtime.LockOSThread()
795830
defer runtime.UnlockOSThread()

0 commit comments

Comments
 (0)