Skip to content

Commit 36fd89f

Browse files
committed
node: services are declared in configuration, never registered at runtime
Remove the /sam/service/register and /sam/service/unregister endpoints: anything a caller can mutate at runtime is an interface it can abuse, and every legitimate use was a declaration in disguise. Services now only exist by declaration at startup — the node's --config services block, or the FFI start configuration on mobile — and the probe gates advertisement, so readiness is observed rather than asserted. The former endpoint paths fall through to the egress proxy like any other /sam/ path, with no special handling; the sandbox boundary still refuses them before the node is ever reached, which the boundary tests keep pinning. Validation moves into SamNode.RegisterService, the one funnel the config and FFI paths share. Tests, harnesses, the mobile app, the Cloud Run example and the architecture doc's ingress section all migrate to declaring backends before their node starts. Runtime-written config files are chmod 644: the node container runs as a non-root user and a restrictive umask would make the mounted config unreadable inside it.
1 parent ae97f2a commit 36fd89f

23 files changed

Lines changed: 605 additions & 916 deletions

.github/k8s/sam-box-canary-template.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ spec:
147147
echo "the sandbox's interfaces (expect lo and tun0 only): $(ip -o link show | cut -d: -f2 | tr -d ' ' | paste -sd,)"
148148
while true; do
149149
echo "allowlisted destination (expect 200): $(curl -s -o /dev/null -w '%{http_code}' http://example.com/)"
150-
echo "the node's own API (expect 403): $(curl -s -o /dev/null -w '%{http_code}' http://mesh.sam.alt/sam/service/register)"
150+
echo "the node's own API (expect 403): $(curl -s -o /dev/null -w '%{http_code}' http://mesh.sam.alt/sam/service/discover)"
151151
curl -s -o /dev/null http://blocked.example/ && echo "unlisted destination was NOT refused" || echo "unlisted destination refused (expected)"
152152
sleep 30
153153
done

internal/node/node.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,6 +1580,15 @@ func (n *SamNode) HandleAuthHandshake(s network.Stream) {
15801580
}
15811581

15821582
func (n *SamNode) RegisterService(ctx context.Context, req *api.RegisterServiceRequest) error {
1583+
if req.Service == nil {
1584+
return fmt.Errorf("service field is required")
1585+
}
1586+
if req.Service.Name == "" || req.Service.Type == api.ServiceType_SERVICE_TYPE_UNSPECIFIED {
1587+
return fmt.Errorf("service name and type are required")
1588+
}
1589+
if req.Backend == nil {
1590+
return fmt.Errorf("service backend is required")
1591+
}
15831592
svc, err := NewServiceFromRequest(req)
15841593
if err != nil {
15851594
return err

internal/node/sidecar.go

Lines changed: 4 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"encoding/base64"
2424
"encoding/json"
2525
"fmt"
26-
"io"
2726
"net"
2827
"net/http"
2928
"net/http/httputil"
@@ -38,7 +37,6 @@ import (
3837
"github.com/libp2p/go-libp2p/core/network"
3938
"github.com/libp2p/go-libp2p/core/peer"
4039
"github.com/prometheus/client_golang/prometheus/promhttp"
41-
"google.golang.org/protobuf/encoding/protojson"
4240
)
4341

4442
// StartSidecarServer serves the node's local API on a TCP address, on a Unix
@@ -57,12 +55,10 @@ func StartSidecarServer(node *SamNode, addr, socketPath, token, certFile, keyFil
5755

5856
// Protected endpoints. allowAuthorizationFallback=true is safe here: none of
5957
// these ever forward the inbound Authorization header to another service.
60-
mux.Handle("/sam/service/register", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61-
handleRegisterService(node, w, r)
62-
}))))
63-
mux.Handle("/sam/service/unregister", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64-
handleUnregisterService(node, w, r)
65-
}))))
58+
// Services are declared in the node's configuration and registered at
59+
// startup; there is deliberately no runtime registration surface, so no
60+
// credential held by an agent can point the mesh at a new backend or
61+
// withdraw a sibling service.
6662
mux.Handle("/sam/service/discover", withAuth(token, true, withMeshConnection(node, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6763
handleDiscoverService(node, w, r)
6864
}))))
@@ -448,90 +444,10 @@ func constantTimeEqual(got, want string) bool {
448444
return subtle.ConstantTimeCompare(gotHash[:], wantHash[:]) == 1
449445
}
450446

451-
type ServiceRequest struct {
452-
ServiceName string `json:"service_name"`
453-
}
454-
455447
// maxRequestBodyBytes caps request bodies read into memory to guard
456448
// against memory-exhaustion from oversized payloads.
457449
const maxRequestBodyBytes = 1 << 20 // 1 MiB
458450

459-
func handleRegisterService(node *SamNode, w http.ResponseWriter, r *http.Request) {
460-
if r.Method != http.MethodPost {
461-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
462-
return
463-
}
464-
465-
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
466-
body, err := io.ReadAll(r.Body)
467-
if err != nil {
468-
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
469-
return
470-
}
471-
_ = r.Body.Close()
472-
473-
var req api.RegisterServiceRequest
474-
if err := protojson.Unmarshal(body, &req); err != nil {
475-
http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest)
476-
return
477-
}
478-
479-
if req.Service == nil {
480-
http.Error(w, "service field is required", http.StatusBadRequest)
481-
return
482-
}
483-
484-
if req.Service.Name == "" || req.Service.Type == api.ServiceType_SERVICE_TYPE_UNSPECIFIED {
485-
http.Error(w, "name and type are required", http.StatusBadRequest)
486-
return
487-
}
488-
489-
if req.Backend == nil {
490-
http.Error(w, "backend is required", http.StatusBadRequest)
491-
return
492-
}
493-
494-
if err := node.RegisterService(r.Context(), &req); err != nil {
495-
logger.Errorf("Failed to register service: %v", err)
496-
http.Error(w, fmt.Sprintf("Failed to register service: %v", err), http.StatusInternalServerError)
497-
return
498-
}
499-
500-
w.WriteHeader(http.StatusOK)
501-
if _, err := w.Write([]byte("Service registered")); err != nil {
502-
logger.Errorf("Failed to write response: %v", err)
503-
}
504-
}
505-
506-
func handleUnregisterService(node *SamNode, w http.ResponseWriter, r *http.Request) {
507-
if r.Method != http.MethodPost {
508-
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
509-
return
510-
}
511-
512-
var req api.ServiceInfo
513-
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
514-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
515-
http.Error(w, "Invalid request body", http.StatusBadRequest)
516-
return
517-
}
518-
519-
if req.Name == "" {
520-
http.Error(w, "name is required", http.StatusBadRequest)
521-
return
522-
}
523-
524-
if err := node.UnregisterService(r.Context(), req.Name); err != nil {
525-
http.Error(w, fmt.Sprintf("Failed to unregister service: %v", err), http.StatusInternalServerError)
526-
return
527-
}
528-
529-
w.WriteHeader(http.StatusOK)
530-
if _, err := w.Write([]byte("Service unregistered")); err != nil {
531-
logger.Errorf("Failed to write response: %v", err)
532-
}
533-
}
534-
535451
func handleDiscoverService(node *SamNode, w http.ResponseWriter, r *http.Request) {
536452
if r.Method != http.MethodGet {
537453
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)

internal/node/sidecar_test.go

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
package node
1616

1717
import (
18-
"bytes"
1918
"context"
2019
"encoding/json"
2120
"net"
@@ -34,7 +33,6 @@ import (
3433
"github.com/libp2p/go-libp2p/core/host"
3534
"github.com/libp2p/go-libp2p/core/peer"
3635
"github.com/modelcontextprotocol/go-sdk/mcp"
37-
"google.golang.org/protobuf/encoding/protojson"
3836
)
3937

4038
// socketClient talks HTTP over a Unix socket; the host in the URL is ignored.
@@ -406,15 +404,16 @@ func TestSidecarServerAuthEnforcement(t *testing.T) {
406404
{"readyz is public", "GET", "/readyz", http.StatusOK, false},
407405
// Liveness says nothing; metrics name peers and count their traffic.
408406
{"metrics is protected", "GET", "/metrics", http.StatusUnauthorized, false},
409-
{"register is protected", "POST", "/sam/service/register", http.StatusUnauthorized, false},
410-
{"unregister is protected", "POST", "/sam/service/unregister", http.StatusUnauthorized, false},
407+
// Services are declared in configuration only: the former runtime
408+
// mutation endpoints are gone, so these paths are plain egress-proxy
409+
// paths (503: not connected) with no special handling.
410+
{"register does not exist", "POST", "/sam/service/register", http.StatusServiceUnavailable, true},
411+
{"unregister does not exist", "POST", "/sam/service/unregister", http.StatusServiceUnavailable, true},
411412
{"discover is protected", "GET", "/sam/service/discover?type=mcp&name=test", http.StatusUnauthorized, false},
412413
{"egress proxy is protected", "GET", "/sam/", http.StatusUnauthorized, false},
413414
{"mcp root is protected", "GET", "/mcp", http.StatusUnauthorized, false},
414415

415-
{"register with token (bad req)", "POST", "/sam/service/register", http.StatusServiceUnavailable, true},
416416
{"metrics with token", "GET", "/metrics", http.StatusOK, true},
417-
{"unregister with token (bad req)", "POST", "/sam/service/unregister", http.StatusServiceUnavailable, true},
418417
// /sam/service/discover without mesh connection will return 503 instead of 400 since node is not connected,
419418
// but as long as it gets past auth, that's what we want to verify.
420419
{"discover with token", "GET", "/sam/service/discover?type=mcp&name=test", http.StatusServiceUnavailable, true},
@@ -514,7 +513,7 @@ func TestSidecarAuthorizationFallbackScope(t *testing.T) {
514513
}
515514
}
516515

517-
func TestHandleRegisterService(t *testing.T) {
516+
func TestRegisterService(t *testing.T) {
518517
h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0"))
519518
if err != nil {
520519
t.Fatal(err)
@@ -555,22 +554,12 @@ func TestHandleRegisterService(t *testing.T) {
555554
upstream := httptest.NewServer(newFakeMCPHandler(t, []*mcp.Tool{}))
556555
defer upstream.Close()
557556

558-
reqBody := &api.RegisterServiceRequest{
557+
req := &api.RegisterServiceRequest{
559558
Service: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "test-service", Description: "test desc"},
560559
Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: upstream.URL},
561560
}
562-
body, err := protojson.Marshal(reqBody)
563-
if err != nil {
564-
t.Fatalf("Failed to marshal request body: %v", err)
565-
}
566-
567-
req := httptest.NewRequest("POST", "/sam/service/register", bytes.NewBuffer(body))
568-
rr := httptest.NewRecorder()
569-
570-
handleRegisterService(node, rr, req)
571-
572-
if rr.Code != http.StatusOK {
573-
t.Errorf("expected status OK, got %d, body: %s", rr.Code, rr.Body.String())
561+
if err := node.RegisterService(context.Background(), req); err != nil {
562+
t.Fatalf("RegisterService: %v", err)
574563
}
575564

576565
if !node.IsServiceRegistered("test-service") {
@@ -584,25 +573,14 @@ func TestHandleRegisterService(t *testing.T) {
584573
}
585574
}
586575

587-
func TestHandleUnregisterService(t *testing.T) {
576+
func TestUnregisterService(t *testing.T) {
588577
node := &SamNode{BiscuitTimeout: 500 * time.Millisecond,
589578
services: NewServiceRegistry(&fakeDHT{}),
590579
}
591580
node.services.insertService(&testService{info: &api.ServiceInfo{Name: "test-service"}})
592581

593-
reqBody := &api.ServiceInfo{Name: "test-service"}
594-
body, err := json.Marshal(reqBody)
595-
if err != nil {
596-
t.Fatalf("Failed to marshal request body: %v", err)
597-
}
598-
599-
req := httptest.NewRequest("POST", "/sam/service/unregister", bytes.NewBuffer(body))
600-
rr := httptest.NewRecorder()
601-
602-
handleUnregisterService(node, rr, req)
603-
604-
if rr.Code != http.StatusOK {
605-
t.Errorf("expected status OK, got %d", rr.Code)
582+
if err := node.UnregisterService(context.Background(), "test-service"); err != nil {
583+
t.Fatalf("UnregisterService: %v", err)
606584
}
607585

608586
if node.IsServiceRegistered("test-service") {
@@ -797,61 +775,47 @@ func TestServiceKeyToCID_Equivalence(t *testing.T) {
797775
}
798776
}
799777

800-
func TestHandleRegisterService_Validation(t *testing.T) {
778+
func TestRegisterService_Validation(t *testing.T) {
801779
node := &SamNode{BiscuitTimeout: 500 * time.Millisecond,
802780
services: NewServiceRegistry(&fakeDHT{}),
803781
}
804782

805783
tests := []struct {
806-
name string
807-
reqBody *api.RegisterServiceRequest
808-
expectedStatus int
784+
name string
785+
reqBody *api.RegisterServiceRequest
809786
}{
810787
{
811788
name: "Missing service",
812789
reqBody: &api.RegisterServiceRequest{
813790
Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: "http://localhost:8080"},
814791
},
815-
expectedStatus: http.StatusBadRequest,
816792
},
817793
{
818794
name: "Missing name",
819795
reqBody: &api.RegisterServiceRequest{
820796
Service: &api.ServiceInfo{Type: api.ServiceType_SERVICE_TYPE_MCP},
821797
Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: "http://localhost:8080"},
822798
},
823-
expectedStatus: http.StatusBadRequest,
824799
},
825800
{
826801
name: "Unspecified type",
827802
reqBody: &api.RegisterServiceRequest{
828803
Service: &api.ServiceInfo{Name: "test-service", Type: api.ServiceType_SERVICE_TYPE_UNSPECIFIED},
829804
Backend: &api.RegisterServiceRequest_TargetUrl{TargetUrl: "http://localhost:8080"},
830805
},
831-
expectedStatus: http.StatusBadRequest,
832806
},
833807
{
834808
name: "Missing backend",
835809
reqBody: &api.RegisterServiceRequest{
836810
Service: &api.ServiceInfo{Name: "test-service", Type: api.ServiceType_SERVICE_TYPE_MCP},
837811
},
838-
expectedStatus: http.StatusBadRequest,
839812
},
840813
}
841814

842815
for _, tt := range tests {
843816
t.Run(tt.name, func(t *testing.T) {
844-
body, err := protojson.Marshal(tt.reqBody)
845-
if err != nil {
846-
t.Fatal(err)
847-
}
848-
req := httptest.NewRequest("POST", "/sam/service/register", bytes.NewBuffer(body))
849-
rr := httptest.NewRecorder()
850-
851-
handleRegisterService(node, rr, req)
852-
853-
if rr.Code != tt.expectedStatus {
854-
t.Errorf("expected status %d, got %d, body: %s", tt.expectedStatus, rr.Code, rr.Body.String())
817+
if err := node.RegisterService(context.Background(), tt.reqBody); err == nil {
818+
t.Errorf("expected RegisterService to reject invalid request")
855819
}
856820
})
857821
}

0 commit comments

Comments
 (0)