From 7b4fdd2899a434c3d23b0095c8cf266e71791789 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 30 Aug 2026 13:22:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20share=20surface=20=E2=80=94=20mir?= =?UTF-8?q?=20share=20ls/revoke,=20guest=20state,=20overview=20(G1d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per G1 spec §6. Owner side: every mint is recorded locally so `mir share ls` lists it (short id, machine, mode, scope, expiry countdown, revoked) and `mir share revoke ` delivers the tombstone over an authenticated attach and flips the record. Revocation stays agent-local (§4): an unreachable machine means the revoke has NOT happened — the copy says so plainly, and says the honest counterpart too: while the machine is offline the guest cannot reach it either. Guest side: `mir ls` renders a share as what it is ("shared with you · read-only · expires in 42 min"), attach checks the grant's clock before dialing (an expired share earns a clear line, not a fake "offline"), and shares whose window has fully closed age out of the local store. Overview: a share renders as ⇢ with the grant summary; `s` on an owned machine drops to the mint ceremony and returns; rename/retire/share on a share answer with a quiet hint instead of acting. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeiotDVE94wEzvc7wcvm1y --- go/internal/cli/cli.go | 1 + go/internal/cli/client_cmds.go | 22 +++ go/internal/cli/overview.go | 84 ++++++++++- go/internal/cli/overview_model.go | 13 +- go/internal/cli/share.go | 132 ++++++++++++++++-- go/internal/cli/share_surface_test.go | 191 ++++++++++++++++++++++++++ go/internal/client/shares.go | 188 +++++++++++++++++++++++++ go/internal/client/shares_test.go | 148 ++++++++++++++++++++ go/internal/client/store.go | 17 +++ 9 files changed, 782 insertions(+), 14 deletions(-) create mode 100644 go/internal/cli/share_surface_test.go create mode 100644 go/internal/client/shares.go create mode 100644 go/internal/client/shares_test.go diff --git a/go/internal/cli/cli.go b/go/internal/cli/cli.go index 9738dc0..a80a3e8 100644 --- a/go/internal/cli/cli.go +++ b/go/internal/cli/cli.go @@ -162,6 +162,7 @@ func (a *app) guide() { p("") p(" Share a terminal (guests, time-boxed):") p(" " + b + " share invite someone in — read-only, expires in an hour") + p(" " + b + " share ls your invites; revoke one: " + b + " share revoke ") p(" " + b + " join claim an invite someone sent you") p("") p(" Identity & machines:") diff --git a/go/internal/cli/client_cmds.go b/go/internal/cli/client_cmds.go index bb41b68..ac2a059 100644 --- a/go/internal/cli/client_cmds.go +++ b/go/internal/cli/client_cmds.go @@ -182,6 +182,7 @@ func (a *app) cmdList(args []string) error { _ = fs.Parse(args) // Cheap, non-blocking update notice (cache-only display; refresh in background). updateClient(a.binary).MaybeNotify(a.errOut, updateCachePath(*dir), version.Version, 24*time.Hour) + client.SweepGuestState(*dir, time.Now()) // shares whose window closed age out here local, err := client.ListMachines(*dir) if err != nil { return err @@ -221,6 +222,16 @@ func (a *app) cmdList(args []string) error { return nil } for _, m := range merged { + if m.Owner != "" { + // A share someone gave this identity: the grant, not the registry, + // says what it is and how long it lasts. + detail := "shared with you" + if g := client.GuestGrantFor(*dir, m.MachineID); g != nil { + detail = fmt.Sprintf("shared with you · %s · %s", modeWord(g.Mode), expiryPhrase(g.NA, false, time.Now())) + } + fmt.Fprintf(a.out, "%-16s %s %s\n", m.Name, m.MachineID, detail) + continue + } tag := "" if discoveredID[m.MachineID] { tag = " (online)" @@ -634,6 +645,17 @@ func (a *app) cmdAttach(args []string) error { if err != nil { return err } + // A share is checked against its own clock before dialing: an expired grant + // would only earn the agent's silent refusal, which reads as "offline". + for _, m := range resolved { + if m.Owner == "" { + continue + } + g := client.GuestGrantFor(*dir, m.MachineID) + if g == nil || g.ValidAt(time.Now()) != nil { + return fmt.Errorf("your share of %q has ended — ask the owner for a new invite", m.Name) + } + } iceList := servers if len(resolved) > 0 && !iceHasTURN(servers) { if warm.ICEErr == nil && sameRelay(warm.ICEFrom, resolved[0].SignalURL) { diff --git a/go/internal/cli/overview.go b/go/internal/cli/overview.go index 980bcc5..964d4b1 100644 --- a/go/internal/cli/overview.go +++ b/go/internal/cli/overview.go @@ -22,6 +22,7 @@ import ( "github.com/srcful/terminal-relay/go/internal/client" "github.com/srcful/terminal-relay/go/internal/defaults" + "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/noise" "github.com/srcful/terminal-relay/go/internal/peer" ) @@ -76,6 +77,8 @@ func (a *app) cmdOverview() error { dir: dir, idn: idn, pump: pump, + fd: fd, + raw: oldState, model: &overviewModel{ Binary: a.binary, Status: "loading your machines…", @@ -130,6 +133,8 @@ type overviewState struct { dir string idn *client.Identity pump *stdinPump + fd int // stdin fd, for suspending raw mode around the share ceremony + raw *term.State // the pre-overview terminal state to restore model *overviewModel machines []client.Machine // row i -> machines[i] fresh map[string]bool // machine ids first seen while this overview is up @@ -198,13 +203,23 @@ func (ov *overviewState) refresh(ctx context.Context, first bool) bool { } rows := make([]overviewRow, 0, len(merged)) for _, m := range merged { - rows = append(rows, overviewRow{ + row := overviewRow{ Name: m.Name, MachineID: m.MachineID, Online: online[m.MachineID], New: ov.fresh[m.MachineID], WindowsLine: ov.windows[m.Name], - }) + } + if m.Owner != "" { + // A share someone gave this identity: mark it and let the grant + // speak for its state — the registry never knows it. + row.Shared = true + row.WindowsLine = "shared with you" + if g := client.GuestGrantFor(ov.dir, m.MachineID); g != nil { + row.WindowsLine = fmt.Sprintf("shared with you · %s · %s", modeWord(g.Mode), expiryPhrase(g.NA, false, time.Now())) + } + } + rows = append(rows, row) } changed := first || !rowsEqual(ov.model.Rows, rows) ov.machines = merged @@ -266,8 +281,22 @@ func (ov *overviewState) handleKey(ctx context.Context, ev keyEvent) (done bool, return false, err } } + case ovShare: + if row, ok := ov.model.Selected(); ok { + if row.Shared { + ov.model.Status = "that's a share — only its owner can share it onward" + break + } + if i := ov.model.Cursor; i >= 0 && i < len(ov.machines) { + ov.share(ctx, ov.machines[i]) + } + } case ovRename: if row, ok := ov.model.Selected(); ok { + if row.Shared { + ov.model.Status = "that's a share — only the owner can rename it; it expires on its own" + break + } ov.prompt = promptRename ov.input = nil ov.model.Prompt = "new name for " + row.Name + ": " @@ -275,6 +304,10 @@ func (ov *overviewState) handleKey(ctx context.Context, ev keyEvent) (done bool, } case ovRetire: if row, ok := ov.model.Selected(); ok { + if row.Shared { + ov.model.Status = "that's a share — it expires on its own; nothing to retire" + break + } ov.prompt = promptRetire ov.input = nil ov.model.Prompt = "Retire " + row.Name + "? It disappears from every device; the machine and its tmux keep running; `" + @@ -557,3 +590,50 @@ func (f *detachFilter) Read(p []byte) (int, error) { f.buf = out } } + +// pumpReader adapts the overview's stdin pump to a plain io.Reader for the +// share ceremony's line prompts (the terminal is back in cooked mode there, so +// the kernel line-buffers and each Read hands over a full line). +type pumpReader struct { + pump *stdinPump + buf []byte +} + +func (r *pumpReader) Read(p []byte) (int, error) { + if len(r.buf) == 0 { + chunk, ok := <-r.pump.ch + if !ok { + return 0, io.EOF + } + r.buf = chunk + } + n := copy(p, r.buf) + r.buf = r.buf[n:] + return n, nil +} + +// share runs the mint ceremony for the selected machine: leave the alt screen +// and raw mode (the ceremony prints a QR and asks questions), run it with the +// share defaults (read-only, 1 h, session main — flags need the command form), +// then come back to the overview. +func (ov *overviewState) share(ctx context.Context, m client.Machine) { + a := ov.app + fmt.Fprint(a.out, altScreenOff) + _ = term.Restore(ov.fd, ov.raw) + + sa := *a + sa.in = &pumpReader{pump: ov.pump} + err := sa.shareResolved(ctx, ov.dir, ov.idn, m, identity.GrantDefaultTTL, false, "main", defaults.WebURL(), ov.ice()) + + if _, rerr := term.MakeRaw(ov.fd); rerr != nil && err == nil { + err = rerr + } + fmt.Fprint(a.out, altScreenOn) + switch { + case err != nil: + ov.model.Status = err.Error() + default: + ov.model.Status = "shared — `" + a.binary + " share ls` lists it" + } + ov.draw() +} diff --git a/go/internal/cli/overview_model.go b/go/internal/cli/overview_model.go index 46be173..e530310 100644 --- a/go/internal/cli/overview_model.go +++ b/go/internal/cli/overview_model.go @@ -25,6 +25,7 @@ const ( ovQuit ovHelp ovEsc + ovShare ovRune // an ordinary byte; Rune carries it (prompt input) ) @@ -77,6 +78,8 @@ func (d *keyDecoder) feed(b byte) keyEvent { return keyEvent{Key: ovRename, Rune: b} case 'x': return keyEvent{Key: ovRetire, Rune: b} + case 's': + return keyEvent{Key: ovShare, Rune: b} case '?': return keyEvent{Key: ovHelp, Rune: b} } @@ -89,7 +92,8 @@ type overviewRow struct { MachineID string Online bool New bool // discovered for the first time while this overview is up - WindowsLine string // dim one-line tmux summary; "" hides the line + Shared bool // a share someone gave this identity (guest entry) + WindowsLine string // dim one-line tmux summary or share detail; "" hides the line } // overviewModel is everything the overview renders. The loop mutates it and @@ -109,8 +113,8 @@ const ( ansiDim = "\x1b[2m" ansiBold = "\x1b[1m" ansiReset = "\x1b[0m" - ovHintBar = "enter attach · r rename · x retire · q quit · ? help" - ovHelpLine = "↑/↓ or j/k move · enter attaches · r renames · x retires (asks first) · q quits" + ovHintBar = "enter attach · s share · r rename · x retire · q quit · ? help" + ovHelpLine = "↑/↓ or j/k move · enter attaches · s shares (owner) · r renames · x retires (asks first) · q quits" ) // MoveCursor moves the selection, clamped to the row list. @@ -162,6 +166,9 @@ func (m *overviewModel) Render() string { if r.Online { state = "●" } + if r.Shared { + state = "⇢" // a share: its grant, not the registry, is its state + } badge := "" if r.New { badge = " " + ansiBold + "NEW" + ansiReset diff --git a/go/internal/cli/share.go b/go/internal/cli/share.go index d7e563d..90f6371 100644 --- a/go/internal/cli/share.go +++ b/go/internal/cli/share.go @@ -25,6 +25,7 @@ import ( "github.com/srcful/terminal-relay/go/internal/defaults" "github.com/srcful/terminal-relay/go/internal/identity" "github.com/srcful/terminal-relay/go/internal/pairing" + "github.com/srcful/terminal-relay/go/internal/peer" "github.com/srcful/terminal-relay/go/internal/sas" ) @@ -34,6 +35,15 @@ var shareIsTTY = func() bool { return term.IsTerminal(int(os.Stdin.Fd())) } const inviteWindow = 5 * time.Minute func (a *app) cmdShare(args []string) error { + // Subcommands first (they take no mint flags): ls / revoke. + if len(args) > 0 { + switch args[0] { + case "ls", "list": + return a.cmdShareLs(args[1:]) + case "revoke": + return a.cmdShareRevoke(args[1:]) + } + } fs := flag.NewFlagSet("share", flag.ExitOnError) dir := fs.String("dir", defaultClientDir(), "client state directory") ttl := fs.Duration("ttl", identity.GrantDefaultTTL, "how long the guest's access lasts (max 24h)") @@ -43,7 +53,7 @@ func (a *app) cmdShare(args []string) error { ice := iceFlags(fs) _ = fs.Parse(args) if len(fs.Args()) != 1 { - return fmt.Errorf("usage: mir share [--ttl 1h] [--write] [--session main]") + return fmt.Errorf("usage: mir share [--ttl 1h] [--write] [--session main] | mir share ls | mir share revoke ") } name := fs.Arg(0) if !shareIsTTY() { @@ -62,9 +72,16 @@ func (a *app) cmdShare(args []string) error { return err } m := machines[0] + return a.shareResolved(context.Background(), *dir, idn, m, *ttl, *write, *session, *webURL, ice()) +} +// shareResolved runs the mint ceremony for an already-resolved machine: the +// write consent, the invite, the room, the safety number, the grant, delivery +// to the agent, and the local record. The overview's `s` action calls it too, +// with its own stdin reader. +func (a *app) shareResolved(parent context.Context, dir string, idn *client.Identity, m client.Machine, ttl time.Duration, write bool, session, webURL string, ice []peer.ICEServer) error { mode := "ro" - if *write { + if write { mode = "rw" fmt.Fprintf(a.out, "Write access means full control of %q as your user — the guest can run anything you can.\n", m.Name) fmt.Fprintf(a.out, "Type the machine name (%s) to confirm write access: ", m.Name) @@ -80,8 +97,8 @@ func (a *app) cmdShare(args []string) error { return err } code := pairing.EncodeCode(m.SignalURL, token) - joinURL := strings.TrimRight(*webURL, "/") + "/#join-" + code - expires := time.Now().Add(*ttl) + joinURL := strings.TrimRight(webURL, "/") + "/#join-" + code + expires := time.Now().Add(ttl) fmt.Fprintf(a.out, "Share %q — %s access until %s.\n", m.Name, modeWord(mode), expires.Format("15:04")) fmt.Fprint(a.out, "\n 📱 Have your guest scan this:\n\n") qrterminal.GenerateHalfBlock(joinURL, qrterminal.L, a.out) @@ -89,7 +106,7 @@ func (a *app) cmdShare(args []string) error { fmt.Fprintf(a.out, " …or on the CLI: %s join %s\n", a.binary, code) fmt.Fprintf(a.out, "\nwaiting for your guest (%d min)…\n", int(inviteWindow.Minutes())) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) defer stop() ctx, cancel := context.WithTimeout(ctx, inviteWindow) defer cancel() @@ -140,7 +157,7 @@ func (a *app) cmdShare(args []string) error { if err != nil { return err } - sg, err := identity.MintGrant(signer, m.MachineID, guestWallet, *session, mode, *ttl, time.Now()) + sg, err := identity.MintGrant(signer, m.MachineID, guestWallet, session, mode, ttl, time.Now()) if err != nil { return err } @@ -151,7 +168,7 @@ func (a *app) cmdShare(args []string) error { // The agent learns first: a guest never holds a grant the machine does not // know about. Failure here means nothing was shared, full stop. - amc, sess, cleanup, err := client.Attach(ctx, m, idn, ice()) + amc, sess, cleanup, err := client.Attach(ctx, m, idn, ice) if err != nil { return fmt.Errorf("%q is unreachable (%v) — nothing was shared; bring it online and mint a new invite", m.Name, err) } @@ -159,10 +176,107 @@ func (a *app) cmdShare(args []string) error { if err := client.GrantOverSession(ctx, amc, sess, record, sg.GID, 8*time.Second); err != nil { return fmt.Errorf("%q did not accept the share (%v) — it may run an agent from before sharing; run `%s update` on it and mint a new invite. Nothing was shared", m.Name, err, a.binary) } + // Record the mint locally so `mir share ls`/`revoke` can find it. The agent + // holds the enforced copy either way. + if err := client.SaveOwnerShare(dir, record, m.Name); err != nil { + fmt.Fprintf(a.errOut, "warning: could not record the share locally: %v\n", err) + } if err := mc.Send([]byte(record)); err != nil { - return fmt.Errorf("the machine accepted the share but the guest disconnected — the access expires %s on its own, or revoke id %s once share revoke ships", expires.Format("15:04"), sg.GID) + return fmt.Errorf("the machine accepted the share but the guest disconnected — the access expires %s on its own, or end it now: `%s share revoke %s`", expires.Format("15:04"), a.binary, sg.GID[:8]) + } + fmt.Fprintf(a.out, "✓ shared %q with %.8s… — %s, expires %s (id %s)\n", m.Name, guestWallet, modeWord(mode), expires.Format("15:04"), sg.GID[:8]) + fmt.Fprintf(a.out, " end it early: %s share revoke %s\n", a.binary, sg.GID[:8]) + return nil +} + +// expiryPhrase renders a grant's clock state for lists ("expires in 42 min"). +func expiryPhrase(na int64, revoked bool, now time.Time) string { + if revoked { + return "revoked" + } + left := time.Unix(na, 0).Sub(now) + switch { + case left <= 0: + return "expired" + case left < time.Minute: + return "expires in under a minute" + case left < time.Hour: + return fmt.Sprintf("expires in %d min", int(left.Minutes())) + default: + return fmt.Sprintf("expires in %dh %02dmin", int(left.Hours()), int(left.Minutes())%60) + } +} + +// cmdShareLs lists this device's recorded mints. +func (a *app) cmdShareLs(args []string) error { + fs := flag.NewFlagSet("share ls", flag.ExitOnError) + dir := fs.String("dir", defaultClientDir(), "client state directory") + _ = fs.Parse(args) + shares, err := client.ListOwnerShares(*dir) + if err != nil { + return err + } + if len(shares) == 0 { + fmt.Fprintf(a.out, "no shares minted from this device — `%s share ` invites someone in\n", a.binary) + return nil + } + now := time.Now() + for _, s := range shares { + fmt.Fprintf(a.out, "%s %-16s %-10s %-8s %s\n", + s.Grant.GID[:8], s.MachineName, modeWord(s.Grant.Mode), s.Grant.Scope, expiryPhrase(s.Grant.NA, s.Revoked, now)) + } + return nil +} + +// cmdShareRevoke ends one share now: deliver the tombstone to the machine's +// agent and mark the local record. Revocation is agent-local (spec §4), so an +// unreachable machine means the revoke has not happened yet — but an offline +// machine cannot serve the guest either, which the copy says plainly. +func (a *app) cmdShareRevoke(args []string) error { + fs := flag.NewFlagSet("share revoke", flag.ExitOnError) + dir := fs.String("dir", defaultClientDir(), "client state directory") + ice := iceFlags(fs) + _ = fs.Parse(args) + if len(fs.Args()) != 1 { + return fmt.Errorf("usage: %s share revoke (ids: `%s share ls`)", a.binary, a.binary) + } + share, err := client.ResolveShareGID(*dir, fs.Arg(0)) + if err != nil { + return err + } + gid := share.Grant.GID + if share.Revoked { + fmt.Fprintf(a.out, "share %s is already revoked\n", gid[:8]) + return nil + } + idn, err := a.identity(*dir) + if err != nil { + return err + } + if err := a.requireRootedIdentity(idn); err != nil { + return err + } + machines, err := a.resolveMachines(context.Background(), *dir, []string{share.MachineName}, idn) + if err != nil { + return err + } + m := machines[0] + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + mc, sess, cleanup, err := client.Attach(ctx, m, idn, ice()) + if err != nil { + return fmt.Errorf("%q is unreachable (%v) — the share is NOT revoked yet, but while the machine is offline the guest cannot reach it either; re-run when it is back online. It expires %s on its own", + m.Name, err, time.Unix(share.Grant.NA, 0).Format("15:04")) + } + defer cleanup() + if err := client.RevokeGrantOverSession(ctx, mc, sess, gid, 8*time.Second); err != nil { + return fmt.Errorf("%q did not confirm the revoke (%v) — it may run an older agent; run `%s update` on it and re-run", m.Name, err, a.binary) + } + if err := client.MarkShareRevoked(*dir, gid); err != nil { + fmt.Fprintf(a.errOut, "warning: revoked on the machine, but the local record could not be updated: %v\n", err) } - fmt.Fprintf(a.out, "✓ shared %q with %.8s… — %s, expires %s (id %s)\n", m.Name, guestWallet, modeWord(mode), expires.Format("15:04"), sg.GID) + fmt.Fprintf(a.out, "✓ revoked %s — %q dropped any live guest immediately\n", gid[:8], m.Name) return nil } diff --git a/go/internal/cli/share_surface_test.go b/go/internal/cli/share_surface_test.go new file mode 100644 index 0000000..e7ebd29 --- /dev/null +++ b/go/internal/cli/share_surface_test.go @@ -0,0 +1,191 @@ +// go/internal/cli/share_surface_test.go — G1d surface: share ls/revoke against +// the live harness, guest-flagged `mir ls` lines, the guest attach clock check, +// the overview's share row, and the expiry phrasing. +package cli + +import ( + "bytes" + "io" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/srcful/terminal-relay/go/internal/client" + "github.com/srcful/terminal-relay/go/internal/identity" + "github.com/srcful/terminal-relay/go/internal/signal" +) + +func TestExpiryPhrase(t *testing.T) { + now := time.Now() + cases := []struct { + na int64 + revoked bool + want string + }{ + {now.Add(30 * time.Second).Unix(), false, "expires in under a minute"}, + {now.Add(42 * time.Minute).Unix(), false, "expires in 4"}, // 41/42 min, rounding + {now.Add(3*time.Hour + 10*time.Minute).Unix(), false, "expires in 3h"}, + {now.Add(-time.Minute).Unix(), false, "expired"}, + {now.Add(time.Hour).Unix(), true, "revoked"}, + } + for _, tc := range cases { + if got := expiryPhrase(tc.na, tc.revoked, now); !strings.HasPrefix(got, tc.want) { + t.Errorf("expiryPhrase(na=%d, revoked=%v) = %q, want prefix %q", tc.na, tc.revoked, got, tc.want) + } + } +} + +// TestShareLsRevokeLiveLoop extends the live harness through the full G1d loop: +// mint+join, `share ls` shows it, `share revoke ` lands on the agent, +// the local record flips, and a re-revoke says so. +func TestShareLsRevokeLiveLoop(t *testing.T) { + h := startShareHarness(t) + withShareTTY(t, true) + deadline := time.Now().Add(40 * time.Second) + + shareOut := &safeBuf{} + shareErr := make(chan error, 1) + shareApp := &app{in: strings.NewReader("y\n"), out: shareOut, errOut: io.Discard, binary: "mir"} + go func() { + shareErr <- shareApp.cmdShare([]string{"--dir", h.ownerDir, "--web", "http://127.0.0.1", "sharebox"}) + }() + joinCode := extractJoinCode(t, shareOut, shareErr, deadline) + + guestDir := t.TempDir() + guestApp := &app{in: strings.NewReader(""), out: io.Discard, errOut: io.Discard, binary: "mir"} + if err := guestApp.cmdJoin([]string{"--dir", guestDir, joinCode}); err != nil { + t.Fatalf("join: %v\n%s", err, shareOut.String()) + } + select { + case err := <-shareErr: + if err != nil { + t.Fatalf("share: %v\n%s", err, shareOut.String()) + } + case <-time.After(20 * time.Second): + t.Fatalf("share did not finish:\n%s", shareOut.String()) + } + + // The guest's list flags the entry as a share, not a machine of their own. + var guestLs bytes.Buffer + lsApp := &app{in: strings.NewReader(""), out: &guestLs, errOut: io.Discard, binary: "mir"} + if err := lsApp.cmdList([]string{"--dir", guestDir}); err != nil { + t.Fatal(err) + } + for _, want := range []string{"sharebox", "shared with you", "read-only", "expires in"} { + if !strings.Contains(guestLs.String(), want) { + t.Fatalf("guest ls missing %q:\n%s", want, guestLs.String()) + } + } + + // share ls on the owner shows the mint with its short id. + shares, err := client.ListOwnerShares(h.ownerDir) + if err != nil || len(shares) != 1 { + t.Fatalf("owner shares = %v (err %v)", shares, err) + } + gid := shares[0].Grant.GID + var lsOut bytes.Buffer + ownerLs := &app{in: strings.NewReader(""), out: &lsOut, errOut: io.Discard, binary: "mir"} + if err := ownerLs.cmdShare([]string{"ls", "--dir", h.ownerDir}); err != nil { + t.Fatal(err) + } + for _, want := range []string{gid[:8], "sharebox", "read-only", "expires in"} { + if !strings.Contains(lsOut.String(), want) { + t.Fatalf("share ls missing %q:\n%s", want, lsOut.String()) + } + } + + // Revoke by prefix against the live agent; the tombstone bars a re-attach. + var revOut bytes.Buffer + revApp := &app{in: strings.NewReader(""), out: &revOut, errOut: io.Discard, binary: "mir"} + if err := revApp.cmdShare([]string{"revoke", "--dir", h.ownerDir, gid[:8]}); err != nil { + t.Fatalf("revoke: %v\n%s", err, revOut.String()) + } + if !strings.Contains(revOut.String(), "✓ revoked "+gid[:8]) { + t.Fatalf("revoke copy:\n%s", revOut.String()) + } + shares, _ = client.ListOwnerShares(h.ownerDir) + if !shares[0].Revoked { + t.Fatal("local record not marked revoked") + } + var againOut bytes.Buffer + againApp := &app{in: strings.NewReader(""), out: &againOut, errOut: io.Discard, binary: "mir"} + if err := againApp.cmdShare([]string{"revoke", "--dir", h.ownerDir, gid[:8]}); err != nil { + t.Fatal(err) + } + if !strings.Contains(againOut.String(), "already revoked") { + t.Fatalf("re-revoke copy:\n%s", againOut.String()) + } +} + +// TestGuestAttachExpiredShareRefusesLocally: an expired share is refused with +// the honest line before any network dial. +func TestGuestAttachExpiredShareRefuses(t *testing.T) { + t.Setenv("MIR_TEST_KEYCHAIN_DIR", t.TempDir()) + t.Setenv("MIR_NO_UPDATE_CHECK", "1") + relay := httptest.NewServer(signal.New().Handler()) + defer relay.Close() + t.Setenv("MIR_SIGNAL", relay.URL) + + dir := t.TempDir() + guestID, err := client.LoadOrCreateIdentity(dir) + if err != nil { + t.Fatal(err) + } + owner := offerCLISigner(t, 0x11) + now := time.Now() + dead, err := owner.SignGrant(identity.Grant{ + V: 1, Owner: owner.Address, Machine: "m-dead", Guest: guestID.OwnerID, + Scope: "main", Mode: "ro", NB: now.Add(-2 * time.Hour).Unix(), NA: now.Add(-time.Hour).Unix(), + GID: "deadbeefdeadbeef", + }) + if err != nil { + t.Fatal(err) + } + rec, _ := dead.JSON() + if err := client.SaveGuestGrant(dir, dead.GID, rec); err != nil { + t.Fatal(err) + } + if err := client.AddMachine(dir, client.Machine{ + Name: "gone", MachineID: "m-dead", HostPubHex: "aa", SignalURL: relay.URL, Owner: owner.Address, + }); err != nil { + t.Fatal(err) + } + + a := &app{in: strings.NewReader(""), out: io.Discard, errOut: io.Discard, binary: "mir"} + err = a.cmdAttach([]string{"--dir", dir, "gone"}) + if err == nil || !strings.Contains(err.Error(), "share of \"gone\" has ended") { + t.Fatalf("expected the honest expired-share refusal, got %v", err) + } +} + +func offerCLISigner(t *testing.T, fill byte) *identity.Signer { + t.Helper() + s, err := identity.DeriveSigner(bytes.Repeat([]byte{fill}, 32)) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestOverviewRenderSharedRow(t *testing.T) { + m := &overviewModel{ + Binary: "mir", + Rows: []overviewRow{ + {Name: "zap-dev", Online: true}, + {Name: "teambox", Shared: true, WindowsLine: "shared with you · read-only · expires in 42 min"}, + }, + Cursor: 1, + Width: 100, + } + out := m.Render() + for _, want := range []string{ + "▸ ⇢ teambox", + "shared with you · read-only · expires in 42 min", + "s share", // the hint bar carries the share action + } { + if !strings.Contains(out, want) { + t.Fatalf("render missing %q:\n%s", want, out) + } + } +} diff --git a/go/internal/client/shares.go b/go/internal/client/shares.go new file mode 100644 index 0000000..d9c9cf8 --- /dev/null +++ b/go/internal/client/shares.go @@ -0,0 +1,188 @@ +// go/internal/client/shares.go +// +// Local share state on the client (G1d). Two sides: +// - OWNER: every mint is recorded under /shares/.json so +// `mir share ls` can list it and `mir share revoke` can find the machine +// to deliver the tombstone to. Purely local bookkeeping — the agent's +// grant store is the authority. +// - GUEST: grants received with `mir join` live under /grants/ (G1b). +// Helpers here read them for `mir ls`/attach display and sweep entries +// whose window has fully closed. +package client + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +// OwnerShare is one recorded mint. +type OwnerShare struct { + Record string `json:"record"` // the signed grant, verbatim + MachineName string `json:"machine_name"` + Revoked bool `json:"revoked"` + + Grant identity.SignedGrant `json:"-"` // parsed from Record on load +} + +func sharesDir(dir string) string { return filepath.Join(dir, "shares") } + +// SaveOwnerShare records a successful mint. +func SaveOwnerShare(dir string, record, machineName string) error { + sg, err := identity.ParseSignedGrant([]byte(record)) + if err != nil { + return err + } + if err := os.MkdirAll(sharesDir(dir), 0o700); err != nil { + return err + } + data, err := json.Marshal(OwnerShare{Record: record, MachineName: machineName}) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(sharesDir(dir), sg.GID+".json"), data, 0o600) +} + +// ListOwnerShares returns recorded mints, newest expiry first. +func ListOwnerShares(dir string) ([]OwnerShare, error) { + entries, err := os.ReadDir(sharesDir(dir)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var out []OwnerShare + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + raw, err := os.ReadFile(filepath.Join(sharesDir(dir), e.Name())) + if err != nil { + continue + } + var s OwnerShare + if json.Unmarshal(raw, &s) != nil { + continue + } + sg, err := identity.ParseSignedGrant([]byte(s.Record)) + if err != nil { + continue + } + s.Grant = *sg + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].Grant.NA > out[j].Grant.NA }) + return out, nil +} + +// MarkShareRevoked flips the local revoked flag after the agent acked the +// tombstone. +func MarkShareRevoked(dir, gid string) error { + p := filepath.Join(sharesDir(dir), gid+".json") + raw, err := os.ReadFile(p) + if err != nil { + return err + } + var s OwnerShare + if err := json.Unmarshal(raw, &s); err != nil { + return err + } + s.Revoked = true + data, err := json.Marshal(s) + if err != nil { + return err + } + return os.WriteFile(p, data, 0o600) +} + +// ResolveShareGID matches a gid prefix against recorded mints: exactly one +// match wins; none or several is an error the caller shows verbatim. +func ResolveShareGID(dir, prefix string) (OwnerShare, error) { + shares, err := ListOwnerShares(dir) + if err != nil { + return OwnerShare{}, err + } + var hits []OwnerShare + for _, s := range shares { + if strings.HasPrefix(s.Grant.GID, prefix) { + hits = append(hits, s) + } + } + switch len(hits) { + case 1: + return hits[0], nil + case 0: + return OwnerShare{}, fmt.Errorf("no share matches %q — see `mir share ls`", prefix) + default: + return OwnerShare{}, fmt.Errorf("%d shares match %q — use more of the id from `mir share ls`", len(hits), prefix) + } +} + +// ListGuestGrants returns the grants this identity received as a guest. +func ListGuestGrants(dir string) []identity.SignedGrant { + entries, err := os.ReadDir(filepath.Join(dir, "grants")) + if err != nil { + return nil + } + var out []identity.SignedGrant + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" || e.Name() == "revoked.json" { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "grants", e.Name())) + if err != nil { + continue + } + sg, err := identity.ParseSignedGrant(raw) + if err != nil { + continue + } + out = append(out, *sg) + } + sort.Slice(out, func(i, j int) bool { return out[i].NA > out[j].NA }) + return out +} + +// GuestGrantFor returns the best (latest-expiring) grant covering machineID, +// or nil. +func GuestGrantFor(dir, machineID string) *identity.SignedGrant { + for _, g := range ListGuestGrants(dir) { + if g.Machine == machineID { + g := g + return &g + } + } + return nil +} + +// SweepGuestState removes guest machine entries (Machine.Owner set) whose +// every grant window has fully closed (past na + skew), plus the closed grant +// files themselves — so `mir ls` does not accumulate dead shares. Live or +// merely offline shares are untouched. +func SweepGuestState(dir string, now time.Time) { + cutoff := now.Add(-identity.GrantSkew).Unix() + live := map[string]bool{} + for _, g := range ListGuestGrants(dir) { + if g.NA >= cutoff { + live[g.Machine] = true + continue + } + _ = os.Remove(filepath.Join(dir, "grants", g.GID+".json")) + } + machines, err := ListMachines(dir) + if err != nil { + return + } + for _, m := range machines { + if m.Owner != "" && !live[m.MachineID] { + _ = RemoveMachine(dir, m.MachineID) + } + } +} diff --git a/go/internal/client/shares_test.go b/go/internal/client/shares_test.go new file mode 100644 index 0000000..57a44a4 --- /dev/null +++ b/go/internal/client/shares_test.go @@ -0,0 +1,148 @@ +// go/internal/client/shares_test.go — local share state: owner mint records, +// gid prefix resolution, guest grant lookup, and the guest-state sweep. +package client + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/srcful/terminal-relay/go/internal/identity" +) + +func shareSigner(t *testing.T, fill byte) *identity.Signer { + t.Helper() + s, err := identity.DeriveSigner(bytes.Repeat([]byte{fill}, 32)) + if err != nil { + t.Fatal(err) + } + return s +} + +func mintRecord(t *testing.T, owner, guest *identity.Signer, machine string, ttl time.Duration) (string, *identity.SignedGrant) { + t.Helper() + sg, err := identity.MintGrant(owner, machine, guest.Address, "", "", ttl, time.Now()) + if err != nil { + t.Fatal(err) + } + rec, err := sg.JSON() + if err != nil { + t.Fatal(err) + } + return rec, sg +} + +func TestOwnerShareRoundTripAndRevokedFlag(t *testing.T) { + dir := t.TempDir() + owner, guest := shareSigner(t, 0x11), shareSigner(t, 0x22) + rec, sg := mintRecord(t, owner, guest, "machine-1", time.Hour) + if err := SaveOwnerShare(dir, rec, "sharebox"); err != nil { + t.Fatal(err) + } + shares, err := ListOwnerShares(dir) + if err != nil || len(shares) != 1 { + t.Fatalf("shares=%v err=%v", shares, err) + } + s := shares[0] + if s.MachineName != "sharebox" || s.Revoked || s.Grant.GID != sg.GID { + t.Fatalf("share = %+v", s) + } + if err := MarkShareRevoked(dir, sg.GID); err != nil { + t.Fatal(err) + } + shares, _ = ListOwnerShares(dir) + if !shares[0].Revoked { + t.Fatal("revoked flag did not persist") + } +} + +func TestResolveShareGIDPrefix(t *testing.T) { + dir := t.TempDir() + owner, guest := shareSigner(t, 0x11), shareSigner(t, 0x22) + recA, sgA := mintRecord(t, owner, guest, "machine-1", time.Hour) + recB, sgB := mintRecord(t, owner, guest, "machine-2", 2*time.Hour) + _ = SaveOwnerShare(dir, recA, "boxA") + _ = SaveOwnerShare(dir, recB, "boxB") + + got, err := ResolveShareGID(dir, sgA.GID[:8]) + if err != nil || got.Grant.GID != sgA.GID { + t.Fatalf("prefix resolve: %+v err=%v", got, err) + } + if _, err := ResolveShareGID(dir, "zzzz"); err == nil { + t.Fatal("unknown prefix resolved") + } + // The empty prefix matches both → ambiguous. + if _, err := ResolveShareGID(dir, ""); err == nil { + t.Fatal("ambiguous prefix resolved") + } + _ = sgB +} + +func TestGuestGrantForPicksLatest(t *testing.T) { + dir := t.TempDir() + owner, guest := shareSigner(t, 0x11), shareSigner(t, 0x22) + recOld, _ := mintRecord(t, owner, guest, "m1", time.Hour) + recNew, sgNew := mintRecord(t, owner, guest, "m1", 2*time.Hour) + for _, rec := range []string{recOld, recNew} { + sg, _ := identity.ParseSignedGrant([]byte(rec)) + if err := SaveGuestGrant(dir, sg.GID, rec); err != nil { + t.Fatal(err) + } + } + g := GuestGrantFor(dir, "m1") + if g == nil || g.GID != sgNew.GID { + t.Fatalf("want the later grant, got %+v", g) + } + if GuestGrantFor(dir, "other") != nil { + t.Fatal("grant for a machine we never joined") + } +} + +func TestSweepGuestStateRemovesClosedShares(t *testing.T) { + dir := t.TempDir() + owner, guest := shareSigner(t, 0x11), shareSigner(t, 0x22) + + // One live share, one whose window has fully closed. + liveRec, liveSG := mintRecord(t, owner, guest, "m-live", time.Hour) + _ = SaveGuestGrant(dir, liveSG.GID, liveRec) + now := time.Now() + dead, err := owner.SignGrant(identity.Grant{ + V: 1, Owner: owner.Address, Machine: "m-dead", Guest: guest.Address, + Scope: "main", Mode: "ro", NB: now.Add(-3 * time.Hour).Unix(), NA: now.Add(-2 * time.Hour).Unix(), + GID: "deaddeaddeaddead", + }) + if err != nil { + t.Fatal(err) + } + deadRec, _ := dead.JSON() + _ = SaveGuestGrant(dir, dead.GID, deadRec) + + for _, m := range []Machine{ + {Name: "live", MachineID: "m-live", HostPubHex: "aa", SignalURL: "https://r", Owner: owner.Address}, + {Name: "dead", MachineID: "m-dead", HostPubHex: "bb", SignalURL: "https://r", Owner: owner.Address}, + {Name: "mine", MachineID: "m-mine", HostPubHex: "cc", SignalURL: "https://r"}, + } { + if err := AddMachine(dir, m); err != nil { + t.Fatal(err) + } + } + + SweepGuestState(dir, now) + + machines, _ := ListMachines(dir) + names := map[string]bool{} + for _, m := range machines { + names[m.Name] = true + } + if !names["live"] || !names["mine"] || names["dead"] { + t.Fatalf("sweep kept/removed the wrong entries: %v", names) + } + if _, err := os.Stat(filepath.Join(dir, "grants", dead.GID+".json")); !os.IsNotExist(err) { + t.Fatal("closed grant file survived the sweep") + } + if _, err := os.Stat(filepath.Join(dir, "grants", liveSG.GID+".json")); err != nil { + t.Fatal("live grant file was swept") + } +} diff --git a/go/internal/client/store.go b/go/internal/client/store.go index 265402a..a8b1bd3 100644 --- a/go/internal/client/store.go +++ b/go/internal/client/store.go @@ -454,6 +454,23 @@ func AddMachine(dir string, m Machine) error { return writeMachines(dir, list) } +// RemoveMachine drops one machine by id. Used by the guest-state sweep — a +// share whose window closed is gone; the pin set for everything else is copied +// through untouched. +func RemoveMachine(dir, machineID string) error { + list, err := ListMachines(dir) + if err != nil { + return err + } + kept := list[:0] + for _, m := range list { + if m.MachineID != machineID { + kept = append(kept, m) + } + } + return writeMachines(dir, kept) +} + // writeMachines writes machines.json atomically (temp file + rename) so a crash // mid-write can't truncate the pin store. func writeMachines(dir string, list []Machine) error {