+
+
@@ -163,6 +170,7 @@

Users

Loading...
+
@@ -172,6 +180,7 @@

Users

Enrolled Nodes

+
@@ -185,6 +194,7 @@

Enrolled Nodes

Loading...
+
@@ -194,6 +204,7 @@

Enrolled Nodes

Enrollment Requests

+
@@ -207,6 +218,7 @@

Enrollment Requests

Loading...
+
@@ -216,6 +228,7 @@

Enrollment Requests

Active Routers (Leases)

+
@@ -228,6 +241,7 @@

Active Routers (Leases)

Loading...
+
@@ -255,6 +269,10 @@

Generate Bootstrap Token

+
+ + +
@@ -264,6 +282,7 @@

Generate Bootstrap Token

Active Tokens

+
@@ -278,6 +297,7 @@

Active Tokens

Loading...
+
diff --git a/cmd/sam-console/public/style.css b/cmd/sam-console/public/style.css index 9a898812..0bf95ad0 100644 --- a/cmd/sam-console/public/style.css +++ b/cmd/sam-console/public/style.css @@ -31,6 +31,7 @@ body { display: flex; height: 100vh; width: 100vw; + position: relative; } /* Sidebar */ @@ -99,6 +100,21 @@ body { display: flex; flex-direction: column; overflow: hidden; + min-width: 0; +} + +.sidebar-toggle { + display: none; + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + padding: 8px; + margin-right: 12px; +} + +.sidebar-scrim { + display: none; } .topbar { @@ -158,6 +174,12 @@ body { padding: 32px; } +.table-responsive { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + /* Views */ .view-section { display: none; @@ -564,3 +586,117 @@ body { .form-control:focus { border-color: var(--accent); } + +/* Mobile / responsive layout */ +@media (max-width: 860px) { + body { + overflow: auto; + height: auto; + min-height: 100vh; + } + + .app-container { + height: auto; + min-height: 100vh; + } + + .sidebar-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1100; + width: 260px; + max-width: 80vw; + transform: translateX(-100%); + transition: transform 0.25s ease; + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-scrim { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1050; + } + + .sidebar-scrim.active { + display: block; + } + + .main-content { + width: 100%; + } + + .topbar { + padding: 0 16px; + height: 60px; + } + + .topbar .search-bar { + display: none; + } + + .user-profile span { + display: none; + } + + .content-scroll { + padding: 16px; + } + + .header-banner { + flex-direction: column; + gap: 12px; + } + + .header-banner h1 { + font-size: 1.5rem; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + .card { + padding: 16px !important; + } + + .data-table th, + .data-table td { + padding: 10px; + white-space: nowrap; + } + + .modal-content { + width: 90vw; + max-width: 400px; + padding: 20px; + } + + .landing-content { + width: 90vw; + padding: 2rem 1.5rem; + } + + #form-generate-token { + max-width: 100% !important; + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } +} diff --git a/development/kind/run.sh b/development/kind/run.sh index 41d6e0d9..3712b28a 100755 --- a/development/kind/run.sh +++ b/development/kind/run.sh @@ -169,7 +169,8 @@ echo "== Deploying SAM Mesh via Helm ==" --set router.externalAddrs[1]="/ip4/127.0.0.1/tcp/4001" \ --set console.service.type=NodePort \ --set console.service.nodePort=30081 \ - --set dex.enabled=true + --set dex.enabled=true \ + --set controlPlane.insecureSkipTlsVerify=true # ISSUER is the kind cluster's own API server, served with a self-signed cert echo "== Waiting for database to be ready ==" kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=ready --timeout=180s pod -l app=sam-mesh-db diff --git a/internal/node/mcp.go b/internal/node/mcp.go index 7cf35de4..2ff0721d 100644 --- a/internal/node/mcp.go +++ b/internal/node/mcp.go @@ -258,6 +258,12 @@ func (n *SamNode) CallMCPTool(ctx context.Context, targetPeer peer.ID, toolName return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, err) } +// ErrAuthRejected marks a service-level authorization denial from a remote peer, +// as opposed to a transport/connectivity failure. Callers use errors.Is to tell +// "you're not allowed" apart from "the service is unreachable" so that discovery +// (find_remote_tools) can hide forbidden services instead of leaking their names. +var ErrAuthRejected = errors.New("auth rejected") + func (n *SamNode) ConnectMCPSession(ctx context.Context, targetPeer peer.ID, targetService string) (*mcp.ClientSession, func(), error) { // Open stream logger.Debugf("Dialing %s for MCP...\n", targetPeer) @@ -344,7 +350,7 @@ func (n *SamNode) ConnectMCPSession(ctx context.Context, targetPeer peer.ID, tar if !resp.Success { cleanup() - return nil, nil, fmt.Errorf("auth rejected by %s: %s", targetPeer, resp.Error) + return nil, nil, fmt.Errorf("%w by %s: %s", ErrAuthRejected, targetPeer, resp.Error) } // Handoff to SDK using custom transport diff --git a/internal/node/mcp_handlers.go b/internal/node/mcp_handlers.go index e859389a..146252cd 100644 --- a/internal/node/mcp_handlers.go +++ b/internal/node/mcp_handlers.go @@ -3,6 +3,7 @@ package node import ( "context" "encoding/json" + "errors" "fmt" "strings" "sync" @@ -465,6 +466,12 @@ func (n *SamNode) fetchRemoteToolCatalogue(ctx context.Context, targetPeer peer. n.preparePeerAddrs(ctx, targetPeer) session, cleanup, err := n.ConnectMCPSession(ctx, targetPeer, connectService) if err != nil { + if errors.Is(err, ErrAuthRejected) { + // Not authorized for this service: omit it entirely rather than + // leaking its existence ("what you see is what you can do", #176). + logger.Debugf("Hiding unauthorized service %s from discovery: %v", targetService, err) + continue + } logger.Debugf("Failed to connect MCP session for service %s: %v", targetService, err) if serviceNameFilter == "" || connectService == serviceNameFilter || strings.HasPrefix(connectService, serviceNameFilter+".") { rows = append(rows, remoteToolRow{ diff --git a/internal/node/mcp_handlers_test.go b/internal/node/mcp_handlers_test.go index 22cd6f9b..5392f004 100644 --- a/internal/node/mcp_handlers_test.go +++ b/internal/node/mcp_handlers_test.go @@ -462,7 +462,7 @@ func buildAndSaveCustomBiscuit(node *SamNode, rootPriv ed25519.PrivateKey, allow return node.Store.SaveIdentity(biscBytes) } -func TestFetchRemoteToolCatalogue_AuthRejected(t *testing.T) { +func TestFetchRemoteToolCatalogue_AuthRejectedHidden(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -502,22 +502,16 @@ func TestFetchRemoteToolCatalogue_AuthRejected(t *testing.T) { t.Fatalf("RegisterService: %v", err) } - // Fetching tools from C should succeed for the catalog, but fail auth for "summarizer" + // Fetching tools from C should succeed for the catalog, but the unauthorized + // "summarizer" service must be hidden entirely rather than leaked as an error + // row (issue #176: discovery should only ever show what the caller can call). rows, err := nodeA.fetchRemoteToolCatalogue(ctx, nodeC.Host.ID(), "") if err != nil { t.Fatalf("fetchRemoteToolCatalogue returned unexpected overall error: %v", err) } - if len(rows) != 1 { - t.Fatalf("expected exactly 1 row with error, got %d: %+v", len(rows), rows) - } - - row := rows[0] - if row.ToolName != "mcp://summarizer" { - t.Errorf("expected ToolName 'mcp://summarizer', got %q", row.ToolName) - } - if !strings.Contains(row.Error, "auth rejected") { - t.Errorf("expected Error to contain 'auth rejected', got %q", row.Error) + if len(rows) != 0 { + t.Fatalf("expected unauthorized service to be hidden (0 rows), got %d: %+v", len(rows), rows) } } diff --git a/tests/e2e/lib/container_mesh.bash b/tests/e2e/lib/container_mesh.bash index 8e7fad6c..ca912dee 100644 --- a/tests/e2e/lib/container_mesh.bash +++ b/tests/e2e/lib/container_mesh.bash @@ -278,6 +278,7 @@ if [[ -z "${MESH_HELPERS_LOADED:-}" ]]; then --set controlPlane.oidcIssuer="${ISSUERS//,/\\,}" \ --set controlPlane.allowedAudiences="sam-mesh-audience\,sam-control-plane-audience" \ --set controlPlane.insecureSkipTlsVerify=true \ + --set controlPlane.adminToken="super-secret-admin-token" \ --set controlPlane.replicaCount=2 \ --set controlPlane.hostPort=8080 \ --set router.useOidcToken=false \