diff --git a/CHANGELOG.md b/CHANGELOG.md index ceeee8b..af22433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ has a matching GitHub release whose body comes from its section below. - Gateway: `POST /v1/berth/switch` and `POST /v1/berth/stop` switch the card to an engine or give it back without sending a prompt. Stop never touches a pinned holder. The `/gui/` board's Launch now uses the same switch. +- `berth pair` pairs a phone or browser with the harbor: a one-time link and its QR code, a + **Pair this device** page, and a device cookie for a year. `--list`, `--revoke ID` and `--revoke-all` + manage devices. berthd keeps only hashes. Pairing is the groundwork for switching engines from the harbor. - Gateway: `GET /v1/berth/board` reads the node in one call: the card and who holds it, every engine's state and actions, and each engine's models with which are loaded. - Linux AMD support (experimental), contributed by @jenerestain. `berth daemon --gpu-backend amd` diff --git a/docs/harbor.md b/docs/harbor.md index 0956d56..91b1502 100644 --- a/docs/harbor.md +++ b/docs/harbor.md @@ -37,8 +37,29 @@ Then, on the iPhone: 3. Name it (`berth` works) and tap **Add**. The Home Screen icon is Pilot, the harbor master llama, and the name under it is `berth`. -The page stays read-only. The harbor has no login, so publish it only on a tailnet or a home -network you trust. +Reading the harbor needs no login, so publish it only on a tailnet or a home network you trust. + +### Pair a device + +Switching and stopping engines from the harbor will need a paired device. Pair one from the +Berth machine: + +```sh +berth pair --url https://..ts.net +``` + +It prints a link and the same link as a QR code. Scan it with the phone, or open the link, +and tap **Pair this device**. The browser keeps a device cookie for a year: `HttpOnly`, +`SameSite=Lax`, and `Secure` over https. The code works once, for 15 minutes, and +opening the link alone (a chat app's preview, say) does not use it. berthd keeps only hashes +of codes and device tokens, in `~/.local/state/berth/harbor-devices.json`. + +- `berth pair --list` shows the paired devices. +- `berth pair --revoke ID` unpairs one; `--revoke-all` unpairs every device. + +Anyone who opens a link first pairs their browser, so send it only to yourself. Over plain +http on a LAN, the cookie crosses the network in the clear; use `tailscale serve` (https) +for a phone. ## Visibility - **Stowaways:** every process holding VRAM without a lease, with memory, identity and diff --git a/go.mod b/go.mod index 83a707a..2e79ce2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( golang.org/x/sys v0.48.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.60.1 + rsc.io/qr v0.2.0 ) require ( diff --git a/go.sum b/go.sum index 13313e4..3cad15f 100644 --- a/go.sum +++ b/go.sum @@ -54,3 +54,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 7dfc9be..cf67044 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -216,13 +216,13 @@ every route needs the API key (berth key prints it).`, }, { Name: "harbor", - Summary: "the web UI, read-only; loopback by default", + Summary: "the web UI; loopback by default", Args: "[flags]", Help: `Serve the read-only web UI: the card and its leases, the engines, the event log, the request KPIs and the tide of card memory over time, persisted in SQLite. It listens on loopback by default; a non-loopback --listen address is -your explicit opt-in to expose it, and there is no authentication in this -release.`, +your explicit opt-in to expose it. Reading needs no login; berth pair pairs a +device for the actions.`, Run: runHarbor, }, { @@ -487,6 +487,22 @@ board. Read only: nothing here changes the node.`, Formats: []Format{FormatHuman, FormatJSON, FormatYAML}, Run: runStatus, }, + { + Name: "pair", + Summary: "let a phone or browser switch and stop engines from the harbor", + Args: "[--url URL] [--list] [--revoke ID] [--revoke-all]", + Help: `Print a one-time link, and its QR code, that pairs a device with this node's +harbor. Open it on the phone (or scan the code) and tap Pair this device: the +browser keeps a device cookie for a year, and the harbor lets it switch and +stop engines. Reading the harbor needs no pairing. + +The code works once, for 15 minutes. --url is the harbor's address as the +device reaches it, such as https://box.tailnet.ts.net behind tailscale serve. +--list shows the paired devices; --revoke ID unpairs one and --revoke-all +unpairs every device.`, + Formats: []Format{FormatHuman, FormatJSON, FormatYAML}, + Run: runPair, + }, { Name: "stop", Summary: "stop an engine on purpose: its leases drain through their own wrappers", diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index f3fb2c4..c112af2 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -21,6 +21,7 @@ import ( "github.com/T-Crypt/berth/internal/gpu" "github.com/T-Crypt/berth/internal/history" "github.com/T-Crypt/berth/internal/host" + "github.com/T-Crypt/berth/internal/pair" "github.com/T-Crypt/berth/internal/procs" "github.com/T-Crypt/berth/internal/proto" "github.com/T-Crypt/berth/internal/runs" @@ -44,6 +45,7 @@ func runDaemon(c *Context) int { webhook := fs.String("webhook", "", "POST squatter events here as JSON") cfgPath := fs.String("config", config.DefaultPath(), "berth.toml: engines to read, /metrics listener ($BERTH_CONFIG); missing = none") countersFile := fs.String("counters", defaultStateFile("counters.json"), "keeps the collisions board across restarts; \"\" = start at zero every time") + devicesFile := fs.String("devices", defaultStateFile("harbor-devices.json"), "devices paired to act from the harbor (berth pair); \"\" = pairing off") usageFile := fs.String("usage", defaultStateFile("usage.json"), "card-hours per engine, the default juggle ranking; \"\" = memory only") footprintFile := fs.String("footprint", defaultStateFile("footprint.json"), "VRAM measured per engine across restarts, the estimate behind EST. VRAM; \"\" = memory only") if code, done := c.Parse(fs); done { @@ -219,6 +221,9 @@ func runDaemon(c *Context) int { x := server.Extras{Engines: watcher.Snapshot, Host: lastHost.Load, History: rec.Get, Insights: func() *proto.Insights { return watcher.Insights(time.Now()) }, Requests: watcher.Requests, Alerts: lastAlerts.Load} + if *devicesFile != "" { + x.Pair = &pair.Store{Path: *devicesFile} + } if err := server.Serve(ctx, ln, b, x, log); err != nil { return c.fail(Systemf("serve: %v", err)) } diff --git a/internal/cli/pair.go b/internal/cli/pair.go new file mode 100644 index 0000000..de07821 --- /dev/null +++ b/internal/cli/pair.go @@ -0,0 +1,115 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package cli + +// berth pair: let a phone or another browser act from the harbor. berthd +// mints a one-time code; the link carries it to the harbor's pair page, +// whose button trades it for a device cookie. --list and --revoke manage +// what is paired. + +import ( + "net/url" + "strings" + "time" + + "github.com/T-Crypt/berth/internal/pair" + "github.com/T-Crypt/berth/internal/proto" + "github.com/T-Crypt/berth/internal/server" +) + +func runPair(c *Context) int { + fs := c.FlagSet() + sock := fs.String("socket", defaultSocket(), "berthd socket ($BERTH_SOCKET)") + base := fs.String("url", "http://127.0.0.1:9798", "the harbor's address as the device reaches it, e.g. https://box.tailnet.ts.net") + list := fs.Bool("list", false, "list the paired devices") + revoke := fs.String("revoke", "", "unpair the device with this ID") + revokeAll := fs.Bool("revoke-all", false, "unpair every device") + if code, done := c.Parse(fs); done { + return code + } + if code, done := c.noArgs(); done { + return code + } + req := proto.Msg{Op: proto.OpPairCode} + switch { + case *list: + req = proto.Msg{Op: proto.OpPairList} + case *revoke != "" || *revokeAll: + req = proto.Msg{Op: proto.OpPairRevoke, Device: *revoke} + } + u, err := url.Parse(strings.TrimRight(*base, "/")) + if req.Op == proto.OpPairCode && (err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "") { + return c.fail(Failf("--url %q is not an http or https address", *base)) + } + conn, raw, err := server.Dial(*sock) + if err != nil { + return c.fail(Systemf("berthd unreachable on %s: %v", *sock, err)) + } + defer raw.Close() + if err := conn.Send(req); err != nil { + return c.fail(Systemf("berthd on %s: %v", *sock, err)) + } + m, err := conn.Recv() + if err != nil { + return c.fail(Systemf("no reply from berthd: %v", err)) + } + if m.Op == proto.OpRefused { + return c.fail(Failf("%s", m.Reason)) + } + v := pairView{op: req.Op, devices: m.Devices, revoked: m.Revoked} + if req.Op == proto.OpPairCode { + v.link = u.String() + "/pair?code=" + m.Code + v.code = m.Code + } + return c.Write(v, nil) +} + +type pairView struct { + op string + link, code string + devices []proto.PairedDevice + revoked int +} + +func (v pairView) Data() any { + switch v.op { + case proto.OpPairList: + return v.devices + case proto.OpPairRevoke: + return struct { + Revoked int `json:"revoked"` + }{v.revoked} + } + return struct { + Link string `json:"link"` + Code string `json:"code"` + Expires int `json:"expires_in_s"` + }{v.link, v.code, int(pair.CodeTTL / time.Second)} +} + +func (v pairView) Human(c *Context, ok bool) error { + switch v.op { + case proto.OpPairList: + if len(v.devices) == 0 { + c.Printf("no paired devices; berth pair pairs one\n") + } + for _, d := range v.devices { + c.Printf("%s %-20s paired %s\n", d.ID, d.Name, d.Created.Local().Format("2006-01-02 15:04")) + } + return nil + case proto.OpPairRevoke: + c.Printf("unpaired %d device%s\n", v.revoked, plural(v.revoked)) + return nil + } + q, err := pair.QRTerminal(v.link) + if err != nil { + return err + } + c.Printf("%s", q) + c.Printf("%s\n", c.Style.Bold(v.link)) + c.Bullet("scan it, or open the link on the device, then tap Pair this device") + c.Bullet("the code %s works once, for %s", v.code, pair.CodeTTL) + c.Bullet("anyone who opens it first can switch and stop engines: send it only to yourself") + return nil +} diff --git a/internal/cli/pair_test.go b/internal/cli/pair_test.go new file mode 100644 index 0000000..9d5a5d8 --- /dev/null +++ b/internal/cli/pair_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/T-Crypt/berth/internal/broker" + "github.com/T-Crypt/berth/internal/pair" + "github.com/T-Crypt/berth/internal/procs" + "github.com/T-Crypt/berth/internal/proto" + "github.com/T-Crypt/berth/internal/server" +) + +func pairDaemon(t *testing.T) (string, *pair.Store) { + t.Helper() + dir := t.TempDir() + sock := filepath.Join(dir, "berth.sock") + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + b := broker.New(broker.Config{Poll: 20 * time.Millisecond, Log: log}, emptyCard{}, procs.System{}) + ln, err := server.Listen(sock) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + s := &pair.Store{Path: filepath.Join(dir, "harbor-devices.json")} + go server.Serve(ctx, ln, b, server.Extras{Pair: s}, log) + return sock, s +} + +// berth pair mints a code berthd accepts once; --list and --revoke manage +// the device it paired, all over the real socket. +func TestPairCLI(t *testing.T) { + isolate(t) + sock, s := pairDaemon(t) + + code, out, errOut := runCLI(t, "pair", "--socket", sock, "--url", "https://box.tail.ts.net/", "--format", "json") + if code != ExitOK { + t.Fatalf("pair = %d: %s", code, errOut) + } + var got struct { + Link, Code string + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatal(err) + } + if got.Link != "https://box.tail.ts.net/pair?code="+got.Code { + t.Fatalf("link = %q", got.Link) + } + tok, err := s.Redeem(got.Code, "iPhone") + if err != nil { + t.Fatalf("berthd's code does not redeem: %v", err) + } + + code, out, _ = runCLI(t, "pair", "--socket", sock, "--list", "--format", "json") + var devs []proto.PairedDevice + if code != ExitOK || json.Unmarshal([]byte(out), &devs) != nil || len(devs) != 1 || devs[0].Name != "iPhone" { + t.Fatalf("list = %d %s", code, out) + } + code, out, _ = runCLI(t, "pair", "--socket", sock, "--revoke", devs[0].ID) + if code != ExitOK || !strings.Contains(out, "unpaired 1 device") || s.Check(tok) { + t.Fatalf("revoke = %d %q", code, out) + } + + code, out, _ = runCLI(t, "pair", "--socket", sock, "--url", "https://box.tail.ts.net") + if code != ExitOK || !strings.Contains(out, "â–€") || !strings.Contains(out, "/pair?code=") { + t.Fatalf("human pair = %d, no QR or link:\n%s", code, out) + } + if code, _, _ := runCLI(t, "pair", "--socket", sock, "--url", "ftp://x"); code == ExitOK { + t.Fatal("a non-http --url was accepted") + } +} diff --git a/internal/harbor/harbor.go b/internal/harbor/harbor.go index bac1139..353084d 100644 --- a/internal/harbor/harbor.go +++ b/internal/harbor/harbor.go @@ -119,6 +119,9 @@ func (h *Harbor) Handler() http.Handler { mux.HandleFunc("GET /v1/models", h.libraryHandler) mux.HandleFunc("GET /v1/mode", h.mode) mux.HandleFunc("GET /v1/update", h.update) + mux.HandleFunc("GET /v1/me", h.me) + mux.HandleFunc("GET /pair", h.pairShow) + mux.HandleFunc("POST /pair", h.pairRedeem) return h.guard(mux) } @@ -136,8 +139,11 @@ func (h *Harbor) guard(next http.Handler) http.Handler { hd.Set("X-Content-Type-Options", "nosniff") hd.Set("Referrer-Policy", "no-referrer") hd.Set("Cross-Origin-Resource-Policy", "same-origin") - if r.Method != http.MethodGet && r.Method != http.MethodHead { - apiError(w, http.StatusMethodNotAllowed, "read_only", "the harbor is read-only in this release") + // Pairing is the one write so far: its POST checks its own origin. + read := r.Method == http.MethodGet || r.Method == http.MethodHead + pairing := r.Method == http.MethodPost && r.URL.Path == "/pair" + if !read && !pairing { + apiError(w, http.StatusMethodNotAllowed, "read_only", "the harbor only reads, apart from pairing a device") return } if h.Mode != ModeLAN && !loopbackHost(r.Host) && !h.allowedHost(r.Host) { diff --git a/internal/harbor/pair.go b/internal/harbor/pair.go new file mode 100644 index 0000000..b9085d4 --- /dev/null +++ b/internal/harbor/pair.go @@ -0,0 +1,144 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package harbor + +// Pairing a device. berth pair prints a link to GET /pair?code=..., which +// only shows a button: link previews in chat apps fetch a GET, and must not +// spend the code. The button's same-origin POST trades the code, through +// berthd, for a device token kept in an HttpOnly cookie. Reading the +// harbor needs no pairing; the actions (the switchboard) do. + +import ( + "encoding/json" + "html/template" + "net/http" + "strings" + + "github.com/T-Crypt/berth/internal/proto" + "github.com/T-Crypt/berth/internal/server" +) + +// cookieName is the device cookie. A year, like oh-my-pocket's sign-in. +const ( + cookieName = "berth_device" + cookieAge = 365 * 24 * 60 * 60 +) + +var pairPage = template.Must(template.New("pair").Parse(` + +Pair this device - berth +
+

{{.Title}}

{{.Body}}

+{{- if .Code}} +
+ + + +
+{{- end}} +

Open the harbor

+
+`)) + +type pairView struct{ Title, Body, Code string } + +func (h *Harbor) pairShow(w http.ResponseWriter, r *http.Request) { + v := pairView{Title: "Pair this device", + Body: "Pairing lets this browser switch and stop engines from the harbor. The code works once.", + Code: r.URL.Query().Get("code")} + if v.Code == "" { + v = pairView{Title: "No pairing code", Body: "Run berth pair on the machine and open the link it prints."} + } + h.pairWrite(w, http.StatusOK, v) +} + +func (h *Harbor) pairWrite(w http.ResponseWriter, status int, v pairView) { + // The guard's CSP forbids every form; this page posts its one form here. + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = pairPage.Execute(w, v) +} + +// crossSite is a form posted from another site, as the browser reports it. +func crossSite(r *http.Request) bool { + s := r.Header.Get("Sec-Fetch-Site") + return s != "" && s != "same-origin" && s != "none" +} + +func (h *Harbor) pairRedeem(w http.ResponseWriter, r *http.Request) { + if crossSite(r) { + h.pairWrite(w, http.StatusForbidden, pairView{Title: "Not paired", Body: "Pair from the harbor's own page."}) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if r.ParseForm() != nil { + h.pairWrite(w, http.StatusBadRequest, pairView{Title: "Not paired", Body: "The form did not arrive whole; open the link again."}) + return + } + name := strings.TrimSpace(r.PostForm.Get("name")) + if len(name) > 40 { + name = name[:40] + } + m, err := h.ask(proto.Msg{Op: proto.OpPairRedeem, Code: strings.ToUpper(strings.TrimSpace(r.PostForm.Get("code"))), DeviceName: name}) + switch { + case err != nil: + h.pairWrite(w, http.StatusBadGateway, pairView{Title: "Not paired", Body: "berthd is not answering: " + err.Error()}) + return + case m.Op == proto.OpRefused: + h.pairWrite(w, http.StatusForbidden, pairView{Title: "Not paired", Body: m.Reason + ". Run berth pair for a new link."}) + return + } + c := &http.Cookie{Name: cookieName, Value: m.Token, Path: "/", MaxAge: cookieAge, + HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure(r)} + http.SetCookie(w, c) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// secure is whether the browser reached the harbor over https: directly, or +// through a TLS-terminating proxy on this machine (tailscale serve) that +// says so. The cookie is then never sent over plain http. +func secure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https") +} + +// paired is whether the request carries a paired device's token, in the +// cookie or as a bearer token for scripts. berthd decides. +func (h *Harbor) paired(r *http.Request) bool { + tok := "" + if c, err := r.Cookie(cookieName); err == nil { + tok = c.Value + } else if b, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { + tok = strings.TrimSpace(b) + } + if tok == "" { + return false + } + m, err := h.ask(proto.Msg{Op: proto.OpPairCheck, Token: tok}) + return err == nil && m.Paired +} + +// me tells the page whether this browser may act, so it shows the actions +// only to a paired device. +func (h *Harbor) me(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(struct { + Paired bool `json:"paired"` + }{h.paired(r)}) +} + +// ask sends one message to berthd and reads its one reply. +func (h *Harbor) ask(req proto.Msg) (proto.Msg, error) { + conn, raw, err := server.Dial(h.Socket) + if err != nil { + return proto.Msg{}, err + } + defer raw.Close() + if err := conn.Send(req); err != nil { + return proto.Msg{}, err + } + return conn.Recv() +} diff --git a/internal/harbor/pair_test.go b/internal/harbor/pair_test.go new file mode 100644 index 0000000..367eb69 --- /dev/null +++ b/internal/harbor/pair_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package harbor + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/T-Crypt/berth/internal/broker" + "github.com/T-Crypt/berth/internal/gpu" + "github.com/T-Crypt/berth/internal/pair" + "github.com/T-Crypt/berth/internal/procs" + "github.com/T-Crypt/berth/internal/server" +) + +type idleCard struct{} + +func (idleCard) Read(context.Context) (gpu.Snapshot, error) { + return gpu.Snapshot{Total: 24 << 30, Free: 24 << 30, At: time.Now()}, nil +} + +// pairHarbor is a harbor in front of a real berthd that owns a pairing file. +func pairHarbor(t *testing.T) (*httptest.Server, *pair.Store) { + t.Helper() + dir := t.TempDir() + sock := filepath.Join(dir, "berth.sock") + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + b := broker.New(broker.Config{Poll: 20 * time.Millisecond, Log: log}, idleCard{}, procs.System{}) + ln, err := server.Listen(sock) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + s := &pair.Store{Path: filepath.Join(dir, "harbor-devices.json")} + go server.Serve(ctx, ln, b, server.Extras{Pair: s}, log) + srv := httptest.NewServer((&Harbor{Socket: sock, Log: log}).Handler()) + t.Cleanup(srv.Close) + return srv, s +} + +var noRedirect = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + +func postPair(t *testing.T, base, code string, hdr map[string]string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, base+"/pair", strings.NewReader(url.Values{"code": {code}, "name": {"iPhone"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for k, v := range hdr { + req.Header.Set(k, v) + } + resp, err := noRedirect.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp +} + +func paired(t *testing.T, base string, c *http.Cookie) bool { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, base+"/v1/me", nil) + if c != nil { + req.AddCookie(c) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var me struct{ Paired bool } + _ = json.NewDecoder(resp.Body).Decode(&me) + return me.Paired +} + +// The link's GET only shows the button, so a chat app's link preview does +// not spend the code; the button's POST pairs the browser once. +func TestPairFlow(t *testing.T) { + srv, s := pairHarbor(t) + code, err := s.Code() + if err != nil { + t.Fatal(err) + } + + resp, err := http.Get(srv.URL + "/pair?code=" + code) + if err != nil { + t.Fatal(err) + } + page, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || !strings.Contains(string(page), "Pair this device") || + !strings.Contains(resp.Header.Get("Content-Security-Policy"), "form-action 'self'") { + t.Fatalf("pair page = %s csp=%q", resp.Status, resp.Header.Get("Content-Security-Policy")) + } + + if r := postPair(t, srv.URL, code, map[string]string{"Sec-Fetch-Site": "cross-site"}); r.StatusCode != http.StatusForbidden { + t.Fatalf("cross-site pair = %s", r.Status) + } + r := postPair(t, srv.URL, strings.ToLower(code), map[string]string{"Sec-Fetch-Site": "same-origin", "X-Forwarded-Proto": "https"}) + if r.StatusCode != http.StatusSeeOther { + t.Fatalf("pair = %s; the GET or the cross-site POST spent the code", r.Status) + } + var c *http.Cookie + for _, x := range r.Cookies() { + if x.Name == cookieName { + c = x + } + } + if c == nil || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.MaxAge != cookieAge { + t.Fatalf("device cookie = %+v", c) + } + if len(s.List()) != 1 || s.List()[0].Name != "iPhone" { + t.Fatalf("devices = %+v", s.List()) + } + + if !paired(t, srv.URL, c) || paired(t, srv.URL, nil) || paired(t, srv.URL, &http.Cookie{Name: cookieName, Value: "forged"}) { + t.Fatal("/v1/me does not tell the paired browser from the others") + } + if r := postPair(t, srv.URL, code, nil); r.StatusCode != http.StatusForbidden { + t.Fatalf("a spent code paired again: %s", r.Status) + } + if _, err := s.Revoke(""); err != nil || paired(t, srv.URL, c) { + t.Fatal("a revoked device is still paired") + } + + resp, _ = http.Post(srv.URL+"/v1/status", "application/json", strings.NewReader("{}")) + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST outside /pair = %s, want 405", resp.Status) + } +} diff --git a/internal/harbor/web/harbor.css b/internal/harbor/web/harbor.css index 46f2683..96ea9f1 100644 --- a/internal/harbor/web/harbor.css +++ b/internal/harbor/web/harbor.css @@ -1882,3 +1882,13 @@ body.reduced-motion .si-live, body.reduced-motion .si-connecting, body.reduced-m .panel { break-inside: avoid; box-shadow: none; } .log { max-height: none; overflow: visible; } } + +/* The pair page (GET /pair): one card, one button, readable on a phone. */ +.pair { max-width: 26rem; margin: 12vh auto; padding: 1.5rem; background: var(--panel); border: 1px solid var(--hair); border-radius: 8px; } +.pair h1 { margin: 0 0 0.5rem; font-size: 1.25rem; color: var(--ink-strong); } +.pair p { color: var(--dim); line-height: 1.5; } +.pair form { display: grid; gap: 0.75rem; margin: 1rem 0; } +.pair label { display: grid; gap: 0.35rem; color: var(--ink); } +.pair input { padding: 0.6rem; font-size: 1rem; color: var(--ink-strong); background: var(--panel-3); border: 1px solid var(--edge); border-radius: 6px; } +.pair button { padding: 0.75rem; font-size: 1rem; color: var(--ink-strong); background: var(--cyan-deep); border: 1px solid var(--cyan-dim); border-radius: 6px; } +.pair a { color: var(--cyan); } diff --git a/internal/pair/pair.go b/internal/pair/pair.go new file mode 100644 index 0000000..8d4cc90 --- /dev/null +++ b/internal/pair/pair.go @@ -0,0 +1,242 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +// Package pair is the harbor's device pairing: a short one-time code, +// minted on this machine, trades for a long-lived device token that lets a +// browser act on the node. berthd is the only process that writes the +// file, so the CLI and the harbor reach it through berthd's socket and no +// file lock is needed. The file holds only hashes. +package pair + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "os" + "sync" + "time" + + "github.com/T-Crypt/berth/internal/fsx" +) + +const ( + // CodeTTL is how long a code may wait to be used. + CodeTTL = 15 * time.Minute + codeLen = 8 + // alphabet has no 0/O, 1/I/L or U, so a code read off a screen types + // right on a phone. + alphabet = "23456789ABCDEFGHJKMNPQRSTVWXYZ" + maxCodes = 5 // live codes kept; a new one ends the oldest + maxFails = 10 // wrong codes a minute before every redeem is refused +) + +var ( + ErrBadCode = errors.New("that pairing code is wrong, used or expired") + ErrTooMany = errors.New("too many wrong pairing codes; try again in a minute") +) + +// Device is one paired browser, as the CLI lists it. +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + Created time.Time `json:"created"` +} + +type code struct { + Hash string `json:"hash"` + Expires time.Time `json:"expires"` +} + +type device struct { + ID string `json:"id"` + Name string `json:"name"` + Hash string `json:"hash"` + Created time.Time `json:"created"` +} + +type file struct { + Devices []device `json:"devices"` + Codes []code `json:"codes"` +} + +// Store is the pairing file. Now is the clock; nil means time.Now. +type Store struct { + Path string + Now func() time.Time + + mu sync.Mutex + fails []time.Time +} + +func (s *Store) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func hash(secret string) string { + h := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(h[:]) +} + +func same(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } + +func (s *Store) load() (file, error) { + var f file + b, err := os.ReadFile(s.Path) + if errors.Is(err, os.ErrNotExist) { + return f, nil + } + if err != nil { + return f, err + } + return f, json.Unmarshal(b, &f) +} + +// save writes f with the expired codes dropped. +func (s *Store) save(f file) error { + live := f.Codes[:0] + for _, c := range f.Codes { + if s.now().Before(c.Expires) { + live = append(live, c) + } + } + f.Codes = live + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + return fsx.WriteFile(s.Path, b, 0o600) +} + +// Code mints a one-time pairing code that lasts CodeTTL. +func (s *Store) Code() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + f, err := s.load() + if err != nil { + return "", err + } + buf := make([]byte, 0, codeLen) + one := make([]byte, 1) + for len(buf) < codeLen { + if _, err := rand.Read(one); err != nil { + return "", err + } + // Rejection keeps every character equally likely: 240 is the + // largest multiple of the alphabet's 30 under 256. + if int(one[0]) < 256/len(alphabet)*len(alphabet) { + buf = append(buf, alphabet[int(one[0])%len(alphabet)]) + } + } + c := string(buf) + f.Codes = append(f.Codes, code{Hash: hash(c), Expires: s.now().Add(CodeTTL)}) + if len(f.Codes) > maxCodes { + f.Codes = f.Codes[len(f.Codes)-maxCodes:] + } + return c, s.save(f) +} + +// Redeem trades a live code for a new device's token. The code is spent. +func (s *Store) Redeem(c, name string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + recent := s.fails[:0] + for _, t := range s.fails { + if now.Sub(t) < time.Minute { + recent = append(recent, t) + } + } + s.fails = recent + if len(s.fails) >= maxFails { + return "", ErrTooMany + } + f, err := s.load() + if err != nil { + return "", err + } + h := hash(c) + at := -1 + for i, x := range f.Codes { + if same(x.Hash, h) && now.Before(x.Expires) { + at = i + } + } + if at < 0 { + s.fails = append(s.fails, now) + return "", ErrBadCode + } + f.Codes = append(f.Codes[:at], f.Codes[at+1:]...) + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + tok := base64.RawURLEncoding.EncodeToString(raw) + if name == "" { + name = "device" + } + f.Devices = append(f.Devices, device{ID: hash(tok)[:8], Name: name, Hash: hash(tok), Created: now}) + return tok, s.save(f) +} + +// Check reports whether tok belongs to a paired device. +func (s *Store) Check(tok string) bool { + if tok == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + f, err := s.load() + if err != nil { + return false + } + h := hash(tok) + ok := false + for _, d := range f.Devices { + if same(d.Hash, h) { + ok = true + } + } + return ok +} + +// List is the paired devices, oldest first. +func (s *Store) List() []Device { + s.mu.Lock() + defer s.mu.Unlock() + f, _ := s.load() + out := make([]Device, 0, len(f.Devices)) + for _, d := range f.Devices { + out = append(out, Device{ID: d.ID, Name: d.Name, Created: d.Created}) + } + return out +} + +// Revoke unpairs the device with the ID, or every device when id is "". +// It returns how many it unpaired. +func (s *Store) Revoke(id string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + f, err := s.load() + if err != nil { + return 0, err + } + kept := f.Devices[:0] + for _, d := range f.Devices { + if id != "" && d.ID != id { + kept = append(kept, d) + } + } + n := len(f.Devices) - len(kept) + f.Devices = kept + if n == 0 { + return 0, nil + } + return n, s.save(f) +} diff --git a/internal/pair/pair_test.go b/internal/pair/pair_test.go new file mode 100644 index 0000000..42250de --- /dev/null +++ b/internal/pair/pair_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package pair + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func newStore(t *testing.T) (*Store, *time.Time) { + t.Helper() + now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC) + s := &Store{Path: filepath.Join(t.TempDir(), "harbor-devices.json"), Now: func() time.Time { return now }} + return s, &now +} + +// A code pairs one device once; the device's token then checks, and the +// file never holds a code or a token in the clear. +func TestPairOnce(t *testing.T) { + s, _ := newStore(t) + code, err := s.Code() + if err != nil { + t.Fatal(err) + } + if len(code) != codeLen || strings.Trim(code, alphabet) != "" { + t.Fatalf("code %q is not %d characters of the alphabet", code, codeLen) + } + tok, err := s.Redeem(code, "iPhone") + if err != nil { + t.Fatal(err) + } + if !s.Check(tok) { + t.Fatal("a fresh device token does not check") + } + if s.Check("") || s.Check(tok+"x") { + t.Fatal("a wrong token checks") + } + if _, err := s.Redeem(code, "again"); !errors.Is(err, ErrBadCode) { + t.Fatalf("second redeem = %v, want ErrBadCode", err) + } + b, err := os.ReadFile(s.Path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), code) || strings.Contains(string(b), tok) { + t.Fatalf("the file holds a secret in the clear: %s", b) + } + if fi, _ := os.Stat(s.Path); fi.Mode().Perm()&0o077 != 0 && os.PathSeparator == '/' { + t.Fatalf("file mode = %v, want owner only", fi.Mode().Perm()) + } + // A restarted daemon reads the same devices back. + if !(&Store{Path: s.Path, Now: s.Now}).Check(tok) { + t.Fatal("a token does not survive a restart") + } +} + +func TestCodeExpires(t *testing.T) { + s, now := newStore(t) + code, _ := s.Code() + *now = now.Add(CodeTTL + time.Second) + if _, err := s.Redeem(code, "late"); !errors.Is(err, ErrBadCode) { + t.Fatalf("expired redeem = %v, want ErrBadCode", err) + } +} + +// Guessing stops after a few wrong codes a minute, even for a right one. +func TestRedeemLimit(t *testing.T) { + s, now := newStore(t) + code, _ := s.Code() + for i := 0; i < maxFails; i++ { + if _, err := s.Redeem("WRONGCOD", ""); !errors.Is(err, ErrBadCode) { + t.Fatalf("wrong code %d = %v", i, err) + } + } + if _, err := s.Redeem(code, ""); !errors.Is(err, ErrTooMany) { + t.Fatalf("redeem after %d failures = %v, want ErrTooMany", maxFails, err) + } + *now = now.Add(time.Minute) + if _, err := s.Redeem(code, ""); err != nil { + t.Fatalf("redeem a minute later = %v", err) + } +} + +func TestRevoke(t *testing.T) { + s, _ := newStore(t) + var toks []string + for _, name := range []string{"phone", "laptop"} { + c, _ := s.Code() + tok, err := s.Redeem(c, name) + if err != nil { + t.Fatal(err) + } + toks = append(toks, tok) + } + devs := s.List() + if len(devs) != 2 || devs[0].Name != "phone" || devs[1].Name != "laptop" { + t.Fatalf("devices = %+v", devs) + } + if n, _ := s.Revoke(devs[0].ID); n != 1 || s.Check(toks[0]) || !s.Check(toks[1]) { + t.Fatalf("revoke one: n=%d", n) + } + if n, _ := s.Revoke("nope"); n != 0 { + t.Fatalf("revoke unknown = %d", n) + } + if n, _ := s.Revoke(""); n != 1 || s.Check(toks[1]) || len(s.List()) != 0 { + t.Fatalf("revoke all: n=%d", n) + } +} diff --git a/internal/pair/qr.go b/internal/pair/qr.go new file mode 100644 index 0000000..6c1f253 --- /dev/null +++ b/internal/pair/qr.go @@ -0,0 +1,64 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package pair + +import ( + "fmt" + "strings" + + "rsc.io/qr" +) + +// quiet is the blank border, in modules, a scanner needs around a code. +const quiet = 2 + +// QRTerminal draws text as a QR code for a terminal: one character per +// module column and two module rows per line (the upper half block, with +// the top module as foreground and the bottom as background). The colors +// are explicit black and white, so it scans on a dark or a light theme. +func QRTerminal(text string) (string, error) { + c, err := qr.Encode(text, qr.M) + if err != nil { + return "", err + } + dark := func(x, y int) bool { + return x >= 0 && y >= 0 && x < c.Size && y < c.Size && c.Black(x, y) + } + var b strings.Builder + for y := -quiet; y < c.Size+quiet; y += 2 { + for x := -quiet; x < c.Size+quiet; x++ { + fg, bg := 97, 107 + if dark(x, y) { + fg = 30 + } + if dark(x, y+1) { + bg = 40 + } + fmt.Fprintf(&b, "\x1b[%d;%dmâ–€", fg, bg) + } + b.WriteString("\x1b[0m\n") + } + return b.String(), nil +} + +// QRSVG draws text as a QR code in SVG, one square per dark module on a +// white ground with a 4-module quiet zone. +func QRSVG(text string) (string, error) { + c, err := qr.Encode(text, qr.M) + if err != nil { + return "", err + } + n := c.Size + 8 + var b strings.Builder + fmt.Fprintf(&b, ``, n, n, n, n) + for y := 0; y < c.Size; y++ { + for x := 0; x < c.Size; x++ { + if c.Black(x, y) { + fmt.Fprintf(&b, ``, x+4, y+4) + } + } + } + b.WriteString(``) + return b.String(), nil +} diff --git a/internal/pair/qr_test.go b/internal/pair/qr_test.go new file mode 100644 index 0000000..e55a311 --- /dev/null +++ b/internal/pair/qr_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package pair + +import ( + "strings" + "testing" + + "rsc.io/qr" +) + +// Both drawings carry exactly the code's modules. +func TestQR(t *testing.T) { + const link = "https://box.tailnet.ts.net/pair?code=ABCD2345" + c, err := qr.Encode(link, qr.M) + if err != nil { + t.Fatal(err) + } + dark := 0 + for y := 0; y < c.Size; y++ { + for x := 0; x < c.Size; x++ { + if c.Black(x, y) { + dark++ + } + } + } + + svg, err := QRSVG(link) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(svg, `width="1" height="1"`); got != dark { + t.Fatalf("svg squares = %d, want %d dark modules", got, dark) + } + + term, err := QRTerminal(link) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(term, "\n"), "\n") + side := c.Size + 2*quiet + if len(lines) != (side+1)/2 { + t.Fatalf("terminal lines = %d, want %d", len(lines), (side+1)/2) + } + for i, l := range lines { + if n := strings.Count(l, "â–€"); n != side { + t.Fatalf("line %d has %d cells, want %d", i, n, side) + } + } + // Every dark module shows as a black top (30) or a black bottom (40). + if got := strings.Count(term, "[30;") + strings.Count(term, ";40m"); got != dark { + t.Fatalf("terminal dark halves = %d, want %d", got, dark) + } +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 69e57ee..9a4e2e2 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -58,6 +58,18 @@ const ( OpParkFailed = "park_failed" OpResume = "resume" OpAwake = "awake" + + // Device pairing for the harbor's actions; berthd owns the file. A + // client mints a code (OpPairCode, reply Code), the harbor trades one + // for a device token (OpPairRedeem: Code and DeviceName in, Token out) + // and checks a token on every write (OpPairCheck: Token in, Paired out). + // OpPairList answers Devices; OpPairRevoke unpairs Device ("" = all) + // and answers Revoked. A refusal is OpRefused with the reason. + OpPairCode = "pair_code" + OpPairRedeem = "pair_redeem" + OpPairCheck = "pair_check" + OpPairList = "pair_list" + OpPairRevoke = "pair_revoke" ) // Msg is every message in both directions; unused fields are omitted. @@ -105,6 +117,21 @@ type Msg struct { // back, so the runner knows to call the adapter's resume instead of // starting anything. Resume bool `json:"resume,omitempty"` + // Pairing (the OpPair* ops). Code and Token are secrets: never logged. + Code string `json:"code,omitempty"` + Token string `json:"token,omitempty"` + DeviceName string `json:"device_name,omitempty"` + Device string `json:"device,omitempty"` + Devices []PairedDevice `json:"devices,omitempty"` + Paired bool `json:"paired,omitempty"` + Revoked int `json:"revoked,omitempty"` +} + +// PairedDevice is one browser paired to act on the node. +type PairedDevice struct { + ID string `json:"id"` + Name string `json:"name"` + Created time.Time `json:"created"` } // Event is one thing berthd saw or did. Seq is gap-free and increases by one, diff --git a/internal/server/pair.go b/internal/server/pair.go new file mode 100644 index 0000000..8d44747 --- /dev/null +++ b/internal/server/pair.go @@ -0,0 +1,56 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package server + +// The OpPair* ops: berthd is the one writer of the pairing file, so the +// CLI and the harbor both reach it here. Reaching the socket already means +// being this machine's user. Codes and tokens are never logged. + +import ( + "log/slog" + + "github.com/T-Crypt/berth/internal/pair" + "github.com/T-Crypt/berth/internal/proto" +) + +func pairing(conn *proto.Conn, log *slog.Logger, req proto.Msg, s *pair.Store) { + if s == nil { + reply(conn, log, proto.Msg{Op: proto.OpRefused, Reason: "device pairing is off on this node"}) + return + } + refuse := func(err error) { reply(conn, log, proto.Msg{Op: proto.OpRefused, Reason: err.Error()}) } + switch req.Op { + case proto.OpPairCode: + c, err := s.Code() + if err != nil { + refuse(err) + return + } + reply(conn, log, proto.Msg{Op: req.Op, Code: c}) + case proto.OpPairRedeem: + tok, err := s.Redeem(req.Code, req.DeviceName) + if err != nil { + refuse(err) + return + } + log.Info("device paired", "name", req.DeviceName) + reply(conn, log, proto.Msg{Op: req.Op, Token: tok}) + case proto.OpPairCheck: + reply(conn, log, proto.Msg{Op: req.Op, Paired: s.Check(req.Token)}) + case proto.OpPairList: + devs := []proto.PairedDevice{} + for _, d := range s.List() { + devs = append(devs, proto.PairedDevice{ID: d.ID, Name: d.Name, Created: d.Created}) + } + reply(conn, log, proto.Msg{Op: req.Op, Devices: devs}) + case proto.OpPairRevoke: + n, err := s.Revoke(req.Device) + if err != nil { + refuse(err) + return + } + log.Info("devices unpaired", "count", n) + reply(conn, log, proto.Msg{Op: req.Op, Revoked: n}) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c969a4e..5c7487b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ import ( "github.com/T-Crypt/berth/internal/broker" "github.com/T-Crypt/berth/internal/gpu" + "github.com/T-Crypt/berth/internal/pair" "github.com/T-Crypt/berth/internal/proto" ) @@ -110,6 +111,8 @@ type Extras struct { // per sample and the UI repolls more often, so they re-align within // one poll. Alerts func() *proto.Alerts + // Pair is the harbor's device pairing; nil turns the OpPair* ops off. + Pair *pair.Store } // Serve answers clients on ln. @@ -178,6 +181,8 @@ func handle(ctx context.Context, c net.Conn, b *broker.Broker, x Extras, log *sl reclaim(conn, log, first, b) case proto.OpStop: stop(conn, log, first, b) + case proto.OpPairCode, proto.OpPairRedeem, proto.OpPairCheck, proto.OpPairList, proto.OpPairRevoke: + pairing(conn, log, first, x.Pair) case proto.OpResume: resume(conn, log, first, b) case proto.OpEvents: