docs/ssh.md covers the flags, setup, what is open and closed, per session state, two sessions on one file, the ssh.* telemetry events and the limits. The architecture, files, testing and docs index pages, the README and the roadmap (SSH mode done, live sharing still shelved) point to it.
- commit
- 2c61af0831f94d0570cf114cf46224188750294f
- parent
- 92ff13232dac807d7f1c00c991f04c67d6687d20
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 2 | + |
| M | ROADMAP.md | 6 | +- |
| M | docs/ | 1 | + |
| M | docs/ | 24 | +++++- |
| M | docs/ | 5 | +- |
| A | docs/ | 139 | ++++++++++++++++++++++++++++++ |
| M | docs/ | 4 | + |
@@ -23,6 +23,7 @@ Requires Go 1.27. Or from a clone: `make build` puts the binary in `bin/012`.
2323 012 # a new sheet2424 012 budget.012 # open or create a sheet2525 012 sales.xlsx # import .xlsx, .csv, .tsv, .sqlite, .parquet or Lotus .wk126+012 serve ~/sheets # serve a directory over SSH, a 012 per session2627 ```2728 2829 F1 shows every shortcut, F10 or Alt+letter opens the menus, and Ctrl+K@@ -79,6 +80,7 @@ kitty, Ghostty and WezTerm they are real images.
7980 - [Charts, links and the terminal](docs/charts.md)8081 - [Files](docs/files.md)8182 - [JEV functions](docs/jev.md)83+- [Serving over SSH](docs/ssh.md): `012 serve`, public-key only, confined to one directory8284 - [Architecture](docs/architecture.md), [UX bar](docs/UX.md) and [testing](docs/testing.md)8385 - [Limits](docs/limits.md): how big a sheet 012 handles and where it slows down8486 - [Observability](docs/observability.md): event logs, DuckDB, and a local Collector, ClickHouse and Grafana stack@@ -76,7 +76,7 @@ Avoid in the main binary: DuckDB and automerge (both need cgo).
7676 - Multiple sheets with references between them, in Sheets' style (`Sheet2!A1`) rather than 1-2-3 R3's `B:A1`, and sheet tabs. Done.7777 - Pivot tables as derived sheets. L.7878 - Macros: replay the command log; Starlark (`go.starlark.net`) for scripts, with step limits.79-- Serve over SSH with `charm.land/wish/v2`.79+- Serve over SSH with `charm.land/wish/v2`. Done: `012 serve [dir]`, a 012 per session confined to the directory, public-key auth only ([docs/ssh.md](docs/ssh.md)). Not shared editing: see below.8080 - Decimal mode for currency (`cockroachdb/apd/v3`), opt-in. Done: File > Settings > Decimal arithmetic, boundary in the README.8181 - Demos in CI with VHS tapes; screenshots with freeze. Tapes done (`make demos`, `demos/`); not yet run in CI.8282 @@ -89,7 +89,9 @@ Avoid in the main binary: DuckDB and automerge (both need cgo).
8989 9090 ## Later: sharing a live sheet (shelved)9191 92-Explored, not scheduled. One session host that runs a Bubble Tea program92+Explored, not scheduled. `012 serve` gives each SSH session its own93+spreadsheet; saving over a file another session saved asks first, and94+that is all the sessions know of each other. One session host that runs a Bubble Tea program9395 for any byte stream with window-size events, fed by SSH9496 (`charm.land/wish/v2`), iroh tickets (the Go transport in9597 `FelineStateMachine/allons` `local/transport/iroh`, which needs cgo and a@@ -9,6 +9,7 @@
99 - [Charts, links and the terminal](charts.md): charts, hyperlinks, terminal features used1010 - [Files](files.md): the `.012` format, import and export1111 - [JEV functions](jev.md): asking a hosted model from formulas12+- [Serving over SSH](ssh.md): `012 serve`, setup, the security model and limits1213 1314 **Working on 012**1415 @@ -4,7 +4,7 @@
44 Lip Gloss v2.55 66 ```7-cmd/012 entry point: flags, JEV setup, opening or importing a file7+cmd/012 entry point: flags, JEV setup, opening or importing a file, 012 serve88 internal/sheet the engine: cells, evaluation, functions, recalculation, undo, files99 internal/formula the formula language: references, lexer, parser, printer, rewriting1010 internal/numfmt number formats, rounding, General, date serials@@ -12,6 +12,8 @@ internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk1
1212 internal/chart chart layout, text rendering and kitty image encoding1313 internal/jev JEV configuration, answer cache and TypeSafe client1414 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats15+internal/serve the SSH server: auth, host key, a Model per session (charm.land/wish/v2)16+internal/confine resolving typed file names, confined to a directory when served1517 internal/stress synthetic worst-case sheets for the -tags stress benchmarks1618 internal/ui the Bubble Tea model: modes, menus, overlays, rendering1719 theme style roles and the widgets drawn with them (frames, key chips)@@ -203,6 +205,26 @@ saved with sheets. Work is bounded by the chart's size, not its data: only
203205 the categories that fit are drawn, pie slices are found by binary search,204206 and a pie image supersamples only pixels on a slice edge or the rim.205207 208+## Serving over SSH209+210+`internal/serve` wraps `charm.land/ssh` (wish's server) with only a211+session channel, public-key auth against authorized_keys and a shell212+request allowed. Each session builds a `ui.Model` of its own and runs it213+in its own `tea.Program` over the session (wish's emulated PTY), with214+the client's environment and window size; `Model.Serve` gives it the215+client's environment for terminal detection and a `confine.Root` for216+file names. Everything a model knows lives in the model, so sessions217+share nothing but read-only tables (the command and function218+registries), the process-wide telemetry and, with JEV on, the HTTP219+client; each gets its own `jev.Cache`, whose queue belongs to that220+session's program. See [ssh.md](ssh.md).221+222+**File names.** The UI keeps names as typed and turns them into paths223+only to read or write, through the model's `confine.Root`: the zero224+root (the local app) uses them as they are, a served session's root225+keeps them inside its directory. Every open, save, import, download and226+file listing goes through `Model.path` or the root.227+206228 ## Telemetry207229 208230 `internal/telemetry` is off unless asked for. When on, spans, events and@@ -36,7 +36,10 @@ A cell without formatting is just what was typed; a formatted cell is a
3636 small object. Version 3 adds named ranges, frozen panes and a filter, and is3737 only written when a sheet uses one of them, so older builds of 012 can open3838 everything else. Charts are an optional `charts` field that older builds39-ignore. Saves are atomic: 012 writes a temporary file and renames it.39+ignore. Saves are atomic: 012 writes a temporary file and renames it. Saving over40+the open file when something else wrote it since it was opened or last41+saved (another program, or another session of [012 serve](ssh.md)) asks42+first: Enter overwrites, S saves under another name, Esc cancels.4043 4144 Other formats import as one sheet named after the file (or the SQLite4245 table); CSV and TSV downloads write the sheet shown, as Sheets' do.@@ -0,0 +1,139 @@
1+# Serving over SSH2+3+`012 serve` runs an SSH server that gives every session its own 012, in4+the client's own terminal, on the files of one directory. It's for5+reaching your sheets from another machine or a tablet with an SSH app,6+without installing anything there. It is not shared editing: two7+sessions are two separate spreadsheets (see8+[the roadmap](../ROADMAP.md#later-sharing-a-live-sheet-shelved)).9+10+```sh11+012 serve ~/sheets # serve ~/sheets on 127.0.0.1:231212+ssh -p 2312 localhost # from this machine13+```14+15+```16+012 serve [--listen addr] [--authorized-keys file] [--host-key file]17+ [--idle-timeout 30m] [--max-sessions 8] [--log file] [--otlp url] [dir]18+```19+20+| Flag | Default | What |21+|---|---|---|22+| `dir` | `.` | The served directory. Sessions open, save, import and download only inside it |23+| `--listen` | `127.0.0.1:2312` | Address to listen on. Loopback only unless you change it |24+| `--authorized-keys` | `~/.ssh/authorized_keys` | Public keys allowed in, in OpenSSH's format |25+| `--host-key` | `<config dir>/012/ssh_host_ed25519_key` | The server's key, generated on first run |26+| `--idle-timeout` | `30m` | End a session after this long without input; `0` never does |27+| `--max-sessions` | `8` | Sessions at once; more are turned away with a message |28+| `--log`, `--otlp` | off | Telemetry, as for the app ([observability.md](observability.md)) |29+30+The config directory is `os.UserConfigDir()`: `~/Library/Application31+Support` on macOS, `~/.config` on Linux. On start, 012 prints the served32+directory, the address, the host key's fingerprint and the command to33+connect.34+35+## Setup36+37+1. Put the public keys that may connect in an authorized_keys file. Your38+ own `~/.ssh/authorized_keys` works, or keep a separate one for 012:39+40+ ```sh41+ cat ~/.ssh/id_ed25519.pub > ~/.config/012/authorized_keys42+ chmod 600 ~/.config/012/authorized_keys43+ 012 serve --authorized-keys ~/.config/012/authorized_keys ~/sheets44+ ```45+46+2. Connect with `ssh -p 2312 host`. The first time, check the host key47+ fingerprint ssh shows against the one 012 printed, or add the key to48+ `known_hosts` yourself from `ssh_host_ed25519_key.pub` next to it.49+50+3. To reach it from another machine, prefer keeping 012 on loopback and51+ tunnelling through the machine's own sshd52+ (`ssh -L 2312:127.0.0.1:2312 host`, then `ssh -p 2312 localhost`), or53+ a private network such as Tailscale with `--listen 100.x.y.z:2312`.54+ Listening beyond loopback prints a warning.55+56+Stop the server with Ctrl+C. Running sessions end with it, and unsaved57+changes in them are lost.58+59+## Security model60+61+The server exposes the files of one directory to whoever holds one of62+the authorized keys. Everything else is closed.63+64+- **Authentication.** Public keys only, checked against the65+ authorized_keys file, which is read again on every login so edits66+ apply at once. No passwords, no keyboard-interactive, no certificates.67+ The file must not be writable by group or others, as with sshd's68+ `StrictModes`. Keys carrying options 012 can't honor (`command=`,69+ `from=`, `no-pty`, `restrict` without `pty`, `expiry-time=`,70+ `cert-authority` and so on) are skipped, so a key restricted to, say,71+ a backup command never gets a spreadsheet. The user name is logged but72+ not checked.73+- **Host key.** An ed25519 key generated on first run, written `0600`74+ in a `0700` directory. 012 refuses to start if the key is readable by75+ others.76+- **What a session can do.** Run 012, nothing else. Exec requests77+ (`ssh host cat /etc/passwd`), subsystems (`sftp`, `scp`), local and78+ remote port forwarding and X11 are refused; agent forwarding requests79+ are accepted by the SSH library but never used. A session without a80+ terminal is told to use `ssh -t` and closed.81+- **Files.** Names typed in File > Open, Save as, Import and Download82+ resolve inside the served directory. Refused, with "outside the served83+ directory": absolute paths, `..` that climbs out, hidden files and84+ directories (`.env`, `.git`, `.ssh`), and symbolic links that lead85+ outside or nowhere. Links that stay inside work. Error messages name86+ files relative to the served directory, not where it is on the87+ server. Files are written with the server's user and permissions.88+- **JEV.** On only when the server process's own environment has89+ `TYPESAFE_API_KEY`; sessions never read `.env` files. Every session90+ has its own answer cache, and all of them spend the server's key.91+- **Logs.** Logins, rejected keys, failed handshakes, sessions turned92+ away and session ends go to stderr and to telemetry as `ssh.*` events93+ with the user name, the key's SHA256 fingerprint and the remote94+ address, never file contents or cells.95+96+| Event | When | Attributes |97+|---|---|---|98+| `ssh.session` | a session starts | `user`, `key`, `remote`, `term`, `width`, `height` |99+| `ssh.session_end` | it ends | `user`, `key`, `remote`, `duration`, `idle` (closed for idling) |100+| `ssh.rejected` | a key not in authorized_keys | `user`, `key`, `remote` |101+| `ssh.full` | a session turned away at `--max-sessions` | `user`, `key`, `remote` |102+| `ssh.failed` | a handshake or login fails | `remote`, `error` |103+| `ssh.auth_error` | authorized_keys can't be read or parsed | `error` |104+105+The `frames` summaries and the `ssh_sessions` gauge cover every session106+together: telemetry is per process.107+108+What it doesn't protect against: someone who can already write to the109+served directory on the server (a symbolic link swapped in between the110+check and the open), and anything a holder of an authorized key does111+with the files they're given.112+113+## Each session114+115+Every session is a separate 012 with nothing shared but the process:116+its own workbook, undo history, clipboard, theme, JEV cache and117+terminal state. What it learns about the terminal comes from the118+client: the window size and its changes, the colors (from the `TERM`119+the client sends), the light or dark background, kitty graphics, and120+the clipboard, which Ctrl+C sets on the client's machine through OSC 52.121+122+## Two sessions, one file123+124+Opening the same file in two sessions gives two copies. Saving over a125+file that changed on disk since it was opened or last saved in this126+session (someone else saved it) asks first on the context line:127+Enter overwrites it, S saves under another name, Esc cancels. The same128+check protects the local app from other programs writing the file.129+130+## Limits131+132+- Sessions start on a new sheet; there's no way to open a file from the133+ ssh command line, since commands are refused.134+- An idle session is closed without saving.135+- Each session holds about 1.3 MiB of heap on a new sheet, plus about136+ 300 B per cell of the sheets it opens; two sessions opening one file137+ hold two copies. With 50 sessions typing at once on loopback, frames138+ still arrive within one frame interval (p95 16.6 ms), the same as with139+ 10: see [limits.md](limits.md#serving-over-ssh).@@ -28,6 +28,10 @@ cursor position, window title, hyperlinks, which screen is active after
2828 quitting. A fake TypeSafe server answers JEV questions, and the harness2929 clears `TYPESAFE_API_KEY` so tests never reach the real service.3030 31+`e2e/ssh_test.go` runs `012 serve` and reaches it with the system's32+`ssh` client inside the same libghostty terminal, so the server path33+is tested as a user sees it (skipped when `ssh` isn't installed).34+3135 `make e2e` builds libghostty-vt from source with Zig into `.deps/` on first3236 use. It is its own Go module so cgo never reaches the main binary.3337