Skip to content

Commit 06cef45

Browse files
authored
Merge pull request #427 from aojea/device-enrollment
sam-one: QR device enrollment, Cloudflare quick tunnels, SAM Connect scanner
2 parents d6d6824 + bbd3601 commit 06cef45

41 files changed

Lines changed: 2778 additions & 235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gemini/styleguide.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,48 @@ explicitly agreed to. If the dependency only runs inside a sandbox image, it
179179
belongs in that command's own module (`cmd/nano-init/go.mod` pattern). Flag
180180
any new root dependency that lacks this justification.
181181

182-
## 5. Review output
182+
## 5. API surfaces and secrets
183+
184+
### Why
185+
186+
SAM has two API surfaces with different encodings (`AGENTS.md` §1, "Two API
187+
surfaces"): the mesh protocol between components is protobuf from
188+
`api/sam.proto`; the operator plane (`/admin/*`, `/users/*`) is JSON whose
189+
shapes are Go structs in `api/`. Shapes defined ad hoc inside a handler, or
190+
borrowed from `internal/storage`, have no single owner: the console, the CLI
191+
and the tests each re-spell the field names, and a rename breaks one of them
192+
silently. Secrets passed as flag values leak through `ps` and shell history
193+
regardless of how carefully the rest of the system handles them.
194+
195+
### Rules
196+
197+
1. **Flag wire shapes defined outside `api/`.** In a handler, `var req struct
198+
{ ... json:"..." }`, `json.NewEncoder(w).Encode(map[string]any{...})`, or a
199+
client building `map[string]any{"ttl_hours": ...}` is a request for
200+
change: name the `api.*Request` / `api.*Response` type that should exist
201+
(or the proto message, if a mesh component consumes it).
202+
2. **Flag internal types on the wire.** `json.NewEncoder(w).Encode(list)`
203+
where `list` is `[]*storage.X`, or a `cmd/` / `internal/console` package
204+
importing `internal/storage` to decode a response, serializes storage
205+
details (hashes, timestamps, column-shaped names) as an API. Propose an
206+
`api.XInfo` type and quote the fields that must not cross.
207+
3. **Flag the wrong encoding for the surface.** A new mesh-protocol message
208+
as JSON, or a new operator endpoint that only the console will call as
209+
protobuf, needs a stated reason. The tell for "mesh protocol" is a
210+
consumer in `internal/node`, `internal/router`, `internal/sambox` or the
211+
FFI.
212+
4. **Flag secrets as flag values.** Any `Flags().StringVar(&x, "...token"|
213+
"...secret"|"...password", ...)` whose value is the credential itself,
214+
rather than a `--*-path` or an env var name, is a finding; so is a
215+
banner or log line that prints a credential the operator supplied.
216+
`sam-control-plane --admin-token-path` and `sam-node
217+
--bootstrap-token-path` are the reference shape.
218+
219+
## 6. Review output
183220

184221
- Group findings by the section numbers above so the author can see which
185222
rule applies.
186-
- For rule 1 and rule 2 findings, always propose the corrected code, not
223+
- For rule 1, rule 2 and rule 5 findings, always propose the corrected code, not
187224
just the diagnosis.
188225
- Do not raise generic Go style nits (naming, comment punctuation, import
189226
ordering) that `gofmt` and `golangci-lint` already enforce via `make lint`.

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ You are an expert software engineering assistant helping to develop, maintain, a
55
## 1. Architecture & Component Independence
66
* **Decoupled Architecture:** The `sam-control-plane`, `sam-router` and `sam-node` components are strictly independent. They must not share internal state or tightly couple their logic.
77
* **API Communication:** All data communication between `sam-control-plane`, `sam-router` and `sam-node` must happen exclusively via the common API defined in `api/sam.proto`.
8+
* **Two API surfaces, two encodings:** the *mesh protocol* — anything a mesh component speaks (node, router, `sam-box`, the agent connector): enrollment, refresh, keys, leases, auth streams, policy sync — is protobuf from `api/sam.proto` (`application/x-protobuf`). The *operator plane* — what humans, the web console and admin CLIs call (`/admin/*`, `/users/*`) — is JSON, with its request and response types declared as Go structs in `api/` (e.g. `api.BootstrapTokenRequest`). Never define a wire shape as an anonymous struct or `map[string]any` inside a handler, and never serialize an `internal/storage` (or any other internal) type onto either surface; clients in `cmd/` and `internal/console` import `api/` only. A message that a mesh component consumes goes in the proto even if a console also reads it.
9+
* **Secrets never travel as flag values:** binaries read credentials from a file (`--*-path`) or the environment, never from a command-line argument that would sit in `ps` output and shell history. Banners and logs name the source of an operator-supplied secret instead of echoing it.
810
* **Sandbox Dataplane:** `sam-box` (one per sandbox) is the single egress policy enforcement point. It holds no libp2p host, no enrollment and no mesh identity, and reaches the mesh exclusively as a client of the local `sam-node` sidecar socket. `nano-init` (PID 1 inside the guest, its own Go module) owns the guest side; its datapath is the `tun2connect` library. The sandbox boundary is a Unix socket speaking named HTTP tunnels: CONNECT (TCP) and connect-udp (UDP) out, `CONNECT <port>` back in. The authoritative design is `site/content/docs/agent-architecture.md`; do not contradict it.
911
* **Enforcement over Convention:** never gate sandbox traffic on the agent's cooperation — no proxy environment variables, no `LD_PRELOAD` shims, no DNS spoofing. The agent harness stays unmodified and mesh-unaware; confinement is a route and a socket, built by the userspace launcher (`nano-init`) and judged in `sam-box`. An agent that must cooperate with its own confinement is not confined.
1012
* **Policy on Names:** egress policy, secret injection and routing decisions are made on the destination *name*, never on an IP. Deny by default.

api/bootstrap_token.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
// BootstrapTokenRequest is the JSON body that mints a bootstrap token, on
18+
// POST /admin/bootstrap-tokens (admin bearer) and POST /users/me/tokens
19+
// (OIDC user). It is the one definition both the control plane and its
20+
// clients (sam-one's CLI, the console) marshal, so a field name exists in
21+
// exactly one place.
22+
type BootstrapTokenRequest struct {
23+
// Role the token enrolls into, e.g. RoleNode. Required on the admin
24+
// endpoint; the user endpoint defaults it to RoleNode.
25+
Role string `json:"role"`
26+
// OwnerID is the user the token is issued on behalf of. Honored by the
27+
// user endpoint only, and only for admins; defaults to the caller.
28+
OwnerID string `json:"owner_id,omitempty"`
29+
// TTLHours bounds the token's validity; the control plane defaults a
30+
// non-positive value to 24.
31+
TTLHours int `json:"ttl_hours"`
32+
// MaxUsages is how many enrollments the token admits; the control plane
33+
// defaults a non-positive value to 1.
34+
MaxUsages int `json:"max_usages"`
35+
// Description is a free-form operator note stored with the token.
36+
Description string `json:"description,omitempty"`
37+
// AutonomousRecovery is copied onto every node the token enrolls: such a
38+
// node may still refresh its credential after the control plane's
39+
// signing key rotated past its grace period, on proof of possession of
40+
// its own key alone. Admin-only, because a node that can always recover
41+
// holds a credential that never expires (see
42+
// storage.EnrolledNode.AutonomousRecovery).
43+
AutonomousRecovery bool `json:"autonomous_recovery"`
44+
}
45+
46+
// BootstrapTokenResponse is returned (201) when a bootstrap token is minted.
47+
// Token is the plaintext and is shown exactly once; the control plane keeps
48+
// only its hash, which is also the ID.
49+
type BootstrapTokenResponse struct {
50+
ID string `json:"id"`
51+
Token string `json:"token"`
52+
Role string `json:"role"`
53+
OwnerID string `json:"owner_id,omitempty"`
54+
ExpiresAt string `json:"expires_at"`
55+
}

api/enroll_uri.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
import (
18+
"errors"
19+
"fmt"
20+
"net/url"
21+
"strings"
22+
)
23+
24+
// ============================================================================
25+
// Device Enrollment URI
26+
// ============================================================================
27+
//
28+
// A control plane hands a new device everything it needs to enroll in one
29+
// scannable string:
30+
//
31+
// sam://enroll?server=<control-plane-url>&token=<bootstrap-token>
32+
//
33+
// The token is an ordinary bootstrap token (POST /enroll spends it), so the
34+
// URI grants exactly what the token grants: one enrollment, in the token's
35+
// role, until it expires, against any control plane deployment — sam-one or
36+
// a full sam-control-plane. The server URL is the same base URL a node
37+
// passes to `sam-node join`, and the same transport rule applies
38+
// (ValidateControlPlaneTransport): https, or plaintext http only to a
39+
// loopback host, because whoever answers that URL becomes the device's trust
40+
// root. Clients (the mobile app, CLIs) parse the URI with ParseEnrollURI and
41+
// must reject anything else; there is deliberately no second form to keep
42+
// the scanner-to-enrollment path free of guesswork.
43+
44+
const (
45+
// EnrollURIScheme is the URI scheme of a device enrollment payload.
46+
EnrollURIScheme = "sam"
47+
// EnrollURIHost is the fixed host component; it names the action.
48+
EnrollURIHost = "enroll"
49+
)
50+
51+
// EnrollURI builds the device enrollment URI for the given control plane
52+
// base URL and bootstrap token.
53+
func EnrollURI(server, token string) string {
54+
q := url.Values{}
55+
q.Set("server", server)
56+
q.Set("token", token)
57+
u := url.URL{Scheme: EnrollURIScheme, Host: EnrollURIHost, RawQuery: q.Encode()}
58+
return u.String()
59+
}
60+
61+
// ParseEnrollURI extracts the control plane URL and bootstrap token from a
62+
// device enrollment URI. The server must be an absolute URL that a device
63+
// may trust as its control plane: https, or http to a loopback host.
64+
func ParseEnrollURI(raw string) (server, token string, err error) {
65+
u, err := url.Parse(strings.TrimSpace(raw))
66+
if err != nil {
67+
return "", "", fmt.Errorf("invalid enrollment URI: %w", err)
68+
}
69+
if u.Scheme != EnrollURIScheme || u.Host != EnrollURIHost {
70+
return "", "", fmt.Errorf("invalid enrollment URI: expected %s://%s?server=<url>&token=<token>", EnrollURIScheme, EnrollURIHost)
71+
}
72+
q := u.Query()
73+
server, token = q.Get("server"), q.Get("token")
74+
if token == "" {
75+
return "", "", fmt.Errorf("invalid enrollment URI: missing token")
76+
}
77+
if su, err := url.Parse(server); err != nil || su.Host == "" {
78+
return "", "", fmt.Errorf("invalid enrollment URI: server must be an absolute http(s) URL")
79+
}
80+
if err := ValidateControlPlaneTransport(server, false); err != nil {
81+
if errors.Is(err, ErrInsecureControlPlaneURL) {
82+
// Devices have no --insecure-control-plane escape hatch.
83+
return "", "", fmt.Errorf("invalid enrollment URI: server %q must use https:// (plaintext http:// is accepted for loopback hosts only)", server)
84+
}
85+
return "", "", fmt.Errorf("invalid enrollment URI: %w", err)
86+
}
87+
return server, token, nil
88+
}

api/enroll_uri_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
import "testing"
18+
19+
func TestEnrollURIRoundTrip(t *testing.T) {
20+
const server, token = "https://abc-def-123.trycloudflare.com", "sam_dev_0123456789abcdef"
21+
uri := EnrollURI(server, token)
22+
if want := "sam://enroll?server=https%3A%2F%2Fabc-def-123.trycloudflare.com&token=sam_dev_0123456789abcdef"; uri != want {
23+
t.Fatalf("EnrollURI = %q, want %q", uri, want)
24+
}
25+
gotServer, gotToken, err := ParseEnrollURI(" " + uri + "\n")
26+
if err != nil {
27+
t.Fatalf("ParseEnrollURI: %v", err)
28+
}
29+
if gotServer != server || gotToken != token {
30+
t.Fatalf("ParseEnrollURI = (%q, %q), want (%q, %q)", gotServer, gotToken, server, token)
31+
}
32+
}
33+
34+
func TestParseEnrollURIAcceptsLoopbackHTTP(t *testing.T) {
35+
// Emulators reach the host over adb reverse, i.e. loopback.
36+
server, _, err := ParseEnrollURI("sam://enroll?server=http%3A%2F%2F127.0.0.1%3A18432&token=t")
37+
if err != nil || server != "http://127.0.0.1:18432" {
38+
t.Fatalf("ParseEnrollURI loopback http = (%q, %v)", server, err)
39+
}
40+
}
41+
42+
func TestParseEnrollURIRejects(t *testing.T) {
43+
for name, raw := range map[string]string{
44+
"wrong scheme": "samone://enroll?server=https%3A%2F%2Fx&token=t",
45+
"wrong host": "sam://join?server=https%3A%2F%2Fx&token=t",
46+
"missing token": "sam://enroll?server=https%3A%2F%2Fx",
47+
"missing server": "sam://enroll?token=t",
48+
"non-http server": "sam://enroll?server=ftp%3A%2F%2Fx&token=t",
49+
"relative server": "sam://enroll?server=x.example.com&token=t",
50+
"plaintext to LAN": "sam://enroll?server=http%3A%2F%2F192.168.1.50%3A18432&token=t",
51+
"plaintext to host": "sam://enroll?server=http%3A%2F%2Fmesh.example.com&token=t",
52+
"plain token only": "sam_dev_0123",
53+
} {
54+
if _, _, err := ParseEnrollURI(raw); err == nil {
55+
t.Errorf("%s: ParseEnrollURI(%q) accepted", name, raw)
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)