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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
25 changes: 23 additions & 2 deletions docs/harbor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<machine>.<tailnet>.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
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
22 changes: 19 additions & 3 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}
Expand Down
115 changes: 115 additions & 0 deletions internal/cli/pair.go
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 81 additions & 0 deletions internal/cli/pair_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
10 changes: 8 additions & 2 deletions internal/harbor/harbor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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) {
Expand Down
Loading
Loading