From 558c662a68b0e437e7322466c6ab470b59bcf5bb Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:02:47 -0600 Subject: [PATCH] harbor: pair a phone or browser with berth pair berth pair prints a one-time link and its QR code. The link opens a page with a Pair this device button, so a chat app's link preview does not spend the code, and the button's same-origin POST trades it for a device cookie (HttpOnly, SameSite=Lax, a year, Secure over https). --list, --revoke and --revoke-all manage devices. berthd owns the pairing file and is its only writer; the CLI and the harbor reach it through new socket ops, so no file lock is needed. The file keeps only hashes, mode 600. Ten wrong codes in a minute stop every redeem for that minute. QR codes come from rsc.io/qr, drawn in the terminal with explicit black and white so they scan on any theme. Reading the harbor still needs no login. Pairing is the groundwork for switching and stopping engines from the harbor. --- CHANGELOG.md | 3 + docs/harbor.md | 25 +++- go.mod | 1 + go.sum | 2 + internal/cli/commands.go | 22 ++- internal/cli/daemon.go | 5 + internal/cli/pair.go | 115 ++++++++++++++++ internal/cli/pair_test.go | 81 +++++++++++ internal/harbor/harbor.go | 10 +- internal/harbor/pair.go | 144 ++++++++++++++++++++ internal/harbor/pair_test.go | 140 +++++++++++++++++++ internal/harbor/web/harbor.css | 10 ++ internal/pair/pair.go | 242 +++++++++++++++++++++++++++++++++ internal/pair/pair_test.go | 113 +++++++++++++++ internal/pair/qr.go | 64 +++++++++ internal/pair/qr_test.go | 55 ++++++++ internal/proto/proto.go | 27 ++++ internal/server/pair.go | 56 ++++++++ internal/server/server.go | 5 + 19 files changed, 1113 insertions(+), 7 deletions(-) create mode 100644 internal/cli/pair.go create mode 100644 internal/cli/pair_test.go create mode 100644 internal/harbor/pair.go create mode 100644 internal/harbor/pair_test.go create mode 100644 internal/pair/pair.go create mode 100644 internal/pair/pair_test.go create mode 100644 internal/pair/qr.go create mode 100644 internal/pair/qr_test.go create mode 100644 internal/server/pair.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 78469d7..28bc586 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 8f8714b..a848876 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: