diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2cb05890..9f60e4cc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -88,6 +88,20 @@ builds: - arm64 ldflags: - -s -w + - id: sam-one + main: ./cmd/sam-one + binary: sam-one + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w - id: nano-init main: . # nano-init is its own module: it carries a userspace TCP stack, and that diff --git a/Dockerfile.sam-console b/Dockerfile.sam-console index 685ee787..dbe38300 100644 --- a/Dockerfile.sam-console +++ b/Dockerfile.sam-console @@ -11,7 +11,7 @@ FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app COPY --from=builder --chown=nonroot:nonroot /bin/sam-console /usr/local/bin/sam-console -COPY --chown=nonroot:nonroot cmd/sam-console/public /app/public +COPY --chown=nonroot:nonroot internal/console/public /app/public USER nonroot:nonroot EXPOSE 8081 diff --git a/Dockerfile.sam-one b/Dockerfile.sam-one new file mode 100644 index 00000000..0c6eda89 --- /dev/null +++ b/Dockerfile.sam-one @@ -0,0 +1,23 @@ +# Stage 1: Build +FROM golang:1.27.0 AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o sam-one ./cmd/sam-one +# Pre-create the data dir owned by the nonroot UID so that a fresh +# named/anonymous volume mounted over it inherits writable ownership instead +# of defaulting to root. +RUN mkdir -p /data-seed && chown 65532:65532 /data-seed + +# Stage 2: Final +FROM gcr.io/distroless/static:nonroot +WORKDIR /tmp +COPY --from=builder --chown=nonroot:nonroot /app/sam-one / +COPY --from=builder --chown=nonroot:nonroot /data-seed /data +USER nonroot:nonroot +ENTRYPOINT ["/sam-one"] +# Default args only: a container needs a stable published port, and overriding +# the command line must re-point --data-dir at a writable mount or the sqlite +# db lands on the container filesystem. +CMD ["--data-dir", "/data", "--port", "8080"] diff --git a/Makefile b/Makefile index e4b986ba..d561a882 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ build: go build -v -o "$(OUT_DIR)/sam-node" ./cmd/sam-node go build -v -o "$(OUT_DIR)/sam-control-plane" ./cmd/sam-control-plane go build -v -o "$(OUT_DIR)/sam-router" ./cmd/sam-router + go build -v -o "$(OUT_DIR)/sam-one" ./cmd/sam-one go build -v -o "$(OUT_DIR)/mcp-client" ./cmd/mcp-client go build -v -o "$(OUT_DIR)/sam-box" ./cmd/sam-box go build -v -o "$(OUT_DIR)/sam-bench" ./cmd/sam-bench diff --git a/cmd/sam-console/main.go b/cmd/sam-console/main.go index 94f06fee..b96056a6 100644 --- a/cmd/sam-console/main.go +++ b/cmd/sam-console/main.go @@ -33,7 +33,7 @@ func main() { controlPlaneURL = flag.String("control-plane", "http://localhost:8080", "URL of the SAM control plane") adminTokenPath = flag.String("admin-token-path", "", "Path to file containing the admin token (or env SAM_ADMIN_TOKEN)") bindAddr = flag.String("bind-addr", ":8081", "Address to bind the console server") - staticDir = flag.String("static-dir", "public", "Directory containing static frontend files") + staticDir = flag.String("static-dir", "", "Directory containing static frontend files (default: assets embedded in the binary)") basePath = flag.String("base-path", "", "Base path prefix for the console (e.g. /console)") externalURL = flag.String("external-url", "", "Origin browsers reach this console on, e.g. https://console.example. Sets the OIDC redirect_uri and cookie Secure flag instead of trusting the Host and X-Forwarded-Proto headers") ) @@ -51,6 +51,7 @@ func main() { ControlPlaneURL: *controlPlaneURL, AdminToken: adminToken, StaticDir: *staticDir, + StaticFS: console.EmbeddedAssets(), BasePath: console.NormalizeBasePath(*basePath), ExternalURL: *externalURL, }) diff --git a/cmd/sam-node/daemonize.go b/cmd/sam-node/daemonize.go index ae59a336..15861418 100644 --- a/cmd/sam-node/daemonize.go +++ b/cmd/sam-node/daemonize.go @@ -63,9 +63,13 @@ func daemonizeRun(socketPath string) error { return err } if !enrolled && bootstrapTokenFlag == "" && bootstrapTokenPathFlag == "" && jwtFlag == "" && jwtPathFlag == "" { + target := controlPlane + if target == "" { + target = "" + } return fmt.Errorf("this node is not enrolled yet, and enrolling needs a one-time login you have to approve:\n"+ " sam-node join --headless %s\n"+ - "then re-run 'sam-node run --daemonize'", controlPlane) + "then re-run 'sam-node run --daemonize'", target) } tokenArgs, tokenPath, err := ensureDaemonToken(dataDir) diff --git a/cmd/sam-node/main.go b/cmd/sam-node/main.go index f24e366c..8962adc0 100644 --- a/cmd/sam-node/main.go +++ b/cmd/sam-node/main.go @@ -137,15 +137,9 @@ func isInteractiveTerminal() bool { return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) } -// Public community meshes a node can join without any private control plane -// of its own. Neither is the default without explicit user confirmation. -const ( - publicTestnetControlPlane = "https://bananas.sam-mesh.dev" // open to anyone, deployed from the tip of main - publicProductionControlPlane = "https://hub.sam-mesh.dev" // open to anyone, deployed from the latest release tag -) - // defaultControlPlane resolves which control plane to use when none was -// explicitly passed: the previously stored one, or the public testnet. +// explicitly passed: the previously stored one, or "" when this node has +// never been pointed at a mesh (joining then requires --control-plane). func defaultControlPlane(store *node.Store, explicit string) string { if explicit != "" { return explicit @@ -153,40 +147,7 @@ func defaultControlPlane(store *node.Store, explicit string) string { if h, err := store.LoadControlPlaneURL(); err == nil && h != "" { return h } - return publicTestnetControlPlane -} - -// choosePublicMesh explains what bananas.sam-mesh.dev and hub.sam-mesh.dev -// are and asks which (if either) to join, since a node must never join a -// public mesh without the user's explicit ack; passing --control-plane -// is the silent, explicit alternative. Returns the chosen URL, or "" if the -// user declined (the default). -func choosePublicMesh() string { - fmt.Printf( - "No control plane specified. Join a public community mesh?\n"+ - " 1) %s - open to anyone, deployed from the tip of main (may be unstable)\n"+ - " 2) %s - open to anyone, deployed from the latest release tag\n"+ - "Choice [1/2] (default: don't join): ", - publicTestnetControlPlane, publicProductionControlPlane) - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - return "" - } - return parseMeshChoice(response) -} - -// parseMeshChoice maps a raw prompt answer to the chosen public mesh's URL, -// or "" for anything other than an explicit "1" or "2". -func parseMeshChoice(response string) string { - switch strings.TrimSpace(response) { - case "1": - return publicTestnetControlPlane - case "2": - return publicProductionControlPlane - default: - return "" - } + return "" } // isYesResponse reports whether a raw prompt answer is an explicit "y"/"yes" @@ -219,9 +180,9 @@ const ( // joinNeedsConfirmSwitch: --control-plane conflicts with the stored // mesh; an interactive terminal must confirm resetting and rejoining. joinNeedsConfirmSwitch - // joinNeedsChooseMesh: no explicit or stored control plane; an - // interactive terminal must pick a public mesh (or decline). - joinNeedsChooseMesh + // joinNeedsControlPlane: no explicit or stored control plane; the + // user must pass --control-plane for the mesh this node should join. + joinNeedsControlPlane // joinProceed: enough is known to go straight to interactiveJoin. joinProceed ) @@ -245,7 +206,7 @@ func decideJoinAction(identityExists, hasPubKey, interactive bool, controlPlaneA case mismatched: return joinNeedsConfirmSwitch case controlPlaneAddr == "" && stored == "": - return joinNeedsChooseMesh + return joinNeedsControlPlane default: return joinProceed } @@ -457,16 +418,12 @@ func main() { logger.Fatalf("Failed to reset stored identity: %v", err) } fallthrough - case joinNeedsChooseMesh, joinProceed: + case joinNeedsControlPlane, joinProceed: if !mismatched && identityExists && len(controlPlanePubKey) == 0 { logger.Warn("Stored identity is missing its control plane public key; re-joining") } if controlPlaneAddr == "" && stored == "" { - chosen := choosePublicMesh() - if chosen == "" { - logger.Fatal("Aborted: no control plane specified.") - } - controlPlaneAddr = chosen + logger.Fatal("No control plane specified: pass --control-plane for the mesh this node should join.") } targetControlPlane := normalizeControlPlaneURL(defaultControlPlane(store, controlPlaneAddr)) jwtStr, controlPlaneInfo, err = interactiveJoin(ctx, store, targetControlPlane) @@ -573,7 +530,7 @@ func main() { if len(routerAddrs) > 0 { initRouterAddrs = routerAddrs } else { - logger.Fatalf("Invalid control plane address and no stored config: %v. You can use community maintained meshes like hub.sam-mesh.dev (Production) or bananas.sam-mesh.dev (Testnet)", err) + logger.Fatalf("Invalid control plane address and no stored config: %v. Pass --control-plane for the mesh this node should join", err) } } } @@ -724,12 +681,7 @@ func main() { } if targetControlPlane == "" { - chosen := choosePublicMesh() - if chosen == "" { - fmt.Println("Aborting join operation.") - return - } - targetControlPlane = chosen + logger.Fatal("No control plane specified: sam-node join ") } targetControlPlane = normalizeControlPlaneURL(targetControlPlane) @@ -899,7 +851,7 @@ func main() { runCmd.Flags().StringSliceVar(&listenAddrs, "listen", []string{"/ip4/0.0.0.0/udp/5001/quic-v1", "/ip4/0.0.0.0/tcp/5002"}, "libp2p Listen Addrs") runCmd.Flags().StringVar(&jwtFlag, "jwt", "", "Pre-fetched JWT token") runCmd.Flags().StringVar(&jwtPathFlag, "jwt-path", "", "Path to file containing JWT token") - runCmd.Flags().BoolVar(&joinFlag, "join", false, "Enroll interactively on first run if no identity exists yet (defaults to the public testnet unless --control-plane is set); a no-op on later restarts") + runCmd.Flags().BoolVar(&joinFlag, "join", false, "Enroll interactively on first run if no identity exists yet (requires --control-plane or a previously stored mesh); a no-op on later restarts") runCmd.Flags().StringVar(&bootstrapTokenFlag, "bootstrap-token", "", "Pre-shared bootstrap token for enrollment") runCmd.Flags().StringVar(&bootstrapTokenPathFlag, "bootstrap-token-path", "", "Path to file containing the bootstrap token (recommended over --bootstrap-token)") runCmd.Flags().StringVar(&clientIDFlag, "client-id", "", "OIDC Client ID for M2M") diff --git a/cmd/sam-node/main_test.go b/cmd/sam-node/main_test.go index 7ffc7441..76d97034 100644 --- a/cmd/sam-node/main_test.go +++ b/cmd/sam-node/main_test.go @@ -104,8 +104,8 @@ func TestDefaultControlPlane(t *testing.T) { if got := defaultControlPlane(store, "https://example.com"); got != "https://example.com" { t.Errorf("explicit control plane should win: got %q", got) } - if got := defaultControlPlane(store, ""); got != publicTestnetControlPlane { - t.Errorf("no explicit and no stored URL should default to the public testnet: got %q", got) + if got := defaultControlPlane(store, ""); got != "" { + t.Errorf("no explicit and no stored URL should resolve to nothing, never a public mesh: got %q", got) } if err := store.SaveControlPlaneURL("https://stored.example.com"); err != nil { @@ -136,25 +136,6 @@ func TestIsYesResponse(t *testing.T) { } } -func TestParseMeshChoice(t *testing.T) { - tests := map[string]string{ - "1": publicTestnetControlPlane, - "1\n": publicTestnetControlPlane, - " 1 \n": publicTestnetControlPlane, - "2": publicProductionControlPlane, - "2\n": publicProductionControlPlane, - "3": "", - "": "", - "\n": "", - "y": "", - } - for in, want := range tests { - if got := parseMeshChoice(in); got != want { - t.Errorf("parseMeshChoice(%q) = %q, want %q", in, got, want) - } - } -} - func TestDecideJoinAction(t *testing.T) { const a, b = "https://a.example.com", "https://b.example.com" @@ -164,7 +145,7 @@ func TestDecideJoinAction(t *testing.T) { controlPlaneAddr, stored string want joinAction }{ - {"no identity, interactive, no urls: choose mesh", false, false, true, "", "", joinNeedsChooseMesh}, + {"no identity, interactive, no urls: needs control plane", false, false, true, "", "", joinNeedsControlPlane}, {"no identity, interactive, explicit url: proceed", false, false, true, a, "", joinProceed}, {"no identity, non-interactive: fall back", false, false, false, "", "", joinFallbackNoTTY}, {"usable identity: skip regardless of terminal", true, true, false, "", a, joinSkip}, diff --git a/cmd/sam-one/admin.go b/cmd/sam-one/admin.go new file mode 100644 index 00000000..06a73c35 --- /dev/null +++ b/cmd/sam-one/admin.go @@ -0,0 +1,239 @@ +// Copyright 2026 Google LLC +// +// 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 main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "text/tabwriter" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/standalone" + "github.com/google/sam/internal/storage" + "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" +) + +// adminClient talks to a running sam-one's admin API; subcommands never open +// the database from a second process. +type adminClient struct { + client *http.Client + server string + token string +} + +// resolveAdminToken picks the admin credential: explicit flag, then the +// SAM_ADMIN_TOKEN env, then the token persisted in data-dir by a previous run. +func resolveAdminToken(flagVal, dataDir string) (string, error) { + if flagVal != "" { + return flagVal, nil + } + if env := os.Getenv("SAM_ADMIN_TOKEN"); env != "" { + return env, nil + } + tok, err := standalone.AdminTokenFromDataDir(dataDir) + if err != nil { + return "", fmt.Errorf("no admin token: pass --admin-token, set SAM_ADMIN_TOKEN, or point --data-dir at a sam-one data directory (%v)", err) + } + return tok, nil +} + +func (c *adminClient) do(method, path, contentType string, body []byte) ([]byte, error) { + req, err := http.NewRequest(method, c.server+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, bytes.TrimSpace(respBody)) + } + return respBody, nil +} + +type createdToken struct { + ID string `json:"id"` + Token string `json:"token"` + Role string `json:"role"` + ExpiresAt string `json:"expires_at"` +} + +func (c *adminClient) createToken(role string, ttlHours, maxUsages int, description string) (*createdToken, error) { + payload, err := json.Marshal(map[string]any{ + "role": role, + "ttl_hours": ttlHours, + "max_usages": maxUsages, + "description": description, + }) + if err != nil { + return nil, err + } + body, err := c.do(http.MethodPost, "/admin/bootstrap-tokens", "application/json", payload) + if err != nil { + return nil, err + } + var created createdToken + if err := json.Unmarshal(body, &created); err != nil { + return nil, fmt.Errorf("failed to decode response %q: %w", body, err) + } + return &created, nil +} + +func (c *adminClient) listTokens() ([]storage.BootstrapToken, error) { + body, err := c.do(http.MethodGet, "/admin/bootstrap-tokens", "", nil) + if err != nil { + return nil, err + } + var list []storage.BootstrapToken + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to decode response %q: %w", body, err) + } + return list, nil +} + +func (c *adminClient) banPeer(peerID string) error { + payload, err := proto.Marshal(&api.TokenRevokeRequest{PeerId: peerID}) + if err != nil { + return err + } + _, err = c.do(http.MethodPost, "/admin/revoke", "application/x-protobuf", payload) + return err +} + +// newAdminSubcommands wires the token and admin command trees onto root. +func newAdminSubcommands() []*cobra.Command { + var ( + server string + adminToken string + dataDir string + clientFactory = func() (*adminClient, error) { + tok, err := resolveAdminToken(adminToken, dataDir) + if err != nil { + return nil, err + } + return &adminClient{ + client: &http.Client{Timeout: 10 * time.Second}, + server: server, + token: tok, + }, nil + } + ) + + addSharedFlags := func(cmd *cobra.Command) { + cmd.PersistentFlags().StringVar(&server, "server", "http://127.0.0.1:8080", "Base URL of the running sam-one server") + cmd.PersistentFlags().StringVar(&adminToken, "admin-token", "", "Admin API bearer token (or env SAM_ADMIN_TOKEN, or read from --data-dir)") + cmd.PersistentFlags().StringVar(&dataDir, "data-dir", ".", "sam-one data directory holding the persisted admin token") + } + + var ( + role string + ttlHours int + maxUsages int + description string + ) + tokenCreate := &cobra.Command{ + Use: "create", + Short: "Generate a new scoped bootstrap token", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := clientFactory() + if err != nil { + return err + } + created, err := c.createToken(role, ttlHours, maxUsages, description) + if err != nil { + return err + } + cmd.Printf("Token: %s\n", created.Token) + cmd.Printf("Role: %s\n", created.Role) + cmd.Printf("Expires: %s\n", created.ExpiresAt) + cmd.Println("The plain token is shown only once; store it now.") + return nil + }, + } + tokenCreate.Flags().StringVar(&role, "role", api.RoleNode, "Role bound to the token") + tokenCreate.Flags().IntVar(&ttlHours, "ttl-hours", 24, "Token validity in hours") + tokenCreate.Flags().IntVar(&maxUsages, "max-usages", 1, "How many enrollments the token allows") + tokenCreate.Flags().StringVar(&description, "description", "", "Free-form note stored with the token") + + tokenList := &cobra.Command{ + Use: "list", + Short: "List active bootstrap tokens", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := clientFactory() + if err != nil { + return err + } + list, err := c.listTokens() + if err != nil { + return err + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "ID\tROLE\tUSAGES\tEXPIRES\tDESCRIPTION") + for _, tok := range list { + id := tok.ID + if len(id) > 12 { + id = id[:12] + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%d/%d\t%s\t%s\n", + id, tok.Role, tok.UsagesCount, tok.MaxUsages, + tok.ExpiresAt.Format(time.RFC3339), tok.Description) + } + return tw.Flush() + }, + } + + tokenCmd := &cobra.Command{Use: "token", Short: "Manage bootstrap tokens on a running sam-one"} + addSharedFlags(tokenCmd) + tokenCmd.AddCommand(tokenCreate, tokenList) + + adminBan := &cobra.Command{ + Use: "ban ", + Short: "Ban a peer ID from the mesh", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := clientFactory() + if err != nil { + return err + } + if err := c.banPeer(args[0]); err != nil { + return err + } + cmd.Printf("Peer %s banned\n", args[0]) + return nil + }, + } + + adminCmd := &cobra.Command{Use: "admin", Short: "Administrative actions on a running sam-one"} + addSharedFlags(adminCmd) + adminCmd.AddCommand(adminBan) + + return []*cobra.Command{tokenCmd, adminCmd} +} diff --git a/cmd/sam-one/admin_test.go b/cmd/sam-one/admin_test.go new file mode 100644 index 00000000..7d2b17ab --- /dev/null +++ b/cmd/sam-one/admin_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 Google LLC +// +// 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 main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/storage" + "google.golang.org/protobuf/proto" +) + +func newFakeAdminAPI(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/admin/bootstrap-tokens", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer adm-tok" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + switch r.Method { + case http.MethodPost: + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "abcdef123456", + "token": "sam-bt-fresh", + "role": req["role"], + "expires_at": "2026-12-31T00:00:00Z", + }) + case http.MethodGet: + _ = json.NewEncoder(w).Encode([]storage.BootstrapToken{{ + ID: "abcdef123456", + Role: api.RoleNode, + MaxUsages: 3, + UsagesCount: 1, + Description: "seeded", + ExpiresAt: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC), + }}) + default: + http.Error(w, "method", http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/admin/revoke", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer adm-tok" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + body, _ := io.ReadAll(r.Body) + var req api.TokenRevokeRequest + if err := proto.Unmarshal(body, &req); err != nil || req.PeerId != "12D3KooTestPeer" { + http.Error(w, "wrong peer", http.StatusBadRequest) + return + } + _, _ = w.Write([]byte("ok")) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestAdminClient(t *testing.T) { + ts := newFakeAdminAPI(t) + c := &adminClient{client: ts.Client(), server: ts.URL, token: "adm-tok"} + + created, err := c.createToken(api.RoleNode, 24, 1, "note") + if err != nil { + t.Fatalf("createToken failed: %v", err) + } + if created.Token != "sam-bt-fresh" || created.Role != api.RoleNode { + t.Errorf("unexpected created token: %+v", created) + } + + list, err := c.listTokens() + if err != nil { + t.Fatalf("listTokens failed: %v", err) + } + if len(list) != 1 || list[0].Description != "seeded" || list[0].UsagesCount != 1 { + t.Errorf("unexpected token list: %+v", list) + } + + if err := c.banPeer("12D3KooTestPeer"); err != nil { + t.Fatalf("banPeer failed: %v", err) + } + + bad := &adminClient{client: ts.Client(), server: ts.URL, token: "wrong"} + if _, err := bad.listTokens(); err == nil || !strings.Contains(err.Error(), "401") { + t.Errorf("expected 401 error with wrong token, got %v", err) + } +} + +func TestResolveAdminToken(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "admin-token"), []byte("sam_adm_file\n"), 0o600); err != nil { + t.Fatalf("failed to write token file: %v", err) + } + + t.Setenv("SAM_ADMIN_TOKEN", "") + if got, err := resolveAdminToken("", dir); err != nil || got != "sam_adm_file" { + t.Errorf("data-dir fallback = %q, %v; want sam_adm_file", got, err) + } + + t.Setenv("SAM_ADMIN_TOKEN", "sam_adm_env") + if got, err := resolveAdminToken("", dir); err != nil || got != "sam_adm_env" { + t.Errorf("env precedence = %q, %v; want sam_adm_env", got, err) + } + if got, err := resolveAdminToken("sam_adm_flag", dir); err != nil || got != "sam_adm_flag" { + t.Errorf("flag precedence = %q, %v; want sam_adm_flag", got, err) + } + + t.Setenv("SAM_ADMIN_TOKEN", "") + if _, err := resolveAdminToken("", t.TempDir()); err == nil { + t.Error("expected an error when no admin token source exists") + } +} diff --git a/cmd/sam-one/main.go b/cmd/sam-one/main.go new file mode 100644 index 00000000..2eb1964e --- /dev/null +++ b/cmd/sam-one/main.go @@ -0,0 +1,184 @@ +// Copyright 2026 Google LLC +// +// 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. + +// sam-one is the all-in-one SAM distribution: control plane, libp2p router +// and storage in a single binary serving a single public port. +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + + "github.com/google/sam/api" + "github.com/google/sam/internal/standalone" + golog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" +) + +var logger = golog.Logger("sam-one") + +func main() { + var ( + bindAddress string + port int + externalURL string + p2pListen []string + dataDir string + dbDriver string + dbDSN string + joinToken string + adminToken string + policyFile string + oidcIssuer string + oidcClientID string + allowedAudiencesFlag string + logLevel string + cpTunables standalone.ControlPlaneTunables + routerTunables standalone.RouterTunables + routerAllowLoopback bool + ) + + rootCmd := &cobra.Command{ + Use: "sam-one", + Short: "Sovereign Agent Mesh - all-in-one standalone server", + Run: func(cmd *cobra.Command, args []string) { + if os.Getenv("LOG_FORMAT") == "json" { + _ = os.Setenv("GOLOG_LOG_FMT", "json") + } + golog.SetAllLoggers(golog.LevelInfo) + if logLevel != "" { + if lvl, err := golog.LevelFromString(logLevel); err == nil { + golog.SetAllLoggers(lvl) + } + } + + // Env fallbacks keep single-container platforms (Cloud Run) + // configurable without flags. + if joinToken == "" { + joinToken = os.Getenv("SAM_TOKEN") + } + if adminToken == "" { + adminToken = os.Getenv("SAM_ADMIN_TOKEN") + } + if externalURL == "" { + externalURL = os.Getenv("SAM_EXTERNAL_URL") + } + + var auds []string + for _, aud := range strings.Split(allowedAudiencesFlag, ",") { + if aud = strings.TrimSpace(aud); aud != "" { + auds = append(auds, aud) + } + } + + routerTunables.DisallowLoopback = !routerAllowLoopback + srv, err := standalone.New(standalone.Options{ + BindAddress: net.JoinHostPort(bindAddress, strconv.Itoa(port)), + ExternalURL: externalURL, + P2PListen: p2pListen, + DataDir: dataDir, + DBDriver: dbDriver, + DBDSN: dbDSN, + JoinToken: joinToken, + AdminToken: adminToken, + PolicyFile: policyFile, + OIDCIssuer: oidcIssuer, + OIDCClientID: oidcClientID, + AllowedAudiences: auds, + ControlPlane: cpTunables, + Router: routerTunables, + }) + if err != nil { + logger.Fatalf("Invalid configuration: %v", err) + } + if err := srv.Start(cmd.Context()); err != nil { + logger.Fatalf("Failed to start: %v", err) + } + defer func() { + if err := srv.Close(); err != nil { + logger.Errorf("Shutdown: %v", err) + } + }() + + printBanner(srv, externalURL) + <-cmd.Context().Done() + }, + } + + rootCmd.Flags().StringVar(&bindAddress, "bind-address", "0.0.0.0", "Host/IP to bind the single HTTP/WebSocket listener") + rootCmd.Flags().IntVar(&port, "port", 0, "TCP port of the single listener; 0 picks a free port, published in the startup banner") + rootCmd.Flags().StringVar(&externalURL, "external-url", "", "Public URL reachable by nodes (or env SAM_EXTERNAL_URL)") + rootCmd.Flags().StringSliceVar(&p2pListen, "p2p-listen", nil, "Optional extra native libp2p listen multiaddrs") + rootCmd.Flags().StringVar(&dataDir, "data-dir", ".", "Directory for the database, router key and generated tokens") + rootCmd.Flags().StringVar(&dbDriver, "db-driver", "sqlite", "Database driver (sqlite or postgres)") + rootCmd.Flags().StringVar(&dbDSN, "db-dsn", "", "Database DSN (default /sam.db for sqlite)") + rootCmd.Flags().StringVar(&joinToken, "token", "", "Cluster join token (or env SAM_TOKEN; auto-generated if empty)") + rootCmd.Flags().StringVar(&adminToken, "admin-token", "", "Admin API bearer token (or env SAM_ADMIN_TOKEN; auto-generated if empty)") + rootCmd.Flags().StringVar(&policyFile, "policy-file", "", "Path to a protojson PolicyConfigUpdateRequest seeding the mesh policy on first boot only") + rootCmd.Flags().StringVar(&oidcIssuer, "issuer", "", "Optional external OIDC issuer URL (comma-separated)") + rootCmd.Flags().StringVar(&oidcClientID, "oidc-client-id", "", "OAuth client id advertised via /info (defaults to the first allowed audience)") + rootCmd.Flags().StringVar(&allowedAudiencesFlag, "allowed-audiences", api.DefaultAudience, "Comma-separated list of allowed OIDC audiences") + rootCmd.Flags().StringVar(&logLevel, "log-level", "", "Log level: debug, info, warn, error") + + // Embedded control plane tunables. + rootCmd.Flags().DurationVar(&cpTunables.LeaseDuration, "control-plane-lease-duration", 0, "Router lease validity (0 keeps the component default)") + rootCmd.Flags().DurationVar(&cpTunables.KeyRotationInterval, "control-plane-key-rotation-interval", 0, "Biscuit signing key rotation interval (0 keeps the component default)") + rootCmd.Flags().DurationVar(&cpTunables.KeyGracePeriod, "control-plane-key-grace-period", 0, "How long rotated-out keys stay valid for verification (0 keeps the component default)") + rootCmd.Flags().DurationVar(&cpTunables.BiscuitTTL, "control-plane-biscuit-ttl", 0, "Lifespan minted into issued biscuits (0 keeps the component default)") + rootCmd.Flags().BoolVar(&cpTunables.ManualEnrollment, "control-plane-manual-enrollment", false, "Queue bootstrap enrollments for admin approval instead of auto-approving") + + // Embedded router tunables. + rootCmd.Flags().DurationVar(&routerTunables.KeysSyncInterval, "router-keys-sync-interval", 0, "Biscuit public key refresh interval (0 keeps the component default)") + rootCmd.Flags().DurationVar(&routerTunables.LeaseRenewInterval, "router-lease-renew-interval", 0, "Lease renewal interval (0 keeps the component default)") + rootCmd.Flags().IntVar(&routerTunables.LowWaterMark, "router-low-watermark", 0, "Connection manager low watermark (0 keeps the component default)") + rootCmd.Flags().IntVar(&routerTunables.HighWaterMark, "router-high-watermark", 0, "Connection manager high watermark (0 keeps the component default)") + rootCmd.Flags().IntVar(&routerTunables.ConnsPerSourceIP, "router-conns-per-source-ip", 0, "Per-source-IP connection budget (0 follows the high watermark; proxied peers share source IPs)") + rootCmd.Flags().DurationVar(&routerTunables.DHTProviderAddrTTL, "router-dht-provider-addr-ttl", 0, "DHT provider address TTL (0 keeps the library default)") + rootCmd.Flags().DurationVar(&routerTunables.DHTMaxRecordAge, "router-dht-max-record-age", 0, "DHT record max age (0 keeps the library default)") + rootCmd.Flags().BoolVar(&routerAllowLoopback, "router-allow-loopback", true, "Advertise loopback addresses (disable on public deployments)") + + rootCmd.AddCommand(newAdminSubcommands()...) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := rootCmd.ExecuteContext(ctx); err != nil { + os.Exit(1) + } +} + +func printBanner(srv *standalone.Server, externalURL string) { + base := externalURL + if base == "" { + base = "http://" + srv.Addr() + } + fmt.Println("══════════════════════════════════════════════════════════════════") + fmt.Println("SAM standalone mesh is ready!") + fmt.Println() + fmt.Printf("API URL: %s\n", base) + fmt.Printf("Web Console: %s/console\n", base) + fmt.Printf("Router Peer: %s\n", srv.PeerID()) + fmt.Printf("Admin Token: %s\n", srv.AdminToken()) + fmt.Printf("Join Token: %s\n", srv.JoinToken()) + fmt.Println() + fmt.Println("To enroll a node:") + fmt.Printf(" sam-node join %s --bootstrap-token %s\n", base, srv.JoinToken()) + fmt.Println("══════════════════════════════════════════════════════════════════") +} diff --git a/cmd/sam-router/main.go b/cmd/sam-router/main.go index 80a678e3..acb4d4df 100644 --- a/cmd/sam-router/main.go +++ b/cmd/sam-router/main.go @@ -38,6 +38,7 @@ var ( jwtPath string keysPath string allowLoopback bool + connsPerSourceIP int logLevel string dhtProviderAddrTTL time.Duration dhtMaxRecordAge time.Duration @@ -76,6 +77,7 @@ func main() { JWTPath: jwtPath, KeysDBPath: keysPath, AllowLoopback: allowLoopback, + ConnsPerSourceIP: connsPerSourceIP, DHTProviderAddrTTL: dhtProviderAddrTTL, DHTMaxRecordAge: dhtMaxRecordAge, LowWaterMark: lowWaterMark, @@ -111,6 +113,7 @@ func main() { rootCmd.Flags().StringVar(&jwtPath, "jwt-path", "", "Path to file containing OIDC JWT token") rootCmd.Flags().StringVar(&keysPath, "keys-path", "router.key", "Path to save/load persistent private key") rootCmd.Flags().BoolVar(&allowLoopback, "allow-loopback", false, "Allow loopback and link-local addresses for discovery") + rootCmd.Flags().IntVar(&connsPerSourceIP, "conns-per-source-ip", 0, "Max inbound connections per source IP (0 keeps libp2p's default of 8); raise behind TLS-terminating proxies or NAT where many peers share source IPs") rootCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)") rootCmd.Flags().DurationVar(&dhtProviderAddrTTL, "dht-provider-addr-ttl", 0, "Time-To-Live for DHT provider addresses (0s uses library default)") rootCmd.Flags().DurationVar(&dhtMaxRecordAge, "dht-max-record-age", 0, "Maximum age for DHT records (0s uses library default)") diff --git a/internal/console/embed.go b/internal/console/embed.go new file mode 100644 index 00000000..92320d85 --- /dev/null +++ b/internal/console/embed.go @@ -0,0 +1,35 @@ +// Copyright 2026 Google LLC +// +// 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 console + +import ( + "embed" + "io/fs" +) + +//go:embed all:public +var embeddedAssets embed.FS + +// EmbeddedAssets returns the compiled-in console frontend, rooted at the +// asset directory so it can be used directly as Config.StaticFS. +func EmbeddedAssets() fs.FS { + sub, err := fs.Sub(embeddedAssets, "public") + if err != nil { + // The subtree name is a compile-time constant matching the embed + // directive; failure here is unreachable. + panic(err) + } + return sub +} diff --git a/cmd/sam-console/public/app.js b/internal/console/public/app.js similarity index 100% rename from cmd/sam-console/public/app.js rename to internal/console/public/app.js diff --git a/cmd/sam-console/public/index.html b/internal/console/public/index.html similarity index 100% rename from cmd/sam-console/public/index.html rename to internal/console/public/index.html diff --git a/cmd/sam-console/public/style.css b/internal/console/public/style.css similarity index 100% rename from cmd/sam-console/public/style.css rename to internal/console/public/style.css diff --git a/cmd/sam-console/public/vendor/js-yaml.LICENSE b/internal/console/public/vendor/js-yaml.LICENSE similarity index 100% rename from cmd/sam-console/public/vendor/js-yaml.LICENSE rename to internal/console/public/vendor/js-yaml.LICENSE diff --git a/cmd/sam-console/public/vendor/js-yaml.min.js b/internal/console/public/vendor/js-yaml.min.js similarity index 100% rename from cmd/sam-console/public/vendor/js-yaml.min.js rename to internal/console/public/vendor/js-yaml.min.js diff --git a/internal/console/server.go b/internal/console/server.go index e601938f..b1d5030a 100644 --- a/internal/console/server.go +++ b/internal/console/server.go @@ -22,12 +22,12 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "net/http/httputil" "net/url" "os" "path" - "path/filepath" "strings" "time" @@ -40,8 +40,12 @@ import ( type Config struct { ControlPlaneURL string AdminToken string - StaticDir string - BasePath string + // StaticDir serves frontend assets from disk (live-editable, used by the + // ui-dev workflow). It takes precedence over StaticFS. + StaticDir string + // StaticFS serves frontend assets from an fs.FS, e.g. EmbeddedAssets(). + StaticFS fs.FS + BasePath string // ExternalURL is the origin browsers reach this console on, e.g. // "https://console.example". When set it decides the OIDC redirect_uri and @@ -107,6 +111,16 @@ func NewServer(cfg Config) (*Server, error) { return nil, fmt.Errorf("ControlPlaneURL is required") } + var assets fs.FS + switch { + case cfg.StaticDir != "": + assets = os.DirFS(cfg.StaticDir) + case cfg.StaticFS != nil: + assets = cfg.StaticFS + default: + return nil, fmt.Errorf("static assets are required: set StaticDir or StaticFS") + } + controlPlaneURL, err := url.Parse(cfg.ControlPlaneURL) if err != nil { return nil, fmt.Errorf("invalid ControlPlaneURL: %w", err) @@ -192,16 +206,18 @@ func NewServer(cfg Config) (*Server, error) { routes.Handle("/api/", http.StripPrefix("/api", proxy)) // Serve static files - fs := http.FileServer(http.Dir(s.cfg.StaticDir)) + fileServer := http.FileServerFS(assets) routes.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Basic check if file exists - path := filepath.Join(s.cfg.StaticDir, r.URL.Path) - if _, err := os.Stat(path); os.IsNotExist(err) && r.URL.Path != "/" { + name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if name == "" { + name = "." + } + if _, err := fs.Stat(assets, name); err != nil && r.URL.Path != "/" { // SPA fallback: return index.html for unknown paths (useful for flutter/react router) - http.ServeFile(w, r, filepath.Join(s.cfg.StaticDir, "index.html")) + http.ServeFileFS(w, r, assets, "index.html") return } - fs.ServeHTTP(w, r) + fileServer.ServeHTTP(w, r) }) // OIDC login endpoints diff --git a/internal/console/server_test.go b/internal/console/server_test.go index 97c67000..0c42211f 100644 --- a/internal/console/server_test.go +++ b/internal/console/server_test.go @@ -19,9 +19,12 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "io" "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync/atomic" "testing" @@ -32,6 +35,101 @@ import ( "google.golang.org/protobuf/proto" ) +// startNoOIDCControlPlaneStub serves a /info with no issuer, the +// bootstrap-token-only mode sam-one runs the console in. +func startNoOIDCControlPlaneStub(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { + data, err := proto.Marshal(&api.ControlPlaneInfoResponse{}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-protobuf") + _, _ = w.Write(data) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestNewServerEmbeddedAssets pins the compiled-in frontend path used by +// sam-one and the cmd default: StaticFS serving, SPA fallback, StaticDir +// precedence for live-editing, and the fail-fast when no assets are set. +func TestNewServerEmbeddedAssets(t *testing.T) { + cpStub := startNoOIDCControlPlaneStub(t) + + t.Run("no assets configured", func(t *testing.T) { + if _, err := NewServer(Config{ControlPlaneURL: cpStub.URL, AdminToken: "x"}); err == nil { + t.Fatal("NewServer without StaticDir or StaticFS should fail") + } + }) + + srv, err := NewServer(Config{ + ControlPlaneURL: cpStub.URL, + AdminToken: "x", + StaticFS: EmbeddedAssets(), + }) + if err != nil { + t.Fatalf("failed to create server with embedded assets: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + get := func(path string) (int, string) { + t.Helper() + resp, err := http.Get(ts.URL + path) + if err != nil { + t.Fatalf("GET %s failed: %v", path, err) + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatalf("failed to read %s body: %v", path, err) + } + return resp.StatusCode, string(body) + } + + if code, body := get("/"); code != http.StatusOK || !strings.Contains(body, " 0. Raise it when the listener sits behind a + // TLS-terminating proxy or NAT, where many peers share a few source IPs. + ConnsPerSourceIP int } // Default sets default values for options. diff --git a/internal/router/router.go b/internal/router/router.go index d2a53be6..ed28cae0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -24,6 +24,7 @@ import ( "io" "net" "net/http" + "net/netip" "net/url" "os" "path/filepath" @@ -45,9 +46,16 @@ import ( "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" + rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager" "github.com/libp2p/go-libp2p/p2p/net/connmgr" "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay" libp2ptls "github.com/libp2p/go-libp2p/p2p/security/tls" + libp2pquic "github.com/libp2p/go-libp2p/p2p/transport/quic" + "github.com/libp2p/go-libp2p/p2p/transport/tcp" + libp2pwebrtc "github.com/libp2p/go-libp2p/p2p/transport/webrtc" + ws "github.com/libp2p/go-libp2p/p2p/transport/websocket" + "github.com/libp2p/go-libp2p/p2p/transport/webtransport" + xrate "github.com/libp2p/go-libp2p/x/rate" "github.com/libp2p/go-msgio" "github.com/multiformats/go-multiaddr" madns "github.com/multiformats/go-multiaddr-dns" @@ -136,6 +144,79 @@ func NewRouter(ctx context.Context, config Options) (*Router, error) { }, nil } +// transportOptions mirrors libp2p.DefaultTransports, attaching the HTTP +// fallback handler to the WebSocket transport in single-port mode. The +// explicit list is required because combining DefaultTransports with an +// extra Transport(websocket.New, ...) registers the WS transport twice and +// fails host construction. +func (r *Router) transportOptions() libp2p.Option { + if r.config.HTTPFallbackHandler == nil { + return libp2p.DefaultTransports + } + return libp2p.ChainOptions( + libp2p.Transport(tcp.NewTCPTransport), + libp2p.Transport(libp2pquic.NewTransport), + libp2p.Transport(ws.New, ws.WithHTTPHandler(r.config.HTTPFallbackHandler)), + libp2p.Transport(libp2pwebtransport.New), + libp2p.Transport(libp2pwebrtc.New), + ) +} + +// perIPConnResourceManager mirrors libp2p's default resource manager with the +// per-source-IP inbound connection cap, the per-subnet connection rate limit +// and the system/transient scopes scaled to carry limit connections; see +// Options.ConnsPerSourceIP. All three matter behind a proxy or NAT: every +// peer shares a few source IPs, so the default per-IP cap (8), the default +// per-IP rate (0.2 conns/s, burst 16) and the small transient scope each +// take down the whole listener under normal reconnect churn. +func perIPConnResourceManager(limit int) (network.ResourceManager, error) { + limits := rcmgr.DefaultLimits + libp2p.SetDefaultServiceLimits(&limits) + scaled := limits.AutoScale() + + overrides := rcmgr.PartialLimitConfig{ + System: rcmgr.ResourceLimits{ + Conns: rcmgr.LimitVal(2 * limit), + ConnsInbound: rcmgr.LimitVal(limit), + FD: rcmgr.LimitVal(2 * limit), + }, + Transient: rcmgr.ResourceLimits{ + Conns: rcmgr.LimitVal(limit), + ConnsInbound: rcmgr.LimitVal(limit), + FD: rcmgr.LimitVal(limit), + }, + } + + // Same shape as the rcmgr default limiter (loopback exempt, no global + // cap), with the per-subnet budget scaled by limit relative to the + // default per-IP cap of 8. + scale := float64(limit) / 8 + connRateLimiter := &xrate.Limiter{ + NetworkPrefixLimits: []xrate.PrefixLimit{ + {Prefix: netip.MustParsePrefix("127.0.0.0/8"), Limit: xrate.Limit{}}, + {Prefix: netip.MustParsePrefix("::1/128"), Limit: xrate.Limit{}}, + }, + SubnetRateLimiter: xrate.SubnetLimiter{ + IPv4SubnetLimits: []xrate.SubnetLimit{ + {PrefixLength: 32, Limit: xrate.Limit{RPS: 0.2 * scale, Burst: 2 * limit}}, + }, + IPv6SubnetLimits: []xrate.SubnetLimit{ + {PrefixLength: 56, Limit: xrate.Limit{RPS: 0.2 * scale, Burst: 2 * limit}}, + }, + GracePeriod: time.Minute, + }, + } + + return rcmgr.NewResourceManager( + rcmgr.NewFixedLimiter(overrides.Build(scaled)), + rcmgr.WithLimitPerSubnet( + []rcmgr.ConnLimitPerSubnet{{PrefixLength: 32, ConnCount: limit}}, + []rcmgr.ConnLimitPerSubnet{{PrefixLength: 56, ConnCount: limit}}, + ), + rcmgr.WithConnRateLimiters(connRateLimiter), + ) +} + // Start performs enrollment, syncs keys, launches libp2p host, and starts tasks. func (r *Router) Start() error { // 1. Load or Generate persistent identity key @@ -179,7 +260,7 @@ func (r *Router) Start() error { p2pOpts := []libp2p.Option{ libp2p.Identity(r.privKey), - libp2p.DefaultTransports, + r.transportOptions(), libp2p.ListenAddrStrings(r.config.ListenAddrs...), libp2p.Security(libp2ptls.ID, libp2ptls.New), libp2p.ConnectionManager(cm), @@ -199,6 +280,14 @@ func (r *Router) Start() error { }), } + if r.config.ConnsPerSourceIP > 0 { + mgr, err := perIPConnResourceManager(r.config.ConnsPerSourceIP) + if err != nil { + return fmt.Errorf("failed to create resource manager: %w", err) + } + p2pOpts = append(p2pOpts, libp2p.ResourceManager(mgr)) + } + hostNode, err := libp2p.New(p2pOpts...) if err != nil { return err diff --git a/internal/standalone/standalone.go b/internal/standalone/standalone.go new file mode 100644 index 00000000..ddd13a4e --- /dev/null +++ b/internal/standalone/standalone.go @@ -0,0 +1,574 @@ +// Copyright 2026 Google LLC +// +// 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 standalone wires the control plane, the libp2p router and the +// shared SQL store into one process serving a single public port: the +// router's WebSocket listener carries libp2p upgrades while every other HTTP +// request falls through to the control-plane mux. The embedded router keeps +// its stock control-plane client, pointed at a loopback-only listener, so no +// component grows in-process shortcuts. +package standalone + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/console" + "github.com/google/sam/internal/controlplane" + "github.com/google/sam/internal/router" + "github.com/google/sam/internal/storage" + golog "github.com/ipfs/go-log/v2" + "github.com/multiformats/go-multiaddr" + "google.golang.org/protobuf/encoding/protojson" +) + +var logger = golog.Logger("sam-one") + +const ( + joinTokenPrefix = "sam_tok_" + adminTokenPrefix = "sam_adm_" + + joinTokenFile = "join-token" + adminTokenFile = "admin-token" + routerKeyFile = "router.key" + + consoleBasePath = "/console" + + // joinTokenTTL bounds the auto-generated join token; it is a development + // credential, not a production secret rotation scheme. + joinTokenTTL = 10 * 365 * 24 * time.Hour + joinTokenMaxUsages = 1 << 30 + + // routerTokenTTL bounds the per-boot single-use token the embedded router + // enrolls with; it never leaves the process. + routerTokenTTL = time.Hour +) + +// Options configures the standalone all-in-one server. +type Options struct { + // BindAddress is the host:port of the single public listener. The host + // must be empty or an IP literal. + BindAddress string + // ExternalURL is the public URL nodes reach this server on; it is turned + // into the ws/wss multiaddr the router advertises. Optional. + ExternalURL string + // P2PListen holds optional extra native libp2p listen multiaddrs. + P2PListen []string + // DataDir stores the SQLite database, the router identity key and the + // generated token files. + DataDir string + // DBDriver selects "sqlite" (default) or "postgres". + DBDriver string + // DBDSN is the database connection string; defaults to + // /sam.db for sqlite. + DBDSN string + // JoinToken is the cluster join token; auto-generated and persisted in + // DataDir when empty. + JoinToken string + // AdminToken protects the admin REST API; auto-generated and persisted in + // DataDir when empty. + AdminToken string + // PolicyFile optionally seeds the mesh policy on first boot from a + // protojson PolicyConfigUpdateRequest payload. + PolicyFile string + // OIDCIssuer optionally enables full OIDC enrollment. + OIDCIssuer string + AllowedAudiences []string + // OIDCClientID is the OAuth client id advertised via /info. + OIDCClientID string + + // ControlPlane and Router forward operator tunables to the embedded + // components; zero values keep each component's defaults. + ControlPlane ControlPlaneTunables + Router RouterTunables +} + +// ControlPlaneTunables are the embedded control plane's operator knobs. +type ControlPlaneTunables struct { + // LeaseDuration bounds how long a router lease stays valid. + LeaseDuration time.Duration + // KeyRotationInterval is how often the biscuit signing key rotates. + KeyRotationInterval time.Duration + // KeyGracePeriod keeps rotated-out keys valid for verification. + KeyGracePeriod time.Duration + // BiscuitTTL is the lifespan minted into issued biscuits. + BiscuitTTL time.Duration + // ManualEnrollment queues bootstrap enrollments for admin approval + // instead of auto-approving them. + ManualEnrollment bool +} + +// RouterTunables are the embedded router's operator knobs. +type RouterTunables struct { + // KeysSyncInterval is how often biscuit public keys are refreshed. + KeysSyncInterval time.Duration + // LeaseRenewInterval is how often the router renews its lease. + LeaseRenewInterval time.Duration + // LowWaterMark / HighWaterMark bound the connection manager. + LowWaterMark int + HighWaterMark int + // ConnsPerSourceIP scales libp2p's per-source-IP budgets; defaults to + // the connection manager high watermark (proxied deployments share + // source IPs, so the global cap should be what binds). + ConnsPerSourceIP int + // DHTProviderAddrTTL / DHTMaxRecordAge tune DHT record lifetimes. + DHTProviderAddrTTL time.Duration + DHTMaxRecordAge time.Duration + // DisallowLoopback stops advertising loopback addresses (useful on + // public deployments; the default keeps local development working). + DisallowLoopback bool +} + +// Default fills unset options with development-friendly values. +func (o *Options) Default() { + if o.BindAddress == "" { + // Port 0 picks a free port; the caller publishes it (banner/Addr), + // like the generated tokens. + o.BindAddress = "0.0.0.0:0" + } + if o.DataDir == "" { + o.DataDir = "." + } + if o.DBDriver == "" { + o.DBDriver = "sqlite" + } + if o.DBDSN == "" && o.DBDriver == "sqlite" { + o.DBDSN = filepath.Join(o.DataDir, "sam.db") + } + if len(o.AllowedAudiences) == 0 { + o.AllowedAudiences = []string{api.DefaultAudience} + } + if o.Router.HighWaterMark == 0 { + o.Router.HighWaterMark = router.DefaultHighWaterMark + } + if o.Router.ConnsPerSourceIP == 0 { + o.Router.ConnsPerSourceIP = o.Router.HighWaterMark + } +} + +// Validate rejects option combinations Start could not honor. +func (o *Options) Validate() error { + if _, err := wsListenMultiaddr(o.BindAddress); err != nil { + return fmt.Errorf("invalid bind address %q: %w", o.BindAddress, err) + } + if o.ExternalURL != "" { + if _, err := externalMultiaddr(o.ExternalURL); err != nil { + return fmt.Errorf("invalid external URL %q: %w", o.ExternalURL, err) + } + } + if o.DBDSN == "" { + return fmt.Errorf("a database DSN is required for driver %q", o.DBDriver) + } + for _, a := range o.P2PListen { + if _, err := multiaddr.NewMultiaddr(a); err != nil { + return fmt.Errorf("invalid p2p listen multiaddr %q: %w", a, err) + } + } + return nil +} + +// Server is the running all-in-one instance. +type Server struct { + opts Options + + store storage.Store + cp *controlplane.Server + router *router.Router + adminToken string + joinToken string + publicAddr string +} + +// New validates the options and prepares a standalone server. +func New(opts Options) (*Server, error) { + opts.Default() + if err := opts.Validate(); err != nil { + return nil, err + } + return &Server{opts: opts}, nil +} + +// Start boots the store, the control plane on a loopback-only listener, and +// the router owning the single public port. It returns once the mesh accepts +// enrollments. +func (s *Server) Start(ctx context.Context) error { + if err := os.MkdirAll(s.opts.DataDir, 0o755); err != nil { + return fmt.Errorf("failed to create data dir: %w", err) + } + + store, err := storage.NewSQLStore(s.opts.DBDriver, s.opts.DBDSN) + if err != nil { + return fmt.Errorf("failed to open store: %w", err) + } + s.store = store + + s.adminToken = s.opts.AdminToken + if s.adminToken == "" { + if s.adminToken, err = loadOrCreateTokenFile(filepath.Join(s.opts.DataDir, adminTokenFile), adminTokenPrefix); err != nil { + return fmt.Errorf("failed to provision admin token: %w", err) + } + } + + // Loopback-only control plane listener: the embedded router (and any + // other in-process client) bootstraps against it before the public port + // exists. Public traffic reaches the same handlers via the router's + // HTTP fallback mux. + cp, err := controlplane.NewServer(controlplane.Options{ + ListenAddr: "127.0.0.1:0", + DriverName: s.opts.DBDriver, + DataSourceName: s.opts.DBDSN, + OIDCIssuer: s.opts.OIDCIssuer, + OIDCClientID: s.opts.OIDCClientID, + AllowedAudiences: s.opts.AllowedAudiences, + LeaseDuration: s.opts.ControlPlane.LeaseDuration, + KeyRotationInterval: s.opts.ControlPlane.KeyRotationInterval, + KeyGracePeriod: s.opts.ControlPlane.KeyGracePeriod, + BiscuitTTL: s.opts.ControlPlane.BiscuitTTL, + BiscuitTimeout: 10 * time.Second, + AdminToken: s.adminToken, + AutoApproveEnrollment: !s.opts.ControlPlane.ManualEnrollment, + }, store) + if err != nil { + return fmt.Errorf("failed to create control plane: %w", err) + } + if err := cp.Start(); err != nil { + return fmt.Errorf("failed to start control plane: %w", err) + } + s.cp = cp + + if err := s.seedPolicyOnFirstBoot(ctx); err != nil { + return err + } + + s.joinToken = s.opts.JoinToken + if s.joinToken == "" { + if s.joinToken, err = loadOrCreateTokenFile(filepath.Join(s.opts.DataDir, joinTokenFile), joinTokenPrefix); err != nil { + return fmt.Errorf("failed to provision join token: %w", err) + } + } + if err := s.ensureBootstrapToken(ctx, s.joinToken, api.RoleNode, joinTokenMaxUsages, joinTokenTTL, "sam-one join token"); err != nil { + return fmt.Errorf("failed to register join token: %w", err) + } + + // Per-boot single-use credential for the embedded router's stock + // enrollment flow; never persisted or displayed. + routerToken, err := generateToken("sam_rtr_") + if err != nil { + return err + } + if err := s.ensureBootstrapToken(ctx, routerToken, api.RoleRouter, 1, routerTokenTTL, "sam-one embedded router token"); err != nil { + return fmt.Errorf("failed to register router token: %w", err) + } + + mux := http.NewServeMux() + cp.RegisterRoutes(mux) + + // The console proxies /console/api/* to the loopback control plane and + // serves the embedded frontend; it queries /info at construction, which is + // already live on the loopback listener. + consoleSrv, err := console.NewServer(console.Config{ + ControlPlaneURL: "http://" + cp.Addr(), + AdminToken: s.adminToken, + StaticFS: console.EmbeddedAssets(), + BasePath: consoleBasePath, + ExternalURL: s.opts.ExternalURL, + }) + if err != nil { + return fmt.Errorf("failed to create console: %w", err) + } + mux.Handle(consoleBasePath+"/", consoleSrv.Handler()) + + wsAddr, err := wsListenMultiaddr(s.opts.BindAddress) + if err != nil { + return err + } + var externalAddrs []string + if s.opts.ExternalURL != "" { + ext, err := externalMultiaddr(s.opts.ExternalURL) + if err != nil { + return err + } + externalAddrs = []string{ext} + } + + rtr, err := router.NewRouter(ctx, router.Options{ + ControlPlaneURL: "http://" + cp.Addr(), + ListenAddrs: append([]string{wsAddr}, s.opts.P2PListen...), + ExternalAddrs: externalAddrs, + AllowLoopback: !s.opts.Router.DisallowLoopback, + KeysDBPath: filepath.Join(s.opts.DataDir, routerKeyFile), + BootstrapToken: routerToken, + KeysSyncInterval: s.opts.Router.KeysSyncInterval, + LeaseRenewInterval: s.opts.Router.LeaseRenewInterval, + LowWaterMark: s.opts.Router.LowWaterMark, + HighWaterMark: s.opts.Router.HighWaterMark, + DHTProviderAddrTTL: s.opts.Router.DHTProviderAddrTTL, + DHTMaxRecordAge: s.opts.Router.DHTMaxRecordAge, + // Single-port deployments typically sit behind a TLS-terminating + // proxy (Cloud Run, L7 LBs) or NAT where every peer shares a few + // source IPs; libp2p's default 8-conns-per-IP cap would throttle + // the whole listener. + ConnsPerSourceIP: s.opts.Router.ConnsPerSourceIP, + HTTPFallbackHandler: mux, + }) + if err != nil { + return fmt.Errorf("failed to create router: %w", err) + } + if err := rtr.Start(); err != nil { + return fmt.Errorf("failed to start router: %w", err) + } + s.router = rtr + + if s.publicAddr, err = s.resolvePublicAddr(); err != nil { + _ = rtr.Close() + return err + } + return nil +} + +// Close shuts down the router, control plane and store. +func (s *Server) Close() error { + var errs []string + if s.router != nil { + if err := s.router.Close(); err != nil { + errs = append(errs, err.Error()) + } + } + if s.cp != nil { + if err := s.cp.Close(); err != nil { + errs = append(errs, err.Error()) + } + } + if s.store != nil { + if err := s.store.Close(); err != nil { + errs = append(errs, err.Error()) + } + } + if len(errs) > 0 { + return fmt.Errorf("standalone shutdown: %s", strings.Join(errs, "; ")) + } + return nil +} + +// Addr returns the public host:port actually bound (useful with port 0). +func (s *Server) Addr() string { return s.publicAddr } + +// AdminToken returns the resolved admin API token. +func (s *Server) AdminToken() string { return s.adminToken } + +// JoinToken returns the resolved cluster join token. +func (s *Server) JoinToken() string { return s.joinToken } + +// PeerID returns the embedded router's peer ID. +func (s *Server) PeerID() string { return s.router.Host.ID().String() } + +// seedPolicyOnFirstBoot installs the mesh policy only when none exists; the +// database stays authoritative afterwards. +func (s *Server) seedPolicyOnFirstBoot(ctx context.Context) error { + roles, _, err := s.store.GetMeshPolicy(ctx) + if err != nil && err != storage.ErrNotFound { + return fmt.Errorf("failed to inspect mesh policy: %w", err) + } + if len(roles) > 0 { + if s.opts.PolicyFile != "" { + logger.Warnf("Mesh policy already present; ignoring --policy-file %s (edit via POST /policies)", s.opts.PolicyFile) + } + return nil + } + + var seed api.PolicyConfigUpdateRequest + if s.opts.PolicyFile != "" { + data, err := os.ReadFile(s.opts.PolicyFile) + if err != nil { + return fmt.Errorf("failed to read policy file: %w", err) + } + if err := protojson.Unmarshal(data, &seed); err != nil { + return fmt.Errorf("failed to parse policy file %s (expects protojson PolicyConfigUpdateRequest): %w", s.opts.PolicyFile, err) + } + logger.Infof("Seeding mesh policy from %s", s.opts.PolicyFile) + } else { + seed.Roles = defaultDevPolicyRoles() + logger.Warn("Seeding OPEN development mesh policy (enrolled nodes may declare any label and register any service); provide --policy-file to restrict") + } + if err := s.store.SaveMeshPolicy(ctx, seed.Roles, seed.Bindings); err != nil { + return fmt.Errorf("failed to seed mesh policy: %w", err) + } + return nil +} + +// defaultDevPolicyRoles mirrors the Helm bootstrap job's role set with the +// node role opened up for zero-config development use. +func defaultDevPolicyRoles() []*api.PolicyRole { + return []*api.PolicyRole{ + {Name: "sam-admin", AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, + {Name: api.RoleRouter, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, + {Name: api.RoleNode, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}, AllowedLabels: []string{"*"}}, + } +} + +// ensureBootstrapToken stores the hashed token if absent (idempotent). +func (s *Server) ensureBootstrapToken(ctx context.Context, plaintext, role string, maxUsages int, ttl time.Duration, description string) error { + id := fmt.Sprintf("%x", sha256.Sum256([]byte(plaintext))) + now := time.Now() + return s.store.SaveBootstrapToken(ctx, &storage.BootstrapToken{ + ID: id, + TokenHash: id, + Role: role, + MaxUsages: maxUsages, + Description: description, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + }) +} + +// resolvePublicAddr recovers the actually-bound port from the router's WS +// listen addr and pairs it with the configured bind host. +func (s *Server) resolvePublicAddr() (string, error) { + host, _, err := net.SplitHostPort(s.opts.BindAddress) + if err != nil { + return "", err + } + if host == "" { + host = "0.0.0.0" + } + for _, a := range s.router.Host.Network().ListenAddresses() { + if _, err := a.ValueForProtocol(multiaddr.P_WS); err != nil { + continue + } + port, err := a.ValueForProtocol(multiaddr.P_TCP) + if err != nil { + continue + } + return net.JoinHostPort(host, port), nil + } + return "", fmt.Errorf("router reports no WebSocket listen address") +} + +func generateToken(prefix string) (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate token: %w", err) + } + return prefix + hex.EncodeToString(b), nil +} + +// AdminTokenFromDataDir reads the admin token a previous run persisted in +// dataDir, so CLI subcommands can authenticate without re-supplying it. +func AdminTokenFromDataDir(dataDir string) (string, error) { + path := filepath.Join(dataDir, adminTokenFile) + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + tok := strings.TrimSpace(string(data)) + if tok == "" { + return "", fmt.Errorf("token file %s is empty", path) + } + return tok, nil +} + +// loadOrCreateTokenFile reuses the token persisted at path, generating and +// saving a fresh one (0600) on first boot. +func loadOrCreateTokenFile(path, prefix string) (string, error) { + data, err := os.ReadFile(path) + if err == nil { + tok := strings.TrimSpace(string(data)) + if tok == "" { + return "", fmt.Errorf("token file %s is empty", path) + } + return tok, nil + } + if !os.IsNotExist(err) { + return "", err + } + tok, err := generateToken(prefix) + if err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(tok+"\n"), 0o600); err != nil { + return "", fmt.Errorf("failed to persist token: %w", err) + } + return tok, nil +} + +// wsListenMultiaddr turns a host:port bind address into a WebSocket listen +// multiaddr. +func wsListenMultiaddr(bind string) (string, error) { + host, port, err := net.SplitHostPort(bind) + if err != nil { + return "", err + } + if host == "" { + host = "0.0.0.0" + } + ip := net.ParseIP(host) + if ip == nil { + return "", fmt.Errorf("bind host %q must be an IP literal", host) + } + if ip.To4() != nil { + return fmt.Sprintf("/ip4/%s/tcp/%s/ws", ip, port), nil + } + return fmt.Sprintf("/ip6/%s/tcp/%s/ws", ip, port), nil +} + +// externalMultiaddr turns a public http(s) URL into the ws/wss multiaddr the +// router advertises (http -> /ws, https -> /wss with TLS terminated at the +// platform edge). +func externalMultiaddr(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + var wsProto, defaultPort string + switch u.Scheme { + case "http": + wsProto, defaultPort = "ws", "80" + case "https": + wsProto, defaultPort = "wss", "443" + default: + return "", fmt.Errorf("scheme %q not supported (use http or https)", u.Scheme) + } + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("URL has no host") + } + port := u.Port() + if port == "" { + port = defaultPort + } + hostProto := "dns4" + if ip := net.ParseIP(host); ip != nil { + if ip.To4() != nil { + hostProto = "ip4" + } else { + hostProto = "ip6" + } + } + addr := fmt.Sprintf("/%s/%s/tcp/%s/%s", hostProto, host, port, wsProto) + if _, err := multiaddr.NewMultiaddr(addr); err != nil { + return "", err + } + return addr, nil +} diff --git a/internal/standalone/standalone_test.go b/internal/standalone/standalone_test.go new file mode 100644 index 00000000..3229b155 --- /dev/null +++ b/internal/standalone/standalone_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 Google LLC +// +// 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 standalone + +import "testing" + +func TestWsListenMultiaddr(t *testing.T) { + cases := []struct { + bind string + want string + wantErr bool + }{ + {bind: "0.0.0.0:8080", want: "/ip4/0.0.0.0/tcp/8080/ws"}, + {bind: "127.0.0.1:0", want: "/ip4/127.0.0.1/tcp/0/ws"}, + {bind: ":9090", want: "/ip4/0.0.0.0/tcp/9090/ws"}, + {bind: "[::1]:8080", want: "/ip6/::1/tcp/8080/ws"}, + {bind: "example.com:8080", wantErr: true}, + {bind: "8080", wantErr: true}, + } + for _, tc := range cases { + got, err := wsListenMultiaddr(tc.bind) + if tc.wantErr { + if err == nil { + t.Errorf("wsListenMultiaddr(%q) = %q, want error", tc.bind, got) + } + continue + } + if err != nil { + t.Errorf("wsListenMultiaddr(%q) failed: %v", tc.bind, err) + continue + } + if got != tc.want { + t.Errorf("wsListenMultiaddr(%q) = %q, want %q", tc.bind, got, tc.want) + } + } +} + +func TestExternalMultiaddr(t *testing.T) { + cases := []struct { + url string + want string + wantErr bool + }{ + {url: "https://my-sam.a.run.app", want: "/dns4/my-sam.a.run.app/tcp/443/wss"}, + {url: "http://192-168-1-50.nip.io:8080", want: "/dns4/192-168-1-50.nip.io/tcp/8080/ws"}, + {url: "http://192.168.1.50:8080", want: "/ip4/192.168.1.50/tcp/8080/ws"}, + {url: "https://mesh.example:8443", want: "/dns4/mesh.example/tcp/8443/wss"}, + {url: "ftp://mesh.example", wantErr: true}, + {url: "http://", wantErr: true}, + } + for _, tc := range cases { + got, err := externalMultiaddr(tc.url) + if tc.wantErr { + if err == nil { + t.Errorf("externalMultiaddr(%q) = %q, want error", tc.url, got) + } + continue + } + if err != nil { + t.Errorf("externalMultiaddr(%q) failed: %v", tc.url, err) + continue + } + if got != tc.want { + t.Errorf("externalMultiaddr(%q) = %q, want %q", tc.url, got, tc.want) + } + } +} diff --git a/mobile/sam-node-ffi/ffi/ffi.go b/mobile/sam-node-ffi/ffi/ffi.go index 482f2ad7..6d1e3969 100644 --- a/mobile/sam-node-ffi/ffi/ffi.go +++ b/mobile/sam-node-ffi/ffi/ffi.go @@ -107,7 +107,9 @@ func StartNode(configJSON string) error { if h, err := store.LoadControlPlaneURL(); err == nil && h != "" { displayControlPlane = h } else { - displayControlPlane = "https://bananas.sam-mesh.dev" + _ = store.Close() + activeStore = nil + return fmt.Errorf("no control plane configured: set ControlPlaneURL to the mesh this node should join") } } bindAddr := config.BindAddr diff --git a/site/content/docs/development/testing.md b/site/content/docs/development/testing.md index 9331f4e1..ad59f968 100644 --- a/site/content/docs/development/testing.md +++ b/site/content/docs/development/testing.md @@ -96,7 +96,7 @@ leaves the console running with its URL and admin token printed. Only the OIDC issuer is a stand-in: it serves a discovery document but cannot sign tokens, which is why enrollment uses bootstrap tokens rather than a JWT. -The console serves its static assets from `cmd/sam-console/public`, so edits to +The console serves its static assets from `internal/console/public`, so edits to the HTML, CSS or JS need only a browser refresh; only Go changes need a rebuild. ## Troubleshooting diff --git a/site/content/docs/user/cloud-run-deployment.md b/site/content/docs/user/cloud-run-deployment.md new file mode 100644 index 00000000..b34e8cec --- /dev/null +++ b/site/content/docs/user/cloud-run-deployment.md @@ -0,0 +1,197 @@ +--- +title: "Cloud Run Deployment" +linkTitle: "Cloud Run Deployment" +weight: 6 +--- + +# Deploying a SAM Mesh on Cloud Run + +`sam-one` is the all-in-one SAM distribution: control plane, libp2p router, +web console and storage in a single binary serving a single public port. +Because everything — REST API, console and mesh (WebSocket) traffic — is +multiplexed on one HTTP port, it runs on Cloud Run and any platform that +forwards HTTP/WebSockets to a container. + +This guide was validated end to end on Cloud Run: node enrollment over +`wss`, a relayed service call between two NAT-hidden nodes through the +Cloud Run router, and the admin CLI against the public URL. + +## 1. Build and push the image + +```bash +# Pick your project/region and an Artifact Registry docker repository. +PROJECT=my-project +REGION=us-central1 +IMG=${REGION}-docker.pkg.dev/${PROJECT}/sam-mesh/sam-one:latest + +docker build -f Dockerfile.sam-one -t "$IMG" . +gcloud auth configure-docker ${REGION}-docker.pkg.dev +docker push "$IMG" +``` + +## 2. Deploy the service + +Cloud Run has no persistent disk, so pass fixed join/admin tokens as +environment variables — otherwise fresh ones are generated on every +instance start: + +```bash +JOIN_TOKEN="sam_tok_$(openssl rand -hex 16)" +ADMIN_TOKEN="sam_adm_$(openssl rand -hex 16)" + +gcloud run deploy sam-one \ + --project "$PROJECT" --region "$REGION" \ + --image "$IMG" \ + --allow-unauthenticated \ + --min-instances 1 --max-instances 1 \ + --port 8080 \ + --no-cpu-throttling \ + --timeout 3600 \ + --set-env-vars "SAM_TOKEN=${JOIN_TOKEN},SAM_ADMIN_TOKEN=${ADMIN_TOKEN}" +``` + +Flag notes, all load-bearing: + +* **`--min-instances 1 --max-instances 1`** — standalone mode is a + singleton: the router's DHT and relay state live in the one process. +* **`--no-cpu-throttling`** — the router runs background loops (leases, + key sync, DHT); request-based CPU throttling stalls them between + requests. +* **`--timeout 3600`** — Cloud Run caps streaming requests; WebSocket mesh + connections live inside that budget. Nodes reconnect automatically when + the cap severs a connection, but a low timeout means needless churn. +* **`--port 8080`** matches the image's default `--port 8080` argument + (see the `CMD` in `Dockerfile.sam-one`). + +Then tell the router its public URL so it advertises a dialable `wss` +multiaddr (the URL is only known after the first deploy): + +```bash +URL=$(gcloud run services describe sam-one --project "$PROJECT" \ + --region "$REGION" --format='value(status.url)') + +gcloud run services update sam-one \ + --project "$PROJECT" --region "$REGION" \ + --update-env-vars "SAM_EXTERNAL_URL=${URL}" +``` + +## 3. Verify + +```bash +curl -s "$URL/readyz" # 200 +curl -s "$URL/info" | head -c 200 # advertises /dns4//tcp/443/wss/p2p/ +``` + +The web console is served from the same URL at `${URL}/console`. + +> [!NOTE] +> `/healthz` returns a Google frontend 404 on `run.app` domains — the +> path is reserved by the platform and never reaches the container. Use +> `/readyz` for probes. + +## 4. Join nodes from anywhere + +```bash +sam-node run --control-plane "$URL" --bootstrap-token "$JOIN_TOKEN" +``` + +The node enrolls over HTTPS, discovers the router's `wss` multiaddr from +`/info`, and connects through the same public port. Nodes behind NAT are +reachable by other nodes via relay circuits through the Cloud Run router — +no inbound connectivity required on either side. + +## 5. Example: share a service across the mesh + +This walkthrough was run verbatim against a Cloud Run deployment: two +nodes on different networks, neither reachable from the other +(`--announce-private=false` withholds their private addresses, forcing all +traffic through the Cloud Run relay). + +On the **provider** machine, run a node and any local HTTP backend: + +```bash +sam-node run --control-plane "$URL" --bootstrap-token "$JOIN_TOKEN" \ + --data-dir ~/provider --announce-private=false & + +mkdir -p /tmp/www && echo "hello from provider" > /tmp/www/hello.txt +python3 -m http.server 9000 --bind 127.0.0.1 --directory /tmp/www & +``` + +Register the backend as a mesh service through the node's local sidecar +socket (owner-only permissions replace the API token) and note the node's +PeerID from its startup output: + +```bash +curl --unix-socket ~/provider/sam.sock -X POST \ + -H "Content-Type: application/json" \ + -d '{"service":{"type":"SERVICE_TYPE_MCP","name":"hello","description":"example"}, + "targetUrl":"http://127.0.0.1:9000"}' \ + http://localhost/sam/service/register +# -> Service registered +``` + +On the **consumer** machine, run a node the same way, then call the +service by peer and name through the local egress proxy: + +```bash +sam-node run --control-plane "$URL" --bootstrap-token "$JOIN_TOKEN" \ + --data-dir ~/consumer --announce-private=false & + +curl --unix-socket ~/consumer/sam.sock \ + "http://localhost/sam//mcp/hello/hello.txt" +# -> hello from provider +``` + +The request crosses consumer → Cloud Run router (relay circuit) → +provider → backend, with mutual Biscuit authentication between the nodes +and policy enforced on the service name. Allow a few seconds after +registration for propagation on first call. + +## 6. Operate with the CLI + +The `sam-one` binary doubles as an admin client for the running service: + +```bash +export SAM_ADMIN_TOKEN="$ADMIN_TOKEN" + +# Mint a scoped, single-use enrollment token +sam-one token create --server "$URL" --role sam:role:node --max-usages 1 + +# List tokens and their usage +sam-one token list --server "$URL" + +# Ban a peer from the mesh +sam-one admin ban --server "$URL" +``` + +## Operational notes + +* **State is ephemeral.** The SQLite database lives in the container's + in-memory filesystem: enrolled nodes and minted tokens are lost on + instance restart, and the router's peer identity rotates (nodes + re-discover it via `/info` and re-enroll with the join token). Keep + `SAM_TOKEN`/`SAM_ADMIN_TOKEN` pinned via env vars. For durable state, + run `sam-one` on a VM with a disk, or point `--db-driver postgres` at a + managed database. +* **Rollouts briefly overlap revisions.** During a deploy, `/info` may + advertise the new instance while some WebSocket upgrades still land on + the draining one; nodes refuse the peer-ID mismatch and retry. Joins + succeed once the old revision drains. +* **Proxied traffic shares source IPs.** All traffic reaches the + container from a handful of frontend proxy IPs. `sam-one` already + raises libp2p's per-source-IP connection and rate budgets for this + (`ConnsPerSourceIP`); if you front a discrete `sam-router` with a proxy + yourself, set `--conns-per-source-ip` accordingly. + +## Anywhere else + +The same single-port binary runs on any host without flags: + +```bash +sam-one --data-dir /var/lib/sam-one +``` + +A free port is picked and published in the startup banner together with +the generated tokens; pass `--port 8080` (and optionally +`--bind-address`) for a fixed one, and `--external-url https://mesh.example.com` +when fronted by a reverse proxy or DNS name. diff --git a/tests/e2e/standalone.bats b/tests/e2e/standalone.bats new file mode 100644 index 00000000..99ebc059 --- /dev/null +++ b/tests/e2e/standalone.bats @@ -0,0 +1,138 @@ +#!/usr/bin/env bats + +# Black-box CUJ for the sam-one all-in-one binary: boot on a random port +# published in the banner, join real sam-nodes through it, drive the admin CLI +# against the live server, and push a request across the dataplane from node B +# to a smoke HTTP service registered on node A (egress proxy -> router -> A). +# In-process coverage lives in tests/integration/standalone_test.go; this file +# only exercises what needs the real binaries. + +setup() { + export SAM_ONE_BINARY="${SAM_ONE_BINARY:-./bin/sam-one}" + export SAM_NODE_BINARY="${SAM_NODE_BINARY:-./bin/sam-node}" + + export TEST_TMPDIR + TEST_TMPDIR="$(mktemp -d)" + export HOME="$TEST_TMPDIR/home" + export XDG_CONFIG_HOME="$HOME/.config" + mkdir -p "$XDG_CONFIG_HOME" + + export SAM_ONE_DATA="$TEST_TMPDIR/sam-one" +} + +teardown() { + for pid in "${BACKEND_PID:-}" "${NODE_A_PID:-}" "${NODE_B_PID:-}" "${SAM_ONE_PID:-}"; do + [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true + done + # Give the processes a moment to release the sqlite/bbolt locks. + wait 2>/dev/null || true + chmod -R +w "$TEST_TMPDIR" || true + rm -rf "$TEST_TMPDIR" +} + +wait_for_http() { + local url="$1" + for _ in $(seq 1 100); do + curl -sf "$url" > /dev/null 2>&1 && return 0 + sleep 0.2 + done + return 1 +} + +wait_for_log() { + local file="$1" needle="$2" + for _ in $(seq 1 150); do + grep -q "$needle" "$file" 2>/dev/null && return 0 + sleep 0.2 + done + echo "timed out waiting for '$needle' in $file:" >&2 + cat "$file" >&2 || true + return 1 +} + +# start_node boots a background sam-node joined via the bootstrap token; sets +# NODE__PID for teardown. The sidecar serves only on the Unix socket so +# two nodes on one host never fight over the default TCP bind, and loopback +# addresses must be publishable or peers on one host cannot dial each other. +start_node() { + local name="$1" + SAM_API_TOKEN=e2e-secret "$SAM_NODE_BINARY" run \ + --control-plane "$SAM_ONE_URL" \ + --bootstrap-token "$JOIN_TOKEN" \ + --data-dir "$TEST_TMPDIR/node-$name" \ + --bind-addr= \ + --allow-loopback \ + --listen "/ip4/127.0.0.1/tcp/0" > "$TEST_TMPDIR/node-$name.log" 2>&1 & + eval "NODE_${name^^}_PID=$!" +} + +@test "sam-one boots, nodes join over the single port, and the dataplane carries a service call" { + "$SAM_ONE_BINARY" --bind-address 127.0.0.1 --port 0 \ + --data-dir "$SAM_ONE_DATA" > "$TEST_TMPDIR/sam-one.log" 2>&1 & + SAM_ONE_PID=$! + + # The random port is published in the banner, like the generated tokens. + wait_for_log "$TEST_TMPDIR/sam-one.log" "Join Token:" + grep -q "Admin Token: sam_adm_" "$TEST_TMPDIR/sam-one.log" + SAM_ONE_PORT="$(grep -oE 'API URL:[[:space:]]+http://[^:]+:[0-9]+' "$TEST_TMPDIR/sam-one.log" | grep -oE '[0-9]+$')" + [[ -n "$SAM_ONE_PORT" ]] + export SAM_ONE_URL="http://127.0.0.1:${SAM_ONE_PORT}" + wait_for_http "$SAM_ONE_URL/healthz" + + # The embedded console is served from the same port. + run curl -sf "$SAM_ONE_URL/console/" + [[ "$status" -eq 0 ]] + [[ "$output" == *" "$TEST_TMPDIR/www/hello.txt" + python3 -u -m http.server 0 --bind 127.0.0.1 --directory "$TEST_TMPDIR/www" \ + > "$TEST_TMPDIR/backend.log" 2>&1 & + BACKEND_PID=$! + wait_for_log "$TEST_TMPDIR/backend.log" "Serving HTTP" + backend_port="$(grep -oE 'port [0-9]+' "$TEST_TMPDIR/backend.log" | grep -oE '[0-9]+')" + + sock_a="$TEST_TMPDIR/node-a/sam.sock" + sock_b="$TEST_TMPDIR/node-b/sam.sock" + [[ -S "$sock_a" && -S "$sock_b" ]] + register_payload="{\"service\":{\"type\":\"SERVICE_TYPE_MCP\",\"name\":\"smoke\",\"description\":\"e2e smoke backend\"},\"targetUrl\":\"http://127.0.0.1:${backend_port}\"}" + run curl -sf --unix-socket "$sock_a" -X POST \ + -H "Content-Type: application/json" -d "$register_payload" \ + http://localhost/sam/service/register + [[ "$status" -eq 0 ]] + + # Node B reaches the service on node A through its egress proxy: the request + # crosses B -> router -> A over the mesh, exercising the full dataplane. + body="" + for _ in $(seq 1 30); do + body="$(curl -sf --unix-socket "$sock_b" "http://localhost/sam/${peer_a}/mcp/smoke/hello.txt" || true)" + [[ "$body" == *"sam-one dataplane ok"* ]] && break + sleep 1 + done + [[ "$body" == *"sam-one dataplane ok"* ]] + + # The admin CLI works against the live server using the persisted admin token. + run "$SAM_ONE_BINARY" token create --server "$SAM_ONE_URL" \ + --data-dir "$SAM_ONE_DATA" --description "e2e token" + [[ "$status" -eq 0 ]] + [[ "$output" == *"Token: sam-bt-"* ]] + + run "$SAM_ONE_BINARY" token list --server "$SAM_ONE_URL" --data-dir "$SAM_ONE_DATA" + [[ "$status" -eq 0 ]] + [[ "$output" == *"e2e token"* ]] + # The two node enrollments above consumed join token usages. + join_row="$(echo "$output" | grep "sam-one join token")" + [[ "$join_row" == *" 2/"* ]] +} diff --git a/tests/integration/singleport_test.go b/tests/integration/singleport_test.go new file mode 100644 index 00000000..57b8bad5 --- /dev/null +++ b/tests/integration/singleport_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 Google LLC +// +// 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 integration_test + +import ( + "context" + "crypto/sha256" + "fmt" + "net" + "net/http" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/controlplane" + "github.com/google/sam/internal/router" + "github.com/google/sam/internal/storage" + "github.com/libp2p/go-libp2p" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/multiformats/go-multiaddr" +) + +// TestSinglePortWebSocketAndHTTP pins the single-binary (sam-one) topology: +// one TCP socket, owned by the router's WebSocket transport, carries both +// libp2p traffic (WebSocket upgrades) and plain HTTP requests (fallback +// handler), including control-plane routes registered on the shared mux. The +// router itself enrolls and leases against the control plane over loopback +// with a bootstrap token and no OIDC issuer configured. +func TestSinglePortWebSocketAndHTTP(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tmp := t.TempDir() + store, err := storage.NewSQLStore("sqlite", filepath.Join(tmp, "cp.db")) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + // Bootstrap-token-only control plane: no OIDC issuer at all. + cp, err := controlplane.NewServer(controlplane.Options{ + ListenAddr: "127.0.0.1:0", + AutoApproveEnrollment: true, + }, store) + if err != nil { + t.Fatalf("failed to create control plane: %v", err) + } + if err := cp.Start(); err != nil { + t.Fatalf("failed to start control plane: %v", err) + } + t.Cleanup(func() { _ = cp.Close() }) + + // Seed the router's bootstrap token directly in the store, the way a + // single-binary distribution provisions its embedded router on first boot. + const routerToken = "single-port-router-token" + tokenID := fmt.Sprintf("%x", sha256.Sum256([]byte(routerToken))) + now := time.Now() + if err := store.SaveBootstrapToken(ctx, &storage.BootstrapToken{ + ID: tokenID, + TokenHash: tokenID, + Role: api.RoleRouter, + MaxUsages: 1, + CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + }); err != nil { + t.Fatalf("failed to seed router bootstrap token: %v", err) + } + + // Shared mux served as the WS transport's HTTP fallback: control-plane + // routes plus a probe endpoint. + mux := http.NewServeMux() + cp.RegisterRoutes(mux) + mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("single-port ok")) + }) + + rtr, err := router.NewRouter(ctx, router.Options{ + ControlPlaneURL: "http://" + cp.Addr(), + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0/ws"}, + AllowLoopback: true, + KeysDBPath: filepath.Join(tmp, "router.key"), + BootstrapToken: routerToken, + HTTPFallbackHandler: mux, + }) + if err != nil { + t.Fatalf("failed to create router: %v", err) + } + if err := rtr.Start(); err != nil { + t.Fatalf("failed to start router: %v", err) + } + t.Cleanup(func() { _ = rtr.Close() }) + + // Locate the bound single port from the router's WS multiaddr. + var wsAddr multiaddr.Multiaddr + var port string + for _, a := range rtr.Host.Addrs() { + if _, err := a.ValueForProtocol(multiaddr.P_WS); err == nil { + wsAddr = a + if port, err = a.ValueForProtocol(multiaddr.P_TCP); err != nil { + t.Fatalf("ws multiaddr %s has no tcp port: %v", a, err) + } + break + } + } + if wsAddr == nil { + t.Fatalf("router advertises no /ws listen addr, got %v", rtr.Host.Addrs()) + } + + // 1. Plain HTTP through the libp2p-owned socket reaches the fallback mux, + // including the control-plane API. + client := &http.Client{Timeout: 5 * time.Second} + base := "http://127.0.0.1:" + port + for _, path := range []string{"/hello", "/healthz", "/info"} { + resp, err := client.Get(base + path) + if err != nil { + t.Fatalf("GET %s over the single port failed: %v", path, err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("GET %s over the single port: status %s, want 200", path, resp.Status) + } + _ = resp.Body.Close() + } + + // 2. A default-transports libp2p client (same dial path as sam-node) + // connects over the very same port. + dialer, err := libp2p.New(libp2p.NoListenAddrs) + if err != nil { + t.Fatalf("failed to create dialer host: %v", err) + } + t.Cleanup(func() { _ = dialer.Close() }) + + if err := dialer.Connect(ctx, peer.AddrInfo{ID: rtr.Host.ID(), Addrs: []multiaddr.Multiaddr{wsAddr}}); err != nil { + t.Fatalf("libp2p dial over the single port failed: %v", err) + } + if got := dialer.Network().Connectedness(rtr.Host.ID()); got != network.Connected { + t.Fatalf("connectedness to router = %s, want Connected", got) + } + + // 3. The router self-enrolled with the bootstrap token and leased over + // loopback: /info must advertise it. + _, cpPortStr, err := net.SplitHostPort(cp.Addr()) + if err != nil { + t.Fatalf("failed to parse control plane addr %q: %v", cp.Addr(), err) + } + cpPort, err := strconv.Atoi(cpPortStr) + if err != nil { + t.Fatalf("failed to parse control plane port %q: %v", cpPortStr, err) + } + waitForActiveRouters(t, cpPort, 1, 10*time.Second) +} diff --git a/tests/integration/standalone_test.go b/tests/integration/standalone_test.go new file mode 100644 index 00000000..a4d2d7ee --- /dev/null +++ b/tests/integration/standalone_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 Google LLC +// +// 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 integration_test + +import ( + "context" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/node" + "github.com/google/sam/internal/standalone" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" +) + +// TestStandaloneNodeJoin pins the sam-one first-boot CUJ end to end: one +// standalone server provisions its own tokens, policy and embedded router, +// and a stock sam-node enrolls with the generated join token and connects to +// the router over WebSocket through the single public port. +func TestStandaloneNodeJoin(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + dataDir := t.TempDir() + srv, err := standalone.New(standalone.Options{ + BindAddress: "127.0.0.1:0", + DataDir: dataDir, + }) + if err != nil { + t.Fatalf("failed to create standalone server: %v", err) + } + if err := srv.Start(ctx); err != nil { + t.Fatalf("failed to start standalone server: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + + // First boot persisted the generated credentials. + for _, f := range []string{"join-token", "admin-token", "router.key", "sam.db"} { + if _, err := os.Stat(filepath.Join(dataDir, f)); err != nil { + t.Errorf("expected %s in data dir: %v", f, err) + } + } + if srv.JoinToken() == "" || srv.AdminToken() == "" { + t.Fatal("expected generated join and admin tokens") + } + + // The embedded router self-enrolled and leased over loopback: /info on + // the public single port advertises it. + _, portStr, err := net.SplitHostPort(srv.Addr()) + if err != nil { + t.Fatalf("failed to parse public addr %q: %v", srv.Addr(), err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("failed to parse public port %q: %v", portStr, err) + } + waitForActiveRouters(t, port, 1, 10*time.Second) + + // A stock node joins through the public port with the generated token and + // ends up connected to the router over WebSocket. + nodeStore, err := node.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("failed to create node store: %v", err) + } + t.Cleanup(func() { _ = nodeStore.Close() }) + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatalf("failed to generate node key: %v", err) + } + samNode, err := node.NewSamNode(node.Options{ + PrivKey: priv, + Store: nodeStore, + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + AllowLoopback: true, + RequiredRole: api.RoleNode, + }) + if err != nil { + t.Fatalf("failed to create node: %v", err) + } + if err := samNode.Start(ctx); err != nil { + t.Fatalf("failed to start node: %v", err) + } + t.Cleanup(func() { + if samNode.Host != nil { + _ = samNode.Host.Close() + } + }) + + if err := samNode.EnrollBootstrap(ctx, "http://"+srv.Addr(), srv.JoinToken()); err != nil { + t.Fatalf("node enrollment through the single port failed: %v", err) + } + + routerID, err := peer.Decode(srv.PeerID()) + if err != nil { + t.Fatalf("failed to decode router peer ID: %v", err) + } + if got := samNode.Host.Network().Connectedness(routerID); got != network.Connected { + t.Fatalf("node connectedness to embedded router = %s, want Connected", got) + } + + // The embedded web console is served through the same public port. + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://" + srv.Addr() + "/console/") + if err != nil { + t.Fatalf("GET /console/ over the single port failed: %v", err) + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + t.Fatalf("failed to read console body: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /console/ status = %s, want 200", resp.Status) + } + if !strings.Contains(string(body), ""${WORK_DIR}/console.log" 2>&1 & PIDS+=($!) wait_for "${STACK_CONSOLE_URL}/" "console"