diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..b6d1ee9f --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +# Toolchain for contributors: Go from the image, Node and Python for the +# SDKs and Docker for `make lint` arrive as features in devcontainer.json. +FROM mcr.microsoft.com/devcontainers/go:1.27-bookworm@sha256:adc326255c019241228f9da4a1cb5d6a89abaaa0eb8d926a355b00af7daafd00 diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..e9a98de6 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,24 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "2.17.0", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511", + "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511" + }, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", + "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..bc1b1c4b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +{ + "name": "SAM (develop)", + "build": { "dockerfile": "Dockerfile" }, + "features": { + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/python:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "onCreateCommand": "make build", + "forwardPorts": [8080], + "portsAttributes": { + "8080": { "label": "SAM mesh (sam-one)", "protocol": "http" } + }, + "customizations": { + "vscode": { + "extensions": ["golang.go"] + }, + "codespaces": { + "openFiles": ["site/content/docs/guides/codespaces.md"] + } + } +} diff --git a/.devcontainer/testnet-latest/devcontainer.json b/.devcontainer/testnet-latest/devcontainer.json new file mode 100644 index 00000000..e8dae3c1 --- /dev/null +++ b/.devcontainer/testnet-latest/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "SAM testnet (latest from main)", + "build": { + "dockerfile": "../testnet/Dockerfile", + "args": { "SAM_CHANNEL": "latest" } + }, + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "forwardPorts": [8080], + "portsAttributes": { + "8080": { "label": "SAM mesh (sam-one)", "protocol": "http" } + }, + "customizations": { + "codespaces": { + "openFiles": ["site/content/docs/guides/codespaces.md"] + } + } +} diff --git a/.devcontainer/testnet/Dockerfile b/.devcontainer/testnet/Dockerfile new file mode 100644 index 00000000..cfae6b60 --- /dev/null +++ b/.devcontainer/testnet/Dockerfile @@ -0,0 +1,11 @@ +# A mesh without a toolchain: the released sam-one and sam-node binaries are +# copied out of the published images. SAM_CHANNEL selects `stable` (the last +# release tag, what hub.sam-mesh.dev runs) or `latest` (main, what +# bananas.sam-mesh.dev runs). +ARG SAM_CHANNEL=stable +FROM ghcr.io/google/sam-one:${SAM_CHANNEL} AS sam-one +FROM ghcr.io/google/sam-node:${SAM_CHANNEL} AS sam-node + +FROM mcr.microsoft.com/devcontainers/base:bookworm@sha256:3aacff4130e6cf04709f9cab1d7a6d3e1cc4bff6202bc61611831a18d3755673 +COPY --from=sam-one /sam-one /usr/local/bin/sam-one +COPY --from=sam-node /sam-node /usr/local/bin/sam-node diff --git a/.devcontainer/testnet/devcontainer.json b/.devcontainer/testnet/devcontainer.json new file mode 100644 index 00000000..8652ae70 --- /dev/null +++ b/.devcontainer/testnet/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "SAM testnet (stable release)", + "build": { + "dockerfile": "Dockerfile", + "args": { "SAM_CHANNEL": "stable" } + }, + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "forwardPorts": [8080], + "portsAttributes": { + "8080": { "label": "SAM mesh (sam-one)", "protocol": "http" } + }, + "customizations": { + "codespaces": { + "openFiles": ["site/content/docs/guides/codespaces.md"] + } + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7953b4f7..c00d27e4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,6 +35,24 @@ updates: cooldown: default-days: 7 + # Dev container features, and the digest-pinned base images of the two + # dev container Dockerfiles. + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + + - package-ecosystem: "docker" + directories: + - "/.devcontainer" + - "/.devcontainer/testnet" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + - package-ecosystem: "npm" directories: - "/tests/ui" diff --git a/.gitignore b/.gitignore index c63f0761..56ed5cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ go.work.sum # bin/ dist/ +# State of `make testnet` (database, router key, tokens) +.sam-one/ # Stray binaries from `go build ./cmd//` in the repo root /sam-node /sam-box diff --git a/Makefile b/Makefile index af562e3c..08129dd9 100644 --- a/Makefile +++ b/Makefile @@ -152,6 +152,17 @@ kind-local-node: kind-e2e-mesh: build ./development/kind/test-mesh-e2e.sh +# A mesh of your own on port 8080. In a GitHub codespace the port is +# published on the codespace's https URL; anywhere else this is a local +# sam-one. Uses ./bin/sam-one when built, else sam-one on PATH. State lives +# in ./.sam-one (ignored by git; in a codespace it survives rebuilds). Extra +# flags pass through: make testnet ARGS="--issuer https://accounts.google.com". +SAM_ONE_BIN ?= $(if $(wildcard $(OUT_DIR)/sam-one),$(OUT_DIR)/sam-one,sam-one) +SAM_ONE_DATA_DIR ?= $(REPO_ROOT)/.sam-one +.PHONY: testnet +testnet: + $(SAM_ONE_BIN) --data-dir "$(SAM_ONE_DATA_DIR)" --port 8080 $(if $(CODESPACE_NAME),--tunnel codespaces) $(ARGS) + test: CGO_ENABLED=1 go test -v -race -count 1 $(if $(WHAT),-run $(WHAT)) ./... CGO_ENABLED=1 go -C cmd/nano-init test -race -count 1 $(if $(WHAT),-run $(WHAT)) ./... diff --git a/README.md b/README.md index 24be25df..5e2c7250 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,16 @@ no uptime promise; the [quick start](https://sam-mesh.dev/docs/getting-started/q walks through it, and [your own mesh](https://sam-mesh.dev/docs/getting-started/your-own-mesh/) runs a control plane on your laptop in one command. +To run a control plane of your own without installing anything, open a +GitHub codespace with the released binaries and run `make testnet`. It +starts on a public `https` URL that your laptop and phone can enroll into, +on your GitHub account's free quota: + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/google/sam?quickstart=1&devcontainer_path=.devcontainer%2Ftestnet%2Fdevcontainer.json) + +The [Codespaces guide](https://sam-mesh.dev/docs/guides/codespaces/) has the +steps, what persists and what stops. + ## What is in a mesh | Program | Role | @@ -57,6 +67,12 @@ runs a control plane on your laptop in one command. - [Preview](https://sam-mesh.dev/docs/preview/): sandboxed agents and the mobile app, which work but are still settling. - [Contributing](https://sam-mesh.dev/docs/contributing/): building, testing and the local kind environment. +The repository has a dev container with the Go, Node and Python toolchains +and Docker, so you can build and test in a codespace or in VS Code without +installing anything: + +[![Develop in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/google/sam?quickstart=1) + ## Status SAM is pre-1.0. The node, routers, control plane, identity and policy model diff --git a/internal/tunnel/codespaces.go b/internal/tunnel/codespaces.go new file mode 100644 index 00000000..feb97042 --- /dev/null +++ b/internal/tunnel/codespaces.go @@ -0,0 +1,179 @@ +// 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 tunnel + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "sync" + "time" +) + +// Codespaces publishes the target on the https URL GitHub Codespaces assigns +// to a forwarded port, https://-.. +// GitHub runs the forwarder, so nothing is started here and the URL is known +// at once. A forwarded port is private to the codespace owner until they +// make it public, and no API inside the codespace can do that; Open returns +// immediately and a background probe reports whether the URL answers from +// the internet, and what to click if it does not. +type Codespaces struct { + // Getenv reads the platform variables; nil uses os.Getenv. + Getenv func(string) string + // Transport performs the probe requests; nil uses the default. + Transport http.RoundTripper + // ProbeTimeout bounds how long the probe waits for a first answer; + // defaults to 90s. + ProbeTimeout time.Duration + // ProbeInterval is the pause between probe requests; defaults to 2s. + ProbeInterval time.Duration +} + +const ( + codespaceNameEnv = "CODESPACE_NAME" + codespaceDomainEnv = "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" +) + +// Name implements Provider. +func (c *Codespaces) Name() string { return "codespaces" } + +// Open implements Provider. +func (c *Codespaces) Open(ctx context.Context, target string) (Tunnel, error) { + getenv := c.Getenv + if getenv == nil { + getenv = os.Getenv + } + name, domain := getenv(codespaceNameEnv), getenv(codespaceDomainEnv) + if name == "" || domain == "" { + return nil, fmt.Errorf("not running in GitHub Codespaces (%s and %s are unset); behind another proxy pass --external-url", codespaceNameEnv, codespaceDomainEnv) + } + t, err := url.Parse(target) + if err != nil || t.Port() == "" { + return nil, fmt.Errorf("tunnel target %q has no port", target) + } + port := t.Port() + public := fmt.Sprintf("https://%s-%s.%s", name, port, domain) + + probeCtx, cancel := context.WithCancel(context.Background()) + tun := &static{url: public, cancel: cancel, done: make(chan struct{})} + go c.watch(probeCtx, public, name, port) + return tun, nil +} + +// watch tells the operator how the public URL answers: at once when the +// port is public, and with the visibility hint when GitHub answers in place +// of the mesh. After the hint it keeps waiting, so flipping the port in the +// PORTS panel is confirmed in the log. +func (c *Codespaces) watch(ctx context.Context, public, name, port string) { + timeout := c.ProbeTimeout + if timeout <= 0 { + timeout = 90 * time.Second + } + switch c.probe(ctx, public, time.Now().Add(timeout), true) { + case probeReachable: + logger.Infof("%s answers from the internet; devices can enroll", public) + case probePrivate: + logger.Warnf("GitHub answers for %s: port %s is private, so only your own browser can open it. "+ + "To let devices enroll, make it public: PORTS tab -> right-click %s -> Port Visibility -> Public "+ + "(or `gh codespace ports visibility %s:public -c %s`)", public, port, port, port, name) + if c.probe(ctx, public, time.Time{}, false) == probeReachable { + logger.Infof("%s answers from the internet; devices can enroll", public) + } + case probeTimeout: + logger.Warnf("%s did not answer within %s; check the PORTS tab lists port %s and forwards it", public, timeout, port) + } +} + +type probeOutcome int + +const ( + probeReachable probeOutcome = iota + probePrivate + probeTimeout + probeCancelled +) + +// probe requests /healthz on base until the mesh answers 200 through the +// proxy, deadline passes (zero means never) or ctx ends. A redirect or an +// authentication status is GitHub's login gate, reported as probePrivate +// when stopOnPrivate is set and otherwise waited out like any other answer. +func (c *Codespaces) probe(ctx context.Context, base string, deadline time.Time, stopOnPrivate bool) probeOutcome { + interval := c.ProbeInterval + if interval <= 0 { + interval = 2 * time.Second + } + client := &http.Client{ + Transport: c.Transport, + Timeout: 5 * time.Second, + // The redirect target is GitHub's login page; following it would + // report the gate as a healthy answer. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/healthz", nil) + if err != nil { + return probeTimeout + } + resp, err := client.Do(req) + if err == nil { + _ = resp.Body.Close() + switch { + case resp.StatusCode == http.StatusOK: + return probeReachable + case stopOnPrivate && isLoginGate(resp.StatusCode): + return probePrivate + } + } + if !deadline.IsZero() && time.Now().After(deadline) { + return probeTimeout + } + select { + case <-ctx.Done(): + return probeCancelled + case <-time.After(interval): + } + } +} + +// isLoginGate reports whether status is what GitHub's proxy returns for a +// private port to a client without a session: a redirect to the login page +// or an authentication failure. Gateway errors mean the mesh is not +// listening yet and are not a verdict on visibility. +func isLoginGate(status int) bool { + return (status >= 300 && status < 400) || status == http.StatusUnauthorized || status == http.StatusForbidden +} + +// static is a Tunnel whose forwarder is run by the platform: it never +// fails on its own and lives until Close. +type static struct { + url string + cancel context.CancelFunc + done chan struct{} + once sync.Once +} + +func (s *static) URL() string { return s.url } +func (s *static) Done() <-chan struct{} { return s.done } +func (s *static) Err() error { return nil } + +func (s *static) Close() error { + s.once.Do(func() { + s.cancel() + close(s.done) + }) + return nil +} diff --git a/internal/tunnel/codespaces_test.go b/internal/tunnel/codespaces_test.go new file mode 100644 index 00000000..24f7cbf4 --- /dev/null +++ b/internal/tunnel/codespaces_test.go @@ -0,0 +1,180 @@ +// 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 tunnel + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +func codespaceEnv(name, domain string) func(string) string { + return func(key string) string { + switch key { + case codespaceNameEnv: + return name + case codespaceDomainEnv: + return domain + } + return "" + } +} + +// rewriteTo sends every request to the test server standing in for GitHub's +// proxy, whatever host the request names. +type rewriteTo string + +func (r rewriteTo) RoundTrip(req *http.Request) (*http.Response, error) { + u, err := url.Parse(string(r)) + if err != nil { + return nil, err + } + req = req.Clone(req.Context()) + req.URL.Scheme, req.URL.Host = u.Scheme, u.Host + return http.DefaultTransport.RoundTrip(req) +} + +// scriptedProxy answers /healthz with the given statuses in order and the +// last one forever after, counting requests. +func scriptedProxy(t *testing.T, statuses ...int) (*httptest.Server, *atomic.Int32) { + t.Helper() + var n atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + t.Errorf("probe requested %s, want /healthz", r.URL.Path) + } + i := int(n.Add(1)) - 1 + if i >= len(statuses) { + i = len(statuses) - 1 + } + if statuses[i] >= 300 && statuses[i] < 400 { + w.Header().Set("Location", "https://github.com/login") + } + w.WriteHeader(statuses[i]) + })) + t.Cleanup(srv.Close) + return srv, &n +} + +func TestCodespacesOpenDerivesURLFromPlatformEnv(t *testing.T) { + c := &Codespaces{ + Getenv: codespaceEnv("octocat-sam-abc123", "app.github.dev"), + Transport: rewriteTo("http://127.0.0.1:0"), + ProbeTimeout: 10 * time.Millisecond, + } + tun, err := c.Open(context.Background(), "http://127.0.0.1:8080") + if err != nil { + t.Fatalf("Open: %v", err) + } + if got, want := tun.URL(), "https://octocat-sam-abc123-8080.app.github.dev"; got != want { + t.Fatalf("URL = %q, want %q", got, want) + } + select { + case <-tun.Done(): + t.Fatal("Done closed before Close") + default: + } + if err := tun.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := tun.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + select { + case <-tun.Done(): + case <-time.After(time.Second): + t.Fatal("Done not closed after Close") + } + if tun.Err() != nil { + t.Fatalf("Err = %v after Close, want nil", tun.Err()) + } +} + +func TestCodespacesOpenRejectsOtherPlatforms(t *testing.T) { + for name, getenv := range map[string]func(string) string{ + "no env": codespaceEnv("", ""), + "name only": codespaceEnv("octocat-sam-abc123", ""), + "domain only": codespaceEnv("", "app.github.dev"), + } { + t.Run(name, func(t *testing.T) { + c := &Codespaces{Getenv: getenv} + if _, err := c.Open(context.Background(), "http://127.0.0.1:8080"); err == nil || !strings.Contains(err.Error(), codespaceNameEnv) { + t.Fatalf("Open error = %v, want one naming %s", err, codespaceNameEnv) + } + }) + } + c := &Codespaces{Getenv: codespaceEnv("octocat-sam-abc123", "app.github.dev")} + if _, err := c.Open(context.Background(), "http://127.0.0.1"); err == nil { + t.Fatal("Open accepted a target without a port") + } +} + +func TestCodespacesProbeWaitsForTheMeshBehindThePublicPort(t *testing.T) { + // 502 is what the proxy returns while sam-one is still binding. + proxy, n := scriptedProxy(t, http.StatusBadGateway, http.StatusBadGateway, http.StatusOK) + c := &Codespaces{Transport: rewriteTo(proxy.URL), ProbeInterval: time.Millisecond} + if got := c.probe(context.Background(), "https://name-8080.app.github.dev", time.Now().Add(5*time.Second), true); got != probeReachable { + t.Fatalf("probe = %v, want probeReachable", got) + } + if n.Load() != 3 { + t.Fatalf("probe made %d requests, want 3", n.Load()) + } +} + +func TestCodespacesProbeReportsGitHubLoginGateOnce(t *testing.T) { + proxy, n := scriptedProxy(t, http.StatusFound, http.StatusUnauthorized, http.StatusOK) + c := &Codespaces{Transport: rewriteTo(proxy.URL), ProbeInterval: time.Millisecond} + if got := c.probe(context.Background(), "https://name-8080.app.github.dev", time.Now().Add(5*time.Second), true); got != probePrivate { + t.Fatalf("first probe = %v, want probePrivate", got) + } + if n.Load() != 1 { + t.Fatalf("private verdict took %d requests, want 1", n.Load()) + } + // Once reported, the gate is waited out until the operator flips the port. + if got := c.probe(context.Background(), "https://name-8080.app.github.dev", time.Time{}, false); got != probeReachable { + t.Fatalf("second probe = %v, want probeReachable", got) + } + if n.Load() != 3 { + t.Fatalf("probe made %d requests in total, want 3", n.Load()) + } +} + +func TestCodespacesProbeGivesUpAtDeadlineAndOnCancel(t *testing.T) { + proxy, _ := scriptedProxy(t, http.StatusBadGateway) + c := &Codespaces{Transport: rewriteTo(proxy.URL), ProbeInterval: time.Millisecond} + if got := c.probe(context.Background(), "https://name-8080.app.github.dev", time.Now().Add(20*time.Millisecond), true); got != probeTimeout { + t.Fatalf("probe = %v, want probeTimeout", got) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if got := c.probe(ctx, "https://name-8080.app.github.dev", time.Time{}, true); got != probeCancelled { + t.Fatalf("probe = %v, want probeCancelled", got) + } +} + +func TestLookupKnowsCodespaces(t *testing.T) { + p, err := Lookup("codespaces") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if _, ok := p.(*Codespaces); !ok || p.Name() != "codespaces" { + t.Fatalf("Lookup returned %T named %q", p, p.Name()) + } +} diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 31024bca..166c0e0a 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -15,8 +15,9 @@ // Package tunnel publishes a local HTTP listener on a public https URL // through a third-party connector, so devices that cannot route to the host // (a phone on cellular, a laptop on another network) can still enroll and -// join the mesh. Providers wrap external programs; the mesh only learns the -// resulting URL, which it advertises exactly like a configured external URL. +// join the mesh. Providers wrap external programs, or name a forwarder the +// hosting platform already runs; the mesh only learns the resulting URL, +// which it advertises exactly like a configured external URL. package tunnel import ( @@ -49,6 +50,7 @@ type Provider interface { var providers = map[string]func() Provider{ "cloudflare": func() Provider { return &Cloudflare{} }, + "codespaces": func() Provider { return &Codespaces{} }, } // Names lists the registered providers. diff --git a/site/content/docs/contributing/_index.md b/site/content/docs/contributing/_index.md index b31d675f..2fd563f6 100644 --- a/site/content/docs/contributing/_index.md +++ b/site/content/docs/contributing/_index.md @@ -45,6 +45,14 @@ make docker-build # container images tagged :local make proto # regenerate api/sam.pb.go after editing sam.proto ``` +The repository has a dev container (`.devcontainer/devcontainer.json`) with +Go, Node, Python and Docker, and `make build` runs when it is created. Open +it in a [GitHub codespace](https://codespaces.new/google/sam?quickstart=1) +or with the VS Code Dev Containers extension to build and test with nothing +installed locally. `make testnet` inside a codespace starts `sam-one` on the +codespace's public URL, so you can point an SDK program or a phone at your +branch; the [Codespaces guide](../guides/codespaces/) has the steps. + Node and router control-plane requests identify themselves as `sam-node/` and `sam-router/`, including the router inside `sam-one`. These headers contain the software component and build version. diff --git a/site/content/docs/getting-started/_index.md b/site/content/docs/getting-started/_index.md index 34e36191..fd0dc63c 100644 --- a/site/content/docs/getting-started/_index.md +++ b/site/content/docs/getting-started/_index.md @@ -19,7 +19,8 @@ Pick the option that matches what you want to do: |---|---|---|---| | **1. Shared Public Testnet** | Already running — use **`https://bananas.sam-mesh.dev`** | Hosted shared control plane and router | Trying `sam-node` and calling your first remote tool or model in 60 seconds ([Quick start](quickstart/)). | | **2. Local Control Plane (`sam-one`)** | Run `sam-one --data-dir ~/sam-one --tunnel cloudflare --tunnel-install` and copy `API URL:` from the startup banner | Single binary (control plane + router + console) with SQLite and an HTTPS tunnel | Running your own private mesh from a workstation or VM in seconds ([Your own mesh](your-own-mesh/)). | -| **3. Cloud Control Plane (`sam-one`)** | **[Cloud Run](../guides/cloud-run/)**: `gcloud run deploy sam-one --image ghcr.io/google/sam-one:latest ...`
**[SkyPilot](../guides/skypilot/)**: `sky launch -c sam-hub deploy/skypilot/sam-one.yaml` | Always-on `sam-one` with managed TLS/WSS ingress + PostgreSQL or persistent disk *(can also run on cloud free tiers for testing)* | Operating a dedicated production control plane in your own cloud account ([Cloud Run](../guides/cloud-run/) · [SkyPilot](../guides/skypilot/)). | +| **3. Codespace Control Plane (`sam-one`)** | Open the repository in a [GitHub codespace](../guides/codespaces/), run `make testnet`, make port 8080 public | `sam-one` in a container on your GitHub account, published on `https://-8080.app.github.dev` | A control plane of your own with nothing installed and no cloud account, for as long as the codespace runs ([Codespaces](../guides/codespaces/)). | +| **4. Cloud Control Plane (`sam-one`)** | **[Cloud Run](../guides/cloud-run/)**: `gcloud run deploy sam-one --image ghcr.io/google/sam-one:latest ...`
**[SkyPilot](../guides/skypilot/)**: `sky launch -c sam-hub deploy/skypilot/sam-one.yaml` | Always-on `sam-one` with managed TLS/WSS ingress + PostgreSQL or persistent disk *(can also run on cloud free tiers for testing)* | Operating a dedicated production control plane in your own cloud account ([Cloud Run](../guides/cloud-run/) · [SkyPilot](../guides/skypilot/)). | --- diff --git a/site/content/docs/getting-started/your-own-mesh.md b/site/content/docs/getting-started/your-own-mesh.md index 798e9f72..5f3c084d 100644 --- a/site/content/docs/getting-started/your-own-mesh.md +++ b/site/content/docs/getting-started/your-own-mesh.md @@ -31,6 +31,7 @@ for nodes to connect to it. | **Across machines, VMs, or phones** *(Instant HTTPS tunnel)* | `sam-one --data-dir ~/sam-one --tunnel cloudflare --tunnel-install` | Public `https://.trycloudflare.com` URL + terminal QR code | | **Same machine only** *(Local development)* | `sam-one --data-dir ~/sam-one` | Local `http://127.0.0.1:` URL | | **Custom domain behind NAT/firewall** | `sam-one --data-dir ~/sam-one --tunnel cloudflare --tunnel-token-path ~/token --external-url https://mesh.example.com` | Permanent `https://mesh.example.com` URL (no inbound firewall ports) | +| **A GitHub codespace, nothing installed** | Open the repository in a codespace and run `make testnet` ([Codespaces](../../guides/codespaces/)) | Public `https://-8080.app.github.dev` URL once you make the port public | | **Always-on Cloud Deployment** *(Cloud Run, SkyPilot)* | See [Cloud Run](../../guides/cloud-run/) (`gcloud run deploy`) or [SkyPilot](../../guides/skypilot/) (`sky launch`) | `https://.a.run.app` or `https://mesh.example.com` | For example, starting `sam-one` locally: diff --git a/site/content/docs/guides/_index.md b/site/content/docs/guides/_index.md index 1f75dd37..18d4be8a 100644 --- a/site/content/docs/guides/_index.md +++ b/site/content/docs/guides/_index.md @@ -9,5 +9,5 @@ aliases: Step-by-step pages for common tasks: publish a service, connect an agent client, enroll machines that cannot log in, and deploy a control plane on -Kubernetes or Cloud Run. Each page assumes that you have read the -[quick start](../getting-started/quickstart/). +Kubernetes, Cloud Run, SkyPilot or a GitHub codespace. Each page assumes +that you have read the [quick start](../getting-started/quickstart/). diff --git a/site/content/docs/guides/codespaces.md b/site/content/docs/guides/codespaces.md new file mode 100644 index 00000000..362a569b --- /dev/null +++ b/site/content/docs/guides/codespaces.md @@ -0,0 +1,176 @@ +--- +title: "GitHub Codespaces" +linkTitle: "Codespaces" +weight: 5 +--- + +A codespace is a container that GitHub runs for you, with a terminal, an +editor and an `https` URL for every port you forward. Started from this +repository, it gives you a control plane of your own with nothing installed +on your machine and no cloud account. `sam-one` runs inside it, your laptop +and your phone enroll over the public URL, and your GitHub account pays with +its free Codespaces quota (120 core-hours a month on a Free plan; a 2-core +machine is enough). The same setup lets you develop SAM, or a program that +uses one of its SDKs, against a mesh that external clients can reach. + +## 1. Open a codespace + +The repository has three dev container configurations. Pick one from the +badge, or from **Code → Codespaces → New with options** on GitHub: + +| Configuration | Contents | For | +|---|---|---| +| **testnet** (default badge in the README) | The released `sam-one` and `sam-node` binaries, copied from the `stable` images that also run `hub.sam-mesh.dev`. No toolchain. | Trying SAM, enrolling your devices. | +| **testnet-latest** | The same, from the `latest` images built from `main`, which also run `bananas.sam-mesh.dev`. | Trying what is not released yet. | +| **develop** (`.devcontainer/devcontainer.json`) | Go, Node, Python and Docker. `make build` runs when the codespace is created, so `./bin` holds the binaries of the branch you opened. | Contributing, or developing an SDK program against your own branch. | + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/google/sam?quickstart=1&devcontainer_path=.devcontainer%2Ftestnet%2Fdevcontainer.json) + +The codespace opens with this page in the editor and a terminal at the +repository root. + +## 2. Start the mesh + +```bash +make testnet +``` + +The target runs one command, which is the same command you would run +anywhere else (the codespace checks the repository out under +`/workspaces/sam`): + +```bash +sam-one --data-dir /workspaces/sam/.sam-one --port 8080 --tunnel codespaces +``` + +`--tunnel codespaces` tells `sam-one` that GitHub already forwards the port: +it reads the codespace name and the forwarding domain from the environment, +advertises `https://-8080.app.github.dev` as the mesh URL, and +starts nothing. After a moment the banner appears: + +```text +══════════════════════════════════════════════════════════════════ +SAM standalone mesh is ready! + +API URL: https://octocat-sam-abc123-8080.app.github.dev +Tunnel: https://octocat-sam-abc123-8080.app.github.dev -> http://0.0.0.0:8080 +Web Console: https://octocat-sam-abc123-8080.app.github.dev/console +Router Peer: 12D3KooWBzUDQCkZhz2rWrYBhpjcCH8VnrRNcwCW6DoF36iADYrY +Admin Token: sam_adm_… +Join Token: sam_tok_… + +To enroll a node: + sam-node join https://octocat-sam-abc123-8080.app.github.dev --bootstrap-token-path /workspaces/sam/.sam-one/join-token +══════════════════════════════════════════════════════════════════ +``` + +A QR code for the [mobile app](../../preview/mobile/) follows the banner. + +Any `sam-one` flag passes through `ARGS`. To let people log in with an +identity provider instead of the join token, for example: + +```bash +make testnet ARGS="--issuer https://accounts.google.com --allowed-audiences " +``` + +The [sam-one reference](../../reference/sam-one/) lists every flag. Outside +a codespace, `make testnet` starts a plain local `sam-one` on port 8080. + +## 3. Make the port public + +Every forwarded port starts **private**: GitHub's proxy lets your own +browser through and answers everyone else with its login page. Open the +**Web Console** URL from the banner in your browser now and it works. A +`sam-node` on your laptop or the app on your phone cannot log in to GitHub, +so `sam-one` tells you in its log, after a few seconds: + +```text +WARN tunnel GitHub answers for https://octocat-sam-abc123-8080.app.github.dev: port 8080 is private, so only your own browser can open it. To let devices enroll, make it public: PORTS tab -> right-click 8080 -> Port Visibility -> Public (or `gh codespace ports visibility 8080:public -c octocat-sam-abc123`) +``` + +Do that once, in the **PORTS** tab next to the terminal. `sam-one` keeps +checking and confirms: + +```text +INFO tunnel https://octocat-sam-abc123-8080.app.github.dev answers from the internet; devices can enroll +``` + +A public port is reachable by anyone who has the URL, with the same exposure +as a `sam-one` on Cloud Run: `/healthz`, `/info` and the console login page +answer without credentials, enrollment needs the join token or a token you +minted, the console and the admin API need the admin token, and every +router connection needs a credential the control plane issued. The first +boot seeds the open development policy and logs a warning; replace it +before you share the URL, as described in +[Your own mesh](../../getting-started/your-own-mesh/#5-before-you-share-it). + +If your organization forbids public ports, keep the port private and let +`sam-one` publish itself through a Cloudflare quick tunnel instead: +`make testnet ARGS="--tunnel cloudflare --tunnel-install"`. + +## 4. Enroll your devices + +On your laptop, install `sam-node` ([quick start](../../getting-started/quickstart/#1-install)), +save the join token from the banner to a file, and join: + +```bash +URL=https://octocat-sam-abc123-8080.app.github.dev +echo -n 'sam_tok_…' > join-token + +sam-node join "$URL" --bootstrap-token-path join-token +sam-node run --daemonize +``` + +The node appears in the console under **Nodes**. From here the +[Your own mesh](../../getting-started/your-own-mesh/#2-put-a-member-on-it) +walkthrough applies unchanged: publish a model or an MCP server from one +device and call it from another. The second device can be the codespace +itself, where `sam-node` is installed too. It reaches `sam-one` over +loopback, which `--allow-loopback` permits, and `--bind-addr=` keeps its +local API on a Unix socket so it does not compete with `sam-one` for port +8080: + +```bash +sam-node run --control-plane http://127.0.0.1:8080 \ + --bootstrap-token-path .sam-one/join-token \ + --data-dir ~/node-a --bind-addr= --allow-loopback +``` + +Scan the QR code under the banner with the mobile app to enroll a phone. + +## 5. Develop against it + +In the **develop** configuration, `make testnet` runs `./bin/sam-one`, the +binary built from your branch. Edit, `make build`, stop the mesh with +`Ctrl-C` and start it again; the data directory keeps the identity and the +tokens, so enrolled devices reconnect without doing anything. + +A program written with a [native SDK](../../guides/native-sdks/) on your +laptop, or a page using the browser SDK, points at the same URL and the +same join token. `make test` and `make lint` run in the codespace like they +do locally; the end-to-end suite needs kind and is better run locally or in +CI. + +## What persists and what stops + +- **The URL.** The codespace name is fixed for the codespace's lifetime, so + the URL survives stop and start. +- **The mesh state.** `.sam-one` in the checkout holds the database (members, + policy, bootstrap tokens), the router key and the two tokens. Git ignores + it, and it survives stops, starts and container rebuilds. Devices keep + their identity across restarts and reconnect on their own. +- **The idle stop.** A codespace stops after 30 minutes without activity by + default; you can raise that to four hours in your GitHub settings. While it + is stopped nothing answers at the URL and devices retry until it returns. + Resume it from [github.com/codespaces](https://github.com/codespaces), the + README badge, or by connecting to it with `gh codespace code`. +- **Port visibility.** Check the PORTS tab after a restart; set it to public + again if it reverted. +- **Deletion.** A stopped codespace is deleted after 30 days by default. The + mesh is gone with it, and devices enroll elsewhere. +- **One mesh per codespace.** The router's relay and discovery state live in + the single `sam-one` process. A second codespace is a second mesh. + +When you want the mesh to stay up, take the same command and its flags to +[Cloud Run](../cloud-run/), [SkyPilot](../skypilot/) or +[Kubernetes](../kubernetes/). diff --git a/site/content/docs/reference/sam-one.md b/site/content/docs/reference/sam-one.md index 82a29ad4..ccfd2c7d 100644 --- a/site/content/docs/reference/sam-one.md +++ b/site/content/docs/reference/sam-one.md @@ -28,7 +28,7 @@ downloaded tunnel connector. | `--bind-address` | `0.0.0.0` | Host to bind. | | `--port` | `0` | TCP port. `0` picks a free one and prints it in the banner. | | `--external-url` | | Public URL on which nodes reach this instance, for a reverse proxy or a hosted platform. Can also be set with `SAM_EXTERNAL_URL`. When omitted behind an HTTPS proxy (Cloud Run, Fly.io), `/info` infers the advertised `wss` address from the incoming `Host` / `X-Forwarded-Proto` headers automatically. | -| `--tunnel` | | Publish the port through a tunnel provider and use the resulting URL as the external URL. The provider is `cloudflare` (defaults to a free quick tunnel on `*.trycloudflare.com`, no account needed). | +| `--tunnel` | | Publish the port through a tunnel provider and use the resulting URL as the external URL. Providers: `cloudflare` (a free quick tunnel on `*.trycloudflare.com` by default, no account needed) and `codespaces` (the `https` URL GitHub Codespaces assigns to the forwarded port, read from `CODESPACE_NAME` and `GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`; nothing is started, and the log reports whether the port answers from the internet). | | `--tunnel-token-path` | | File containing the tunnel provider authentication token (can also be set with `SAM_TUNNEL_TOKEN`). Use together with `--tunnel --external-url https://mesh.example.com` for a permanent custom domain. | | `--tunnel-install` | `false` | Download the pinned, digest-verified `cloudflared` into `/bin` without asking. Implies acceptance of its license. | | `--cloudflared-path` | `PATH`, then `/bin` | Explicit connector binary. | @@ -106,6 +106,9 @@ The subcommands talk to a running instance over its HTTP API. Shared flags: - **A laptop behind NAT**: `--tunnel cloudflare` gives a temporary `https` hostname. See [your own mesh](../../getting-started/your-own-mesh/). +- **A GitHub codespace**: `--port 8080 --tunnel codespaces` advertises the + codespace's forwarded-port URL. The port must be public for devices to + reach it. See the [Codespaces guide](../../guides/codespaces/). - **A host with a name**: `--port 8080 --external-url https://mesh.example.com` behind a reverse proxy that forwards WebSockets. - **Cloud Run**: pinned `SAM_TOKEN` and `SAM_ADMIN_TOKEN`, one instance, no