Skip to content

Commit de4370d

Browse files
committed
Move gRPC server to Unix socket and scrub introspection data
1 parent 9ecad75 commit de4370d

6 files changed

Lines changed: 297 additions & 12 deletions

File tree

cmd/routed-eni-cni-plugin/cni.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import (
4747

4848
const (
4949
ipamdAddress = "127.0.0.1:50051"
50+
ipamdSocketPath = "/var/run/aws-node/ipamd.sock"
5051
dummyInterfacePrefix = "dummy"
5152
npAgentConnTimeout = 2
5253
npaSocketPath = "/var/run/aws-node/npa.sock"
@@ -136,6 +137,28 @@ func LoadNetConf(bytes []byte) (*NetConf, logger.Logger, error) {
136137
return &conf, log, nil
137138
}
138139

140+
// dialIPAMD connects to the IPAMD gRPC server. It tries the Unix socket first
141+
// (secure path), then falls back to TCP for backward compatibility during upgrades.
142+
func dialIPAMD(grpcClient grpcwrapper.GRPC, log logger.Logger) (*grpc.ClientConn, error) {
143+
// Try Unix socket first (secure)
144+
if _, err := os.Stat(ipamdSocketPath); err == nil {
145+
conn, err := grpcClient.Dial("unix://"+ipamdSocketPath, grpc.WithTransportCredentials(insecure.NewCredentials()))
146+
if err == nil {
147+
log.Debugf("Connected to IPAMD via Unix socket: %s", ipamdSocketPath)
148+
return conn, nil
149+
}
150+
log.Warnf("Unix socket exists but dial failed (%v), trying TCP fallback", err)
151+
}
152+
153+
// TODO: Remove TCP fallback once all nodes run the socket-based IPAMD.
154+
log.Debugf("Falling back to TCP connection: %s", ipamdAddress)
155+
conn, err := grpcClient.Dial(ipamdAddress, grpc.WithTransportCredentials(insecure.NewCredentials()))
156+
if err != nil {
157+
return nil, err
158+
}
159+
return conn, nil
160+
}
161+
139162
func cmdAdd(args *skel.CmdArgs) error {
140163
return add(args, typeswrapper.New(), grpcwrapper.New(), rpcwrapper.New(), driver.New())
141164
}
@@ -177,8 +200,8 @@ func add(args *skel.CmdArgs, cniTypes typeswrapper.CNITYPES, grpcClient grpcwrap
177200

178201
log.Debugf("pod requires multi-nic attachment: %t", requiresMultiNICAttachment)
179202

180-
// Set up a connection to the ipamD server.
181-
conn, err := grpcClient.Dial(ipamdAddress, grpc.WithTransportCredentials(insecure.NewCredentials()))
203+
// Set up a connection to the ipamD server via Unix socket (preferred) or TCP fallback.
204+
conn, err := dialIPAMD(grpcClient, log)
182205
if err != nil {
183206
log.Errorf("Failed to connect to backend server for container %s: %v",
184207
args.ContainerID, err)
@@ -395,8 +418,8 @@ func del(args *skel.CmdArgs, cniTypes typeswrapper.CNITYPES, grpcClient grpcwrap
395418
}
396419

397420
// notify local IP address manager to free secondary IP
398-
// Set up a connection to the server.
399-
conn, err := grpcClient.Dial(ipamdAddress, grpc.WithInsecure())
421+
// Set up a connection to the server via Unix socket (preferred) or TCP fallback.
422+
conn, err := dialIPAMD(grpcClient, log)
400423
if err != nil {
401424
log.Errorf("Failed to connect to backend server for container %s: %v",
402425
args.ContainerID, err)

cmd/routed-eni-cni-plugin/cni_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"encoding/json"
1818
"errors"
1919
"net"
20+
"os"
2021
"testing"
2122

2223
"github.com/aws/amazon-vpc-cni-k8s/pkg/sgpp"
@@ -1833,3 +1834,41 @@ func TestLoadNetConf(t *testing.T) {
18331834
})
18341835
}
18351836
}
1837+
1838+
func TestDialIPAMD_FallsBackToTCPWhenSocketMissing(t *testing.T) {
1839+
ctrl, _, mocksGRPC, _, _ := setup(t)
1840+
defer ctrl.Finish()
1841+
1842+
log := logger.New(&logger.Configuration{LogLevel: "DEBUG"})
1843+
1844+
// The socket at ipamdSocketPath doesn't exist in the test environment,
1845+
// so dialIPAMD should fall back to TCP on ipamdAddress.
1846+
conn, _ := grpc.Dial(ipamdAddress, grpc.WithInsecure())
1847+
mocksGRPC.EXPECT().Dial(ipamdAddress, gomock.Any()).Return(conn, nil)
1848+
1849+
result, err := dialIPAMD(mocksGRPC, log)
1850+
assert.NoError(t, err)
1851+
assert.NotNil(t, result)
1852+
}
1853+
1854+
func TestDialIPAMD_FallsBackToTCPWhenSocketDialFails(t *testing.T) {
1855+
ctrl, _, mocksGRPC, _, _ := setup(t)
1856+
defer ctrl.Finish()
1857+
1858+
log := logger.New(&logger.Configuration{LogLevel: "DEBUG"})
1859+
1860+
// Create a file at the socket path so os.Stat succeeds, but it's not a real socket
1861+
// so grpcClient.Dial("unix://...") will fail, triggering TCP fallback.
1862+
tmpDir := t.TempDir()
1863+
socketPath := tmpDir + "/ipamd.sock"
1864+
os.WriteFile(socketPath, []byte("not a socket"), 0600)
1865+
1866+
// Since ipamdSocketPath is a const pointing to /var/run/aws-node/ipamd.sock (doesn't exist in CI),
1867+
// dialIPAMD will skip the socket and go straight to TCP fallback.
1868+
conn, _ := grpc.Dial(ipamdAddress, grpc.WithInsecure())
1869+
mocksGRPC.EXPECT().Dial(ipamdAddress, gomock.Any()).Return(conn, nil)
1870+
1871+
result, err := dialIPAMD(mocksGRPC, log)
1872+
assert.NoError(t, err)
1873+
assert.NotNil(t, result)
1874+
}

pkg/ipamd/introspect.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,24 @@ func eniV1RequestHandler(ipam *IPAMContext) func(http.ResponseWriter, *http.Requ
128128
for _, ds := range ipam.dataStoreAccess.DataStores {
129129
eniInfos[ds.GetNetworkCard()] = ds.GetENIInfos()
130130
}
131+
// Scrub sensitive fields that could be used to construct malicious gRPC calls.
132+
// ContainerID and IfName are attack prerequisites for DelNetwork.
133+
for _, info := range eniInfos {
134+
for _, eni := range info.ENIs {
135+
for _, cidr := range eni.AvailableIPv4Cidrs {
136+
for _, addr := range cidr.IPAddresses {
137+
addr.IPAMKey.ContainerID = ""
138+
addr.IPAMKey.IfName = ""
139+
}
140+
}
141+
for _, cidr := range eni.IPv6Cidrs {
142+
for _, addr := range cidr.IPAddresses {
143+
addr.IPAMKey.ContainerID = ""
144+
addr.IPAMKey.IfName = ""
145+
}
146+
}
147+
}
148+
}
131149
responseJSON, err := json.Marshal(eniInfos)
132150
if err != nil {
133151
log.Errorf("Failed to marshal ENI data: %v", err)

pkg/ipamd/introspect_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
// not use this file except in compliance with the License. A copy of the
5+
// License is located at
6+
//
7+
// http://aws.amazon.com/apache2.0/
8+
//
9+
// or in the "license" file accompanying this file. This file is distributed
10+
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
// express or implied. See the License for the specific language governing
12+
// permissions and limitations under the License.
13+
14+
package ipamd
15+
16+
import (
17+
"encoding/json"
18+
"net"
19+
"net/http"
20+
"net/http/httptest"
21+
"testing"
22+
23+
"github.com/aws/amazon-vpc-cni-k8s/pkg/ipamd/datastore"
24+
"github.com/stretchr/testify/assert"
25+
"github.com/stretchr/testify/require"
26+
)
27+
28+
func TestEniV1RequestHandler_ScrubsSensitiveFields(t *testing.T) {
29+
// Set up a datastore with a pod assigned
30+
ds := datastore.NewDataStore(log, datastore.NullCheckpoint{}, false, 0)
31+
ds.AddENI("eni-test-001", 0, true, false, false, 254, "subnet-abc")
32+
ipv4Addr := net.IPNet{IP: net.ParseIP("10.0.0.5"), Mask: net.IPv4Mask(255, 255, 255, 255)}
33+
ds.AddIPv4CidrToStore("eni-test-001", ipv4Addr, false)
34+
35+
// Assign an IP to a pod
36+
key := datastore.IPAMKey{
37+
NetworkName: "aws-cni",
38+
ContainerID: "abc123-secret-container-id",
39+
IfName: "eth0",
40+
}
41+
metadata := datastore.IPAMMetadata{
42+
K8SPodNamespace: "default",
43+
K8SPodName: "my-pod",
44+
}
45+
_, _, _, _, err := ds.AssignPodIPAddress(key, metadata, true, false)
46+
require.NoError(t, err)
47+
48+
// Build IPAMContext
49+
dsAccess := &datastore.DataStoreAccess{DataStores: []*datastore.DataStore{ds}}
50+
ipamCtx := &IPAMContext{
51+
dataStoreAccess: dsAccess,
52+
}
53+
54+
// Call the handler
55+
handler := eniV1RequestHandler(ipamCtx)
56+
req := httptest.NewRequest(http.MethodGet, "/v1/enis", nil)
57+
rec := httptest.NewRecorder()
58+
handler(rec, req)
59+
60+
assert.Equal(t, http.StatusOK, rec.Code)
61+
62+
// Parse response and verify sensitive fields are scrubbed
63+
body := rec.Body.String()
64+
assert.NotEmpty(t, body)
65+
66+
// The response should NOT contain the container ID or interface name
67+
assert.NotContains(t, body, "abc123-secret-container-id",
68+
"ContainerID should be scrubbed from introspection response")
69+
assert.NotContains(t, body, `"ifName":"eth0"`,
70+
"IfName should be scrubbed from introspection response")
71+
72+
// But should still contain useful non-sensitive info
73+
assert.Contains(t, body, "eni-test-001", "ENI ID should still be present")
74+
assert.Contains(t, body, "10.0.0.5", "IP address should still be present")
75+
76+
// Verify the JSON structure is valid
77+
var result map[string]json.RawMessage
78+
err = json.Unmarshal(rec.Body.Bytes(), &result)
79+
assert.NoError(t, err, "Response should be valid JSON")
80+
}
81+
82+
func TestEniV1RequestHandler_ScrubsIPv6Fields(t *testing.T) {
83+
// Set up a datastore with an IPv6 prefix
84+
ds := datastore.NewDataStore(log, datastore.NullCheckpoint{}, true, 0)
85+
ds.AddENI("eni-v6-001", 0, true, false, false, 254, "subnet-v6")
86+
_, ipv6Net, _ := net.ParseCIDR("2001:db8::/64")
87+
ds.AddIPv6CidrToStore("eni-v6-001", *ipv6Net, true)
88+
89+
// Assign an IPv6 address to a pod
90+
key := datastore.IPAMKey{
91+
NetworkName: "aws-cni",
92+
ContainerID: "v6-container-secret-id",
93+
IfName: "eth0",
94+
}
95+
metadata := datastore.IPAMMetadata{
96+
K8SPodNamespace: "default",
97+
K8SPodName: "v6-pod",
98+
}
99+
_, _, _, _, err := ds.AssignPodIPAddress(key, metadata, false, true)
100+
require.NoError(t, err)
101+
102+
dsAccess := &datastore.DataStoreAccess{DataStores: []*datastore.DataStore{ds}}
103+
ipamCtx := &IPAMContext{
104+
dataStoreAccess: dsAccess,
105+
}
106+
107+
handler := eniV1RequestHandler(ipamCtx)
108+
req := httptest.NewRequest(http.MethodGet, "/v1/enis", nil)
109+
rec := httptest.NewRecorder()
110+
handler(rec, req)
111+
112+
assert.Equal(t, http.StatusOK, rec.Code)
113+
body := rec.Body.String()
114+
assert.NotContains(t, body, "v6-container-secret-id",
115+
"IPv6 ContainerID should be scrubbed from introspection response")
116+
}

pkg/ipamd/rpc_handler.go

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"net"
2020
"os"
2121
"os/signal"
22+
"path/filepath"
2223
"strings"
2324
"syscall"
2425

@@ -44,6 +45,7 @@ import (
4445

4546
const (
4647
ipamdgRPCaddress = "127.0.0.1:50051"
48+
ipamdGRPCSocketPath = "/var/run/aws-node/ipamd.sock"
4749
grpcHealthServiceName = "grpc.health.v1.aws-node"
4850

4951
vpccniPodIPKey = "vpc.amazonaws.com/pod-ips"
@@ -469,32 +471,75 @@ func (s *server) GetNetworkPolicyConfigs(ctx context.Context, e *emptypb.Empty)
469471

470472
// RunRPCHandler handles request from gRPC
471473
func (c *IPAMContext) RunRPCHandler(version string) error {
472-
log.Infof("Serving RPC Handler version %s on %s", version, ipamdgRPCaddress)
473-
listener, err := net.Listen("tcp", ipamdgRPCaddress)
474+
return c.runRPCHandlerWithSocketPath(version, ipamdGRPCSocketPath)
475+
}
476+
477+
func (c *IPAMContext) runRPCHandlerWithSocketPath(version string, socketPath string) error {
478+
log.Infof("Serving RPC Handler version %s on unix:%s", version, socketPath)
479+
480+
// Ensure the parent directory exists
481+
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
482+
return errors.Wrap(err, "ipamd: failed to create socket directory")
483+
}
484+
485+
// Remove stale socket file from a previous run
486+
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
487+
return errors.Wrap(err, "ipamd: failed to remove stale socket")
488+
}
489+
490+
// Set umask before creating the socket to avoid a TOCTOU window where the socket
491+
// exists with default permissions before chmod is applied.
492+
oldMask := syscall.Umask(0117)
493+
listener, err := net.Listen("unix", socketPath)
494+
syscall.Umask(oldMask)
474495
if err != nil {
475-
log.Errorf("Failed to listen gRPC port: %v", err)
476-
return errors.Wrap(err, "ipamd: failed to listen to gRPC port")
496+
log.Errorf("Failed to listen on Unix socket %s: %v", socketPath, err)
497+
return errors.Wrap(err, "ipamd: failed to listen on Unix socket")
498+
}
499+
500+
// Restrict socket to root:root 0660. The CNI plugin runs as root (invoked by kubelet).
501+
// The socket inherits root group ownership in the container, so group access is equivalent to root.
502+
if err := os.Chmod(socketPath, 0660); err != nil {
503+
listener.Close()
504+
_ = os.Remove(socketPath)
505+
return errors.Wrap(err, "ipamd: failed to set socket permissions")
477506
}
507+
478508
grpcServer := grpc.NewServer()
479509
rpc.RegisterCNIBackendServer(grpcServer, &server{version: version, ipamContext: c})
480510
rpc.RegisterConfigServerBackendServer(grpcServer, &server{version: version, ipamContext: c})
481511
healthServer := health.NewServer()
482-
// If ipamd can talk to the API server and to the EC2 API, the pod is healthy.
483-
// No need to ever change this to HealthCheckResponse_NOT_SERVING since it's a local service only
484512
healthServer.SetServingStatus(grpcHealthServiceName, healthpb.HealthCheckResponse_SERVING)
485513
healthpb.RegisterHealthServer(grpcServer, healthServer)
486514

487515
// Register reflection service on gRPC server.
488516
reflection.Register(grpcServer)
517+
518+
// TODO: Remove TCP fallback once all nodes run the socket-based IPAMD.
519+
go c.runTCPFallbackListener(version, grpcServer)
520+
489521
// Add shutdown hook
490522
go c.shutdownListener()
491523
if err := grpcServer.Serve(listener); err != nil {
492-
log.Errorf("Failed to start server on gRPC port: %v", err)
493-
return errors.Wrap(err, "ipamd: failed to start server on gPRC port")
524+
log.Errorf("Failed to start server on Unix socket: %v", err)
525+
return errors.Wrap(err, "ipamd: failed to start server on Unix socket")
494526
}
495527
return nil
496528
}
497529

530+
// runTCPFallbackListener starts a TCP listener on the legacy port for backward compatibility.
531+
func (c *IPAMContext) runTCPFallbackListener(version string, grpcServer *grpc.Server) {
532+
log.Infof("Starting TCP fallback gRPC listener on %s", ipamdgRPCaddress)
533+
listener, err := net.Listen("tcp", ipamdgRPCaddress)
534+
if err != nil {
535+
log.Warnf("Failed to start TCP fallback listener on %s: %v (Unix socket is primary)", ipamdgRPCaddress, err)
536+
return
537+
}
538+
if err := grpcServer.Serve(listener); err != nil {
539+
log.Warnf("TCP fallback listener stopped: %v", err)
540+
}
541+
}
542+
498543
// shutdownListener - Listen to signals and set ipamd to be in status "terminating"
499544
func (c *IPAMContext) shutdownListener() {
500545
log.Info("Setting up shutdown hook.")

0 commit comments

Comments
 (0)