Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
23 changes: 23 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
19 changes: 19 additions & 0 deletions .devcontainer/testnet-latest/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
11 changes: 11 additions & 0 deletions .devcontainer/testnet/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions .devcontainer/testnet/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` in the repo root
/sam-node
/sam-box
Expand Down
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ./...
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
179 changes: 179 additions & 0 deletions internal/tunnel/codespaces.go
Original file line number Diff line number Diff line change
@@ -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://<CODESPACE_NAME>-<port>.<forwarding domain>.
// 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
}
Loading
Loading