Merge pull request #294 from sotashimozono/docs/expansion

docs: expansion — migrate existing + add cookbook, glossary, roadmap, tutorial, API examples
This commit is contained in:
sotashimozono 2026-05-10 15:49:07 +09:00 committed by GitHub
commit 440ba16a91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1112 additions and 62 deletions

View file

@ -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"
}
}

322
docs/en/api/examples.md Normal file
View file

@ -0,0 +1,322 @@
---
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 — `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":["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"
}
}
```
## 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 <vaultRoot>/.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 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
# Emit one LSP-framed JSON-RPC message:
# Content-Length: <N>\r\n
# \r\n
# <N bytes of UTF-8 JSON>
frame() {
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"
```
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`.

View file

@ -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: <N>\r\n\r\n<N bytes of UTF-8 JSON>` 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)
@ -20,6 +21,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
@ -54,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: <N>\r\n\r\n<body>
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

View file

@ -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

View file

@ -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/<id>/` 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/<id>/` workspace partition |
## Common threads across all three

View file

@ -2,8 +2,8 @@
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
overall architecture) and [testing-strategy.md](./testing-strategy.md)
[shadow-vault.md](./shadow-vault.md) (the
overall architecture) and [testing-strategy.md](../contributing/testing-strategy.md)
(how the epic gets verified).
## Goals
@ -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`.

View file

@ -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; ~3060 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

View file

@ -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](../architecture/shadow-vault.md) — the
shadow-vault flow is what we test; this doc explains *how*.
## Goals
@ -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<br/>vitest, fully mocked<br/>~330 tests, ~2 s"]
unit["Unit tests<br/>vitest, fully mocked<br/>~950 tests across 60+ files"]
types["TypeScript noEmit<br/>+ ESLint"]
bundle["Production build<br/>+ bundle-size guard (<600 KB)"]
bundle["Production build<br/>+ bundle-size guard (<800 KB)"]
end
subgraph Container["Per-PR (Linux only)"]
sftp_int["SSH integration<br/>SftpClient vs Docker sshd"]

19
docs/en/cookbook/index.md Normal file
View file

@ -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.

View file

@ -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 | `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`.
The plugin uploads the daemon binary (~5 MB), starts it, opens a shadow vault window. First connect ~58 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

View file

@ -0,0 +1,94 @@
---
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 IP and MagicDNS hostname:
```bash
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.<your-tailnet>.ts.net
# Find your tailnet name in the Tailscale admin console.
```
## 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)

View file

@ -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"

View file

@ -0,0 +1,119 @@
---
title: systemd-managed daemon
tags: [cookbook, how-to, systemd, security]
---
# systemd-managed daemon (with cosign verification)
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`.
> 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
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 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
```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

View file

@ -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?

View file

@ -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

View file

@ -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**.

122
docs/en/glossary.md Normal file
View file

@ -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/<client-id>/` 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/<profile-id>/` 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/`).

View file

@ -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

View file

@ -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 `<plugin>/logs/*.jsonl` for per-op timings.
For deep perf debug: enable [[en/configuration/advanced|Debug logging]] and check `<plugin>/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 `<plugin>/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 `<vault>/.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.**

59
docs/en/roadmap.md Normal file
View file

@ -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)

View file

@ -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 <socket>` — 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.

View file

@ -35,7 +35,7 @@ Vault on SD card: fine, but `fs.walk` on a large vault can take 510s 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

View file

@ -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

153
docs/en/tutorial.md Normal file
View file

@ -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 `<your-vault>/.obsidian/plugins/remote-ssh/server-bin/obsidian-remote-server-linux-<arch>` 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 '<vault>/.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/<you>/notes` (must exist) |
| Mode | `Daemon (deploys helper on connect)` |
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: ~58 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/<profile-id>/` 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.

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": {