From 4a5c2065fbd3984efefba3a5df683dc07c096fce Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 27 Sep 2026 11:15:07 +0000 Subject: [PATCH 1/3] sam-one: a codespaces tunnel provider `--tunnel codespaces` advertises the https URL GitHub Codespaces assigns to the forwarded port, https://-., read from the environment GitHub sets. Nothing is started: GitHub runs the forwarder, and the provider is the same Tunnel shape cloudflare uses, so the banner, the QR code and the router advertisement work unchanged. A forwarded port is private to the codespace owner until they make it public, and no API inside the codespace can do that. The provider probes its own public URL in the background: GitHub's login redirect earns one warning with the exact click (and the gh command), and the first 200 through the proxy is confirmed in the log, so flipping the port is visible. --- internal/tunnel/codespaces.go | 179 ++++++++++++++++++++++++ internal/tunnel/codespaces_test.go | 180 +++++++++++++++++++++++++ internal/tunnel/tunnel.go | 6 +- site/content/docs/reference/sam-one.md | 5 +- 4 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 internal/tunnel/codespaces.go create mode 100644 internal/tunnel/codespaces_test.go 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/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 From 3a6fa5990abed84810f88f8766e4fe8cc205c70f Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 27 Sep 2026 11:15:07 +0000 Subject: [PATCH 2/3] devcontainers: a mesh of your own in a GitHub codespace Two dev container configurations and one make target. `testnet` (and `testnet-latest`) copy the released sam-one and sam-node out of the published `stable` (or `latest`) images onto a plain base image, so a practitioner never compiles; `develop` is the Go, Node, Python and Docker toolchain with `make build` on create. In either, `make testnet` runs sam-one on port 8080 with `--tunnel codespaces`, keeps its state in the checkout's ignored .sam-one, and passes extra flags through ARGS; outside a codespace the same target is a local sam-one. The Codespaces guide states the contract: what GitHub provides (URL, idle stop, retention, private ports), what a public port exposes (the Cloud Run stance), what persists, and that the command is the one you take to Cloud Run or Kubernetes. README badges point at the testnet and develop configurations; Dependabot follows the features and the pinned base images. --- .devcontainer/Dockerfile | 3 + .devcontainer/devcontainer-lock.json | 19 ++ .devcontainer/devcontainer.json | 22 +++ .../testnet-latest/devcontainer.json | 16 ++ .devcontainer/testnet/Dockerfile | 11 ++ .devcontainer/testnet/devcontainer.json | 16 ++ .github/dependabot.yml | 18 ++ .gitignore | 2 + Makefile | 11 ++ README.md | 16 ++ site/content/docs/contributing/_index.md | 8 + site/content/docs/getting-started/_index.md | 3 +- .../docs/getting-started/your-own-mesh.md | 1 + site/content/docs/guides/_index.md | 4 +- site/content/docs/guides/codespaces.md | 176 ++++++++++++++++++ 15 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/testnet-latest/devcontainer.json create mode 100644 .devcontainer/testnet/Dockerfile create mode 100644 .devcontainer/testnet/devcontainer.json create mode 100644 site/content/docs/guides/codespaces.md 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..8e9ed57d --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "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" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..815abc74 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +{ + "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": {} + }, + "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..6ea923dc --- /dev/null +++ b/.devcontainer/testnet-latest/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "SAM testnet (latest from main)", + "build": { + "dockerfile": "../testnet/Dockerfile", + "args": { "SAM_CHANNEL": "latest" } + }, + "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..771f629f --- /dev/null +++ b/.devcontainer/testnet/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "SAM testnet (stable release)", + "build": { + "dockerfile": "Dockerfile", + "args": { "SAM_CHANNEL": "stable" } + }, + "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/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/). From 7c725d74e25b676ec3b37e8d7d5e93aba98d573f Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 27 Sep 2026 11:33:11 +0000 Subject: [PATCH 3/3] devcontainers: an SSH server, for gh codespace ssh and cp The devcontainers go and base images ship without sshd, so gh codespace ssh cannot reach a codespace built from them. The sshd feature is what GitHub's own documentation prescribes for that case. --- .devcontainer/devcontainer-lock.json | 5 +++++ .devcontainer/devcontainer.json | 3 ++- .devcontainer/testnet-latest/devcontainer.json | 3 +++ .devcontainer/testnet/devcontainer.json | 3 +++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 8e9ed57d..e9a98de6 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -14,6 +14,11 @@ "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 index 815abc74..bc1b1c4b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,8 @@ "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/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/sshd:1": {} }, "onCreateCommand": "make build", "forwardPorts": [8080], diff --git a/.devcontainer/testnet-latest/devcontainer.json b/.devcontainer/testnet-latest/devcontainer.json index 6ea923dc..e8dae3c1 100644 --- a/.devcontainer/testnet-latest/devcontainer.json +++ b/.devcontainer/testnet-latest/devcontainer.json @@ -4,6 +4,9 @@ "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" } diff --git a/.devcontainer/testnet/devcontainer.json b/.devcontainer/testnet/devcontainer.json index 771f629f..8652ae70 100644 --- a/.devcontainer/testnet/devcontainer.json +++ b/.devcontainer/testnet/devcontainer.json @@ -4,6 +4,9 @@ "dockerfile": "Dockerfile", "args": { "SAM_CHANNEL": "stable" } }, + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + }, "forwardPorts": [8080], "portsAttributes": { "8080": { "label": "SAM mesh (sam-one)", "protocol": "http" }