From cf3888e34c47882359df52d826392ae2a39e1997 Mon Sep 17 00:00:00 2001 From: Souta Date: Sun, 10 May 2026 14:59:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20expansion=20=E2=80=94=20A=20axis=20?= =?UTF-8?q?(migrate=20existing)=20+=20B/C=20axes=20(synthesis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the doc count from 35 → 49 pages. Three axes: A. Migrate existing trusted content into the en/ tree (zero fabrication risk): - docs/architecture-shadow-vault.md (565 lines) → docs/en/architecture/shadow-vault.md - docs/architecture-perf.md → docs/en/architecture/perf.md - docs/architecture-collab.md → docs/en/architecture/collab.md - docs/plugin-compatibility.md → docs/en/user-guide/plugin-compatibility.md - docs/testing-strategy.md → docs/en/contributing/testing-strategy.md Wikilinks updated to match new locations. B. Synthesis content (medium fabrication risk; verified against actual sources): - en/glossary.md — terms used across the docs, defined once - en/roadmap.md — distilled from the 2 open tracking issues (#126, #151) - en/cookbook/ — 4 task-oriented walkthroughs + index: - raspberry-pi-vault.md (zero to first connect on a Pi) - ssh-keygen.md (generate + load + copy keys) - share-via-tailscale.md (multi-editor without port-forward) - systemd-managed-daemon.md (cosign-verify + systemd unit + plugin attach) C. Verification-required content (high fabrication risk — sampled against source): - en/api/examples.md — copy-pasteable JSON-RPC envelopes for every common method. Field names + shapes verified against server/internal/proto/types.go. - en/tutorial.md — long-form synthesis of install + quickstart + first-connect + cosign-verify (low risk: pure composition of existing pages). Skipped from C scope: "migration from Obsidian Sync" — high fabrication risk for me without first-hand knowledge of Sync's wire-level specifics. Landing page (en/index.md) updated with new section cards. Bump: 1.0.44-beta.2 → 1.0.44-beta.3. --- docs/en/api/examples.md | 321 ++++++++++++++++++ docs/en/api/overview.md | 1 + .../architecture/collab.md} | 4 +- docs/en/architecture/index.md | 6 +- .../architecture/perf.md} | 2 +- .../architecture/shadow-vault.md} | 0 .../{ => en/contributing}/testing-strategy.md | 2 +- docs/en/cookbook/index.md | 19 ++ docs/en/cookbook/raspberry-pi-vault.md | 71 ++++ docs/en/cookbook/share-via-tailscale.md | 87 +++++ docs/en/cookbook/ssh-keygen.md | 74 ++++ docs/en/cookbook/systemd-managed-daemon.md | 117 +++++++ docs/en/getting-started/first-connect.md | 2 +- docs/en/glossary.md | 122 +++++++ docs/en/index.md | 14 +- docs/en/roadmap.md | 59 ++++ docs/en/tutorial.md | 153 +++++++++ .../user-guide}/plugin-compatibility.md | 0 manifest-beta.json | 2 +- plugin/manifest.json | 2 +- plugin/package-lock.json | 4 +- plugin/package.json | 2 +- 22 files changed, 1048 insertions(+), 16 deletions(-) create mode 100644 docs/en/api/examples.md rename docs/{architecture-collab.md => en/architecture/collab.md} (98%) rename docs/{architecture-perf.md => en/architecture/perf.md} (99%) rename docs/{architecture-shadow-vault.md => en/architecture/shadow-vault.md} (100%) rename docs/{ => en/contributing}/testing-strategy.md (98%) create mode 100644 docs/en/cookbook/index.md create mode 100644 docs/en/cookbook/raspberry-pi-vault.md create mode 100644 docs/en/cookbook/share-via-tailscale.md create mode 100644 docs/en/cookbook/ssh-keygen.md create mode 100644 docs/en/cookbook/systemd-managed-daemon.md create mode 100644 docs/en/glossary.md create mode 100644 docs/en/roadmap.md create mode 100644 docs/en/tutorial.md rename docs/{ => en/user-guide}/plugin-compatibility.md (100%) diff --git a/docs/en/api/examples.md b/docs/en/api/examples.md new file mode 100644 index 0000000..dc00944 --- /dev/null +++ b/docs/en/api/examples.md @@ -0,0 +1,321 @@ +--- +title: API examples +tags: [api, reference, examples] +--- + +# API examples + +Copy-pasteable JSON-RPC envelopes for the most common methods. Field names + shapes are taken directly from `server/internal/proto/types.go` so they match the wire exactly. + +For the broader protocol reference (framing, paths, error codes), see [[en/api/overview|API overview]]. + +## Handshake + +Every connection starts with these two calls before any `fs.*` method. + +```json +// → request: authenticate +{"jsonrpc":"2.0","id":1,"method":"auth","params":{"token":"abc...64hex"}} + +// ← response +{"jsonrpc":"2.0","id":1,"result":{"ok":true}} + +// → request: capability + version handshake +{"jsonrpc":"2.0","id":2,"method":"server.info","params":{}} + +// ← response +{ + "jsonrpc":"2.0","id":2, + "result":{ + "version":"0.1.0", + "protocolVersion":1, + "capabilities":["fs.stat","fs.exists","fs.list","fs.walk","fs.readText","fs.readBinary","fs.readBinaryRange","fs.write","fs.writeBinary","fs.append","fs.appendBinary","fs.mkdir","fs.remove","fs.rmdir","fs.rename","fs.copy","fs.trashLocal","fs.thumbnail","fs.watch","fs.unwatch"], + "vaultRoot":"/home/pi/notes" + } +} +``` + +## Read + +### Stat one path + +```json +// → request +{"jsonrpc":"2.0","id":3,"method":"fs.stat","params":{"path":"notes/today.md"}} + +// ← response (file exists) +{ + "jsonrpc":"2.0","id":3, + "result":{"type":"file","mtime":1715333412000,"size":1284,"mode":420} +} + +// ← response (file does not exist) — null is the success case +{"jsonrpc":"2.0","id":3,"result":null} +``` + +### List a directory + +```json +// → request +{"jsonrpc":"2.0","id":4,"method":"fs.list","params":{"path":"notes"}} + +// ← response +{ + "jsonrpc":"2.0","id":4, + "result":{ + "entries":[ + {"name":"today.md","type":"file","mtime":1715333412000,"size":1284}, + {"name":"archive","type":"folder","mtime":1715200000000,"size":0} + ] + } +} +``` + +### Walk recursively + +```json +// → request +{ + "jsonrpc":"2.0","id":5,"method":"fs.walk", + "params":{"path":"notes","recursive":true,"maxEntries":1000} +} + +// ← response +{ + "jsonrpc":"2.0","id":5, + "result":{ + "entries":[ + {"path":"notes/today.md","type":"file","mtime":1715333412000,"size":1284}, + {"path":"notes/archive","type":"folder","mtime":1715200000000,"size":0}, + {"path":"notes/archive/old.md","type":"file","mtime":1715100000000,"size":512} + ], + "truncated":false + } +} +``` + +> `recursive` defaults to `false` (the Go zero value) — set `true` explicitly to descend. +> `truncated` is `true` when `maxEntries` was reached before the walk completed. + +### Read text + +```json +// → request +{"jsonrpc":"2.0","id":6,"method":"fs.readText","params":{"path":"notes/today.md"}} + +// ← response +{ + "jsonrpc":"2.0","id":6, + "result":{ + "content":"# Today\n\nMeeting notes...\n", + "mtime":1715333412000, + "size":1284, + "encoding":"utf8" + } +} +``` + +### Read binary + +```json +// → request +{"jsonrpc":"2.0","id":7,"method":"fs.readBinary","params":{"path":"images/diagram.png"}} + +// ← response +{ + "jsonrpc":"2.0","id":7, + "result":{ + "contentBase64":"iVBORw0KGgoAAAANSUhEUgAA...", + "mtime":1715333000000, + "size":24576 + } +} +``` + +### Read partial binary + +For large files (or thumbnail logic), pull a byte range: + +```json +// → request: bytes [0, 4096) of a 100 MB file +{ + "jsonrpc":"2.0","id":8,"method":"fs.readBinaryRange", + "params":{"path":"big.bin","offset":0,"length":4096} +} + +// ← response — note `size` is the FULL file size, content is just the slice +{ + "jsonrpc":"2.0","id":8, + "result":{ + "contentBase64":"AAAAAAAA...4096-bytes...", + "mtime":1715300000000, + "size":104857600 + } +} +``` + +`expectedMtime` (optional) makes the read fail with `PreconditionFailed (-32020)` if the file changed mid-stream — useful when chunking through a multi-call download. + +## Write + +### Atomic text write with conflict detection + +```json +// → request: write only if mtime is still 1715333412000 +{ + "jsonrpc":"2.0","id":9,"method":"fs.write", + "params":{ + "path":"notes/today.md", + "content":"# Today\n\nUpdated content\n", + "expectedMtime":1715333412000 + } +} + +// ← response (write succeeded) +{"jsonrpc":"2.0","id":9,"result":{"mtime":1715333500000}} + +// ← response (someone else modified it first) +{ + "jsonrpc":"2.0","id":9, + "error":{ + "code":-32020, + "message":"precondition failed: mtime mismatch (expected 1715333412000, current 1715333450000)" + } +} +``` + +> The write is atomic (tmp file + rename), so a crashed daemon never leaves a half-written file. +> Omit `expectedMtime` when you don't care about clobbering — fresh writes from a clean slate. + +### Append + +```json +{ + "jsonrpc":"2.0","id":10,"method":"fs.append", + "params":{"path":"daily/log.md","content":"\n- new entry at 14:32\n"} +} + +// ← response +{"jsonrpc":"2.0","id":10,"result":{"mtime":1715333612000}} +``` + +## Tree mutations + +### mkdir -p + +```json +// → request +{ + "jsonrpc":"2.0","id":11,"method":"fs.mkdir", + "params":{"path":"projects/2026/q2","recursive":true} +} + +// ← response +{"jsonrpc":"2.0","id":11,"result":{}} +``` + +### Rename + +```json +// → request +{ + "jsonrpc":"2.0","id":12,"method":"fs.rename", + "params":{"oldPath":"notes/draft.md","newPath":"notes/published/post.md"} +} + +// ← response +{"jsonrpc":"2.0","id":12,"result":{"mtime":1715333700000}} +``` + +Rename creates intermediate destination directories if needed. + +### Trash + +```json +// → request: move to /.trash/... +{"jsonrpc":"2.0","id":13,"method":"fs.trashLocal","params":{"path":"old/note.md"}} + +// ← response +{"jsonrpc":"2.0","id":13,"result":{}} +``` + +## Watch + +### Subscribe + receive + +```json +// → request: watch a folder recursively +{ + "jsonrpc":"2.0","id":14,"method":"fs.watch", + "params":{"path":"notes","recursive":true} +} + +// ← response +{"jsonrpc":"2.0","id":14,"result":{"subscriptionId":"sub_abc123"}} + +// ← server-pushed notification (note: NO id field — JSON-RPC notifications spec) +{ + "jsonrpc":"2.0", + "method":"fs.changed", + "params":{ + "subscriptionId":"sub_abc123", + "path":"notes/today.md", + "event":"modified", + "mtime":1715333800000 + } +} +``` + +The current daemon emits only `created` / `modified` / `deleted` (no `renamed`). Renames surface as a `deleted` + `created` pair on the affected paths. + +### Unsubscribe + +```json +// → request +{"jsonrpc":"2.0","id":15,"method":"fs.unwatch","params":{"subscriptionId":"sub_abc123"}} + +// ← response (idempotent — unknown id is silently OK) +{"jsonrpc":"2.0","id":15,"result":{}} +``` + +## Errors + +A typical error response (no `data` field — the daemon always omits it via Go `omitempty`): + +```json +{ + "jsonrpc":"2.0","id":99, + "error":{ + "code":-32010, + "message":"file not found: nonexistent/path.md" + } +} +``` + +Full error catalogue: [[en/api/errors|API → Error codes]]. + +## Smoke-testing from the shell + +If you want to poke the daemon directly (skip the plugin), open the SSH-tunneled socket via `nc -U` or `socat`: + +```bash +TOKEN=$(cat ~/.obsidian-remote/token) +SOCK=~/.obsidian-remote/server.sock + +# Length-prefixed JSON-RPC framing helper +frame() { + local json="$1" + local len=${#json} + printf '%b' "$(printf '\\x%02x' \ + $((len >> 24 & 0xff)) $((len >> 16 & 0xff)) \ + $((len >> 8 & 0xff)) $((len & 0xff)))$json" +} + +# Authenticate + list root +{ + frame '{"jsonrpc":"2.0","id":1,"method":"auth","params":{"token":"'"$TOKEN"'"}}' + frame '{"jsonrpc":"2.0","id":2,"method":"fs.list","params":{"path":""}}' +} | nc -U "$SOCK" | xxd | head -40 +``` + +For real client-side use, write a small JSON-RPC library that handles framing — manual frames are only for spot-checks. diff --git a/docs/en/api/overview.md b/docs/en/api/overview.md index f08c0e4..02bd604 100644 --- a/docs/en/api/overview.md +++ b/docs/en/api/overview.md @@ -20,6 +20,7 @@ The daemon speaks **JSON-RPC 2.0** over a length-prefixed framing on a Unix sock - **[[en/api/filesystem|Filesystem]]** — `fs.stat`, `fs.read*`, `fs.write*`, `fs.list`, `fs.walk`, `fs.mkdir`, `fs.remove`, etc. - **[[en/api/watch|Watch / notifications]]** — `fs.watch`, `fs.unwatch`, `fs.changed` (server-push) - **[[en/api/errors|Error codes]]** — full error reference +- **[[en/api/examples|Examples]]** — copy-pasteable JSON-RPC envelopes for every common operation ## Protocol version diff --git a/docs/architecture-collab.md b/docs/en/architecture/collab.md similarity index 98% rename from docs/architecture-collab.md rename to docs/en/architecture/collab.md index 9c8424e..a0b574c 100644 --- a/docs/architecture-collab.md +++ b/docs/en/architecture/collab.md @@ -2,8 +2,8 @@ This doc records the design for the conflict + offline-resilience epic landing in v0.4.29 onwards. Companion to -[architecture-shadow-vault.md](./architecture-shadow-vault.md) and -[architecture-perf.md](./architecture-perf.md). +[shadow-vault.md](./shadow-vault.md) and +[perf.md](./perf.md). ## Goals diff --git a/docs/en/architecture/index.md b/docs/en/architecture/index.md index 63949b4..34d33ca 100644 --- a/docs/en/architecture/index.md +++ b/docs/en/architecture/index.md @@ -11,9 +11,9 @@ Design specs for the major subsystems. These document **why** decisions were mad | Doc | What it covers | |---|---| -| [[architecture-shadow-vault\|Shadow vault]] | The "local Obsidian vault that mirrors remote" model — shadow lifecycle, file routing, sync events | -| [[architecture-perf\|Performance]] | Sync latency budget, perf bench, the per-merge baseline tracking on `perf-baseline` branch | -| [[architecture-collab\|Collaboration]] | Multi-client editing, conflict handling, the per-client `.obsidian/user//` workspace partition | +| [[shadow-vault\|Shadow vault]] | The "local Obsidian vault that mirrors remote" model — shadow lifecycle, file routing, sync events | +| [[perf\|Performance]] | Sync latency budget, perf bench, the per-merge baseline tracking on `perf-baseline` branch | +| [[collab\|Collaboration]] | Multi-client editing, conflict handling, the per-client `.obsidian/user//` workspace partition | ## Common threads across all three diff --git a/docs/architecture-perf.md b/docs/en/architecture/perf.md similarity index 99% rename from docs/architecture-perf.md rename to docs/en/architecture/perf.md index 7d5f0b2..4640b8e 100644 --- a/docs/architecture-perf.md +++ b/docs/en/architecture/perf.md @@ -2,7 +2,7 @@ This doc is the design record for the performance epic that lands in v0.4.23 onwards. It complements -[architecture-shadow-vault.md](./architecture-shadow-vault.md) (the +[shadow-vault.md](./shadow-vault.md) (the overall architecture) and [testing-strategy.md](./testing-strategy.md) (how the epic gets verified). diff --git a/docs/architecture-shadow-vault.md b/docs/en/architecture/shadow-vault.md similarity index 100% rename from docs/architecture-shadow-vault.md rename to docs/en/architecture/shadow-vault.md diff --git a/docs/testing-strategy.md b/docs/en/contributing/testing-strategy.md similarity index 98% rename from docs/testing-strategy.md rename to docs/en/contributing/testing-strategy.md index 703de52..522df4e 100644 --- a/docs/testing-strategy.md +++ b/docs/en/contributing/testing-strategy.md @@ -2,7 +2,7 @@ This document records the test architecture for `obsidian-remote-ssh`, adopted in v0.4.19 (Phase A) and v0.4.22 (Phase B). It complements -[architecture-shadow-vault.md](./architecture-shadow-vault.md) — the +[shadow-vault.md](./shadow-vault.md) — the shadow-vault flow is what we test; this doc explains *how*. ## Goals diff --git a/docs/en/cookbook/index.md b/docs/en/cookbook/index.md new file mode 100644 index 0000000..04e96ec --- /dev/null +++ b/docs/en/cookbook/index.md @@ -0,0 +1,19 @@ +--- +title: Cookbook +tags: [cookbook, how-to] +--- + +# Cookbook + +Goal-oriented walkthroughs that compose pieces from the rest of these docs. If you want a step-by-step "how do I…" answer, start here. + +## Recipes + +| Goal | Page | +|---|---| +| Set up a Pi as your home vault server (zero to first connect) | [[en/cookbook/raspberry-pi-vault\|Raspberry Pi vault from scratch]] | +| Generate an SSH key the plugin can use | [[en/cookbook/ssh-keygen\|Generating an SSH key]] | +| Edit your vault from a Pi while a colleague edits via Tailscale | [[en/cookbook/share-via-tailscale\|Share a vault via Tailscale]] | +| Replace the auto-deployed daemon with a systemd-managed one + cosign-verify it | [[en/cookbook/systemd-managed-daemon\|systemd-managed daemon]] | + +If you want a recipe that's not here, open a [discussion](https://github.com/sotashimozono/obsidian-remote-ssh/discussions) — common asks become recipes. diff --git a/docs/en/cookbook/raspberry-pi-vault.md b/docs/en/cookbook/raspberry-pi-vault.md new file mode 100644 index 0000000..946458a --- /dev/null +++ b/docs/en/cookbook/raspberry-pi-vault.md @@ -0,0 +1,71 @@ +--- +title: Raspberry Pi vault from scratch +tags: [cookbook, how-to, raspberry-pi] +--- + +# Raspberry Pi vault from scratch + +Goal: a Pi running on your home network that hosts your Obsidian vault, accessed from your laptop via this plugin. About 30 minutes including the Pi OS install. + +## Hardware + OS + +- Pi 4 / Pi 5 (4 GB RAM is plenty). Pi Zero 2 W works for small vaults. +- 32 GB+ microSD or (better) a USB-attached SSD. +- Raspberry Pi OS (Bookworm) or Ubuntu Server 22.04+ for arm64. + +Flash with [Raspberry Pi Imager](https://www.raspberrypi.com/software/). Under the gear icon, set: + +- Hostname: `obsidian-vault.local` (anything; pick something memorable) +- Enable SSH: yes, with public-key auth using your existing public key +- Wi-Fi credentials (or wire it ethernet) + +Boot the Pi, wait ~60 seconds. + +## First connect from your laptop + +Verify SSH works from your normal terminal first: +```bash +ssh pi@obsidian-vault.local +``` + +If that's good, you're done with Pi setup — the plugin needs nothing else on the remote yet. Make a vault directory: +```bash +ssh pi@obsidian-vault.local 'mkdir -p ~/notes' +``` + +## Add the profile in the plugin + +**Settings** → **Remote SSH** → **Add profile**: + +| Field | Value | +|---|---| +| Profile name | `Pi vault` | +| Host | `obsidian-vault.local` (or the Pi's IP) | +| Port | `22` | +| Username | `pi` | +| Authentication | `SSH agent` (recommended) or your private key path | +| Remote vault path | `/home/pi/notes` (or `~/notes`) | +| Mode | `RPC daemon` (lower latency than SFTP) | + +Click **Save**, then connect from the command palette: "Remote SSH: Connect" → pick `Pi vault`. + +The plugin uploads the daemon binary (~5 MB), starts it, opens a shadow vault window. First connect ~5–8 s; subsequent connects ~1 s. + +## Make the daemon outlive plugin reconnects + +Optional, but worth it for a Pi you'll keep on 24/7. Put the daemon under systemd so it survives plugin restarts and Pi reboots — see [[en/cookbook/systemd-managed-daemon|systemd-managed daemon]]. + +## Check it's working from the other side + +Edit a note in Obsidian, then on the Pi: +```bash +ls -lt ~/notes | head +``` + +Your latest edit should be on top with a recent mtime. + +## See also + +- [[en/server/raspberry-pi|Server / Raspberry Pi notes]] — performance ceilings per Pi model + tuning notes +- [[en/operations/troubleshooting|Troubleshooting]] — what to check if first connect fails +- [[en/cookbook/ssh-keygen|Generating an SSH key]] — if `ssh pi@obsidian-vault.local` asked for a password diff --git a/docs/en/cookbook/share-via-tailscale.md b/docs/en/cookbook/share-via-tailscale.md new file mode 100644 index 0000000..a136293 --- /dev/null +++ b/docs/en/cookbook/share-via-tailscale.md @@ -0,0 +1,87 @@ +--- +title: Share a vault via Tailscale +tags: [cookbook, how-to, tailscale] +--- + +# Share a vault via Tailscale + +Goal: a vault on a home Pi (or NAS, VPS) that you and a collaborator both edit from your respective laptops, with no port forwarding and no third-party cloud. + +## Why Tailscale + +The plugin needs SSH reachability to the host. The default options: + +| Approach | Setup | Trade-offs | +|---|---|---| +| Port-forward 22 on your home router | High effort, security-sensitive | Exposes sshd to the public internet | +| Cloudflare Tunnel | Medium effort | Requires CF account + a domain | +| **Tailscale** | Low effort | One install per device, mesh VPN; no public exposure | + +For "two laptops + one home server" sharing, Tailscale is the lowest-friction path. + +## On the host + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up +``` + +The `up` command shows a one-time auth URL. Open it, sign into your tailnet (or create one). When the host appears in your tailnet, note its tailscale hostname: + +```bash +tailscale status +# host 100.64.0.1 tagged-machine +# alias: obsidian-vault.tailnet-XXXX.ts.net +``` + +## On each editor's laptop + +Install Tailscale (macOS / Windows / Linux installers at [tailscale.com](https://tailscale.com/download)). Sign into the same tailnet. + +## Plugin profile + +For each editor, in the plugin: + +| Field | Value | +|---|---| +| Host | `obsidian-vault.tailnet-XXXX.ts.net` (or the `100.x.y.z` IP) | +| Port | `22` | +| Username | `pi` (or whatever your remote user is) | +| Authentication | SSH agent (recommended) | +| Remote vault path | `/home/pi/notes` | + +That's it — no jump host needed. Tailscale provides the path; the plugin sees a normal SSH host. + +## Multi-editor caveats + +Two people editing the same file at the same time = a conflict. The plugin detects + offers resolution (see [[en/user-guide/conflicts|Conflict handling]]) but you'll want to talk to your collaborator about who owns which area of the vault. + +The plugin's per-client `Client ID` keeps your workspace state (open tabs, panes, cursor position) from stomping on each other; see [[en/configuration/this-device|Configuration → This device]]. + +## ACL hardening (optional) + +Tailscale's default policy lets every device in the tailnet reach every other on every port. Lock down to "only laptops can SSH to the vault host" in the [Tailscale ACL editor](https://login.tailscale.com/admin/acls). + +```hujson +{ + "groups": { + "group:editors": ["alice@example.com", "bob@example.com"], + }, + "tagOwners": { + "tag:obsidian-vault": ["group:editors"], + }, + "acls": [ + { "action": "accept", "src": ["group:editors"], "dst": ["tag:obsidian-vault:22"] }, + ], +} +``` + +Then re-tag the host: +```bash +sudo tailscale up --advertise-tags=tag:obsidian-vault +``` + +## See also + +- [[en/user-guide/jump-host|User guide → Jump hosts]] — for cases where Tailscale isn't an option +- [[en/security/model|Security → Threat model]] — what the plugin defends against (Tailscale stacks neatly under it) diff --git a/docs/en/cookbook/ssh-keygen.md b/docs/en/cookbook/ssh-keygen.md new file mode 100644 index 0000000..0e0fe8e --- /dev/null +++ b/docs/en/cookbook/ssh-keygen.md @@ -0,0 +1,74 @@ +--- +title: Generating an SSH key +tags: [cookbook, how-to, ssh] +--- + +# Generating an SSH key + +If `ssh user@host` asks you for a password, you do not have a usable key for that host yet. Here is the 5-minute fix. + +## On your local machine + +Generate a modern Ed25519 key: + +```bash +ssh-keygen -t ed25519 -C "your-email@example.com" +``` + +Press Enter to accept the default path (`~/.ssh/id_ed25519`). When asked for a passphrase: enter one. The plugin works with passphrase-protected keys when you use **SSH agent** auth. + +## Add it to your agent + +```bash +# macOS / Linux +eval "$(ssh-agent -s)" +ssh-add ~/.ssh/id_ed25519 +``` + +```powershell +# Windows (one-time) +Get-Service ssh-agent | Set-Service -StartupType Automatic +Start-Service ssh-agent +ssh-add $HOME\.ssh\id_ed25519 +``` + +The agent unlocks the key once per session; subsequent `ssh` calls (and the plugin) won't re-prompt. + +## Copy the public key to the remote + +```bash +ssh-copy-id user@host +``` + +On Windows / hosts without `ssh-copy-id`, the manual form: + +```bash +cat ~/.ssh/id_ed25519.pub | ssh user@host \ + 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys' +``` + +## Verify + +```bash +ssh user@host "echo it works" +``` + +You should NOT be asked for a password. + +## In the plugin + +Profile **Authentication**: +- **SSH agent** — preferred. The agent has the unlocked key in memory; the plugin never sees the passphrase. +- **Private key** — point at `~/.ssh/id_ed25519`. The plugin prompts for the passphrase once per connect. + +## Hardware-key alternatives + +Anything `ssh-agent` can sign with works. Common setups: + +- **YubiKey / Nitrokey** — set up `ssh-add -K` (resident key) or use the OpenSSH `sk-ssh-ed25519` key type. Then pick **SSH agent** in the plugin. +- **Apple Secure Enclave** — `ssh-keygen -t ed25519-sk -O resident` requires a TouchID prompt; pair with the `ssh-agent` daemon shipped with macOS. + +## See also + +- [[en/user-guide/ssh-config|User guide → SSH config & keys]] +- [[en/security/host-keys|Host-key trust]] — the OTHER half of "how does the plugin know it's talking to the right host" diff --git a/docs/en/cookbook/systemd-managed-daemon.md b/docs/en/cookbook/systemd-managed-daemon.md new file mode 100644 index 0000000..3bfe8db --- /dev/null +++ b/docs/en/cookbook/systemd-managed-daemon.md @@ -0,0 +1,117 @@ +--- +title: systemd-managed daemon +tags: [cookbook, how-to, systemd, security] +--- + +# systemd-managed daemon (with cosign verification) + +Goal: instead of letting the plugin auto-deploy + redeploy the daemon on every connect, run a **cosign-verified** binary you control under systemd. Useful for: hosts you keep on 24/7, hosts where you want explicit lifecycle ownership, hosts shared with other users. + +> Trade-off: at time of writing the plugin's auto-deploy still runs on every connect by default — it will overwrite the binary you placed under systemd. The "reuse existing daemon" profile flag is on the roadmap; until then, treat this setup as "warm spare" + accept the redeploy. + +## 1. Download + verify the binary + +On a trusted machine (your laptop), grab the binary + bundle for your remote's arch from the [Releases](https://github.com/sotashimozono/obsidian-remote-ssh/releases) page: + +```bash +gh release download 1.0.43 --repo sotashimozono/obsidian-remote-ssh \ + --pattern 'obsidian-remote-server-linux-arm64*' \ + --pattern 'daemon-manifest.json*' +``` + +Verify the manifest: + +```bash +cosign verify-blob \ + --bundle daemon-manifest.json.bundle \ + --certificate-identity-regexp \ + 'https://github.com/sotashimozono/obsidian-remote-ssh/.github/workflows/release.yml@.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + daemon-manifest.json +``` + +You should see `Verified OK`. Then check the binary's hash matches the manifest: + +```bash +grep linux-arm64 daemon-manifest.json +# "obsidian-remote-server-linux-arm64": "abc123...sha256...def456" +sha256sum obsidian-remote-server-linux-arm64 +# abc123...def456 obsidian-remote-server-linux-arm64 +``` + +Hashes match → the binary is the one signed by THIS repo's release pipeline. + +For full background on what cosign is checking, see [[en/security/cosign-verify|Cosign verify]]. + +## 2. Copy the binary to the remote + +```bash +scp obsidian-remote-server-linux-arm64 user@remote:~/ +ssh user@remote 'sudo install -m 0755 -o $USER -g $USER \ + ~/obsidian-remote-server-linux-arm64 \ + /usr/local/bin/obsidian-remote-server' +``` + +## 3. systemd unit (per-user) + +`~/.config/systemd/user/obsidian-remote-server.service` on the remote: + +```ini +[Unit] +Description=obsidian-remote-ssh daemon +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/obsidian-remote-server \ + --vault-root=%h/notes \ + --socket=%h/.obsidian-remote/server.sock \ + --token-file=%h/.obsidian-remote/token \ + --verbose +Restart=on-failure +RestartSec=2s +MemoryMax=512M +CPUQuota=50% + +[Install] +WantedBy=default.target +``` + +Replace `%h/notes` with your vault path. Then: + +```bash +mkdir -p ~/.obsidian-remote && chmod 700 ~/.obsidian-remote +systemctl --user daemon-reload +systemctl --user enable --now obsidian-remote-server +``` + +To survive logout (no console session attached): + +```bash +sudo loginctl enable-linger $USER +``` + +## 4. Plugin profile + +In the plugin, configure the profile to point at the same paths the unit uses: + +| Field | Value | +|---|---| +| Daemon socket path | `.obsidian-remote/server.sock` (home-relative) | +| Daemon token path | `.obsidian-remote/token` (home-relative) | + +Connect once. The plugin will (currently) redeploy the binary, then attach to the socket. Your systemd unit's binary gets overwritten on this first connect — that's the unfortunate-but-temporary trade-off. The systemd lifecycle still wins after that: the daemon survives plugin restarts, and on Pi reboot systemd brings it back up before you connect from anywhere. + +## 5. Logs + +```bash +journalctl --user -u obsidian-remote-server -f +``` + +Replaces `~/.obsidian-remote/server.log` for the systemd-managed instance. + +## See also + +- [[en/server/systemd|Server / systemd unit]] — the canonical reference +- [[en/security/cosign-verify|Cosign verify]] — the full verify story +- [[en/server/auto-deploy|Server / auto-deploy]] — what the plugin does by default diff --git a/docs/en/getting-started/first-connect.md b/docs/en/getting-started/first-connect.md index c874fc1..b41091f 100644 --- a/docs/en/getting-started/first-connect.md +++ b/docs/en/getting-started/first-connect.md @@ -17,7 +17,7 @@ obsidian-remote-ssh does not edit your remote files directly through Obsidian's Result: Obsidian thinks it's editing a local vault. All Obsidian features (Dataview, Templater, Excalidraw, …) work because they ARE working against a local vault. The "remote-ness" lives in the sync layer, invisible to most plugins. -See [[architecture-shadow-vault|Shadow vault architecture]] for the full design. +See [[shadow-vault|Shadow vault architecture]] for the full design. ## What gets installed where diff --git a/docs/en/glossary.md b/docs/en/glossary.md new file mode 100644 index 0000000..72d6359 --- /dev/null +++ b/docs/en/glossary.md @@ -0,0 +1,122 @@ +--- +title: Glossary +tags: [glossary, reference] +--- + +# Glossary + +Terms that show up across these docs, defined once. If a term should be here and isn't, open an issue. + +## A + +**Adapter (Obsidian)** +The interface Obsidian uses to read/write files in a vault (`vault.adapter`). The plugin's shadow-vault model leaves the local adapter pointing at a real on-disk vault and routes file operations through the daemon RPC instead. + +**Auto-deploy** *(of the daemon)* +The default behavior: when you connect via an RPC profile, the plugin uploads the daemon binary to the remote and starts it under your SSH user. See [[en/server/auto-deploy|Server / auto-deploy]]. + +## B + +**BRAT** — *Beta Reviewers Auto-update Tool* +[obsidian42-brat](https://github.com/TfTHacker/obsidian42-brat) — a community plugin that installs other plugins straight from a GitHub repo's `manifest-beta.json`. We publish every merge to `next` as a BRAT-installable prerelease. See [[en/getting-started/install|Install]]. + +## C + +**Channel** *(release)* +Either **stable** (main branch, plain `X.Y.Z`, Obsidian Community Plugins store) or **beta** (next branch, `X.Y.Z-beta.N`, BRAT --beta). The version shape is the source of truth — see the project's `CONTRIBUTING.md` Branching model. + +**Client ID** +Per-device identifier, settable in **Settings → This device**. The plugin uses it to namespace the per-client `.obsidian/user//` subtree on the remote vault so multiple devices' workspace state doesn't collide. See [[en/configuration/this-device|Configuration → This device]]. + +**Cosign keyless** +[Sigstore](https://www.sigstore.dev/) signing flow that binds a signature to a CI workflow identity (no private keys to manage). The release pipeline signs every daemon binary this way. See [[en/security/cosign-verify|Cosign verify]]. + +## D + +**Daemon** — *the server-side `obsidian-remote-server` process* +A single Go binary that listens on a Unix socket on the remote, mediates all vault file operations, and authenticates the plugin via a per-startup token. See [[en/server/overview|Server overview]]. + +**Daemon panel** +The settings sub-panel that appears when an RPC profile has an active daemon — shows version + capabilities + a "View log" button. See [[en/operations/daemon-panel|Daemon panel]]. + +## F + +**Fingerprint** *(host key)* +The colon-separated SHA-256 of the remote SSH host key (e.g. `aa:bb:cc:dd:…`). The plugin's host-key store maps `host:port` to fingerprint and uses it on every reconnect to detect MITM. See [[en/security/host-keys|Host-key trust]]. + +**`fs.changed`** +Server-pushed JSON-RPC notification announcing a file/directory tree change. The plugin subscribes via `fs.watch` and uses these to keep the shadow vault in sync. See [[en/api/watch|API → watch]]. + +## H + +**Host-key store** +The plugin's own `known_hosts` equivalent (separate from `~/.ssh/known_hosts`). Persisted as the `hostKeyStore` key in the plugin's `data.json`. See [[en/security/host-keys|Security → Host-key trust]]. + +## J + +**JSON-RPC 2.0** +The wire protocol the plugin and daemon speak. All `fs.*` calls + `auth` + `server.info` follow the [JSON-RPC 2.0 spec](https://www.jsonrpc.org/specification) over a length-prefixed framing on a Unix socket. See [[en/api/overview|API overview]]. + +**Jump host** *(bastion)* +An intermediate SSH host you must traverse before reaching the target. Configurable per-profile; see [[en/user-guide/jump-host|Jump hosts]]. + +## M + +**Manifest** +The plugin's metadata file (`manifest.json`). The repo has three: `plugin/manifest.json` (canonical, bundled in the .zip), root-level `manifest.json` (mirrored for the Obsidian Community Plugins store), and root-level `manifest-beta.json` (mirrored for BRAT --beta). See `CONTRIBUTING.md` → Version bumps for how they stay in sync. + +**MITM** — *Man-in-the-Middle* +An attacker positioned between you and the remote host, attempting to intercept traffic. The plugin's host-key check + SSH transport make passive MITM ineffective; defending against active MITM requires verifying the host key out-of-band. See [[en/security/model|Security threat model]]. + +## N + +**`next`** *(branch)* +The integration / beta channel. Day-to-day work merges here; every merge publishes a `vX.Y.Z-beta.N` GitHub prerelease. See `CONTRIBUTING.md` → Branching model. + +## P + +**Profile** *(SSH)* +A named connection target — host, port, user, auth, remote vault path, optional jump-host chain. Configured under **Settings → Profiles**. See [[en/configuration/profiles|Configuration → Profiles]]. + +**Promotion PR** +A `next → main` PR that drops the `-beta.N` suffix and ships the accumulated work as a stable release. The `bump:stable` script in `plugin/scripts/bump-stable.mjs` strips the suffix. + +## R + +**Reconnect manager** +The component that retries an SSH session after a transport drop. Uses exponential backoff (×1.5, ±20% jitter, 30 s cap) up to a configurable retry budget. See [[en/operations/reconnect|Reconnect behavior]]. + +**RPC mode** +The default-recommended transport mode — plugin auto-deploys the daemon and speaks JSON-RPC over an SSH-tunneled Unix socket. Faster than SFTP and supports server-push `fs.changed` events. The current factory default is **SFTP mode** (legacy); switch the profile **Mode** field to opt in. + +## S + +**SFTP mode** +The current factory default transport — uses SSH's SFTP subsystem directly (no daemon). Higher per-op latency, no `fs.watch`, but works on hosts where you cannot deploy a binary. + +**Shadow vault** +A local-disk Obsidian vault under `~/.obsidian-remote/vaults//` that mirrors the remote vault. Obsidian thinks it's editing local files; the plugin syncs them to the remote via the daemon. See [[en/architecture/shadow-vault|Shadow vault architecture]]. + +**Sync workflow** — *`.github/workflows/sync-main-to-next.yml`* +After every push to `main`, opens a PR `main → next` and enables auto-merge so the histories rejoin. Prevents drift between channels. + +## T + +**TOFU** — *Trust On First Use* +The "first time you see this host's key, you decide whether to trust it" model. The plugin shows a fingerprint dialog on first connect; trusting writes the fingerprint into the host-key store for silent verification thereafter. See [[en/security/host-keys|Host-key trust]]. + +**Token** *(daemon auth)* +A 32-byte random secret the daemon writes to `~/.obsidian-remote/token` (mode 0600) at startup. The plugin reads it via SFTP and presents it on the `auth` RPC. See [[en/security/token|Token & socket]]. + +**Trust-once** +Trust a host-key fingerprint for the current session only — held in RAM, dropped on disconnect. Available as a button on the TOFU (first-trust) dialog. NOT available on the mismatch dialog. + +## V + +**Vault root** +The absolute path on the remote filesystem that the daemon treats as the vault. Configured per-profile. The daemon refuses any path that escapes this root (`PathOutsideVault` error). + +## W + +**Wikilink** +The `[[target|alias]]` syntax used throughout these docs. Quartz resolves them via shortest-path matching from the docs root (`docs/`). diff --git a/docs/en/index.md b/docs/en/index.md index dd02f24..6c3a559 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -12,22 +12,30 @@ tags: [home] | If you want to… | Read | |---|---| | Try it in 5 minutes | [[en/getting-started/quickstart\|Quickstart]] | +| Walk through the whole setup once, with verification | [[en/tutorial\|Tutorial — zero to a working vault]] | | Understand what gets installed where | [[en/getting-started/install\|Install]] → [[en/getting-started/first-connect\|First connect]] | +| Cookbook: do task X | [[en/cookbook/index\|Cookbook]] (RPi vault, SSH key, Tailscale, systemd) | | Look up a specific setting | [[en/configuration/profiles\|Configuration reference]] | | Run your own server | [[en/server/overview\|Server / deploy guide]] | | Verify the daemon binary you downloaded | [[en/security/cosign-verify\|Cosign verification]] | -| Build something against the protocol | [[en/api/overview\|API & protocol reference]] | +| Build something against the protocol | [[en/api/overview\|API & protocol reference]] (and [[en/api/examples\|copy-pasteable JSON-RPC examples]]) | +| Look up a term | [[en/glossary\|Glossary]] | +| What's coming next | [[en/roadmap\|Roadmap]] | ## Sections - **[[en/getting-started/install|Getting started]]** — install, first connect, what to expect -- **[[en/user-guide/ssh-config|User guide]]** — SSH config import, jump hosts, host keys, conflict handling, terminal pane +- **[[en/tutorial|Tutorial]]** — long-form walkthrough from zero to verified setup +- **[[en/cookbook/index|Cookbook]]** — task-oriented how-tos (RPi vault, SSH keygen, Tailscale, systemd) +- **[[en/user-guide/ssh-config|User guide]]** — SSH config import, jump hosts, host keys, conflict handling, terminal pane, plugin compatibility - **[[en/configuration/profiles|Configuration reference]]** — every plugin setting documented - **[[en/server/overview|Server / deploy]]** — Docker, systemd, Raspberry Pi, auto-deploy -- **[[en/api/overview|API & protocol]]** — RPC methods, error codes, payload shapes +- **[[en/api/overview|API & protocol]]** — RPC methods, error codes, payload shapes, copy-pasteable [[en/api/examples|examples]] - **[[en/security/model|Security]]** — threat model, signing, token handling, host-key trust - **[[en/operations/troubleshooting|Operations]]** — logs, daemon panel, reconnect, common failures - **[[en/architecture/index|Architecture]]** — shadow vault design, sync internals, performance +- **[[en/glossary|Glossary]]** — terms used across the docs, defined once +- **[[en/roadmap|Roadmap]]** — what's left before v1.0 + v2 mobile plan - **[[en/faq|FAQ]]** — quick answers to recurring questions ## Release channels diff --git a/docs/en/roadmap.md b/docs/en/roadmap.md new file mode 100644 index 0000000..7c02d00 --- /dev/null +++ b/docs/en/roadmap.md @@ -0,0 +1,59 @@ +--- +title: Roadmap +tags: [roadmap, project] +--- + +# Roadmap + +Distilled from the live tracking issues. The authoritative source is always [GitHub issues](https://github.com/sotashimozono/obsidian-remote-ssh/issues) — this page reflects the state at last edit. + +## Where we are + +**Plugin**: BRAT-distributed beta (current `next` channel). The full shadow-vault architecture is operational, the daemon binary set is signed, BRAT users are running it day-to-day. + +**Submission to Obsidian Community Plugins**: PR open at [obsidianmd/obsidian-releases#12390](https://github.com/obsidianmd/obsidian-releases/pull/12390). Awaiting Obsidian team review. + +## What's left before v1.0 + +Tracked in [#126](https://github.com/sotashimozono/obsidian-remote-ssh/issues/126). + +| Item | Status | +|---|---| +| **Public bug bash** — solicit BRAT testers via Obsidian Discord/forum, target ≥10 external installs + real-world bug reports | Open | +| **Beta success-metric review** — 2 weeks of BRAT distribution with no P0 bugs = green light | Open (gates on bug bash) | +| **CI gate switches** — flip `PERF_GATE` to enforcing once nightly baseline stabilises; tighten coverage gate; flip gosec/trivy from informational to blocking | Open (#110) | +| Settings UI rework, status bar, onboarding wizard | Done (Phase F) | +| Telemetry opt-in, manifest-beta, BRAT distribution | Done (Phase G — F22, F23) | +| README polish, animated GIF demo, Community Plugins submission | Done (Phase H — F30, #236, #242, #251) | + +Estimated remaining PR count: 3-5 small ones to flip the CI gates + close the bug-bash loop. + +## v2.0 — mobile (long-term) + +Tracked in [#151](https://github.com/sotashimozono/obsidian-remote-ssh/issues/151). **Deferred until v1.x has shipped + stabilised; do not pick up before then.** + +The current architecture spawns a daemon binary on the remote, which works on desktop where Obsidian is Electron + has Node `net.Socket` + the `ssh2` library. Mobile (iOS / Android Obsidian) is a Capacitor WebView — no Node networking, no native binaries. The only network primitive is HTTPS / WSS. + +**Plan in outline:** +- **Daemon**: add a WSS listener (TLS via Let's Encrypt or self-signed). Same JSON-RPC dispatcher behind it — `fs.*` etc. work unchanged. +- **Plugin**: a new `WSSTransport` implementing the same `Transport` interface as `RpcRemoteFsClient`. Auth via API token (rotated, scoped) instead of SSH keys. +- **Mobile UX**: audit which existing UI assumes desktop (window-spawning, terminal pane) and gate. +- **Manifest**: flip `isDesktopOnly: false` only after WSS path is hardened end-to-end. + +This is a second transport, not a tweak — meaningful new surface area, scheduled appropriately. + +## Closed scope (won't fix soon) + +- **Single-vault concurrent mobile + desktop sessions** — deferred until v1.1+. +- **Native Windows daemon** — daemon binaries today are Linux + macOS; remote on Windows works via WSL. No active plan to add a native Windows build. + +## How to influence the roadmap + +- **Bug reports** that affect the bug-bash gate are the highest-leverage way to shape v1.0 timing. +- **Use cases that bend the architecture** (multi-vault, large vaults > 100k files, exotic SSH setups) are valuable — open a discussion or issue with concrete numbers. +- **PRs welcome** — the [Phase F/G items in #126](https://github.com/sotashimozono/obsidian-remote-ssh/issues/126) marked `[ ]` are the most direct way to land contributions. + +## See also + +- Recent releases: [GitHub Releases](https://github.com/sotashimozono/obsidian-remote-ssh/releases) +- Branching model + how releases get cut: [`CONTRIBUTING.md` → Branching model](https://github.com/sotashimozono/obsidian-remote-ssh/blob/next/CONTRIBUTING.md#branching-model--next-beta--main-stable) diff --git a/docs/en/tutorial.md b/docs/en/tutorial.md new file mode 100644 index 0000000..a750fb2 --- /dev/null +++ b/docs/en/tutorial.md @@ -0,0 +1,153 @@ +--- +title: Tutorial — zero to a working vault +tags: [tutorial, getting-started] +--- + +# Tutorial — zero to a working vault + +A long-form walkthrough that takes you from "I just heard about this plugin" to "I'm editing notes on a remote server and trust the setup". About 20 minutes if you already have a server with SSH access; closer to an hour if you also need to set up the server. + +This synthesises [[en/getting-started/install|Install]], [[en/getting-started/quickstart|Quickstart]], [[en/getting-started/first-connect|First connect]], and [[en/security/cosign-verify|Cosign verify]]. If you only want one of those, jump there directly. + +## What we're going to build + +```mermaid +graph LR + L[Your laptop] -.SSH.- R[Remote host] + L --- O[Obsidian] + O --- P[Plugin] + P --- SV[Local shadow vault] + R --- D[Daemon] + D --- V[Real vault files] + P -.RPC over SSH tunnel.- D + SV -.synced via daemon.- V +``` + +By the end: + +- A signed daemon binary running on a remote host you control +- An Obsidian window editing files that physically live on that remote +- The trust chain (host key, daemon binary signature) explicitly verified + +## Step 1 — Install Obsidian + the plugin + +If you don't have Obsidian: install it from [obsidian.md](https://obsidian.md). Open it once and create or open any vault — Obsidian needs a vault loaded for the plugin to install into. + +Install the plugin via one of: + +- **Stable** (Community Plugins store): Settings → Community plugins → Browse → search **Remote SSH**. Awaiting the store team's review at time of writing — see [[en/getting-started/install|Install]]. +- **Beta** (BRAT): install BRAT, paste `sotashimozono/obsidian-remote-ssh` with **--beta**. + +Enable it. You'll see a "Remote SSH" entry in Settings. + +## Step 2 — Pick (or set up) a remote host + +You need an SSH-reachable Linux or macOS host with: + +- Your public SSH key in `~/.ssh/authorized_keys` +- A directory to use as a vault (can be empty) + +Got one already? Skip ahead. + +If not: + +- **Cheapest**: a Raspberry Pi 4 + microSD on your home network — see [[en/cookbook/raspberry-pi-vault|Cookbook → Raspberry Pi vault]]. +- **Cloud VPS**: any $5/month tier from Hetzner / DigitalOcean / OVH works fine; see [[en/cookbook/share-via-tailscale|Cookbook → Share via Tailscale]] for a sane way to reach it without exposing port 22 to the public internet. +- **Sandbox just to try**: clone the repo and run `cd deploy/docker && docker compose up -d --build`; see [[en/server/docker|Server → Docker]]. + +If `ssh user@host` works from a normal terminal, you're ready. + +## Step 3 — Verify the daemon binary you're about to deploy (optional but recommended) + +The plugin auto-uploads its bundled daemon binary on first connect. If you'd like to know what's getting uploaded, verify it with cosign first. + +Find the bundled binary at `/.obsidian/plugins/remote-ssh/server-bin/obsidian-remote-server-linux-` after enabling the plugin. Match it against the signed manifest from the latest [release](https://github.com/sotashimozono/obsidian-remote-ssh/releases): + +```bash +gh release download 1.0.43 --repo sotashimozono/obsidian-remote-ssh \ + --pattern 'daemon-manifest.json*' + +cosign verify-blob \ + --bundle daemon-manifest.json.bundle \ + --certificate-identity-regexp \ + 'https://github.com/sotashimozono/obsidian-remote-ssh/.github/workflows/release.yml@.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + daemon-manifest.json +# → Verified OK + +sha256sum '/.obsidian/plugins/remote-ssh/server-bin/obsidian-remote-server-linux-amd64' +grep linux-amd64 daemon-manifest.json +# Hashes should match. +``` + +For the full background on what cosign is checking, see [[en/security/cosign-verify|Cosign verify]]. + +## Step 4 — Add a profile + +In Obsidian: Settings → Remote SSH → **Add profile**. + +| Field | Value | +|---|---| +| Profile name | `My host` | +| Host | `192.168.1.50` (or your hostname / Tailscale name) | +| Port | `22` | +| Username | your remote user | +| Authentication | **SSH agent** (preferred) or **Private key** + path | +| Remote vault path | `/home//notes` (must exist) | +| Mode | `RPC daemon` | + +Save. + +## Step 5 — Connect for the first time + +Command palette → **Remote SSH: Connect** → pick your profile. + +What happens, in order: + +1. **Host-key trust dialog** appears with the remote's fingerprint. Compare with the value from a different channel (a console session on the remote, your provider's web console, your previous `ssh` session's output) — see [[en/security/host-keys|Host-key trust]] for what each button does. Click **Trust & remember** when satisfied. + +2. **Plugin uploads the daemon** to `~/.obsidian-remote/server` and verifies the SHA256 round-trip. + +3. **Plugin starts the daemon** under your remote user. The daemon writes a 32-byte token to `~/.obsidian-remote/token` (mode 0600). + +4. **A new Obsidian window opens** showing the contents of your remote vault. + +First connect: ~5–8 seconds (binary upload). Subsequent connects: ~1 second. + +## Step 6 — Verify it's actually editing the remote + +In the new window, create a file `tutorial-test.md` with some content. Save (Ctrl-S / ⌘S). + +In a separate terminal: +```bash +ssh user@host 'cat ~/notes/tutorial-test.md' +# → your content +``` + +The file is on the remote. There's no local copy outside the shadow-vault cache (which lives at `~/.obsidian-remote/vaults//` on your laptop and is automatically managed). + +## Step 7 — Edit from the other side + +While the Obsidian window is open, on the remote: +```bash +ssh user@host 'echo "edit from terminal" >> ~/notes/tutorial-test.md' +``` + +Within ~500 ms, Obsidian's view should reflect the new line. That's the daemon's `fs.watch` subscription pushing the change through the SSH tunnel. + +## Step 8 — Disconnect cleanly + +Command palette → **Remote SSH: Disconnect**, or just close the shadow-vault window. + +The daemon stays running on the remote for ~5 minutes (configurable) so a quick reconnect skips the binary upload + token re-read. + +## What's next + +You're done with the basic setup. Common next steps: + +- **Conflicts**: read [[en/user-guide/conflicts|Conflict handling]] before two people / two devices touch the same vault — the resolution flow is unforgiving (no automatic backup of the rejected side). +- **Performance tuning** for large vaults: [[en/operations/troubleshooting|Troubleshooting]] (look at "Performance feels slow") + [[en/server/raspberry-pi|RPi notes]] if applicable. +- **Long-running daemon**: if you keep your remote on 24/7, [[en/cookbook/systemd-managed-daemon|systemd-managed daemon]] is worth the 10-minute setup. +- **Multiple devices editing the same vault**: works out of the box — each gets its own [[en/configuration/this-device|Client ID]] for workspace isolation. Conflict-resolution caveats apply (see above). + +If something didn't work: [[en/operations/troubleshooting|Troubleshooting]] is the first place to look. diff --git a/docs/plugin-compatibility.md b/docs/en/user-guide/plugin-compatibility.md similarity index 100% rename from docs/plugin-compatibility.md rename to docs/en/user-guide/plugin-compatibility.md diff --git a/manifest-beta.json b/manifest-beta.json index 15f2cba..c39f365 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -1,7 +1,7 @@ { "id": "remote-ssh", "name": "Remote SSH", - "version": "1.0.44-beta.2", + "version": "1.0.44-beta.3", "minAppVersion": "1.5.0", "description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.", "author": "souta shimozono", diff --git a/plugin/manifest.json b/plugin/manifest.json index 15f2cba..c39f365 100644 --- a/plugin/manifest.json +++ b/plugin/manifest.json @@ -1,7 +1,7 @@ { "id": "remote-ssh", "name": "Remote SSH", - "version": "1.0.44-beta.2", + "version": "1.0.44-beta.3", "minAppVersion": "1.5.0", "description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.", "author": "souta shimozono", diff --git a/plugin/package-lock.json b/plugin/package-lock.json index 09cfba7..5e080f2 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-remote-ssh", - "version": "1.0.44-beta.2", + "version": "1.0.44-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-remote-ssh", - "version": "1.0.44-beta.2", + "version": "1.0.44-beta.3", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/plugin/package.json b/plugin/package.json index ab82583..8152c5b 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-remote-ssh", - "version": "1.0.44-beta.2", + "version": "1.0.44-beta.3", "description": "VS Code Remote SSH-like experience for Obsidian", "main": "main.js", "scripts": { From 07435647d3f14764a8cd48a9b06ed13af1499c40 Mon Sep 17 00:00:00 2001 From: Souta Date: Sun, 10 May 2026 15:18:20 +0900 Subject: [PATCH 2/5] fix(docs): correct review-pass findings on PR #294 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - api/examples.md + api/overview.md: framing protocol was wrong. Daemon uses LSP-style `Content-Length: \r\n\r\n` framing (server/internal/rpc/frame.go), NOT 4-byte big-endian length prefix. The shell smoke-test as written would have failed with "missing Content-Length". Both files now show the correct framing + the 16 MiB max-message cap. Worst self-fabrication of this PR — the examples.md preamble explicitly claimed wire-accuracy from proto/types.go while echoing this pre-existing bug. - 4 broken cross-folder relative links from the architecture/contributing migration: perf.md → testing-strategy.md, shadow-vault.md → plugin-compatibility.md (×2), testing-strategy.md → shadow-vault.md. All now use the correct `..//.md` paths. Important: - api/examples.md server.info capabilities array: actual response is alphabetically sorted (sort.Strings) and includes EVERY registered method — auth + server.info + 20 fs.* methods = 22 entries. The doc showed a hand-curated 20-entry list in registration order. - plugin-compatibility.md: 5 GitHub issue links used `../../issues/NNN` which resolved to repo-root when the file lived at docs/, but now resolves to docs/issues/ (404). Switched to absolute github.com URLs. Advisory: - "RPC daemon" UI label inaccuracy in 3 docs (raspberry-pi-vault, tutorial, quickstart) — actual ProfileForm.ts shows "Daemon (deploys helper on connect)" / "SFTP (direct)" labels. - Fake `tailscale status` output line (no `alias:` row exists); replaced with realistic format + an `tailscale ip -4` example. --- docs/en/api/examples.md | 23 +++++++++++----------- docs/en/api/overview.md | 15 +++++++------- docs/en/architecture/perf.md | 2 +- docs/en/architecture/shadow-vault.md | 4 ++-- docs/en/contributing/testing-strategy.md | 2 +- docs/en/cookbook/raspberry-pi-vault.md | 2 +- docs/en/cookbook/share-via-tailscale.md | 15 ++++++++++---- docs/en/getting-started/quickstart.md | 2 +- docs/en/tutorial.md | 2 +- docs/en/user-guide/plugin-compatibility.md | 10 +++++----- 10 files changed, 43 insertions(+), 34 deletions(-) diff --git a/docs/en/api/examples.md b/docs/en/api/examples.md index dc00944..a4fc3b8 100644 --- a/docs/en/api/examples.md +++ b/docs/en/api/examples.md @@ -23,13 +23,14 @@ Every connection starts with these two calls before any `fs.*` method. // → request: capability + version handshake {"jsonrpc":"2.0","id":2,"method":"server.info","params":{}} -// ← response +// ← response — `capabilities` is sorted alphabetically by the daemon +// and includes EVERY registered method (auth + server.info too) { "jsonrpc":"2.0","id":2, "result":{ "version":"0.1.0", "protocolVersion":1, - "capabilities":["fs.stat","fs.exists","fs.list","fs.walk","fs.readText","fs.readBinary","fs.readBinaryRange","fs.write","fs.writeBinary","fs.append","fs.appendBinary","fs.mkdir","fs.remove","fs.rmdir","fs.rename","fs.copy","fs.trashLocal","fs.thumbnail","fs.watch","fs.unwatch"], + "capabilities":["auth","fs.append","fs.appendBinary","fs.copy","fs.exists","fs.list","fs.mkdir","fs.readBinary","fs.readBinaryRange","fs.readText","fs.remove","fs.rename","fs.rmdir","fs.stat","fs.thumbnail","fs.trashLocal","fs.unwatch","fs.walk","fs.watch","fs.write","fs.writeBinary","server.info"], "vaultRoot":"/home/pi/notes" } } @@ -296,26 +297,26 @@ Full error catalogue: [[en/api/errors|API → Error codes]]. ## Smoke-testing from the shell -If you want to poke the daemon directly (skip the plugin), open the SSH-tunneled socket via `nc -U` or `socat`: +If you want to poke the daemon directly (skip the plugin), open the Unix socket via `nc -U` or `socat`. The wire framing is **LSP-style**: a single `Content-Length:` header, blank line, then the JSON body — see `server/internal/rpc/frame.go`. ```bash TOKEN=$(cat ~/.obsidian-remote/token) SOCK=~/.obsidian-remote/server.sock -# Length-prefixed JSON-RPC framing helper +# Emit one LSP-framed JSON-RPC message: +# Content-Length: \r\n +# \r\n +# frame() { - local json="$1" - local len=${#json} - printf '%b' "$(printf '\\x%02x' \ - $((len >> 24 & 0xff)) $((len >> 16 & 0xff)) \ - $((len >> 8 & 0xff)) $((len & 0xff)))$json" + local body="$1" + printf 'Content-Length: %d\r\n\r\n%s' "${#body}" "$body" } # Authenticate + list root { frame '{"jsonrpc":"2.0","id":1,"method":"auth","params":{"token":"'"$TOKEN"'"}}' frame '{"jsonrpc":"2.0","id":2,"method":"fs.list","params":{"path":""}}' -} | nc -U "$SOCK" | xxd | head -40 +} | nc -U "$SOCK" ``` -For real client-side use, write a small JSON-RPC library that handles framing — manual frames are only for spot-checks. +For real client-side use, write a small JSON-RPC library that handles the header parsing + max-message-size cap — the daemon caps inbound messages at **16 MiB** (`DefaultMaxMessageBytes`) and closes the connection on a missing or malformed `Content-Length`. diff --git a/docs/en/api/overview.md b/docs/en/api/overview.md index 02bd604..d35667a 100644 --- a/docs/en/api/overview.md +++ b/docs/en/api/overview.md @@ -5,12 +5,13 @@ tags: [api, reference] # API & protocol — overview -The daemon speaks **JSON-RPC 2.0** over a length-prefixed framing on a Unix socket. The plugin opens the socket via SSH local port-forward; you can also connect directly with any tool that speaks JSON-RPC over a Unix socket (curl with `--unix-socket`, websocat, custom tooling). +The daemon speaks **JSON-RPC 2.0** with **LSP-style framing** on a Unix socket. The plugin opens the socket via SSH local port-forward; you can also connect directly with any tool that can write LSP-framed JSON-RPC over a Unix socket. ## Wire format - **Transport**: Unix socket (default `~/.obsidian-remote/server.sock`) -- **Framing**: 4-byte big-endian length prefix + JSON payload +- **Framing**: `Content-Length: \r\n\r\n` per message — same as the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart). No other headers are recognised; missing or malformed `Content-Length` closes the connection. +- **Max message size**: 16 MiB (server-side cap; oversized messages close the connection) - **Encoding**: UTF-8 JSON - **Spec**: [JSON-RPC 2.0](https://www.jsonrpc.org/specification) @@ -55,20 +56,20 @@ Connect, authenticate, list the root: # Read the token (mode 0600, only your user can read it) TOKEN=$(cat ~/.obsidian-remote/token) -# Frame helper: 4-byte length prefix + JSON +# LSP-style frame: Content-Length: \r\n\r\n frame() { - local json="$1" - printf '%b' "$(printf '\x%02x' $((${#json} >> 24 & 0xff)) $((${#json} >> 16 & 0xff)) $((${#json} >> 8 & 0xff)) $((${#json} & 0xff)))$json" + local body="$1" + printf 'Content-Length: %d\r\n\r\n%s' "${#body}" "$body" } # Authenticate, then fs.list { frame '{"jsonrpc":"2.0","id":1,"method":"auth","params":{"token":"'"$TOKEN"'"}}' frame '{"jsonrpc":"2.0","id":2,"method":"fs.list","params":{"path":""}}' -} | nc -U ~/.obsidian-remote/server.sock | xxd +} | nc -U ~/.obsidian-remote/server.sock ``` -(For real tooling, use a JSON-RPC library that handles framing; this is just for spot-checks.) +(For real tooling, use a JSON-RPC library that handles header parsing — this is just for spot-checks.) ## Stability diff --git a/docs/en/architecture/perf.md b/docs/en/architecture/perf.md index 4640b8e..59ff5da 100644 --- a/docs/en/architecture/perf.md +++ b/docs/en/architecture/perf.md @@ -3,7 +3,7 @@ This doc is the design record for the performance epic that lands in v0.4.23 onwards. It complements [shadow-vault.md](./shadow-vault.md) (the -overall architecture) and [testing-strategy.md](./testing-strategy.md) +overall architecture) and [testing-strategy.md](../contributing/testing-strategy.md) (how the epic gets verified). ## Goals diff --git a/docs/en/architecture/shadow-vault.md b/docs/en/architecture/shadow-vault.md index e020db8..7b1c4bf 100644 --- a/docs/en/architecture/shadow-vault.md +++ b/docs/en/architecture/shadow-vault.md @@ -373,7 +373,7 @@ post-pivot reality. ### Phase 6C deliverable (pending) Manual smoke through the 11 plugins in -[plugin-compatibility.md](plugin-compatibility.md). Each row +[plugin-compatibility.md](../user-guide/plugin-compatibility.md). Each row moves from 🟡 → ✅ verified or ❌ broken (with category — fs-direct reader, etc.). User-driven; ~30–60 min. @@ -438,7 +438,7 @@ for every file in the model. - Plugins that hit `fs` directly (Omnisearch, possibly some media indexers) cannot be made to work without mirroring the file content to a real local path. Mark these `❌ broken (fs-direct)` in - [docs/plugin-compatibility.md](plugin-compatibility.md), with a + [docs/en/user-guide/plugin-compatibility.md](../user-guide/plugin-compatibility.md), with a one-line note pointing here. - A future enhancement (out of scope for the initial pivot) could shadow-write actual file content to the local basePath as a diff --git a/docs/en/contributing/testing-strategy.md b/docs/en/contributing/testing-strategy.md index 522df4e..2bce3f3 100644 --- a/docs/en/contributing/testing-strategy.md +++ b/docs/en/contributing/testing-strategy.md @@ -2,7 +2,7 @@ This document records the test architecture for `obsidian-remote-ssh`, adopted in v0.4.19 (Phase A) and v0.4.22 (Phase B). It complements -[shadow-vault.md](./shadow-vault.md) — the +[shadow-vault.md](../architecture/shadow-vault.md) — the shadow-vault flow is what we test; this doc explains *how*. ## Goals diff --git a/docs/en/cookbook/raspberry-pi-vault.md b/docs/en/cookbook/raspberry-pi-vault.md index 946458a..2b42d24 100644 --- a/docs/en/cookbook/raspberry-pi-vault.md +++ b/docs/en/cookbook/raspberry-pi-vault.md @@ -45,7 +45,7 @@ ssh pi@obsidian-vault.local 'mkdir -p ~/notes' | Username | `pi` | | Authentication | `SSH agent` (recommended) or your private key path | | Remote vault path | `/home/pi/notes` (or `~/notes`) | -| Mode | `RPC daemon` (lower latency than SFTP) | +| Mode | `Daemon (deploys helper on connect)` (lower latency than the SFTP default) | Click **Save**, then connect from the command palette: "Remote SSH: Connect" → pick `Pi vault`. diff --git a/docs/en/cookbook/share-via-tailscale.md b/docs/en/cookbook/share-via-tailscale.md index a136293..c805c40 100644 --- a/docs/en/cookbook/share-via-tailscale.md +++ b/docs/en/cookbook/share-via-tailscale.md @@ -26,12 +26,19 @@ curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up ``` -The `up` command shows a one-time auth URL. Open it, sign into your tailnet (or create one). When the host appears in your tailnet, note its tailscale hostname: +The `up` command shows a one-time auth URL. Open it, sign into your tailnet (or create one). When the host appears in your tailnet, note its Tailscale IP and MagicDNS hostname: ```bash -tailscale status -# host 100.64.0.1 tagged-machine -# alias: obsidian-vault.tailnet-XXXX.ts.net +tailscale ip -4 +# 100.64.0.1 + +tailscale status | head -3 +# 100.64.0.1 obsidian-vault you@example.com linux - +# 100.64.0.2 laptop you@example.com macOS active + +# MagicDNS hostnames look like: +# obsidian-vault..ts.net +# Find your tailnet name in the Tailscale admin console. ``` ## On each editor's laptop diff --git a/docs/en/getting-started/quickstart.md b/docs/en/getting-started/quickstart.md index 79d0a34..3c53c45 100644 --- a/docs/en/getting-started/quickstart.md +++ b/docs/en/getting-started/quickstart.md @@ -30,7 +30,7 @@ Click **Add profile** and fill in: | Authentication | `Private key` | See [[en/configuration/profiles#authentication\|auth options]] | | Private key path | `~/.ssh/id_ed25519` | Tilde-expanded at runtime | | Remote vault path | `/home/pi/notes` or `~/notes` | Must exist on the remote | -| Mode | `RPC daemon` | The default; auto-deploys the signed server binary | +| Mode | `Daemon (deploys helper on connect)` | Switch from the factory default `SFTP (direct)` for lower latency + push notifications; auto-deploys the signed server binary | Click **Save**. diff --git a/docs/en/tutorial.md b/docs/en/tutorial.md index a750fb2..d8f64e9 100644 --- a/docs/en/tutorial.md +++ b/docs/en/tutorial.md @@ -94,7 +94,7 @@ In Obsidian: Settings → Remote SSH → **Add profile**. | Username | your remote user | | Authentication | **SSH agent** (preferred) or **Private key** + path | | Remote vault path | `/home//notes` (must exist) | -| Mode | `RPC daemon` | +| Mode | `Daemon (deploys helper on connect)` | Save. diff --git a/docs/en/user-guide/plugin-compatibility.md b/docs/en/user-guide/plugin-compatibility.md index d8dfce3..b26bf0d 100644 --- a/docs/en/user-guide/plugin-compatibility.md +++ b/docs/en/user-guide/plugin-compatibility.md @@ -43,7 +43,7 @@ Numbers in the table reflect a `~/work/VaultDev`-class remote | QuickLatex | renders LaTeX inline. Pure UI, no FS access | ✅ verified-by-architecture | Not affected. | | Importer | converts external formats (Evernote `.enex`, etc.) to MD using `path.join(getBasePath(), folder.path)` as `outputDir` for the Yarle Evernote converter (Node `fs.writeFile`) | 🟡 expected (smoke pending after #170) | `getBasePath()` now returns the shadow-vault local root via #170, so the converter writes files into the shadow dir; the file-watcher propagates them to the remote. Initial conversion of a large `.enex` may produce many writes; watch for queue lag. | | Copilot | reads `getBasePath?.()` then falls back to `basePath` for local-context AI indexing (`src/miyo/miyoUtils.ts`) | 🟡 expected (smoke pending after #170) | Either form now resolves to the shadow-vault local root via #170, so Copilot's local-context indexing operates against the synced copy. The remote is the source of truth; the index reflects whatever has been mirrored to the shadow dir. | -| Git (Vinzent03) | desktop hardcodes `SimpleGit` (spawns local `git` against `getBasePath()`); mobile uses `IsomorphicGit` via `MyAdapter(vault.adapter)` (pure JS, no shell-out) | ❌ broken on desktop / fixable upstream | The desktop path silently mis-routes commits to the shadow git repo, not the remote. The isomorphic-git path *would* work transparently against our remote adapter but is gated behind `Platform.isDesktopApp` with no toggle. **We won't ship a workaround** — see [#150](../../issues/150) for the rationale. Users wanting git on a remote vault should use the integrated terminal pane ([#149](../../issues/149)) or file a feature request at Vinzent03/obsidian-git for a `forceIsomorphicGit` toggle. | +| Git (Vinzent03) | desktop hardcodes `SimpleGit` (spawns local `git` against `getBasePath()`); mobile uses `IsomorphicGit` via `MyAdapter(vault.adapter)` (pure JS, no shell-out) | ❌ broken on desktop / fixable upstream | The desktop path silently mis-routes commits to the shadow git repo, not the remote. The isomorphic-git path *would* work transparently against our remote adapter but is gated behind `Platform.isDesktopApp` with no toggle. **We won't ship a workaround** — see [#150](https://github.com/sotashimozono/obsidian-remote-ssh/issues/150) for the rationale. Users wanting git on a remote vault should use the integrated terminal pane ([#149](https://github.com/sotashimozono/obsidian-remote-ssh/issues/149)) or file a feature request at Vinzent03/obsidian-git for a `forceIsomorphicGit` toggle. | | Excalidraw | drawings stored as `.excalidraw.md` (JSON) or embedded markdown; embedded images go through `getResourcePath`. `pathToFileURL(adapter.basePath)` is used as a vault-membership prefix check (`src/utils/fileUtils.ts:343`). | ✅ verified-by-harness (#124 F13) | The `RPC` transport is required for the ResourceBridge to serve images. On `SFTP` transport, embedded images fall back to a broken `data:` URL. First read of a large `.excalidraw.md` pulls the whole JSON; subsequent edits stream cleanly. After #170 the prefix check stays internally consistent (both sides see the shadow path). F13 harness scenario (`plugin/tests/compat/excalidraw.test.ts`) covers `.excalidraw.md` text round-trip + binary attachment CRUD with byte-exact equality on a 1 KB cyclic blob and a 4 KB PNG-magic + xorshift32 payload. | | Remotely Save | `getBasePath().split("?")[0]` as a vault-instance ID for cloud-sync conflict detection (`src/main.ts:1736`) | 🟡 expected | Shadow-vault path is fine: gives a stable per-machine ID after #170. Cloud-sync semantics aren't affected. | | Tasks | scans `metadataCache.getFileCache(file).listItems` across every markdown file for `- [ ]` checkboxes; aggregates open / done counts | ✅ verified-by-harness (#124 F14) | Pure `metadataCache` reader, no `basePath` access. F14 harness scenario (`plugin/tests/compat/tasks.test.ts`) drives the per-file aggregation against a 10-file fixture vault (mixed open/done counts, frontmatter-tagged entries, empty/no-task negative-controls) and asserts vault-wide totals (18 open / 16 done / 34 total / 8 with-tasks) plus DataviewJS-shape filters (project-tag, completion ratio, top-3 by open count). | @@ -186,7 +186,7 @@ changes ship with this survey. hardcodes `SimpleGit` (shells out to local `git`) on desktop. Its bundled `IsomorphicGit` path uses a `vault.adapter` wrapper and would work against our remote adapter, but the gating - (`Platform.isDesktopApp`) has no toggle. Tracked in [#150](../../issues/150) + (`Platform.isDesktopApp`) has no toggle. Tracked in [#150](https://github.com/sotashimozono/obsidian-remote-ssh/issues/150) as won't-do; see table row on line 46. - The method form `getBasePath()` is more common in real usage than the property `.basePath` (Templater, Importer, Copilot, Remotely @@ -207,7 +207,7 @@ compare, child-process cwd, etc.). | Tasks | 3.4M | none in plugin source | none | none | n/a | | Advanced Tables | 2.8M | none | none | none | n/a | | Calendar | 2.6M | none | none | none | n/a | -| Git (Vinzent03) | 2.5M | `src/main.ts:466` — `path.join(getBasePath(), filePath)` for `electron.shell.showItemInFolder`; `simpleGit.ts:44` uses `getBasePath()` as `simple-git` `baseDir` (spawns local `git`); `gitManager/myAdapter.ts:10-37` wraps `vault.adapter` for `IsomorphicGit` (mobile-only path) | fs-read + child-process | high (fixable upstream) | `SimpleGit` (desktop) shells out to local `git` against a local path; patching `basePath` would silently mis-route. `IsomorphicGit` (mobile, gated `Platform.isDesktopApp`) routes through `vault.adapter` and would work transparently against our remote adapter — but the gating has no toggle. We won't ship a workaround; see [#150](../../issues/150) and table row on line 46 | +| Git (Vinzent03) | 2.5M | `src/main.ts:466` — `path.join(getBasePath(), filePath)` for `electron.shell.showItemInFolder`; `simpleGit.ts:44` uses `getBasePath()` as `simple-git` `baseDir` (spawns local `git`); `gitManager/myAdapter.ts:10-37` wraps `vault.adapter` for `IsomorphicGit` (mobile-only path) | fs-read + child-process | high (fixable upstream) | `SimpleGit` (desktop) shells out to local `git` against a local path; patching `basePath` would silently mis-route. `IsomorphicGit` (mobile, gated `Platform.isDesktopApp`) routes through `vault.adapter` and would work transparently against our remote adapter — but the gating has no toggle. We won't ship a workaround; see [#150](https://github.com/sotashimozono/obsidian-remote-ssh/issues/150) and table row on line 46 | | Style Settings | 2.3M | none | none | none | n/a | | Kanban | 2.2M | `src/components/Item/helpers.ts:450` — `(adapter as any).basePath` joined with attachment path, fed to `fs.copyFile` on Electron clipboard image paste | fs-read | high | Patch returns shadow-vault path; `fs.copyFile` lands in shadow vault and syncs up | | Iconize | 2.0M | none | none | none | n/a | @@ -239,9 +239,9 @@ compare, child-process cwd, etc.). canonical remote one. obsidian-Git's bundled `IsomorphicGit` path *does* go through `vault.adapter` and would work against our remote adapter transparently, but it's gated `Platform.isDesktopApp` with - no toggle. We won't ship a workaround (see [#150](../../issues/150)); + no toggle. We won't ship a workaround (see [#150](https://github.com/sotashimozono/obsidian-remote-ssh/issues/150)); users who want git on a remote vault should use the integrated - terminal pane ([#149](../../issues/149)). + terminal pane ([#149](https://github.com/sotashimozono/obsidian-remote-ssh/issues/149)). - **14 / 20 top plugins read no `basePath` at all**, so the blast radius of the current "do nothing" stance is ~30% of the most- installed plugins. That is too large to ignore but small enough From 7c243ad047af07c39cfcc526f367e3823090a517 Mon Sep 17 00:00:00 2001 From: Souta Date: Sun, 10 May 2026 15:29:42 +0900 Subject: [PATCH 3/5] fix(docs): un-stale 600 KB / 330-test claims in migrated docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-2 review caught two threshold claims in the migrated architecture/ contributing docs that drifted from reality: - Bundle-size guard cited as <600 KB in two places — actual is 800 KB (raised in #149 to absorb bundled xterm.js + addon-fit). Fixed in testing-strategy.md mermaid + perf.md prose. - "~330 unit tests" cited in testing-strategy.md mermaid — actual is ~947 tests across 60+ files (3x undercount). Updated to "~950 tests across 60+ files" — close enough to reality without anchoring on a number that drifts every PR. --- docs/en/architecture/perf.md | 2 +- docs/en/contributing/testing-strategy.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/en/architecture/perf.md b/docs/en/architecture/perf.md index 59ff5da..ef5335c 100644 --- a/docs/en/architecture/perf.md +++ b/docs/en/architecture/perf.md @@ -116,7 +116,7 @@ sequenceDiagram cross-compilable from any host. Plain Go: `image/jpeg`, `image/png`, `golang.org/x/image/draw`. HEIC needs cgo; defer. - **N-β2** Daemon binary size grows by ~1.5 MB after pulling in the - image libs. Plugin bundle (<600 KB guard) is unaffected. + image libs. Plugin bundle (<800 KB guard) is unaffected. - **N-β3** Backwards compat as in α: an older daemon without `fs.thumbnail` makes ResourceBridge transparently fall back to `fs.readBinary`. diff --git a/docs/en/contributing/testing-strategy.md b/docs/en/contributing/testing-strategy.md index 2bce3f3..1be6d35 100644 --- a/docs/en/contributing/testing-strategy.md +++ b/docs/en/contributing/testing-strategy.md @@ -17,9 +17,9 @@ shadow-vault flow is what we test; this doc explains *how*. ```mermaid flowchart TB subgraph Local["Per-PR / per-push"] - unit["Unit tests
vitest, fully mocked
~330 tests, ~2 s"] + unit["Unit tests
vitest, fully mocked
~950 tests across 60+ files"] types["TypeScript noEmit
+ ESLint"] - bundle["Production build
+ bundle-size guard (<600 KB)"] + bundle["Production build
+ bundle-size guard (<800 KB)"] end subgraph Container["Per-PR (Linux only)"] sftp_int["SSH integration
SftpClient vs Docker sshd"] From d281d8dcbe6d6ee92fa16e8a2da097cf6af87750 Mon Sep 17 00:00:00 2001 From: Souta Date: Sun, 10 May 2026 15:31:48 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(docs):=20clarify=20framing=20=E2=80=94?= =?UTF-8?q?=20unknown=20headers=20silently=20ignored,=20not=20rejected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framing description in overview.md said "No other headers are recognised" which echoed the package-level Go comment in frame.go:10. The implementation comment at frame.go:72 is more precise: "Any other header is ignored for forward-compat." Updated the doc to match the actual behaviour so future protocol additions (e.g. an Authorization header that the v1 daemon ignores) don't surprise readers. --- docs/en/api/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/api/overview.md b/docs/en/api/overview.md index d35667a..8937158 100644 --- a/docs/en/api/overview.md +++ b/docs/en/api/overview.md @@ -10,7 +10,7 @@ The daemon speaks **JSON-RPC 2.0** with **LSP-style framing** on a Unix socket. ## Wire format - **Transport**: Unix socket (default `~/.obsidian-remote/server.sock`) -- **Framing**: `Content-Length: \r\n\r\n` per message — same as the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart). No other headers are recognised; missing or malformed `Content-Length` closes the connection. +- **Framing**: `Content-Length: \r\n\r\n` per message — same as the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart). Unknown headers are silently ignored for forward-compat; missing or malformed `Content-Length` closes the connection. - **Max message size**: 16 MiB (server-side cap; oversized messages close the connection) - **Encoding**: UTF-8 JSON - **Spec**: [JSON-RPC 2.0](https://www.jsonrpc.org/specification) From 545bbe0862e54418c9c95b930932d0f65621cf28 Mon Sep 17 00:00:00 2001 From: Souta Date: Sun, 10 May 2026 15:47:27 +0900 Subject: [PATCH 5/5] fix(docs): correct reuse-existing-daemon claim across 5 docs + 3 troubleshooting nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-3 review caught a sustained fabrication: I'd been claiming "the plugin always kills + redeploys the daemon on every connect, the 'reuse existing daemon' profile flag is on the roadmap" across 5 docs. Reality (verified against plugin/src/transport/DaemonProbe.ts:49 and plugin/src/ConnectionManager.ts:87): tryReuseExistingDaemon already runs unconditionally before deploy. It probes socket + token + handshake; on success the deploy is skipped entirely. The "roadmap flag" framing was pure invention — the feature ships today. Fixes (all narrative-level, no behavior change): - en/server/auto-deploy.md: rewrote "What happens, in order" to lead with the reuse probe as step 2 (the real first action after path resolution); removed the "planned Reuse existing daemon" claim from "What if I don't want auto-deploy" — answer is "you already don't, the reuse path runs by default". - en/server/systemd.md: replaced the "warm spare only" caveat with the accurate story (systemd-managed daemon gets reused via the probe). - en/cookbook/systemd-managed-daemon.md: rewrote the trade-off note + the "Connect once" paragraph. The recipe now leads with the right expectation (reuse skips the upload) instead of an apologetic "accept the redeploy". - en/faq.md: same correction in the "does the plugin re-deploy every connect" answer. - en/server/raspberry-pi.md: corrected the throwaway "kills + restarts on every connect" line at the bottom. Other pass-3 findings: - en/api/authentication.md: server.info capabilities array used the "fs.read*"/"fs.write*" glob notation, which the daemon never emits. Replaced with the actual sorted 22-entry array (matches examples.md). - en/operations/troubleshooting.md: removed "coalesced too aggressively" diagnosis (no server-side coalescing exists, per proto/README.md). Updated plugin log path from "/logs/*.jsonl" to actual console.log location. Updated "Settings → About" (no such section) to the real version-display location. --- docs/en/api/authentication.md | 2 +- docs/en/cookbook/systemd-managed-daemon.md | 8 +++-- docs/en/faq.md | 8 +++-- docs/en/operations/troubleshooting.md | 10 +++--- docs/en/server/auto-deploy.md | 37 ++++++++++++---------- docs/en/server/raspberry-pi.md | 2 +- docs/en/server/systemd.md | 2 +- 7 files changed, 39 insertions(+), 30 deletions(-) diff --git a/docs/en/api/authentication.md b/docs/en/api/authentication.md index 78493fd..d1c6eb7 100644 --- a/docs/en/api/authentication.md +++ b/docs/en/api/authentication.md @@ -46,7 +46,7 @@ A successful `auth` pins the connection to one client identity. Re-`auth` with a "result": { "version": "0.1.0", "protocolVersion": 1, - "capabilities": ["fs.stat", "fs.exists", "fs.list", "fs.walk", "fs.read*", "fs.write*", "fs.mkdir", "fs.remove", "fs.rmdir", "fs.rename", "fs.copy", "fs.trashLocal", "fs.thumbnail", "fs.watch"], + "capabilities": ["auth", "fs.append", "fs.appendBinary", "fs.copy", "fs.exists", "fs.list", "fs.mkdir", "fs.readBinary", "fs.readBinaryRange", "fs.readText", "fs.remove", "fs.rename", "fs.rmdir", "fs.stat", "fs.thumbnail", "fs.trashLocal", "fs.unwatch", "fs.walk", "fs.watch", "fs.write", "fs.writeBinary", "server.info"], "vaultRoot": "/home/pi/notes" } } diff --git a/docs/en/cookbook/systemd-managed-daemon.md b/docs/en/cookbook/systemd-managed-daemon.md index 3bfe8db..46da327 100644 --- a/docs/en/cookbook/systemd-managed-daemon.md +++ b/docs/en/cookbook/systemd-managed-daemon.md @@ -5,9 +5,9 @@ tags: [cookbook, how-to, systemd, security] # systemd-managed daemon (with cosign verification) -Goal: instead of letting the plugin auto-deploy + redeploy the daemon on every connect, run a **cosign-verified** binary you control under systemd. Useful for: hosts you keep on 24/7, hosts where you want explicit lifecycle ownership, hosts shared with other users. +Goal: run a **cosign-verified** daemon binary you control under systemd, so the daemon survives plugin restarts and OS reboots. Useful for: hosts you keep on 24/7, hosts shared with other users, hosts where you want central logging via `journalctl`. -> Trade-off: at time of writing the plugin's auto-deploy still runs on every connect by default — it will overwrite the binary you placed under systemd. The "reuse existing daemon" profile flag is on the roadmap; until then, treat this setup as "warm spare" + accept the redeploy. +> Good news: the plugin already supports this without any opt-in flag. The reuse-existing-daemon probe (see [[en/server/auto-deploy#what-happens-in-order|auto-deploy step 2]]) attaches to your systemd-managed daemon when it's healthy and skips the binary upload. The deploy fallback only fires if the socket / token / handshake fails — most commonly because your binary version doesn't match the bundle the current plugin was built against. ## 1. Download + verify the binary @@ -100,7 +100,9 @@ In the plugin, configure the profile to point at the same paths the unit uses: | Daemon socket path | `.obsidian-remote/server.sock` (home-relative) | | Daemon token path | `.obsidian-remote/token` (home-relative) | -Connect once. The plugin will (currently) redeploy the binary, then attach to the socket. Your systemd unit's binary gets overwritten on this first connect — that's the unfortunate-but-temporary trade-off. The systemd lifecycle still wins after that: the daemon survives plugin restarts, and on Pi reboot systemd brings it back up before you connect from anywhere. +Connect from the plugin. The reuse probe will find the live socket + valid token your systemd-managed daemon set up and skip the binary upload entirely. + +If the connect surprises you by deploying anyway, the most common cause is a binary-version mismatch: the daemon you put under systemd was built against a different protocol/version than the plugin bundle expects, the handshake fails, and the plugin falls through to deploy. Re-download the binary from the [release matching your current plugin version](https://github.com/sotashimozono/obsidian-remote-ssh/releases) and re-`systemctl --user restart`. ## 5. Logs diff --git a/docs/en/faq.md b/docs/en/faq.md index 512da39..68cc8cc 100644 --- a/docs/en/faq.md +++ b/docs/en/faq.md @@ -50,11 +50,13 @@ No. All traffic is over your SSH connection to your host. Telemetry counters (op Trust scoping. See [[en/security/host-keys|Host-key trust]] for the long answer. -## Why does the plugin re-deploy the daemon every time I connect? +## Does the plugin re-deploy the daemon every time I connect? -Default behaviour, easy to override (planned profile flag). Re-deploy is ~5 seconds and guarantees you are running the version the plugin was built against. Reusing skips that latency but means you can drift between plugin and daemon versions. +No — the plugin has a reuse probe that runs first ([[en/server/auto-deploy#what-happens-in-order|step 2 in auto-deploy]]). If your previous daemon's socket + token are still healthy and the protocol version matches, it attaches and skips the binary upload entirely (~1 s reconnect instead of ~5 s). -If your remote daemon is managed by [[en/server/systemd|systemd]] and you do not want the plugin touching it, a "reuse existing daemon" profile flag is on the roadmap; until then, the plugin will redeploy on connect. +The deploy fallback only fires when the probe fails — typically because the daemon isn't running yet (first connect or after a reboot), the token is gone, or the daemon's protocol version doesn't match what the current plugin bundle expects. + +Run your remote daemon under [[en/server/systemd|systemd]] and the reuse path will pick it up cleanly on every connect — no extra flag needed. ## How do I uninstall cleanly? diff --git a/docs/en/operations/troubleshooting.md b/docs/en/operations/troubleshooting.md index 504e8e6..c690fb4 100644 --- a/docs/en/operations/troubleshooting.md +++ b/docs/en/operations/troubleshooting.md @@ -36,7 +36,7 @@ Common patterns: | Symptom | Cause | Fix | |---|---|---| | Local edits don't appear on remote | Plugin write is failing silently — open developer console (`Cmd+Opt+I` / `Ctrl+Shift+I`), look for `[remote-ssh]` errors | Often a permission issue on the remote vault dir | -| Remote edits don't appear locally | `fs.watch` subscription not active, or coalesced too aggressively | Settings → Daemon → Restart; check `inotify` limits | +| Remote edits don't appear locally | `fs.watch` subscription not active, or hit the `inotify` watch limit | Settings → Daemon → Restart; raise `fs.inotify.max_user_watches` (see [[en/api/watch\|fs.watch caveats]]) | | Specific file always conflicts | Two-way edit collision | See [[en/user-guide/conflicts\|Conflict handling]] | | Big binary files take forever | Plugin downloads on-demand; first open is slow | Expected — local cache survives across reopens | @@ -49,15 +49,15 @@ Likely culprits, in order of frequency: 3. **Vault on slow disk** (SD card on Pi) — `fs.walk` cold-cache is the slowest op. Move to USB SSD if possible. 4. **First connect on a new host** — binary upload is one-time, ~5 MB. Subsequent connects skip it. -For deep perf debug: enable [[en/configuration/advanced|Debug logging]] and check `/logs/*.jsonl` for per-op timings. +For deep perf debug: enable [[en/configuration/advanced|Debug logging]] and check `/console.log` (rotated `console.log` + `.1` + `.2` + `.3`) for per-op timings. ## How to ask for help When opening an issue, paste: -1. **Plugin version**: Settings → About. -2. **Daemon version**: Settings → Daemon → status badge. -3. **Plugin log** (last 50 lines from `/logs/`). +1. **Plugin version**: Settings → Community plugins → "Remote SSH" entry shows the version. +2. **Daemon version**: Settings → Daemon panel → status badge. +3. **Plugin log** (last 50 lines from `/.obsidian/plugins/remote-ssh/console.log`). 4. **Daemon log** (last 50 lines from `~/.obsidian-remote/server.log`). 5. **Local OS** + **remote OS / arch** (`uname -a` on remote). 6. **What you expected vs what happened.** diff --git a/docs/en/server/auto-deploy.md b/docs/en/server/auto-deploy.md index b46b217..cfa2b5f 100644 --- a/docs/en/server/auto-deploy.md +++ b/docs/en/server/auto-deploy.md @@ -15,19 +15,21 @@ This is the default. When you connect via an RPC profile, the plugin uploads the - Token: `~/.obsidian-remote/token` - Log: `~/.obsidian-remote/server.log` -2. **Kill prior daemon** (`pkill -f` against suffix-form binary path — matches both relative and absolute argv). +2. **Try to reuse an existing daemon** (`tryReuseExistingDaemon` in `plugin/src/transport/DaemonProbe.ts`): + - `test -S ` — does a Unix socket exist at the expected path? + - Read the token file via SFTP. + - Open a Unix-socket stream + send `auth(token)` + `server.info`. + - If all four steps succeed → **skip the rest of the deploy flow** and use the running daemon. Subsequent connects within ~5 minutes hit this path and finish in ~1 second. -3. **Create directory**: `mkdir -p ~/.obsidian-remote && chmod 700 ~/.obsidian-remote`. +3. If reuse failed (no socket, bad token, version mismatch, etc.) — fall through to deploy: + - **Kill any partial prior daemon** (`pkill -f` against suffix-form binary path — matches both relative and absolute argv). + - **Create directory**: `mkdir -p ~/.obsidian-remote && chmod 700 ~/.obsidian-remote`. + - **Upload binary** via SFTP (~5 MB, fast on LAN, ~2-3s on slow links). + - **SHA256 verify**: plugin computes hash locally, runs `sha256sum` on remote, fails connect if they differ. Catches transport corruption and (less commonly) hostile MITM swaps. + - **Start daemon** (one nohup line piping stdout/stderr into `~/.obsidian-remote/server.log`, stdin closed). + - **Wait for token**: the daemon writes a 32-byte token to `~/.obsidian-remote/token` (mode 0600) at startup. Plugin polls (every 150 ms, default 5s deadline). -4. **Upload binary** via SFTP (~5 MB, fast on LAN, ~2-3s on slow links). - -5. **SHA256 verify**: plugin computes hash locally, runs `sha256sum` on remote, fails connect if they differ. Catches transport corruption and (less commonly) hostile MITM swaps. - -6. **Start daemon** (one nohup line piping stdout/stderr into `~/.obsidian-remote/server.log`, stdin closed). - -7. **Wait for token**: the daemon writes a 32-byte token to `~/.obsidian-remote/token` (mode 0600) at startup. Plugin polls (every 150 ms, default 5s deadline). - -8. **Open RPC tunnel**: SSH local port-forward to the Unix socket, send `auth(token)`, then `server.info` for handshake. +4. **Open RPC tunnel**: SSH local port-forward to the Unix socket, send `auth(token)`, then `server.info` for handshake. ## Tuning @@ -41,12 +43,15 @@ Per-profile overrides under **Profile** → **Transport**: ## What if I do not want auto-deploy? -If you would rather pre-install the daemon (Docker, systemd) and have the plugin attach instead of redeploying: +You don't need any opt-in flag — the reuse path (step 2 above) already attaches to a pre-existing daemon when one is healthy. The recipe is simply: -1. Pre-stage the binary at the path the plugin expects (default `~/.obsidian-remote/server`). -2. Start it with the same flags shown in `~/.obsidian-remote/server.log` after one auto-deploy. -3. The plugin's "kill prior + redeploy" logic still runs by default. To disable: set the (planned) `Reuse existing daemon` profile flag. +1. Pre-stage the binary at the path the plugin expects (default `~/.obsidian-remote/server`) via Docker / systemd / your own deployment. +2. Start it with the same flags shown in `~/.obsidian-remote/server.log` after one auto-deploy (`--vault-root` / `--socket` / `--token-file`). +3. Connect from the plugin. The reuse probe finds the live socket + valid token and skips the binary upload entirely. -> Roadmap: a "reuse existing daemon" profile flag — until then, the kill-and-redeploy step always runs. +If reuse fails (token missing, version mismatch with the bundled daemon, socket stale), the plugin falls through to deploy — your pre-staged binary will be killed + replaced. To prevent that fallback you can either: + +- Keep your binary version pinned to whatever the plugin bundle was built against (so the version handshake passes), or +- Run your daemon under a different binary path / socket path than the plugin's defaults and configure those paths in the profile (the plugin will reuse those paths if healthy and only fall back to the defaults' deploy if not). Next: [[en/server/docker|Docker]] for sandbox / shared hosts. diff --git a/docs/en/server/raspberry-pi.md b/docs/en/server/raspberry-pi.md index 20cb8f1..c46fcb2 100644 --- a/docs/en/server/raspberry-pi.md +++ b/docs/en/server/raspberry-pi.md @@ -35,7 +35,7 @@ Vault on SD card: fine, but `fs.walk` on a large vault can take 5–10s on the f ## Long-lived daemon -The plugin's auto-deploy kills + restarts the daemon on every connect. For a Pi you'll keep on 24/7, run the daemon under [[en/server/systemd|systemd]] so it survives plugin reconnects and reboots. +The plugin's auto-deploy reuses an already-running daemon when one is healthy ([[en/server/auto-deploy#what-happens-in-order|reuse probe]]) and only redeploys when reuse fails. For a Pi you'll keep on 24/7, run the daemon under [[en/server/systemd|systemd]] so it survives Pi reboots — the plugin will pick it up on connect via the reuse path. ## Cooling / throttling diff --git a/docs/en/server/systemd.md b/docs/en/server/systemd.md index 8c4fefb..6dc1c9e 100644 --- a/docs/en/server/systemd.md +++ b/docs/en/server/systemd.md @@ -82,7 +82,7 @@ In the plugin profile, set: | Daemon socket path | `.obsidian-remote/server.sock` (home-relative) | | Daemon token path | `.obsidian-remote/token` (home-relative) | -> The remote daemon binary path is currently fixed at `~/.obsidian-remote/server` in the plugin (no UI override yet). For a true "systemd-managed daemon living elsewhere" setup, you'd need the planned `Reuse existing daemon` flag — until then, the plugin's auto-redeploy will overwrite your systemd-managed binary on every connect. Run systemd as a "warm spare" only, not as the primary lifecycle owner. +> The remote daemon binary path is currently fixed at `~/.obsidian-remote/server` in the plugin (no UI override yet). The plugin's reuse-existing-daemon probe (see [[en/server/auto-deploy#what-happens-in-order|auto-deploy step 2]]) will attach to your systemd-managed daemon when it's healthy and skip the binary upload entirely. The deploy fallback only kicks in if the socket / token / handshake fails — typically when your binary version doesn't match the one the current plugin bundle expects. ## Logs