Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ permissions:
contents: write

jobs:
test:
uses: ./.github/workflows/test.yaml

goreleaser:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: test

on:
pull_request:
push:
branches: [master]
workflow_call:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.25"
- run: go vet ./...
- run: go test ./...
31 changes: 29 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cmd/nssh/ The single binary (session wrapper + shim + --infect, dis
internal/wire/ Shared envelope type and parser
internal/ntfy/ Shared ntfy HTTP helpers (publish, attach, fetch)
internal/clipboard/ macOS pasteboard helpers (pbcopy, pbpaste, pngpaste, osascript)
docs/ Design docs
docs/ internals.md (architecture, flows) + protocol.md (wire/log schema)
.github/workflows/ CI (cachix.yaml for nix, release.yml for tagged releases)
justfile Build recipes
flake.nix Nix package
Expand Down Expand Up @@ -103,7 +103,34 @@ Priority: `NSSH_NTFY_BASE` env > config.toml > session file > defaults.

## Key constraints

- stdlib only — no external Go dependencies
- Minimize external Go dependencies — prefer stdlib + `golang.org/x/*`
modules over third-party. Pull in a dep only when it replaces a
hand-rolled re-implementation that's a real correctness or
ergonomics liability (current set: `golang.org/x/mod/semver`).
- Single binary cross-compiles for macOS and Linux with zero runtime deps
- Never eval or execute received content on local side
- Only bridge CLIPBOARD selection, not PRIMARY

## Maintaining docs

`docs/internals.md` and `docs/protocol.md` are the precise current-state
references. Update them in the same change that makes them stale —
don't ship the code change and document later. Things that require a
docs touch:

- New or removed envelope `kind` → update the kinds table in
`protocol.md` and any flow diagrams in `internals.md` that mention it.
- New, renamed, or removed `LogEvent` field or event name → update
the schema and event vocabulary in `protocol.md`.
- New config key, env var, or precedence change → update the config
section in `protocol.md`.
- New ntfy endpoint or change to inline-vs-attachment rules → update
the endpoints / transport sections in `protocol.md`.
- New shim persona, transport (e.g. ssh/mosh siblings), or
cross-cutting design choice → update the relevant `internals.md`
section and the persona table here in CLAUDE.md.

Pure refactors (file splits, helper extractions) usually don't need
doc updates unless they change a name the docs reference. The README
is for the pitch and the install path; everything precise lives under
`docs/`.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ _Built with [Claude Opus 4.7](https://www.anthropic.com/news/claude-opus-4-7) vi

`nssh` bridges your local machine (macOS, primarily) to a headless Linux VM to
let you use tools like `xdg-open` or `xclip` that otherwise require X and a display to work.

It was originally written to let OAuth-based authentication flows that expect
you to click a link to complete the login process open the browser on my laptop instead of
having `xdg-open` in the VM throw an error. It did so by adding a shim script ahead of `xdg-open`
in $PATH that would emit the url to a topic on ntfy.sh, with a corresponding local helper subscribed
to that topic that would open up an SSH port forward back to the VM to return the OAuth token.

Paste images into [Claude Code](https://claude.ai/claude-code) over SSH. Also bridges text clipboard, `xdg-open` URLs, and OAuth callbacks between remote sessions and your local machine — over SSH or mosh.

Expand Down Expand Up @@ -147,6 +153,14 @@ The `NSSH_NTFY_BASE` environment variable overrides the server.
- **Optional:** Self-hosted [ntfy](https://docs.ntfy.sh/install/) for privacy (public ntfy.sh works out of the box).
- **Optional:** `mosh` on both ends for session roaming.

## Further reading

- [docs/internals.md](./docs/internals.md) — architecture, end-to-end flows
(clipboard paste, OAuth callback), and the reasoning behind ntfy / argv[0]
dispatch / topic-as-secret.
- [docs/protocol.md](./docs/protocol.md) — wire envelope schema, log event
vocabulary, config precedence, ntfy endpoints.

## License

MIT
48 changes: 12 additions & 36 deletions cmd/nssh/clipboard.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package main

import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
Expand All @@ -12,28 +10,22 @@ import (
"github.com/abizer/nssh/v2/internal/wire"
)

const inlineThreshold = 3 * 1024

func handleClipWrite(env wire.Envelope, att *ntfy.Attachment) {
var data []byte
var err error

switch {
case env.Body != "":
data, err = base64.StdEncoding.DecodeString(env.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "nssh: clip-write: base64 decode: %v\n", err)
// handleClipWrite writes incoming clipboard data to the local clipboard.
// body is the pre-decoded inline payload (nil when none); when nil and an
// attachment is present, the attachment is fetched.
func handleClipWrite(env wire.Envelope, att *ntfy.Attachment, body []byte) {
data := body
if data == nil {
if att == nil || att.URL == "" {
fmt.Fprintln(os.Stderr, "nssh: clip-write: no data (empty body and no attachment)")
return
}
case att != nil && att.URL != "":
var err error
data, err = ntfy.FetchAttachment(att.URL)
if err != nil {
fmt.Fprintf(os.Stderr, "nssh: clip-write: %v\n", err)
return
}
default:
fmt.Fprintln(os.Stderr, "nssh: clip-write: no data (empty body and no attachment)")
return
}

mime := env.Mime
Expand Down Expand Up @@ -68,32 +60,16 @@ func handleClipReadRequest(env wire.Envelope, topicURL string) {
if err != nil {
fmt.Fprintf(os.Stderr, "nssh: clip-read: %v\n", err)
resp := wire.Envelope{Kind: "clip-read-response", ID: env.ID}
resp.Body = base64.StdEncoding.EncodeToString([]byte("ERROR: " + err.Error()))
body, _ := json.Marshal(resp)
if perr := ntfy.PublishMessage(topicURL, string(body)); perr != nil {
if perr := wire.Publish(topicURL, resp, []byte("ERROR: "+err.Error())); perr != nil {
fmt.Fprintf(os.Stderr, "nssh: clip-read error response: %v\n", perr)
}
logMessage("out", resp, 0)
return
}

resp := wire.Envelope{Kind: "clip-read-response", ID: env.ID, Mime: mime}

if len(data) <= inlineThreshold && !strings.HasPrefix(mime, "image/") {
resp.Body = base64.StdEncoding.EncodeToString(data)
body, _ := json.Marshal(resp)
if err := ntfy.PublishMessage(topicURL, string(body)); err != nil {
fmt.Fprintf(os.Stderr, "nssh: clip-read response: %v\n", err)
}
} else {
respJSON, _ := json.Marshal(resp)
filename := "clip.dat"
if strings.HasPrefix(mime, "image/png") {
filename = "clip.png"
}
if err := ntfy.PublishAttachment(topicURL, string(respJSON), data, filename); err != nil {
fmt.Fprintf(os.Stderr, "nssh: clip-read response: %v\n", err)
}
if err := wire.Publish(topicURL, resp, data); err != nil {
fmt.Fprintf(os.Stderr, "nssh: clip-read response: %v\n", err)
}
logMessage("out", resp, len(data))
}
96 changes: 54 additions & 42 deletions cmd/nssh/infect.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
Expand All @@ -12,6 +13,8 @@ import (
"runtime"
"runtime/debug"
"strings"

"golang.org/x/mod/semver"
)

// personas are the argv[0] names nssh answers to when symlinked.
Expand Down Expand Up @@ -42,43 +45,24 @@ func latestReleaseTag() (string, error) {
if resp.StatusCode != 200 {
return "", fmt.Errorf("github api: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
var rel struct {
TagName string `json:"tag_name"`
}
const key = `"tag_name":"`
i := strings.Index(string(body), key)
if i < 0 {
return "", fmt.Errorf("no tag_name in github response")
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", fmt.Errorf("github api: decode: %w", err)
}
rest := string(body[i+len(key):])
j := strings.Index(rest, `"`)
if j < 0 {
return "", fmt.Errorf("malformed tag_name")
if rel.TagName == "" {
return "", fmt.Errorf("no tag_name in github response")
}
return rest[:j], nil
return rel.TagName, nil
}

// looksLikeSemver reports whether v is a clean "vX.Y.Z" tag (no +dirty etc).
func looksLikeSemver(v string) bool {
if !strings.HasPrefix(v, "v") || strings.ContainsAny(v, "+ ") {
return false
}
parts := strings.Split(v[1:], ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, r := range p {
if r < '0' || r > '9' {
return false
}
}
}
return true
// isReleaseVersion reports whether v is a clean release tag — valid semver
// with no prerelease (-rc1) and no build metadata (+dirty). Used to gate
// the version-mismatch nag at session start: dev builds shouldn't prompt
// the user to overwrite the remote with an in-flight build.
func isReleaseVersion(v string) bool {
return semver.IsValid(v) && semver.Prerelease(v) == "" && semver.Build(v) == ""
}

// detectLocalDesktop returns (true, reason) if a desktop session appears to be
Expand Down Expand Up @@ -110,9 +94,7 @@ ls /tmp/.X11-unix/ 2>/dev/null | head -1 | grep -q . && { ls /tmp/.X11-unix/ | h
ls /run/user/*/wayland-* 2>/dev/null | head -1 | grep -q . && { ls /run/user/*/wayland-* | head -1; exit 0; }
exit 1
`
cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash -l -s")
cmd.Stdin = strings.NewReader(script)
out, err := cmd.Output()
out, err := runRemoteScript(sshTarget, script)
if err != nil {
// Exit 1 from our script = no desktop. ssh errors also end up here.
return false, ""
Expand Down Expand Up @@ -301,7 +283,7 @@ func infectRemote(sshTarget string, force bool) {
fmt.Fprintf(os.Stderr, "nssh: remote is %s/%s\n", goos, goarch)

tag := version()
if !looksLikeSemver(tag) {
if !isReleaseVersion(tag) {
t, err := latestReleaseTag()
if err != nil {
fmt.Fprintf(os.Stderr, "nssh: couldn't resolve release tag: %v\n", err)
Expand All @@ -318,7 +300,7 @@ func infectRemote(sshTarget string, force bool) {
}

fmt.Fprintf(os.Stderr, "nssh: copying to %s:~/.local/bin/nssh\n", sshTarget)
if err := exec.Command("ssh", sshTarget, "mkdir -p ~/.local/bin").Run(); err != nil {
if _, err := runRemoteScript(sshTarget, "mkdir -p ~/.local/bin\n"); err != nil {
fmt.Fprintf(os.Stderr, "nssh: mkdir: %v\n", err)
os.Exit(1)
}
Expand All @@ -331,16 +313,15 @@ func infectRemote(sshTarget string, force bool) {

// Let the freshly-installed nssh infect the remote itself — this keeps
// the symlink list in one place (personas var here) and means nssh always
// owns its own symlinks.
cmd := exec.Command("ssh", sshTarget, "bash -l -c '~/.local/bin/nssh infect self --force'")
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
// owns its own symlinks. infectSelf writes to stderr (which passes
// through), so we discard the captured stdout.
if _, err := runRemoteScript(sshTarget, "~/.local/bin/nssh infect self --force\n"); err != nil {
fmt.Fprintf(os.Stderr, "nssh: remote infect self: %v\n", err)
os.Exit(1)
}

// Sanity-check PATH ordering.
out, _ := exec.Command("ssh", sshTarget, `bash -l -c 'command -v xclip'`).Output()
out, _ := runRemoteScript(sshTarget, "command -v xclip\n")
resolved := strings.TrimSpace(string(out))
if !strings.Contains(resolved, ".local/bin/xclip") {
fmt.Fprintln(os.Stderr, "nssh: WARNING: ~/.local/bin/xclip is not first in PATH on the remote")
Expand All @@ -352,3 +333,34 @@ func infectRemote(sshTarget string, force bool) {

fmt.Fprintln(os.Stderr, "nssh: infection complete")
}

// infectCmd parses `nssh infect [--force] <host|self>` and dispatches to
// infectSelf or infectRemote.
func infectCmd(args []string) {
force := false
var target string
for _, a := range args {
switch a {
case "--force":
force = true
case "-h", "--help":
fmt.Fprintln(os.Stderr, "usage: nssh infect [--force] <host|self>")
os.Exit(1)
default:
if target != "" {
fmt.Fprintf(os.Stderr, "nssh: unexpected arg %q\n", a)
os.Exit(1)
}
target = a
}
}
if target == "" {
fmt.Fprintln(os.Stderr, "usage: nssh infect [--force] <host|self>")
os.Exit(1)
}
if target == "self" {
infectSelf(force)
return
}
infectRemote(target, force)
}
Loading