Skip to content

docs: durable SSH tunnel for day-to-day access (closes #16) - #26

Merged
tylervick merged 2 commits into
mainfrom
tylervick/remote-access-docs
Jul 30, 2026
Merged

tylervick merged 2 commits into
mainfrom
tylervick/remote-access-docs

Conversation

@tylervick

Copy link
Copy Markdown
Owner

Summary

Documents a launchd-supervised SSH port-forward as the durable day-to-day path, replacing the ad-hoc master socket that lapses after an hour idle. Direction confirmed on #16 — LAN-scoped durability; off-LAN reach is explicitly out of scope for now.

Why not Tailscale on the device

Evaluated and rejected on memory, with upstream measurements on this same hardware:

  • tailscaled settles around 40 MB resident on a NanoKVM (sipeed/NanoKVM#366), against the 43 MB available this project measured in device recon.
  • Upstream needs GOMEMLIMIT=100, GOGC=50, and a cron reboot every two days to stay up — and still saw an OOM kill at 78 MB — on a board with the larger 158 MB user-space split, i.e. ~11 MB more free RAM than ours (sipeed/NanoKVM#660).
  • Tuning doesn't recover it: ~86% of that heap is wireguard-go per-interface buffer pools, an allocation floor rather than collectable garbage (tailscale/tailscale#16258).

An OOM here isn't graceful — the kernel takes the largest resident process, which may be the firmware's video pipeline, so the KVM function dies along with the way back in to fix it.

This makes the README's existing "costs nothing" claim wrong, so it's corrected here and in the design spec. The reasoning behind it still holds — the LAN is the wrong place for this listener — it's the cost that was misstated.

Security model: unchanged

The tunnel needs no revision to the Security model section. The bind stays 127.0.0.1:8080, and reaching it costs an SSH credential plus the bearer token — two independent layers preserved. Binding to a tailnet address would have deleted the loopback-default bullet; tailscale serve would have kept it but still needed the daemon resident.

Changes

  • README — new ### Durable access: a launchd-supervised tunnel under "Connecting an MCP client"; rewritten Tailscale paragraph in "Security model"; generic client config now points at 127.0.0.1 with the reasoning; the Claude Code section's "Durable remote access path for day-to-day use #16 is tracking this" aside now links to the new section.
  • Design spec — dated correction on the "costs nothing" bullet (amended rather than rewritten, to keep the record), architecture diagram, and risk-table mitigation.

Two things the docs call out because they fail silently otherwise:

  • The SSH key prerequisite. Unattended reconnect can't answer a password prompt and the stock firmware ships no authorized keys. Documented with a dropbear-version check (ed25519 needs 2020.79+), an RSA fallback, the /etc/dropbear/authorized_keys variant, a verify step, and the caveat that it survives app updates but not a rootfs reflash. It replaces PR docs: day-to-day Claude Code setup (closes #9) #18's PubkeyAuthentication=no agent-flood workaround rather than adding to it.
  • Three load-bearing plist details — no -f (launchd supervises a foreground process), ControlMaster=no (a ControlMaster auto in ~/.ssh/config would make the job hand off its forward and exit into a respawn loop), and ExitOnForwardFailure=yes with the server-alive limits (launchd can only restart something that exits).

Verification

The plist was extracted from the rendered README, placeholders substituted, and checked with plutil -lint (OK) and plutil -convert json to confirm it parses to exactly the intended argv — the column alignment inside ProgramArguments is ignored as expected.

Not run end-to-end against hardware: I have no tunnel to the device from this workspace, and I did not install Tailscale on it to measure directly — that's an invasive change I shouldn't make unasked, and #366/#660 measured it on the same board.

Docs only; no code, no CHANGELOG.md (release PR in flight).

Closes #16.

🤖 Generated with Claude Code

The ad-hoc master socket lapses after an hour idle. Document a
launchd-supervised port-forward that reconnects on its own, and correct
the Tailscale recommendation it replaces.

Tailscale on the device was evaluated and rejected on memory. tailscaled
settles around 40 MB resident on this hardware (sipeed/NanoKVM#366)
against the 43 MB available measured in device recon; upstream needs
GOMEMLIMIT=100, GOGC=50 and a two-day cron reboot to keep it up on a
board with more free RAM than ours, and still saw an OOM kill at 78 MB
(sipeed/NanoKVM#660). GOMEMLIMIT does not recover it — ~86% of that heap
is wireguard-go per-interface buffer pools, an allocation floor rather
than collectable garbage (tailscale/tailscale#16258). The README's
"costs nothing" claim was wrong and is corrected here and in the design
spec; the exposure reasoning behind it still holds.

The tunnel leaves the security model unchanged: the bind stays on
loopback, and reaching it costs an SSH credential plus the bearer token.
Off-LAN reach, when it is wanted, belongs on a subnet router on another
host rather than on the device.

Documents the SSH key prerequisite unattended reconnect requires, which
replaces PR #18's dropbear agent-flood workaround rather than adding to
it, and the three plist details that otherwise fail silently: no -f,
ControlMaster=no, and ExitOnForwardFailure with server-alive limits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@tylervick, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bba775cb-2f72-4842-962e-2f9488416ec1

📥 Commits

Reviewing files that changed from the base of the PR and between b9d16e5 and 7e9c42b.

📒 Files selected for processing (2)
  • README.md
  • docs/superpowers/specs/2026-07-22-nanokvm-mcp-sidecar-design.md
📝 Walkthrough

Walkthrough

The documentation changes revise MCP access from device-side Tailscale exposure to loopback binding with SSH forwarding, update client configuration examples and token guidance, and add launchd instructions for maintaining a persistent tunnel across reboots and network interruptions.

Changes

MCP access guidance

Layer / File(s) Summary
Loopback exposure model
README.md, docs/superpowers/specs/...
Documents loopback-only binding, external SSH forwarding, Tailscale memory considerations, and corresponding architecture and risk guidance.
Loopback client connection
README.md
Changes the MCP URL example to http://127.0.0.1:8080/ and explains SSH forwarding and token retrieval.
launchd-supervised tunnel
README.md
Adds persistent SSH tunnel setup, lifecycle commands, verification steps, and reuse of the existing MCP registration.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: documenting a durable SSH tunnel for day-to-day access.
Description check ✅ Passed The description is directly related to the docs changes and matches the tunnel-focused scope.
Linked Issues check ✅ Passed The PR satisfies #16 by documenting a durable client-side launchd SSH tunnel as the chosen access path.
Out of Scope Changes check ✅ Passed The added security-model and design-spec corrections support the same access-path change and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tylervick/remote-access-docs

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
README.md (1)

257-259: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not recommend storing the bearer token in a shell profile.

This contradicts the preceding claim that the secret stays out of configs and persists the full token in plaintext. Keep the export session-scoped or document a secure secret-store mechanism.

Suggested adjustment
-export NANOKVM_MCP_TOKEN=$(cat ~/.nanokvm-token)   # add to your shell profile
+export NANOKVM_MCP_TOKEN="$(cat ~/.nanokvm-token)" # current shell only; do not add to shell profiles
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 257 - 259, Update the README instructions around the
NANOKVM_MCP_TOKEN export to remove the recommendation to add the bearer token to
a shell profile. Keep the token session-scoped, or replace it with documented
secure secret-store guidance without persisting the full token in plaintext
configuration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/specs/2026-07-22-nanokvm-mcp-sidecar-design.md`:
- Around line 257-266: Remove or revise the preceding paragraph that recommends
Tailscale and claims it “costs nothing,” so the specification consistently
recommends SSH port-forwarding to the loopback listener and off-LAN access
through a subnet router on another host. Preserve the corrected 2026-07-30
guidance and eliminate the contradictory exposure recommendation.

In `@README.md`:
- Around line 351-360: Update the plist example’s SSH command to use non-XML
placeholder text instead of angle-bracket placeholders such as <you> and
<device>, and state that users must replace those placeholders with their actual
values before loading or validating the plist with plutil. Keep the example
valid, copyable XML.
- Around line 303-310: Update the SSH key setup documentation around the
ssh-keygen command to explicitly state that -N '' creates an unencrypted private
key and installing its public key for root grants full device SSH access.
Explain that this is an intentional tradeoff for unattended launchd reconnects,
and mention keychain/agent-backed storage or supported Dropbear key restrictions
as safer alternatives.
- Around line 201-205: The documentation references conflicting bearer-token
locations. Align the token source described near NANOKVM_MCP_TOKEN in README.md
and the corresponding sidecar design specification, choosing one canonical path
and preserving the generated-token fallback behavior consistently in both
documents.
- Around line 356-358: Add the SSH option ConnectTimeout=10 alongside the
existing options in the documented launchd configuration, preserving the current
ServerAliveInterval and ServerAliveCountMax settings.
- Around line 355-356: Update both README examples containing the SSH options to
add ControlPath=none alongside ControlMaster=no, and revise the accompanying
explanations to state that disabling multiplexing also requires preventing any
configured control path from being used.

---

Outside diff comments:
In `@README.md`:
- Around line 257-259: Update the README instructions around the
NANOKVM_MCP_TOKEN export to remove the recommendation to add the bearer token to
a shell profile. Keep the token session-scoped, or replace it with documented
secure secret-store guidance without persisting the full token in plaintext
configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 20a65abb-67d4-4399-88b7-865a3e357ba1

📥 Commits

Reviewing files that changed from the base of the PR and between d0e103f and b9d16e5.

📒 Files selected for processing (2)
  • README.md
  • docs/superpowers/specs/2026-07-22-nanokvm-mcp-sidecar-design.md

Comment thread docs/superpowers/specs/2026-07-22-nanokvm-mcp-sidecar-design.md
Comment thread README.md
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md
Comment thread README.md
ControlMaster=no does not prevent multiplexing. Per ssh_config(5) it is
the default and is exactly the value that lets a session join an existing
master; it governs whether this ssh becomes a master, not whether it
reuses one. Only ControlPath=none disables sharing. The plist and the
explanation for it were both wrong — a reader with ControlPath in their
ssh config would have hit the respawn loop that bullet claimed to prevent.

Add ConnectTimeout=10 so a connect to a sleeping device fails in 10s
rather than sitting in the system TCP timeout before launchd sees an exit.

The plist used <you>/<device> placeholders, which XML parses as elements,
so the block as printed was not valid and was not what plutil validated
earlier. Switched to YOUR-USER/DEVICE-ADDRESS and noted why the
angle-bracket convention used elsewhere cannot apply inside XML. The block
now lints verbatim.

Document what the SSH key actually grants: -N '' leaves it unencrypted at
rest, and an unrestricted key in root's authorized_keys is a root shell,
not just a forward. Install it behind restrict,no-pty,command="/bin/false"
so -N forwarding still works while interactive use does not, with the
dropbear-version caveat and a verification that tests the forward rather
than a shell. Note that neither failure mode locks you out, since password
auth remains.

Spec: strike through the superseded Tailscale sentence rather than only
appending a correction under it, and correct the bearer-token source —
it is the NANOKVM_MCP_TOKEN environment variable per
internal/config/config.go, not /etc/kvm/.nanokvm_mcp_token, which nothing
reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tylervick

Copy link
Copy Markdown
Owner Author

Review addressed — 6 of 7 applied in 7e9c42b

Five were straightforwardly right; one I applied differently than suggested; one I am declining on the facts.

Finding Outcome
ControlPath=none Fixed — real bug, detail below
ConnectTimeout=10 Added
plist XML validity Fixed, now lints verbatim
Unencrypted root SSH key Documented and constrained with restrict,no-pty,command="/bin/false"
Stale token path in spec Fixed — spec was factually wrong
Stale Tailscale paragraph Struck through + marked superseded, not deleted
Token in shell profile Declining — see below

The ControlPath one was the best catch and I had it backwards. ssh_config(5): "Additional sessions can connect to this socket using the same ControlPath with ControlMaster set to no (the default)." no is exactly the value that joins an existing master. My bullet claimed it prevented the handoff, so a reader with ControlPath in their ssh config would have hit the precise respawn loop that bullet promised to prevent. Only ControlPath=none disables sharing.

Declining: "do not store the bearer token in a shell profile"

The premise does not hold. The line is:

export NANOKVM_MCP_TOKEN=$(cat ~/.nanokvm-token)   # add to your shell profile

What goes into the profile is that line — a command substitution that reads the mode-0600 file at shell startup. The token itself is never written to the profile, so the finding's stated harm ("persists the full token in plaintext") does not occur. This is the by-reference pattern working as designed, not a contradiction of it.

The suggested diff would also make the export session-only, which breaks the documented workflow: Claude Code expands ${NANOKVM_MCP_TOKEN} at connection time and needs it set in every shell that starts a session.

There is a residual — the token lands in the environment of every process in that shell — but that is inherent to the ${VAR} expansion Claude Code performs, it is the pattern PR #18 validated, and the line predates this PR and sits outside its diff. Changing it belongs in its own PR with that tradeoff argued explicitly, not here.

One thing I could not verify: whether this device's dropbear build parses restrict. Documented as a check-don't-assume step, with the note that neither failure mode locks you out since password auth remains.

🤖 Generated with Claude Code

tylervick added a commit that referenced this pull request Jul 30, 2026
Keeps the stacked branch current with the corrections made on PR #26
(ControlPath=none, restricted SSH key, plist XML validity).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tylervick
tylervick merged commit 7ef4831 into main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Durable remote access path for day-to-day use

1 participant