From da3d64637e7b9c18088c928b5410103f0a31f008 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Thu, 30 Apr 2026 17:14:14 -0700 Subject: [PATCH 01/13] dead code cleanup --- cmd/nssh/log.go | 15 +++------------ cmd/nssh/status.go | 8 -------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/cmd/nssh/log.go b/cmd/nssh/log.go index bedb973..7044484 100644 --- a/cmd/nssh/log.go +++ b/cmd/nssh/log.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "os" "path/filepath" "sync" @@ -12,10 +11,9 @@ import ( ) var ( - logFile *os.File - logMu sync.Mutex - logTopic string - logSide string // "session" (local) or persona name (remote shim) + logFile *os.File + logMu sync.Mutex + logSide string // "session" (local) or persona name (remote shim) ) // openLog opens the per-topic JSONL log file for appending. Silently no-ops @@ -34,7 +32,6 @@ func openLog(topic, side string) { return } logFile = f - logTopic = topic logSide = side } @@ -61,12 +58,6 @@ func logEvent(event string, fields map[string]any) { logFile.Write(append(data, '\n')) } -// logErr logs an error event and also prints to stderr. -func logErr(where string, err error) { - fmt.Fprintf(os.Stderr, "nssh: %s: %v\n", where, err) - logEvent("error", map[string]any{"where": where, "err": err.Error()}) -} - // logMessage emits a msg-send or msg-recv event with a consistent schema so // both sides of the tunnel produce the same wire-event shape. "dir" is "in" // when the envelope arrived from the topic, "out" when we're publishing. diff --git a/cmd/nssh/status.go b/cmd/nssh/status.go index cb1ef8f..350e284 100644 --- a/cmd/nssh/status.go +++ b/cmd/nssh/status.go @@ -196,14 +196,6 @@ func truncate(s string, n int) string { return s[:n-1] + "…" } -func shortPath(p string) string { - home, _ := os.UserHomeDir() - if home != "" && strings.HasPrefix(p, home) { - return "~" + p[len(home):] - } - return p -} - func shortDuration(d time.Duration) string { if d < time.Minute { return strconv.Itoa(int(d.Seconds())) + "s" From 4a36238244b94cd44e21f4c2ae7c1d94f91c4203 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Mon, 4 May 2026 18:23:00 -0700 Subject: [PATCH 02/13] ci: gate cachix and release on tests - new test.yaml: go vet + go test on PRs and master pushes, also callable as a reusable workflow. - release.yml: gate goreleaser on the test workflow via needs. - flake.nix: doCheck = true so cachix.yaml's nix build runs go test as part of the build (gates the cache push automatically). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 4 ++++ .github/workflows/test.yaml | 18 ++++++++++++++++++ flake.nix | 1 + 3 files changed, 23 insertions(+) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4da45fa..295b557 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..b7f606b --- /dev/null +++ b/.github/workflows/test.yaml @@ -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 ./... diff --git a/flake.nix b/flake.nix index 25f2e7f..fae4fa4 100644 --- a/flake.nix +++ b/flake.nix @@ -17,6 +17,7 @@ src = self; vendorHash = null; subPackages = [ "cmd/nssh" ]; + doCheck = true; meta = { description = "SSH/mosh wrapper with clipboard bridge and xdg-open forwarding via ntfy"; From eb2bb127307737afb8e379444110bccc5fcac902 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Mon, 4 May 2026 18:30:04 -0700 Subject: [PATCH 03/13] nssh: drop stale docs/ reference from CLAUDE.md The docs/ directory has been empty for a while. Remove the layout entry until we write proper docs (tracked separately). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2e0ffb..41f138a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,6 @@ 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 .github/workflows/ CI (cachix.yaml for nix, release.yml for tagged releases) justfile Build recipes flake.nix Nix package From dd3982a4d19e74c9b32e04769efcaed6f9f3891d Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Mon, 4 May 2026 18:33:20 -0700 Subject: [PATCH 04/13] nssh: parse latestReleaseTag with encoding/json Replace strings.Index hunting for `"tag_name":"` with json.NewDecoder into a typed struct. Same correctness, no quirks. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/nssh/infect.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/cmd/nssh/infect.go b/cmd/nssh/infect.go index 8716ecb..32add23 100644 --- a/cmd/nssh/infect.go +++ b/cmd/nssh/infect.go @@ -3,6 +3,7 @@ package main import ( "archive/tar" "compress/gzip" + "encoding/json" "fmt" "io" "net/http" @@ -42,21 +43,16 @@ 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). From f7b28ccef3895d0ec30dd347efa14d508de75565 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Mon, 4 May 2026 18:38:15 -0700 Subject: [PATCH 05/13] clipboard: gate macOS impl behind GOOS=darwin Renames clipboard.go to clipboard_darwin.go (file-suffix makes it auto-darwin) and adds clipboard_other.go with stub functions returning errUnsupported on every other GOOS. The remote-shim path on Linux never calls into clipboard, so this is a no-op for current users while unblocking a clean build matrix and a future Linux client backend. Test file similarly renamed; the now-redundant runtime.GOOS skip is removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../{clipboard.go => clipboard_darwin.go} | 0 ...clipboard_test.go => clipboard_darwin_test.go} | 4 ---- internal/clipboard/clipboard_other.go | 15 +++++++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) rename internal/clipboard/{clipboard.go => clipboard_darwin.go} (100%) rename internal/clipboard/{clipboard_test.go => clipboard_darwin_test.go} (95%) create mode 100644 internal/clipboard/clipboard_other.go diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard_darwin.go similarity index 100% rename from internal/clipboard/clipboard.go rename to internal/clipboard/clipboard_darwin.go diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_darwin_test.go similarity index 95% rename from internal/clipboard/clipboard_test.go rename to internal/clipboard/clipboard_darwin_test.go index c0ca757..729c27d 100644 --- a/internal/clipboard/clipboard_test.go +++ b/internal/clipboard/clipboard_darwin_test.go @@ -7,15 +7,11 @@ import ( "image/png" "os" "os/exec" - "runtime" "testing" ) func skip(t *testing.T) { t.Helper() - if runtime.GOOS != "darwin" { - t.Skip("darwin-only") - } if os.Getenv("NSSH_CLIPBOARD_TESTS") != "1" { t.Skip("set NSSH_CLIPBOARD_TESTS=1 to run (will clobber your clipboard)") } diff --git a/internal/clipboard/clipboard_other.go b/internal/clipboard/clipboard_other.go new file mode 100644 index 0000000..a1fa95b --- /dev/null +++ b/internal/clipboard/clipboard_other.go @@ -0,0 +1,15 @@ +//go:build !darwin + +// Package clipboard wraps the local-machine clipboard for the nssh session +// wrapper. Today only macOS is implemented; on other platforms every call +// returns errUnsupported. See task #13 for Linux client support. +package clipboard + +import "errors" + +var errUnsupported = errors.New("clipboard: only macOS is supported as a local nssh client") + +func ReadText() ([]byte, error) { return nil, errUnsupported } +func WriteText(_ []byte) error { return errUnsupported } +func ReadImagePNG() ([]byte, error) { return nil, errUnsupported } +func WriteImagePNG(_ []byte) error { return errUnsupported } From 22aac85ad9db04996fa3eaf90ce5a1e1bbcfc269 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 00:40:33 -0700 Subject: [PATCH 06/13] nssh: extract runRemoteScript SSH helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five sites repeated ssh + bash + script-via-stdin boilerplate: prepareRemote, detectRemoteDesktop, and the trio in infectRemote (mkdir, infect self, command -v xclip). Consolidate into one helper that always uses BatchMode=yes and pipes stderr to os.Stderr so SSH errors surface to the user. Behavior change worth noting: the three infectRemote sites now use BatchMode=yes — password-auth-only setups (rare) will see "Permission denied (publickey)" instead of repeated password prompts. Pubkey auth that works for the initial scp works for every subsequent ssh. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/nssh/infect.go | 15 ++++++--------- cmd/nssh/main.go | 5 +---- cmd/nssh/remote.go | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 cmd/nssh/remote.go diff --git a/cmd/nssh/infect.go b/cmd/nssh/infect.go index 32add23..4ca623f 100644 --- a/cmd/nssh/infect.go +++ b/cmd/nssh/infect.go @@ -106,9 +106,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, "" @@ -314,7 +312,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) } @@ -327,16 +325,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") diff --git a/cmd/nssh/main.go b/cmd/nssh/main.go index bb932ab..ca61109 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -61,10 +61,7 @@ cat >> "$dir/nssh.%s.jsonl" <<'NSSH_LOG_EOF' NSSH_LOG_EOF `, cfg.Server, cfg.Topic, cfg.Topic, string(eventJSON)) - cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash", "-l", "-s") - cmd.Stdin = strings.NewReader(script) - cmd.Stderr = os.Stderr - out, err := cmd.Output() + out, err := runRemoteScript(sshTarget, script) if err != nil { fmt.Fprintf(os.Stderr, "nssh: remote prepare: %v\n", err) return "" diff --git a/cmd/nssh/remote.go b/cmd/nssh/remote.go new file mode 100644 index 0000000..8653012 --- /dev/null +++ b/cmd/nssh/remote.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + "os/exec" + "strings" +) + +// runRemoteScript pipes a shell script to `bash -l -s` on the remote and +// returns its stdout. Stderr passes through to the user's terminal so SSH +// errors (auth, connection refused, host key) are visible. BatchMode=yes +// — every caller wants a deterministic non-interactive run; key auth that +// works for the initial connection works for every subsequent call. +func runRemoteScript(sshTarget, script string) ([]byte, error) { + cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash", "-l", "-s") + cmd.Stdin = strings.NewReader(script) + cmd.Stderr = os.Stderr + return cmd.Output() +} From 89097561835ce43bd96731ae2ec65b3458bd41db Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 00:43:42 -0700 Subject: [PATCH 07/13] nssh: extract wire.Publish helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sites duplicated the "marshal envelope, choose inline vs attachment by inlineThreshold and image-mime, call ntfy.Publish*" flow: clipboard.go's clip-read success and error responses, and shim.go's clip-write request. Consolidate behind wire.Publish, which takes the envelope by value so the inline-mode mutation of Body doesn't leak back to the caller (callers immediately log the envelope after publish, and the log shouldn't include base64 noise). inlineThreshold moves into wire as the exported InlineThreshold — the inline-vs-attachment decision is a protocol concern that lives naturally with the envelope type. wire now imports ntfy; both packages remain leaves of cmd/nssh. Tests cover both transport paths, the large-text → attachment threshold, the image → attachment unconditional rule, the nil-data case (kind=open), and the no-mutation contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/nssh/clipboard.go | 25 +------ cmd/nssh/shim.go | 20 +---- internal/wire/publish.go | 38 ++++++++++ internal/wire/publish_test.go | 135 ++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 39 deletions(-) create mode 100644 internal/wire/publish.go create mode 100644 internal/wire/publish_test.go diff --git a/cmd/nssh/clipboard.go b/cmd/nssh/clipboard.go index 929cdb5..b260a77 100644 --- a/cmd/nssh/clipboard.go +++ b/cmd/nssh/clipboard.go @@ -2,7 +2,6 @@ package main import ( "encoding/base64" - "encoding/json" "fmt" "os" "strings" @@ -12,8 +11,6 @@ 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 @@ -68,9 +65,7 @@ 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) @@ -78,22 +73,8 @@ func handleClipReadRequest(env wire.Envelope, topicURL string) { } 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)) } diff --git a/cmd/nssh/shim.go b/cmd/nssh/shim.go index f2282f3..3029cef 100644 --- a/cmd/nssh/shim.go +++ b/cmd/nssh/shim.go @@ -39,23 +39,9 @@ func shimClipWrite(topicURL, mime string) { } env := wire.Envelope{Kind: "clip-write", Mime: mime} - if len(data) <= inlineThreshold && !strings.HasPrefix(mime, "image/") { - env.Body = base64.StdEncoding.EncodeToString(data) - body, _ := json.Marshal(env) - if err := ntfy.PublishMessage(topicURL, string(body)); err != nil { - fmt.Fprintf(os.Stderr, "nssh: %v\n", err) - os.Exit(1) - } - } else { - msg, _ := json.Marshal(env) - filename := "clip.dat" - if strings.HasPrefix(mime, "image/png") { - filename = "clip.png" - } - if err := ntfy.PublishAttachment(topicURL, string(msg), data, filename); err != nil { - fmt.Fprintf(os.Stderr, "nssh: %v\n", err) - os.Exit(1) - } + if err := wire.Publish(topicURL, env, data); err != nil { + fmt.Fprintf(os.Stderr, "nssh: %v\n", err) + os.Exit(1) } logMessage("out", env, len(data)) } diff --git a/internal/wire/publish.go b/internal/wire/publish.go new file mode 100644 index 0000000..b4956af --- /dev/null +++ b/internal/wire/publish.go @@ -0,0 +1,38 @@ +package wire + +import ( + "encoding/base64" + "encoding/json" + "strings" + + "github.com/abizer/nssh/v2/internal/ntfy" +) + +// InlineThreshold is the size cutoff for inline-vs-attachment publish. +// Payloads ≤ InlineThreshold bytes with a non-image mime ride as base64 +// inside the envelope's Body field; larger payloads and all images go +// out as ntfy attachments. +const InlineThreshold = 3 * 1024 + +// Publish marshals env and ships it to topicURL, choosing inline (base64 +// in Body) vs. attachment automatically based on len(data) and env.Mime. +// data may be nil for envelopes that carry no payload. +// +// env is taken by value so the inline-mode mutation of env.Body does not +// leak back to the caller — useful for log lines built from the same +// envelope after publish. +func Publish(topicURL string, env Envelope, data []byte) error { + if len(data) <= InlineThreshold && !strings.HasPrefix(env.Mime, "image/") { + if data != nil { + env.Body = base64.StdEncoding.EncodeToString(data) + } + body, _ := json.Marshal(env) + return ntfy.PublishMessage(topicURL, string(body)) + } + body, _ := json.Marshal(env) + filename := "clip.dat" + if strings.HasPrefix(env.Mime, "image/png") { + filename = "clip.png" + } + return ntfy.PublishAttachment(topicURL, string(body), data, filename) +} diff --git a/internal/wire/publish_test.go b/internal/wire/publish_test.go new file mode 100644 index 0000000..eb8b8be --- /dev/null +++ b/internal/wire/publish_test.go @@ -0,0 +1,135 @@ +package wire + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// captureServer returns an httptest server that records the last request's +// method, body, and the Filename / X-Message attachment headers. +type capture struct { + method, filename, message string + body []byte +} + +func captureServer(t *testing.T) (*httptest.Server, *capture) { + t.Helper() + c := &capture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.method = r.Method + c.filename = r.Header.Get("Filename") + c.message = r.Header.Get("X-Message") + c.body, _ = io.ReadAll(r.Body) + })) + t.Cleanup(srv.Close) + return srv, c +} + +func TestPublishInlineSmallText(t *testing.T) { + srv, got := captureServer(t) + env := Envelope{Kind: "clip-write", Mime: "text/plain"} + data := []byte("hello, world") + + if err := Publish(srv.URL, env, data); err != nil { + t.Fatal(err) + } + if got.method != "POST" { + t.Errorf("method = %q, want POST (inline path)", got.method) + } + + var sent Envelope + if err := json.Unmarshal(got.body, &sent); err != nil { + t.Fatalf("unmarshal: %v (body=%q)", err, got.body) + } + if sent.Kind != "clip-write" || sent.Mime != "text/plain" { + t.Errorf("envelope round trip mismatched: %+v", sent) + } + decoded, err := base64.StdEncoding.DecodeString(sent.Body) + if err != nil { + t.Fatalf("Body not base64: %v", err) + } + if !bytes.Equal(decoded, data) { + t.Errorf("Body decode mismatch: %q vs %q", decoded, data) + } +} + +func TestPublishAttachmentForImage(t *testing.T) { + srv, got := captureServer(t) + env := Envelope{Kind: "clip-write", Mime: "image/png"} + data := []byte{0x89, 0x50, 0x4E, 0x47} // small but image → attachment + + if err := Publish(srv.URL, env, data); err != nil { + t.Fatal(err) + } + if got.method != "PUT" { + t.Errorf("method = %q, want PUT (attachment path)", got.method) + } + if got.filename != "clip.png" { + t.Errorf("filename = %q, want clip.png", got.filename) + } + if !bytes.Equal(got.body, data) { + t.Errorf("attachment body mismatch") + } + // X-Message carries the JSON envelope. + var sent Envelope + if err := json.Unmarshal([]byte(got.message), &sent); err != nil { + t.Fatalf("X-Message not envelope JSON: %v", err) + } + if sent.Body != "" { + t.Errorf("attachment envelope should not carry inline Body, got %q", sent.Body) + } +} + +func TestPublishAttachmentForLargeText(t *testing.T) { + srv, got := captureServer(t) + env := Envelope{Kind: "clip-write", Mime: "text/plain"} + data := []byte(strings.Repeat("x", InlineThreshold+1)) + + if err := Publish(srv.URL, env, data); err != nil { + t.Fatal(err) + } + if got.method != "PUT" { + t.Errorf("method = %q, want PUT (over threshold)", got.method) + } + if got.filename != "clip.dat" { + t.Errorf("filename = %q, want clip.dat (non-image)", got.filename) + } +} + +func TestPublishCallerEnvelopeUnmutated(t *testing.T) { + srv, _ := captureServer(t) + env := Envelope{Kind: "clip-write", Mime: "text/plain"} + if err := Publish(srv.URL, env, []byte("hi")); err != nil { + t.Fatal(err) + } + if env.Body != "" { + t.Errorf("caller's envelope mutated: Body = %q", env.Body) + } +} + +func TestPublishNilData(t *testing.T) { + srv, got := captureServer(t) + env := Envelope{Kind: "open", URL: "https://example.com"} + if err := Publish(srv.URL, env, nil); err != nil { + t.Fatal(err) + } + if got.method != "POST" { + t.Errorf("method = %q, want POST", got.method) + } + var sent Envelope + if err := json.Unmarshal(got.body, &sent); err != nil { + t.Fatal(err) + } + if sent.Body != "" { + t.Errorf("Body should be empty for nil data, got %q", sent.Body) + } + if sent.URL != "https://example.com" { + t.Errorf("URL not preserved: %q", sent.URL) + } +} From 7d1cd4ad74fd4153bff795b4e556675fd1c9c241 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 01:03:01 -0700 Subject: [PATCH 08/13] nssh: type the JSONL log schema as LogEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now, both the writer (logEvent + logMessage) and the reader (status.go::formatEvent / formatWireMessage) handled events as map[string]any with stringly-typed field names. Renaming a field or adding one was grep-and-pray. LogEvent in log.go is now the shared schema. omitempty drops fields that aren't relevant to a given event, so the on-disk JSON shape is unchanged. Exit and Mosh are *int / *bool so callers can record an explicit zero/false (session-end exit=0 success) without being dropped. Updated five logEvent call sites (main.go × 3, shim.go × 6 — wait, six in shim.go, three in main.go) to pass typed LogEvent struct literals. logMessage and the inline session-open emitter in prepareRemote use the same type. Reader unmarshals straight into LogEvent, with the per-event-detail fields rendered alphabetically to preserve current `nssh status --tail` output. Adds log_test.go covering: zero-fields omitted, explicit Exit=0 and Mosh=true preserved, full round-trip including pointer fields, and the wire-message subset emitted by logMessage. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/nssh/log.go | 85 ++++++++++++++++++++++-------------- cmd/nssh/log_test.go | 95 ++++++++++++++++++++++++++++++++++++++++ cmd/nssh/main.go | 25 +++++------ cmd/nssh/shim.go | 12 +++--- cmd/nssh/status.go | 100 ++++++++++++++++++++++++++----------------- 5 files changed, 226 insertions(+), 91 deletions(-) create mode 100644 cmd/nssh/log_test.go diff --git a/cmd/nssh/log.go b/cmd/nssh/log.go index 7044484..2868460 100644 --- a/cmd/nssh/log.go +++ b/cmd/nssh/log.go @@ -10,6 +10,40 @@ import ( "github.com/abizer/nssh/v2/internal/wire" ) +// LogEvent is the on-disk schema for each JSONL log line. Both the writer +// (logEvent below) and the reader (status.go::formatEvent) marshal against +// this type, so renaming or adding a field is type-checked instead of +// grep-and-pray. Exit and Mosh are pointers so callers can record an +// explicit zero/false without omitempty dropping the field. +type LogEvent struct { + TS string `json:"ts"` + Event string `json:"event"` + Side string `json:"side,omitempty"` + PID int `json:"pid,omitempty"` + + // Wire-message details (msg-send / msg-recv). + Kind string `json:"kind,omitempty"` + Mime string `json:"mime,omitempty"` + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` + Size int `json:"size,omitempty"` + + // Session lifecycle. + Target string `json:"target,omitempty"` + Server string `json:"server,omitempty"` + Topic string `json:"topic,omitempty"` + Version string `json:"version,omitempty"` + Exit *int `json:"exit,omitempty"` + Mosh *bool `json:"mosh,omitempty"` + + // Shim invocation. + Persona string `json:"persona,omitempty"` + Args []string `json:"args,omitempty"` + + // Error context. + Err string `json:"err,omitempty"` +} + var ( logFile *os.File logMu sync.Mutex @@ -35,21 +69,18 @@ func openLog(topic, side string) { logSide = side } -// logEvent writes one JSONL line. Safe to call before openLog (no-op). -// Line writes are atomic under POSIX O_APPEND for size < PIPE_BUF (~4KB), -// so concurrent shim invocations on the same log don't interleave. -func logEvent(event string, fields map[string]any) { +// logEvent writes one JSONL line, stamping ts/side/pid. Safe to call before +// openLog (no-op). Line writes are atomic under POSIX O_APPEND for size < +// PIPE_BUF (~4KB), so concurrent shim invocations on the same log don't +// interleave. +func logEvent(e LogEvent) { if logFile == nil { return } - if fields == nil { - fields = map[string]any{} - } - fields["ts"] = time.Now().UTC().Format(time.RFC3339Nano) - fields["event"] = event - fields["side"] = logSide - fields["pid"] = os.Getpid() - data, err := json.Marshal(fields) + e.TS = time.Now().UTC().Format(time.RFC3339Nano) + e.Side = logSide + e.PID = os.Getpid() + data, err := json.Marshal(e) if err != nil { return } @@ -58,28 +89,20 @@ func logEvent(event string, fields map[string]any) { logFile.Write(append(data, '\n')) } -// logMessage emits a msg-send or msg-recv event with a consistent schema so -// both sides of the tunnel produce the same wire-event shape. "dir" is "in" -// when the envelope arrived from the topic, "out" when we're publishing. -// size is the payload size in bytes — attachment size for images, decoded -// body length for inline text, 0 if unknown. +// logMessage emits a msg-send (dir=="out") or msg-recv (otherwise) event +// with the wire envelope details. size is the payload in bytes — attachment +// size for images, decoded body length for inline text, 0 if unknown. func logMessage(dir string, env wire.Envelope, size int) { event := "msg-recv" if dir == "out" { event = "msg-send" } - fields := map[string]any{"kind": env.Kind} - if env.Mime != "" { - fields["mime"] = env.Mime - } - if env.ID != "" { - fields["id"] = env.ID - } - if env.URL != "" { - fields["url"] = env.URL - } - if size > 0 { - fields["size"] = size - } - logEvent(event, fields) + logEvent(LogEvent{ + Event: event, + Kind: env.Kind, + Mime: env.Mime, + ID: env.ID, + URL: env.URL, + Size: size, + }) } diff --git a/cmd/nssh/log_test.go b/cmd/nssh/log_test.go new file mode 100644 index 0000000..6311916 --- /dev/null +++ b/cmd/nssh/log_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestLogEventOmitsZeros(t *testing.T) { + e := LogEvent{Event: "shim-start", Persona: "xclip"} + data, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + got := string(data) + for _, banned := range []string{`"size":`, `"exit":`, `"mosh":`, `"err":`, `"args":`, `"url":`} { + if strings.Contains(got, banned) { + t.Errorf("unexpected zero field in JSON: %s\n full=%s", banned, got) + } + } + if !strings.Contains(got, `"event":"shim-start"`) || !strings.Contains(got, `"persona":"xclip"`) { + t.Errorf("required fields missing: %s", got) + } +} + +func TestLogEventExitZeroPreserved(t *testing.T) { + zero := 0 + yes := true + e := LogEvent{Event: "session-end", Exit: &zero, Mosh: &yes} + data, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + got := string(data) + if !strings.Contains(got, `"exit":0`) { + t.Errorf("exit=0 was dropped: %s", got) + } + if !strings.Contains(got, `"mosh":true`) { + t.Errorf("mosh=true missing: %s", got) + } +} + +func TestLogEventRoundTrip(t *testing.T) { + exit := 42 + mosh := false + want := LogEvent{ + TS: "2026-05-05T07:43:42Z", + Event: "session-end", + Side: "session", + PID: 12345, + Target: "devbox", + Server: "https://ntfy.sh", + Topic: "nssh_abc", + Version: "v0.1.0", + Exit: &exit, + Mosh: &mosh, + } + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got LogEvent + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.Event != want.Event || got.Side != want.Side || got.Target != want.Target { + t.Errorf("round trip mismatch:\n want=%+v\n got=%+v", want, got) + } + if got.Exit == nil || *got.Exit != exit { + t.Errorf("Exit not preserved: %v", got.Exit) + } + if got.Mosh == nil || *got.Mosh != mosh { + t.Errorf("Mosh not preserved: %v", got.Mosh) + } +} + +func TestLogEventWireMessage(t *testing.T) { + // What logMessage would emit for a clip-write of a 2KB text payload. + e := LogEvent{Event: "msg-send", Kind: "clip-write", Mime: "text/plain", Size: 2048} + data, _ := json.Marshal(e) + var got LogEvent + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.Kind != "clip-write" || got.Mime != "text/plain" || got.Size != 2048 { + t.Errorf("wire fields not preserved: %+v", got) + } + // And no spurious zero-valued fields leaked through. + gotS := string(data) + for _, banned := range []string{`"exit":`, `"mosh":`, `"id":`, `"url":`, `"persona":`} { + if strings.Contains(gotS, banned) { + t.Errorf("unexpected zero field in JSON: %s\n full=%s", banned, gotS) + } + } +} diff --git a/cmd/nssh/main.go b/cmd/nssh/main.go index ca61109..9a497c1 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -30,14 +30,14 @@ var localhostRe = regexp.MustCompile(`(?:localhost|127\.0\.0\.1):(\d+)`) // remote nssh version, or "" if not installed / unreadable. Non-fatal on // errors — shim may still work with a pinned config.toml or no log at all. func prepareRemote(sshTarget string, cfg nsshConfig) string { - event := map[string]any{ - "ts": time.Now().UTC().Format(time.RFC3339Nano), - "event": "session-open", - "side": "session-init", - "server": cfg.Server, - "topic": cfg.Topic, - "target": sshTarget, - "version": buildVersion, + event := LogEvent{ + TS: time.Now().UTC().Format(time.RFC3339Nano), + Event: "session-open", + Side: "session-init", + Server: cfg.Server, + Topic: cfg.Topic, + Target: sshTarget, + Version: buildVersion, } eventJSON, _ := json.Marshal(event) @@ -151,7 +151,7 @@ func handleMessage(msg ntfy.Msg, topicURL, sshTarget string) { env, ok := wire.Parse(msg.Message) if !ok { fmt.Fprintf(os.Stderr, "nssh: ignoring unrecognized message (%d bytes)\n", len(msg.Message)) - logEvent("msg-unknown", map[string]any{"size": len(msg.Message)}) + logEvent(LogEvent{Event: "msg-unknown", Size: len(msg.Message)}) return } size := 0 @@ -440,10 +440,7 @@ func nsshMain() { fmt.Fprintf(os.Stderr, "nssh: subscribing to %s\n", cfg.topicURL()) openLog(cfg.Topic, "session") - logEvent("session-start", map[string]any{ - "target": sshTarget, - "server": cfg.Server, - }) + logEvent(LogEvent{Event: "session-start", Target: sshTarget, Server: cfg.Server}) sessionFile, err := registerSession(cfg, sshTarget) if err != nil { @@ -502,7 +499,7 @@ func nsshMain() { if exitErr, ok := sessErr.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } - logEvent("session-end", map[string]any{"exit": exitCode, "mosh": useMosh}) + logEvent(LogEvent{Event: "session-end", Exit: &exitCode, Mosh: &useMosh}) unregisterSession(sessionFile) // defers don't fire under os.Exit if exitCode != 0 { os.Exit(exitCode) diff --git a/cmd/nssh/shim.go b/cmd/nssh/shim.go index 3029cef..8405d73 100644 --- a/cmd/nssh/shim.go +++ b/cmd/nssh/shim.go @@ -34,7 +34,7 @@ func shimClipWrite(topicURL, mime string) { os.Exit(1) } if len(data) == 0 { - logEvent("clip-write-empty", map[string]any{"mime": mime}) + logEvent(LogEvent{Event: "clip-write-empty", Mime: mime}) return } @@ -91,7 +91,7 @@ func shimClipRead(topicURL, mime string) { } if strings.HasPrefix(string(data), "ERROR: ") { fmt.Fprintln(os.Stderr, string(data)) - logEvent("clip-read-error", map[string]any{"id": id, "err": string(data)}) + logEvent(LogEvent{Event: "clip-read-error", ID: id, Err: string(data)}) os.Exit(1) } logMessage("in", env, len(data)) @@ -108,12 +108,12 @@ func shimClipRead(topicURL, mime string) { os.Stdout.Write(data) return } - logEvent("clip-read-empty", map[string]any{"id": id}) + logEvent(LogEvent{Event: "clip-read-empty", ID: id}) return } fmt.Fprintln(os.Stderr, "nssh: clipboard read timed out") - logEvent("clip-read-timeout", map[string]any{"id": id}) + logEvent(LogEvent{Event: "clip-read-timeout", ID: id}) os.Exit(1) } @@ -145,7 +145,7 @@ func doXdgOpen(args []string) { // this we silently exit 255 (system xdg-open's exit code on headless // hosts), which gives no clue what went wrong (e.g. ntfy 429s). fmt.Fprintf(os.Stderr, "nssh: publish failed: %v\n", err) - logEvent("publish-failed", map[string]any{"kind": "open", "err": err.Error()}) + logEvent(LogEvent{Event: "publish-failed", Kind: "open", Err: err.Error()}) runFallback("xdg-open", args) } logMessage("out", env, 0) @@ -242,7 +242,7 @@ func shimMain(persona string, args []string) { cfg := loadConfig() if cfg.Topic != "" { openLog(cfg.Topic, persona) - logEvent("shim-start", map[string]any{"persona": persona, "args": args}) + logEvent(LogEvent{Event: "shim-start", Persona: persona, Args: args}) } switch persona { case "xdg-open", "sensible-browser": diff --git a/cmd/nssh/status.go b/cmd/nssh/status.go index 350e284..09d1f60 100644 --- a/cmd/nssh/status.go +++ b/cmd/nssh/status.go @@ -272,66 +272,86 @@ func tailOne(path, label string, out chan<- string, stop <-chan struct{}) { } func formatEvent(raw, label string) string { - var m map[string]any - if err := json.Unmarshal([]byte(raw), &m); err != nil { + var e LogEvent + if err := json.Unmarshal([]byte(raw), &e); err != nil { return fmt.Sprintf("[%s] %s", label, raw) } ts := "" - if v, ok := m["ts"].(string); ok { - if t, err := time.Parse(time.RFC3339Nano, v); err == nil { - ts = t.Local().Format("15:04:05") - } + if t, err := time.Parse(time.RFC3339Nano, e.TS); err == nil { + ts = t.Local().Format("15:04:05") } - event, _ := m["event"].(string) - switch event { + switch e.Event { case "msg-send": - return formatWireMessage(label, ts, "→", m) + return formatWireMessage(label, ts, "→", e) case "msg-recv": - return formatWireMessage(label, ts, "←", m) + return formatWireMessage(label, ts, "←", e) } - var keys []string - for k := range m { - switch k { - case "ts", "event", "pid", "side": - continue - } - keys = append(keys, k) + var sb strings.Builder + fmt.Fprintf(&sb, "[%s] %s %s", label, ts, e.Event) + if e.Side != "" && e.Side != "session" { + fmt.Fprintf(&sb, " (%s)", e.Side) } - sort.Strings(keys) - var sb strings.Builder - fmt.Fprintf(&sb, "[%s] %s %s", label, ts, event) - if side, _ := m["side"].(string); side != "" && side != "session" { - fmt.Fprintf(&sb, " (%s)", side) + // Per-event fields, alphabetical (matches the prior sorted-map order). + if e.Args != nil { + fmt.Fprintf(&sb, " args=%v", e.Args) + } + if e.Err != "" { + fmt.Fprintf(&sb, " err=%s", e.Err) + } + if e.Exit != nil { + fmt.Fprintf(&sb, " exit=%d", *e.Exit) + } + if e.ID != "" { + fmt.Fprintf(&sb, " id=%s", e.ID) + } + if e.Kind != "" { + fmt.Fprintf(&sb, " kind=%s", e.Kind) + } + if e.Mime != "" { + fmt.Fprintf(&sb, " mime=%s", e.Mime) } - for _, k := range keys { - fmt.Fprintf(&sb, " %s=%v", k, m[k]) + if e.Mosh != nil { + fmt.Fprintf(&sb, " mosh=%v", *e.Mosh) + } + if e.Persona != "" { + fmt.Fprintf(&sb, " persona=%s", e.Persona) + } + if e.Server != "" { + fmt.Fprintf(&sb, " server=%s", e.Server) + } + if e.Size > 0 { + fmt.Fprintf(&sb, " size=%d", e.Size) + } + if e.Target != "" { + fmt.Fprintf(&sb, " target=%s", e.Target) + } + if e.Topic != "" { + fmt.Fprintf(&sb, " topic=%s", e.Topic) + } + if e.URL != "" { + fmt.Fprintf(&sb, " url=%s", e.URL) + } + if e.Version != "" { + fmt.Fprintf(&sb, " version=%s", e.Version) } return sb.String() } -func formatWireMessage(label, ts, arrow string, m map[string]any) string { - kind, _ := m["kind"].(string) - mime, _ := m["mime"].(string) - urlStr, _ := m["url"].(string) - var size int64 - if v, ok := m["size"].(float64); ok { - size = int64(v) - } - +func formatWireMessage(label, ts, arrow string, e LogEvent) string { var detail strings.Builder switch { - case urlStr != "": - fmt.Fprintf(&detail, " %s", urlStr) - case mime != "": - fmt.Fprintf(&detail, " %s", mime) + case e.URL != "": + fmt.Fprintf(&detail, " %s", e.URL) + case e.Mime != "": + fmt.Fprintf(&detail, " %s", e.Mime) } - if size > 0 { - fmt.Fprintf(&detail, " (%s)", humanSize(size)) + if e.Size > 0 { + fmt.Fprintf(&detail, " (%s)", humanSize(int64(e.Size))) } - return fmt.Sprintf("[%s] %s %s %s%s", label, ts, arrow, kind, detail.String()) + return fmt.Sprintf("[%s] %s %s %s%s", label, ts, arrow, e.Kind, detail.String()) } func humanSize(n int64) string { From 04d65eea10528d35d4b86a8e671858f89652a871 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 01:08:26 -0700 Subject: [PATCH 09/13] nssh: split main.go into focused files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.go was 507 lines mixing CLI dispatch, version printing, OAuth proxy, ntfy subscriber loop, message routing, prepareRemote, and the session exec lifecycle. Split into: - main.go (82 lines): main, usage, printVersion, buildVersion — pure CLI dispatch. - session.go (new, 288 lines): nsshMain plus the runtime guts — subscribeNtfy, deadlineConn, handleMessage, runSession, resetTerminal, remoteHasMosh. - oauth.go (new, 83 lines): localhostRe, extractLocalhostPort, proxyOAuthCallback, handleOpen. - remote.go: now also holds prepareRemote and resolveShortHost alongside runRemoteScript. - infect.go: gains infectCmd (the subcommand parser belongs with infectSelf and infectRemote). No behavior change. Each function moved verbatim; doc comments on the moved-in functions were tightened but otherwise the implementations are identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/nssh/infect.go | 31 ++++ cmd/nssh/main.go | 433 +------------------------------------------- cmd/nssh/oauth.go | 83 +++++++++ cmd/nssh/remote.go | 78 ++++++++ cmd/nssh/session.go | 288 +++++++++++++++++++++++++++++ 5 files changed, 484 insertions(+), 429 deletions(-) create mode 100644 cmd/nssh/oauth.go create mode 100644 cmd/nssh/session.go diff --git a/cmd/nssh/infect.go b/cmd/nssh/infect.go index 4ca623f..e839bbd 100644 --- a/cmd/nssh/infect.go +++ b/cmd/nssh/infect.go @@ -345,3 +345,34 @@ func infectRemote(sshTarget string, force bool) { fmt.Fprintln(os.Stderr, "nssh: infection complete") } + +// infectCmd parses `nssh infect [--force] ` 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] ") + 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] ") + os.Exit(1) + } + if target == "self" { + infectSelf(force) + return + } + infectRemote(target, force) +} diff --git a/cmd/nssh/main.go b/cmd/nssh/main.go index 9a497c1..d525452 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -1,294 +1,16 @@ package main import ( - "bufio" - "context" - "encoding/base64" - "encoding/json" "fmt" - "net" - "net/http" - "net/url" "os" - "os/exec" - "os/signal" "path/filepath" - "regexp" "runtime/debug" - "strings" - "syscall" - "time" - - "github.com/abizer/nssh/v2/internal/ntfy" - "github.com/abizer/nssh/v2/internal/wire" ) -var localhostRe = regexp.MustCompile(`(?:localhost|127\.0\.0\.1):(\d+)`) - -// prepareRemote probes the remote's nssh version and writes the session file + -// seeds the JSONL log in a single SSH login-shell invocation. Returns the -// remote nssh version, or "" if not installed / unreadable. Non-fatal on -// errors — shim may still work with a pinned config.toml or no log at all. -func prepareRemote(sshTarget string, cfg nsshConfig) string { - event := LogEvent{ - TS: time.Now().UTC().Format(time.RFC3339Nano), - Event: "session-open", - Side: "session-init", - Server: cfg.Server, - Topic: cfg.Topic, - Target: sshTarget, - Version: buildVersion, - } - eventJSON, _ := json.Marshal(event) - - // bash -l so PATH includes ~/.local/bin even for non-interactive sessions. - // Heredocs with quoted delimiters ('EOF') prevent shell expansion inside, - // so TOML and JSON pass through verbatim regardless of contents. - script := fmt.Sprintf(`set -e -if command -v nssh >/dev/null 2>&1; then - echo "NSSH_VERSION: $(nssh --version 2>/dev/null | head -1 | awk '{print $2}')" -else - echo "NSSH_VERSION: none" -fi -dir="${XDG_STATE_HOME:-$HOME/.local/state}/nssh" -mkdir -p "$dir" -cat > "$dir/session" <<'NSSH_SESSION_EOF' -server = "%s" -topic = "%s" -NSSH_SESSION_EOF -cat >> "$dir/nssh.%s.jsonl" <<'NSSH_LOG_EOF' -%s -NSSH_LOG_EOF -`, cfg.Server, cfg.Topic, cfg.Topic, string(eventJSON)) - - out, err := runRemoteScript(sshTarget, script) - if err != nil { - fmt.Fprintf(os.Stderr, "nssh: remote prepare: %v\n", err) - return "" - } - for _, line := range strings.Split(string(out), "\n") { - v, ok := strings.CutPrefix(line, "NSSH_VERSION: ") - if !ok { - continue - } - v = strings.TrimSpace(v) - if v == "" || v == "none" { - return "" - } - return v - } - return "" -} - -func resolveShortHost(sshArgs []string) string { - out, err := exec.Command("ssh", append([]string{"-G"}, sshArgs...)...).Output() - if err != nil { - return "" - } - for _, line := range strings.Split(string(out), "\n") { - if strings.HasPrefix(line, "hostname ") { - host := strings.TrimSpace(strings.TrimPrefix(line, "hostname ")) - if idx := strings.Index(host, "."); idx >= 0 { - host = host[:idx] - } - return host - } - } - return "" -} - -func extractLocalhostPort(rawURL string) string { - u, err := url.Parse(rawURL) - if err == nil { - if h := u.Hostname(); h == "localhost" || h == "127.0.0.1" { - return u.Port() - } - for _, vals := range u.Query() { - for _, v := range vals { - if m := localhostRe.FindStringSubmatch(v); len(m) > 1 { - return m[1] - } - } - } - } - if m := localhostRe.FindStringSubmatch(rawURL); len(m) > 1 { - return m[1] - } - return "" -} - -func proxyOAuthCallback(port, sshTarget string) { - ln, err := net.Listen("tcp", "localhost:"+port) - if err != nil { - fmt.Fprintf(os.Stderr, "nssh: listen :%s: %v\n", port, err) - return - } - fmt.Fprintf(os.Stderr, "nssh: ready for OAuth callback on :%s\n", port) - conn, err := ln.Accept() - ln.Close() - if err != nil { - return - } - defer conn.Close() - fwd := exec.Command("ssh", "-W", fmt.Sprintf("localhost:%s", port), sshTarget) - fwd.Stdin = conn - fwd.Stdout = conn - fwd.Stderr = os.Stderr - if err := fwd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "nssh: forward :%s: %v\n", port, err) - return - } - fmt.Fprintf(os.Stderr, "nssh: OAuth callback on :%s done\n", port) -} - -func resetTerminal() { - os.Stdout.WriteString( - "\x1b[?1000l" + "\x1b[?1002l" + "\x1b[?1003l" + "\x1b[?1006l" + "\x1b[?25h", - ) -} - -func handleMessage(msg ntfy.Msg, topicURL, sshTarget string) { - env, ok := wire.Parse(msg.Message) - if !ok { - fmt.Fprintf(os.Stderr, "nssh: ignoring unrecognized message (%d bytes)\n", len(msg.Message)) - logEvent(LogEvent{Event: "msg-unknown", Size: len(msg.Message)}) - return - } - size := 0 - if msg.Attachment != nil { - size = int(msg.Attachment.Size) - } else if env.Body != "" { - if decoded, err := base64.StdEncoding.DecodeString(env.Body); err == nil { - size = len(decoded) - } - } - logMessage("in", env, size) - - switch env.Kind { - case "open": - handleOpen(env.URL, sshTarget) - case "clip-write": - handleClipWrite(env, msg.Attachment) - case "clip-read-request": - handleClipReadRequest(env, topicURL) - case "clip-read-response": - // Responses are for the remote shim, not us. Ignore. - default: - fmt.Fprintf(os.Stderr, "nssh: unknown envelope kind %q\n", env.Kind) - } -} - -func handleOpen(rawURL, sshTarget string) { - if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { - return - } - if port := extractLocalhostPort(rawURL); port != "" { - go proxyOAuthCallback(port, sshTarget) - } - if err := exec.Command("open", rawURL).Run(); err != nil { - fmt.Fprintf(os.Stderr, "nssh: open: %v\n", err) - } -} - -// deadlineConn wraps net.Conn to push the read deadline forward on every Read. -// The ntfy server sends keepalive events every ~55s, so if no bytes arrive -// for well past that window the connection is silently dead (laptop sleep, NAT -// rebind, proxy drop) — the next Read returns i/o timeout and the subscriber -// reconnects. Without this, Read can block forever on a zombie TCP socket. -type deadlineConn struct { - net.Conn - period time.Duration -} - -func (c *deadlineConn) Read(p []byte) (int, error) { - _ = c.Conn.SetReadDeadline(time.Now().Add(c.period)) - return c.Conn.Read(p) -} - -func subscribeNtfy(ctx context.Context, cfg nsshConfig, sshTarget string) { - topicURL := cfg.topicURL() - endpoint := topicURL + "/json" - - dialer := &net.Dialer{KeepAlive: 15 * time.Second} - client := &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - conn, err := dialer.DialContext(ctx, network, addr) - if err != nil { - return nil, err - } - return &deadlineConn{Conn: conn, period: 90 * time.Second}, nil - }, - ResponseHeaderTimeout: 30 * time.Second, - }, - } - - for { - if ctx.Err() != nil { - return - } - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) - if err != nil { - return - } - resp, err := client.Do(req) - if err != nil { - if ctx.Err() != nil { - return - } - fmt.Fprintf(os.Stderr, "nssh: ntfy: %v — retrying\n", err) - select { - case <-ctx.Done(): - return - case <-time.After(5 * time.Second): - } - continue - } - - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - var msg ntfy.Msg - if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { - continue - } - if msg.Event == "message" && msg.Message != "" { - go handleMessage(msg, topicURL, sshTarget) - } - } - if err := scanner.Err(); err != nil && ctx.Err() == nil { - fmt.Fprintf(os.Stderr, "nssh: ntfy stream ended (%v) — reconnecting\n", err) - } - resp.Body.Close() - - select { - case <-ctx.Done(): - return - case <-time.After(time.Second): - } - } -} - -func remoteHasMosh(sshTarget string) bool { - cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "command -v mosh-server >/dev/null 2>&1") - return cmd.Run() == nil -} - -func runSession(cmd *exec.Cmd, sigs <-chan os.Signal) error { - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - if err := cmd.Start(); err != nil { - return err - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - for { - select { - case err := <-done: - return err - case sig := <-sigs: - cmd.Process.Signal(sig) - } - } -} +// buildVersion is set by ldflags at release build time (e.g. in the homebrew +// formula). For go install / go build with a tagged module it's empty and we +// fall back to debug.ReadBuildInfo. +var buildVersion string func usage() { fmt.Fprintln(os.Stderr, "usage:") @@ -300,11 +22,6 @@ func usage() { os.Exit(1) } -// buildVersion is set by ldflags at release build time (e.g. in the homebrew -// formula). For go install / go build with a tagged module it's empty and we -// fall back to debug.ReadBuildInfo. -var buildVersion string - func printVersion() { info, ok := debug.ReadBuildInfo() if !ok { @@ -363,145 +80,3 @@ func main() { } nsshMain() } - -// infectCmd handles `nssh infect [--force] `. -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] ") - 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] ") - os.Exit(1) - } - if target == "self" { - infectSelf(force) - return - } - infectRemote(target, force) -} - -func nsshMain() { - args := os.Args[1:] - forceSSH := false - forceMosh := false - for len(args) > 0 { - switch args[0] { - case "--ssh": - forceSSH = true - args = args[1:] - continue - case "--mosh": - forceMosh = true - args = args[1:] - continue - case "-h", "--help": - usage() - } - break - } - if forceSSH && forceMosh { - fmt.Fprintln(os.Stderr, "nssh: --ssh and --mosh are mutually exclusive") - os.Exit(1) - } - if len(args) < 1 { - usage() - } - - sshArgs := args - sshTarget := args[0] - - shortHost := resolveShortHost(sshArgs) - if shortHost == "" { - fmt.Fprintln(os.Stderr, "nssh: could not resolve hostname, falling back to plain ssh") - cmd := exec.Command("ssh", sshArgs...) - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - cmd.Run() - return - } - - cfg := loadConfig() - if cfg.Topic == "" { - cfg.Topic = generateTopic() - } - fmt.Fprintf(os.Stderr, "nssh: subscribing to %s\n", cfg.topicURL()) - - openLog(cfg.Topic, "session") - logEvent(LogEvent{Event: "session-start", Target: sshTarget, Server: cfg.Server}) - - sessionFile, err := registerSession(cfg, sshTarget) - if err != nil { - fmt.Fprintf(os.Stderr, "nssh: register session: %v\n", err) - } - defer unregisterSession(sessionFile) - - // One SSH login-shell to probe version, write the session file, and seed - // the remote JSONL log before the interactive session starts. - remoteVer := prepareRemote(sshTarget, cfg) - if localVer := version(); looksLikeSemver(localVer) { - switch { - case remoteVer == "": - fmt.Fprintln(os.Stderr, "nssh: not installed on remote — clipboard bridge will not work") - if promptYes(" install it now?") { - infectRemote(sshTarget, false) - } - case remoteVer != localVer: - fmt.Fprintf(os.Stderr, "nssh: remote version %s, local %s\n", remoteVer, localVer) - if promptYes(" update remote to " + localVer + "?") { - infectRemote(sshTarget, false) - } - } - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go subscribeNtfy(ctx, cfg, sshTarget) - - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - - useMosh := false - switch { - case forceSSH: - case forceMosh: - useMosh = true - default: - if _, err := exec.LookPath("mosh"); err == nil && remoteHasMosh(sshTarget) { - useMosh = true - } - } - - var session *exec.Cmd - if useMosh { - fmt.Fprintln(os.Stderr, "nssh: using mosh for interactive session") - session = exec.Command("mosh", sshTarget) - session.Env = append(os.Environ(), "LC_ALL=C.UTF-8", "LANG=C.UTF-8") - } else { - session = exec.Command("ssh", sshArgs...) - } - - sessErr := runSession(session, sigs) - resetTerminal() - exitCode := 0 - if exitErr, ok := sessErr.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } - logEvent(LogEvent{Event: "session-end", Exit: &exitCode, Mosh: &useMosh}) - unregisterSession(sessionFile) // defers don't fire under os.Exit - if exitCode != 0 { - os.Exit(exitCode) - } -} diff --git a/cmd/nssh/oauth.go b/cmd/nssh/oauth.go new file mode 100644 index 0000000..cef43df --- /dev/null +++ b/cmd/nssh/oauth.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "net" + "net/url" + "os" + "os/exec" + "regexp" + "strings" +) + +var localhostRe = regexp.MustCompile(`(?:localhost|127\.0\.0\.1):(\d+)`) + +// extractLocalhostPort scans rawURL for a localhost: reference and +// returns the port if found. Recognizes the host directly, query-string +// values (the typical OAuth redirect_uri encoding), and a final regex +// fallback for any other shape. Returns "" if no localhost reference. +func extractLocalhostPort(rawURL string) string { + u, err := url.Parse(rawURL) + if err == nil { + if h := u.Hostname(); h == "localhost" || h == "127.0.0.1" { + return u.Port() + } + for _, vals := range u.Query() { + for _, v := range vals { + if m := localhostRe.FindStringSubmatch(v); len(m) > 1 { + return m[1] + } + } + } + } + if m := localhostRe.FindStringSubmatch(rawURL); len(m) > 1 { + return m[1] + } + return "" +} + +// proxyOAuthCallback opens a one-shot listener on localhost:port and pipes +// the first incoming connection through `ssh -W localhost: `, +// effectively forwarding the browser's OAuth callback to the same port on +// the remote machine. A fresh ssh -W per callback works regardless of the +// outer transport (mosh, no ControlMaster, etc). +func proxyOAuthCallback(port, sshTarget string) { + ln, err := net.Listen("tcp", "localhost:"+port) + if err != nil { + fmt.Fprintf(os.Stderr, "nssh: listen :%s: %v\n", port, err) + return + } + fmt.Fprintf(os.Stderr, "nssh: ready for OAuth callback on :%s\n", port) + conn, err := ln.Accept() + ln.Close() + if err != nil { + return + } + defer conn.Close() + fwd := exec.Command("ssh", "-W", fmt.Sprintf("localhost:%s", port), sshTarget) + fwd.Stdin = conn + fwd.Stdout = conn + fwd.Stderr = os.Stderr + if err := fwd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "nssh: forward :%s: %v\n", port, err) + return + } + fmt.Fprintf(os.Stderr, "nssh: OAuth callback on :%s done\n", port) +} + +// handleOpen opens an http(s) URL in the local browser. If the URL contains +// a localhost: reference (OAuth redirect_uri), starts a one-shot +// proxy goroutine in parallel so the callback can flow back to the remote. +// Non-http URLs are silently ignored — this is only for browser-bound +// content from the remote shim. +func handleOpen(rawURL, sshTarget string) { + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + return + } + if port := extractLocalhostPort(rawURL); port != "" { + go proxyOAuthCallback(port, sshTarget) + } + if err := exec.Command("open", rawURL).Run(); err != nil { + fmt.Fprintf(os.Stderr, "nssh: open: %v\n", err) + } +} diff --git a/cmd/nssh/remote.go b/cmd/nssh/remote.go index 8653012..4eed0ba 100644 --- a/cmd/nssh/remote.go +++ b/cmd/nssh/remote.go @@ -1,9 +1,12 @@ package main import ( + "encoding/json" + "fmt" "os" "os/exec" "strings" + "time" ) // runRemoteScript pipes a shell script to `bash -l -s` on the remote and @@ -17,3 +20,78 @@ func runRemoteScript(sshTarget, script string) ([]byte, error) { cmd.Stderr = os.Stderr return cmd.Output() } + +// prepareRemote probes the remote's nssh version and writes the session file + +// seeds the JSONL log in a single SSH login-shell invocation. Returns the +// remote nssh version, or "" if not installed / unreadable. Non-fatal on +// errors — shim may still work with a pinned config.toml or no log at all. +func prepareRemote(sshTarget string, cfg nsshConfig) string { + event := LogEvent{ + TS: time.Now().UTC().Format(time.RFC3339Nano), + Event: "session-open", + Side: "session-init", + Server: cfg.Server, + Topic: cfg.Topic, + Target: sshTarget, + Version: buildVersion, + } + eventJSON, _ := json.Marshal(event) + + // bash -l so PATH includes ~/.local/bin even for non-interactive sessions. + // Heredocs with quoted delimiters ('EOF') prevent shell expansion inside, + // so TOML and JSON pass through verbatim regardless of contents. + script := fmt.Sprintf(`set -e +if command -v nssh >/dev/null 2>&1; then + echo "NSSH_VERSION: $(nssh --version 2>/dev/null | head -1 | awk '{print $2}')" +else + echo "NSSH_VERSION: none" +fi +dir="${XDG_STATE_HOME:-$HOME/.local/state}/nssh" +mkdir -p "$dir" +cat > "$dir/session" <<'NSSH_SESSION_EOF' +server = "%s" +topic = "%s" +NSSH_SESSION_EOF +cat >> "$dir/nssh.%s.jsonl" <<'NSSH_LOG_EOF' +%s +NSSH_LOG_EOF +`, cfg.Server, cfg.Topic, cfg.Topic, string(eventJSON)) + + out, err := runRemoteScript(sshTarget, script) + if err != nil { + fmt.Fprintf(os.Stderr, "nssh: remote prepare: %v\n", err) + return "" + } + for _, line := range strings.Split(string(out), "\n") { + v, ok := strings.CutPrefix(line, "NSSH_VERSION: ") + if !ok { + continue + } + v = strings.TrimSpace(v) + if v == "" || v == "none" { + return "" + } + return v + } + return "" +} + +// resolveShortHost queries `ssh -G` to resolve the user's host alias to its +// real hostname, then strips the domain suffix. Returns "" if ssh -G fails +// or the alias has no hostname mapping. +func resolveShortHost(sshArgs []string) string { + out, err := exec.Command("ssh", append([]string{"-G"}, sshArgs...)...).Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "hostname ") { + host := strings.TrimSpace(strings.TrimPrefix(line, "hostname ")) + if idx := strings.Index(host, "."); idx >= 0 { + host = host[:idx] + } + return host + } + } + return "" +} diff --git a/cmd/nssh/session.go b/cmd/nssh/session.go new file mode 100644 index 0000000..f5603f7 --- /dev/null +++ b/cmd/nssh/session.go @@ -0,0 +1,288 @@ +package main + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "github.com/abizer/nssh/v2/internal/ntfy" + "github.com/abizer/nssh/v2/internal/wire" +) + +// nsshMain runs the default `nssh [--ssh|--mosh] ` flow: +// resolves the host, opens the per-session log, prepares the remote +// (writes session file + seeds remote log + probes installed version), +// starts the ntfy subscriber goroutine, then execs ssh or mosh +// interactively and waits for it to exit. +func nsshMain() { + args := os.Args[1:] + forceSSH := false + forceMosh := false + for len(args) > 0 { + switch args[0] { + case "--ssh": + forceSSH = true + args = args[1:] + continue + case "--mosh": + forceMosh = true + args = args[1:] + continue + case "-h", "--help": + usage() + } + break + } + if forceSSH && forceMosh { + fmt.Fprintln(os.Stderr, "nssh: --ssh and --mosh are mutually exclusive") + os.Exit(1) + } + if len(args) < 1 { + usage() + } + + sshArgs := args + sshTarget := args[0] + + shortHost := resolveShortHost(sshArgs) + if shortHost == "" { + fmt.Fprintln(os.Stderr, "nssh: could not resolve hostname, falling back to plain ssh") + cmd := exec.Command("ssh", sshArgs...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Run() + return + } + + cfg := loadConfig() + if cfg.Topic == "" { + cfg.Topic = generateTopic() + } + fmt.Fprintf(os.Stderr, "nssh: subscribing to %s\n", cfg.topicURL()) + + openLog(cfg.Topic, "session") + logEvent(LogEvent{Event: "session-start", Target: sshTarget, Server: cfg.Server}) + + sessionFile, err := registerSession(cfg, sshTarget) + if err != nil { + fmt.Fprintf(os.Stderr, "nssh: register session: %v\n", err) + } + defer unregisterSession(sessionFile) + + // One SSH login-shell to probe version, write the session file, and seed + // the remote JSONL log before the interactive session starts. + remoteVer := prepareRemote(sshTarget, cfg) + if localVer := version(); looksLikeSemver(localVer) { + switch { + case remoteVer == "": + fmt.Fprintln(os.Stderr, "nssh: not installed on remote — clipboard bridge will not work") + if promptYes(" install it now?") { + infectRemote(sshTarget, false) + } + case remoteVer != localVer: + fmt.Fprintf(os.Stderr, "nssh: remote version %s, local %s\n", remoteVer, localVer) + if promptYes(" update remote to " + localVer + "?") { + infectRemote(sshTarget, false) + } + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go subscribeNtfy(ctx, cfg, sshTarget) + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + useMosh := false + switch { + case forceSSH: + case forceMosh: + useMosh = true + default: + if _, err := exec.LookPath("mosh"); err == nil && remoteHasMosh(sshTarget) { + useMosh = true + } + } + + var session *exec.Cmd + if useMosh { + fmt.Fprintln(os.Stderr, "nssh: using mosh for interactive session") + session = exec.Command("mosh", sshTarget) + session.Env = append(os.Environ(), "LC_ALL=C.UTF-8", "LANG=C.UTF-8") + } else { + session = exec.Command("ssh", sshArgs...) + } + + sessErr := runSession(session, sigs) + resetTerminal() + exitCode := 0 + if exitErr, ok := sessErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + logEvent(LogEvent{Event: "session-end", Exit: &exitCode, Mosh: &useMosh}) + unregisterSession(sessionFile) // defers don't fire under os.Exit + if exitCode != 0 { + os.Exit(exitCode) + } +} + +// runSession execs the interactive ssh/mosh subprocess, wires its stdio to +// our terminal, and forwards INT/TERM/HUP signals until it exits. +func runSession(cmd *exec.Cmd, sigs <-chan os.Signal) error { + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + for { + select { + case err := <-done: + return err + case sig := <-sigs: + cmd.Process.Signal(sig) + } + } +} + +// resetTerminal disables xterm mouse-tracking modes and re-shows the cursor. +// vim, htop, etc. enable these and don't always restore them on exit; this +// runs after the interactive session ends so the local prompt is sane again. +func resetTerminal() { + os.Stdout.WriteString( + "\x1b[?1000l" + "\x1b[?1002l" + "\x1b[?1003l" + "\x1b[?1006l" + "\x1b[?25h", + ) +} + +// remoteHasMosh checks if mosh-server is on the remote's PATH. Used to +// auto-select transport when neither --ssh nor --mosh is given. +func remoteHasMosh(sshTarget string) bool { + cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "command -v mosh-server >/dev/null 2>&1") + return cmd.Run() == nil +} + +// deadlineConn wraps net.Conn to push the read deadline forward on every Read. +// The ntfy server sends keepalive events every ~55s, so if no bytes arrive +// for well past that window the connection is silently dead (laptop sleep, NAT +// rebind, proxy drop) — the next Read returns i/o timeout and the subscriber +// reconnects. Without this, Read can block forever on a zombie TCP socket. +type deadlineConn struct { + net.Conn + period time.Duration +} + +func (c *deadlineConn) Read(p []byte) (int, error) { + _ = c.Conn.SetReadDeadline(time.Now().Add(c.period)) + return c.Conn.Read(p) +} + +// subscribeNtfy maintains a long-lived GET on the ntfy /json endpoint, +// reconnecting on disconnect/timeout. Each line of the response is a ntfy +// event; messages with non-empty bodies are dispatched to handleMessage in +// their own goroutines. Stops when ctx is canceled. +func subscribeNtfy(ctx context.Context, cfg nsshConfig, sshTarget string) { + topicURL := cfg.topicURL() + endpoint := topicURL + "/json" + + dialer := &net.Dialer{KeepAlive: 15 * time.Second} + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialer.DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + return &deadlineConn{Conn: conn, period: 90 * time.Second}, nil + }, + ResponseHeaderTimeout: 30 * time.Second, + }, + } + + for { + if ctx.Err() != nil { + return + } + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return + } + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return + } + fmt.Fprintf(os.Stderr, "nssh: ntfy: %v — retrying\n", err) + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + continue + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + var msg ntfy.Msg + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + continue + } + if msg.Event == "message" && msg.Message != "" { + go handleMessage(msg, topicURL, sshTarget) + } + } + if err := scanner.Err(); err != nil && ctx.Err() == nil { + fmt.Fprintf(os.Stderr, "nssh: ntfy stream ended (%v) — reconnecting\n", err) + } + resp.Body.Close() + + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +// handleMessage parses an incoming ntfy message envelope and dispatches it +// to the appropriate handler based on Kind. Unknown kinds are logged and +// dropped. clip-read-response is intentionally ignored — it's the remote +// shim's response to an outgoing read request, not for us. +func handleMessage(msg ntfy.Msg, topicURL, sshTarget string) { + env, ok := wire.Parse(msg.Message) + if !ok { + fmt.Fprintf(os.Stderr, "nssh: ignoring unrecognized message (%d bytes)\n", len(msg.Message)) + logEvent(LogEvent{Event: "msg-unknown", Size: len(msg.Message)}) + return + } + size := 0 + if msg.Attachment != nil { + size = int(msg.Attachment.Size) + } else if env.Body != "" { + if decoded, err := base64.StdEncoding.DecodeString(env.Body); err == nil { + size = len(decoded) + } + } + logMessage("in", env, size) + + switch env.Kind { + case "open": + handleOpen(env.URL, sshTarget) + case "clip-write": + handleClipWrite(env, msg.Attachment) + case "clip-read-request": + handleClipReadRequest(env, topicURL) + case "clip-read-response": + // Responses are for the remote shim, not us. Ignore. + default: + fmt.Fprintf(os.Stderr, "nssh: unknown envelope kind %q\n", env.Kind) + } +} From a70ba23927a7227cfb29bf512e6ff4fc1d8c190c Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 01:17:29 -0700 Subject: [PATCH 10/13] nssh: post-review polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small follow-ups from self-review of the cleanup branch: - oauth: cap proxyOAuthCallback's Accept at 5 minutes via a TCPListener deadline. Previously, an abandoned OAuth flow (user closes the tab) leaked the listener + goroutine until the nssh session ended. - session: handleMessage now decodes the inline base64 body once and passes it to handleClipWrite; the handler's own decode block is gone. Cheap (≤3KB inline payload max) but the redundancy was visible. - session: extract selectTransport from nsshMain — flag-driven ssh-vs-mosh choice plus locale env-setup is now a focused helper. - clipboard: drop the local-task-id reference from clipboard_other.go's package doc; replace with a more durable forward-pointer. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/nssh/clipboard.go | 23 ++++----- cmd/nssh/oauth.go | 15 ++++++ cmd/nssh/session.go | 67 ++++++++++++++++----------- internal/clipboard/clipboard_other.go | 3 +- 4 files changed, 67 insertions(+), 41 deletions(-) diff --git a/cmd/nssh/clipboard.go b/cmd/nssh/clipboard.go index b260a77..6ca8a37 100644 --- a/cmd/nssh/clipboard.go +++ b/cmd/nssh/clipboard.go @@ -1,7 +1,6 @@ package main import ( - "encoding/base64" "fmt" "os" "strings" @@ -11,26 +10,22 @@ import ( "github.com/abizer/nssh/v2/internal/wire" ) -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 diff --git a/cmd/nssh/oauth.go b/cmd/nssh/oauth.go index cef43df..89c3a5b 100644 --- a/cmd/nssh/oauth.go +++ b/cmd/nssh/oauth.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "net" "net/url" @@ -8,8 +9,15 @@ import ( "os/exec" "regexp" "strings" + "time" ) +// oauthAcceptTimeout caps how long proxyOAuthCallback waits for the browser +// to make its callback. OAuth flows are normally <1 minute end-to-end; if +// the user closes the tab, the listener would otherwise leak the port and a +// goroutine until the nssh session ends. +const oauthAcceptTimeout = 5 * time.Minute + var localhostRe = regexp.MustCompile(`(?:localhost|127\.0\.0\.1):(\d+)`) // extractLocalhostPort scans rawURL for a localhost: reference and @@ -48,9 +56,16 @@ func proxyOAuthCallback(port, sshTarget string) { return } fmt.Fprintf(os.Stderr, "nssh: ready for OAuth callback on :%s\n", port) + if tcpLn, ok := ln.(*net.TCPListener); ok { + _ = tcpLn.SetDeadline(time.Now().Add(oauthAcceptTimeout)) + } conn, err := ln.Accept() ln.Close() if err != nil { + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + fmt.Fprintf(os.Stderr, "nssh: no OAuth callback on :%s within %s — gave up\n", port, oauthAcceptTimeout) + } return } defer conn.Close() diff --git a/cmd/nssh/session.go b/cmd/nssh/session.go index f5603f7..cde2b86 100644 --- a/cmd/nssh/session.go +++ b/cmd/nssh/session.go @@ -102,26 +102,7 @@ func nsshMain() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - useMosh := false - switch { - case forceSSH: - case forceMosh: - useMosh = true - default: - if _, err := exec.LookPath("mosh"); err == nil && remoteHasMosh(sshTarget) { - useMosh = true - } - } - - var session *exec.Cmd - if useMosh { - fmt.Fprintln(os.Stderr, "nssh: using mosh for interactive session") - session = exec.Command("mosh", sshTarget) - session.Env = append(os.Environ(), "LC_ALL=C.UTF-8", "LANG=C.UTF-8") - } else { - session = exec.Command("ssh", sshArgs...) - } - + session, useMosh := selectTransport(forceSSH, forceMosh, sshArgs, sshTarget) sessErr := runSession(session, sigs) resetTerminal() exitCode := 0 @@ -170,6 +151,31 @@ func remoteHasMosh(sshTarget string) bool { return cmd.Run() == nil } +// selectTransport picks ssh or mosh based on the user's flags and (if +// neither is forced) whether mosh is installed locally and on the remote. +// Returns the configured exec.Cmd plus a useMosh flag for downstream +// logging. When mosh is selected we force a UTF-8 locale because mosh's +// terminal emulation breaks under POSIX/C locales on minimal images. +func selectTransport(forceSSH, forceMosh bool, sshArgs []string, sshTarget string) (*exec.Cmd, bool) { + useMosh := false + switch { + case forceSSH: + case forceMosh: + useMosh = true + default: + if _, err := exec.LookPath("mosh"); err == nil && remoteHasMosh(sshTarget) { + useMosh = true + } + } + if useMosh { + fmt.Fprintln(os.Stderr, "nssh: using mosh for interactive session") + cmd := exec.Command("mosh", sshTarget) + cmd.Env = append(os.Environ(), "LC_ALL=C.UTF-8", "LANG=C.UTF-8") + return cmd, true + } + return exec.Command("ssh", sshArgs...), false +} + // deadlineConn wraps net.Conn to push the read deadline forward on every Read. // The ntfy server sends keepalive events every ~55s, so if no bytes arrive // for well past that window the connection is silently dead (laptop sleep, NAT @@ -263,13 +269,22 @@ func handleMessage(msg ntfy.Msg, topicURL, sshTarget string) { logEvent(LogEvent{Event: "msg-unknown", Size: len(msg.Message)}) return } - size := 0 + + // Decode the inline body once: handlers that need it use this slice and + // logMessage uses its length for size accounting. + var body []byte + if env.Body != "" { + decoded, err := base64.StdEncoding.DecodeString(env.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "nssh: %s: base64 decode: %v\n", env.Kind, err) + return + } + body = decoded + } + + size := len(body) if msg.Attachment != nil { size = int(msg.Attachment.Size) - } else if env.Body != "" { - if decoded, err := base64.StdEncoding.DecodeString(env.Body); err == nil { - size = len(decoded) - } } logMessage("in", env, size) @@ -277,7 +292,7 @@ func handleMessage(msg ntfy.Msg, topicURL, sshTarget string) { case "open": handleOpen(env.URL, sshTarget) case "clip-write": - handleClipWrite(env, msg.Attachment) + handleClipWrite(env, msg.Attachment, body) case "clip-read-request": handleClipReadRequest(env, topicURL) case "clip-read-response": diff --git a/internal/clipboard/clipboard_other.go b/internal/clipboard/clipboard_other.go index a1fa95b..71ae9dd 100644 --- a/internal/clipboard/clipboard_other.go +++ b/internal/clipboard/clipboard_other.go @@ -2,7 +2,8 @@ // Package clipboard wraps the local-machine clipboard for the nssh session // wrapper. Today only macOS is implemented; on other platforms every call -// returns errUnsupported. See task #13 for Linux client support. +// returns errUnsupported. A Linux client backend (xclip / wl-clipboard) is +// the natural follow-up. package clipboard import "errors" From fe4908488e4ad34d5f4a6ec9870a54b61ccff4b4 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 01:39:02 -0700 Subject: [PATCH 11/13] nssh: use x/mod/semver for version comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled looksLikeSemver was a strict shape check (vX.Y.Z, no prerelease, no build metadata) that doubled as the gate for the session-start version-mismatch nag. The string-equality compare on mismatch was a latent bug: v1.2.3+build1 vs v1.2.3+build2 would fire even though they're the same release. Drop in golang.org/x/mod/semver, rename the gate to isReleaseVersion (more honest about its actual semantics — strictly stricter than semver.IsValid because we exclude prereleases and build metadata), and replace the != string compare with semver.Compare so build metadata is ignored. Behavior change: a release client connecting to a remote running a +dirty dev build of the same release tag no longer prompts to overwrite. Also relax CLAUDE.md's "stdlib only" rule to "minimize external deps; prefer stdlib + golang.org/x/* over third-party." Pulls in x/mod and (transitively) x/tools — both Go-team maintained. flake.nix vendorHash flipped from null to the computed hash now that go.mod has a real require. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 5 ++++- cmd/nssh/infect.go | 30 +++++++++--------------------- cmd/nssh/session.go | 6 ++++-- flake.nix | 2 +- go.mod | 4 +++- go.sum | 4 ++++ 6 files changed, 25 insertions(+), 26 deletions(-) create mode 100644 go.sum diff --git a/CLAUDE.md b/CLAUDE.md index 41f138a..932b487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,10 @@ 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 diff --git a/cmd/nssh/infect.go b/cmd/nssh/infect.go index e839bbd..900519b 100644 --- a/cmd/nssh/infect.go +++ b/cmd/nssh/infect.go @@ -13,6 +13,8 @@ import ( "runtime" "runtime/debug" "strings" + + "golang.org/x/mod/semver" ) // personas are the argv[0] names nssh answers to when symlinked. @@ -55,26 +57,12 @@ func latestReleaseTag() (string, error) { 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 @@ -295,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) diff --git a/cmd/nssh/session.go b/cmd/nssh/session.go index cde2b86..566e1c9 100644 --- a/cmd/nssh/session.go +++ b/cmd/nssh/session.go @@ -14,6 +14,8 @@ import ( "syscall" "time" + "golang.org/x/mod/semver" + "github.com/abizer/nssh/v2/internal/ntfy" "github.com/abizer/nssh/v2/internal/wire" ) @@ -80,14 +82,14 @@ func nsshMain() { // One SSH login-shell to probe version, write the session file, and seed // the remote JSONL log before the interactive session starts. remoteVer := prepareRemote(sshTarget, cfg) - if localVer := version(); looksLikeSemver(localVer) { + if localVer := version(); isReleaseVersion(localVer) { switch { case remoteVer == "": fmt.Fprintln(os.Stderr, "nssh: not installed on remote — clipboard bridge will not work") if promptYes(" install it now?") { infectRemote(sshTarget, false) } - case remoteVer != localVer: + case semver.Compare(remoteVer, localVer) != 0: fmt.Fprintf(os.Stderr, "nssh: remote version %s, local %s\n", remoteVer, localVer) if promptYes(" update remote to " + localVer + "?") { infectRemote(sshTarget, false) diff --git a/flake.nix b/flake.nix index fae4fa4..4b08ff4 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ pname = "nssh"; version = self.shortRev or self.dirtyShortRev or "dev"; src = self; - vendorHash = null; + vendorHash = "sha256-bKX+nLOSmc1oZpT+Yg3XMuC+MBYwq0+vMeVKWUMD9fk="; subPackages = [ "cmd/nssh" ]; doCheck = true; diff --git a/go.mod b/go.mod index 73e5b50..d178757 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/abizer/nssh/v2 -go 1.25 +go 1.25.0 + +require golang.org/x/mod v0.35.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d39d6c2 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= From 5dac06c5b7ab807aa52e99b013490186a6458a34 Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 01:47:32 -0700 Subject: [PATCH 12/13] docs: write internals.md and protocol.md Two focused docs to capture the things that aren't obvious from the code or the README: - docs/internals.md (~260 lines): architecture, end-to-end flows for clipboard paste and OAuth callback, and the reasoning behind the unusual choices (ntfy as side channel, argv[0] dispatch, topic as secret, infect-self desktop refusal, what we deliberately don't do). - docs/protocol.md (~190 lines): wire envelope schema, inline-vs- attachment rules, the four kinds, log event vocabulary, config precedence, state directory layout, ntfy endpoints touched. CLAUDE.md regains the docs/ entry. README links both at the bottom. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + README.md | 14 +++ docs/internals.md | 259 ++++++++++++++++++++++++++++++++++++++++++++++ docs/protocol.md | 190 ++++++++++++++++++++++++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 docs/internals.md create mode 100644 docs/protocol.md diff --git a/CLAUDE.md b/CLAUDE.md index 932b487..c507784 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +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/ 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 diff --git a/README.md b/README.md index cc82512..3e47a90 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/docs/internals.md b/docs/internals.md new file mode 100644 index 0000000..c479422 --- /dev/null +++ b/docs/internals.md @@ -0,0 +1,259 @@ +# Internals + +This is the "why" doc — design decisions, end-to-end flows, and the +reasoning behind the more unusual choices. For the on-the-wire schema, +see [protocol.md](./protocol.md). + +## Why ntfy + +The design constraint is **mosh compatibility**. mosh is UDP, deliberately +opaque, and offers no in-band channels: + +- No port forwarding (`-L`, `-R`, `-D`) +- No Unix-socket multiplexing (`ControlMaster`) +- OSC 52 is the only escape hatch, and mosh caps it at 256 bytes and + doesn't support binary + +That eliminates the obvious solutions. Anything we want to send between +local and remote has to ride a side channel that's reachable from both. + +The criteria for a side channel: + +1. **Roaming-safe.** Survives laptop sleep, NAT rebind, network change. + mosh users care about this — it's why they use mosh. +2. **No inbound connectivity required on the laptop.** The laptop is + often behind NAT, on a hotel Wi-Fi, etc. We can't ask the remote to + open a TCP connection back. +3. **Authentication-light.** A long-lived ssh key per host is one + authentication boundary; we'd rather not stack a second one per + session. +4. **Stateless on the server side.** No per-user accounts, no admin + overhead. + +ntfy fits all four. A topic is just a path; subscribing is HTTP GET on +`//json`; publishing is HTTP POST to `/`. The default +public ntfy.sh works out of the box; users who want privacy run their +own. Subscriptions are streamed responses — the client holds them open, +the server pushes events as newline-delimited JSON, and a 90-second +read deadline is enough to detect zombie connections (laptop closed, +NAT dropped) without polling. See `subscribeNtfy` in `session.go`. + +The security boundary becomes "anyone with the topic can publish and +subscribe." Topics are generated per-session via 12 random bytes of +base32 (`generateTopic` in `config.go`) — unguessable in practice. A +user who wants a stable topic can pin it in `config.toml`; any password +auth on top is delegated to ntfy's own ACL config (we don't reimplement +it). + +## Why dispatch on argv[0] + +Tools like Claude Code, `gh auth login`, and `gcloud auth login` call +`xclip`, `xdg-open`, etc. by name via `exec.Command` (or PATH lookup). +We can't change those callers. The cleanest interception is to **be +those tools** — same name, same flags, same exit semantics, but with a +custom implementation. + +Dispatch on `argv[0]` means the same binary can answer to multiple +names by symlinking. `nssh infect ` does this on the remote: scp +the binary to `~/.local/bin/nssh`, then create symlinks +`~/.local/bin/{xclip,wl-copy,wl-paste,xdg-open,sensible-browser}` → +`nssh`. As long as `~/.local/bin` is first on `$PATH`, our shim wins. + +The dispatch happens at the top of `main()`: + +```go +persona := filepath.Base(os.Args[0]) +switch persona { +case "xdg-open", "sensible-browser", "xclip", "wl-copy", "wl-paste": + shimMain(persona, os.Args[1:]) + return +} +``` + +If the persona doesn't match a shim we own, we fall through to +nssh-as-itself: subcommands (`infect`, `status`) or the default +`nssh ` session wrapper. + +The shims (`shim.go`) parse just enough of each tool's flag vocabulary +to do their job, and shell out to `/usr/bin/` for cases we don't +handle (e.g. `xdg-open `, `xclip -selection primary`). + +## Why refuse `infect self` on a desktop + +Persona symlinks shadow the real `xclip`/`xdg-open`. On a headless dev +box that's the whole point: we replace tools that would fail (no +display) with tools that work (forward to the laptop). On a desktop, +the real `xclip` is what your password manager and browser use to +write/read the clipboard — shadowing it would silently break them. + +`detectLocalDesktop` and `detectRemoteDesktop` (in `infect.go`) sniff +the obvious markers: `$DISPLAY`, `$WAYLAND_DISPLAY`, an X11 socket in +`/tmp/.X11-unix`, or a Wayland socket under `/run/user/*/wayland-*`. +If any of those exist, we refuse to install (or refuse to run `infect +self`) without `--force`. + +A Linux laptop user who runs `nssh devbox` is a perfectly normal case +— they'd never want `infect self` on the laptop, only on remotes. The +desktop check protects them automatically. + +## End-to-end: paste image (laptop → remote Claude Code) + +``` +[mac] [ntfy] [linux remote] + +User: Cmd-Shift-Ctrl-4 (screenshot to clipboard) + User: Ctrl-V in Claude Code + + Claude Code: spawns + `xclip -t image/png -o` + (which is our nssh shim) + + shim: publish + envelope kind=clip-read-request + id= + mime=image/png + POST /topic ─────────► + subscribe GET /topic/json + ?since= (5s timeout) + + ◄─ POST /topic ────────── (line received) + +local nssh subscriber: + scanner.Scan() returns + the published line. + handleMessage dispatches + to handleClipReadRequest. + + pngpaste reads the Mac + pasteboard → PNG bytes. + wire.Publish picks + attachment path (image + mime + bytes > 3KB). + +PUT /topic ─────────────────► (received by ntfy, attachment URL) + + ─────► /topic/json line (shim's GET completes) + + shim: line has + kind=clip-read-response + id= + attachment.url=... + shim fetches the URL, + writes PNG bytes + to its stdout. + + Claude Code reads + stdout → rendered. +``` + +The whole round trip is typically ~200ms over a public ntfy.sh. The +correlation `id` ensures the shim's `since=`-bounded subscription +only consumes the response intended for *this* request — multiple +concurrent reads on the same topic don't cross paths. + +## End-to-end: OAuth callback + +``` +[mac] [linux remote] + + $ gh auth login + Press Enter to open in browser + + gh: spawns + `xdg-open https://github.com/.../oauth?... + redirect_uri=http%3A%2F%2Flocalhost%3A8585%2Fcb` + + shim publishes + envelope kind=open + url= + +local nssh subscriber: + handleMessage → handleOpen. + handleOpen URL-parses, sees + redirect_uri contains + localhost:8585. Spawns + `proxyOAuthCallback` goroutine + that listens on :8585, then + runs `open ` to launch + the browser locally. + +User logs in via browser. +Browser GETs http://localhost:8585/cb?code=... + + proxyOAuthCallback: ln.Accept() + returns a conn. Spawns + `ssh -W localhost:8585 ` + with conn piped to ssh's + stdin/stdout. + + gh: HTTP server bound to + localhost:8585 receives the + forwarded request via + ssh's accepted forward. + Returns 200 OK. + + ssh -W exits when the conn closes. + proxyOAuthCallback prints + "OAuth callback on :8585 done". + + gh: token exchanged. + ✓ Logged in. +``` + +Notes on this flow: + +- We use a fresh `ssh -W` per callback. No `ControlMaster`, no socket + files. This makes it work whether the outer session is ssh or mosh + — `ssh -W` is its own connection. The user authenticates once when + the session starts (or relies on a key); subsequent `ssh -W` calls + for OAuth callbacks reuse the same auth. +- `ln.Accept()` has a 5-minute deadline (`oauthAcceptTimeout` in + `oauth.go`). If the user closes the browser tab without completing, + we don't leak the listener. +- The shim sends the URL via ntfy *and* falls back to `/usr/bin/xdg-open` + on publish failure — so if ntfy is down or the topic is misconfigured, + the user still gets the same exit code they'd get on a normal + headless system (255 — "no display"). + +## Why one binary, three roles + +A unified binary keeps deployment trivial: `infect` scps a single file, +makes 5 symlinks, done. There's no separate "shim" package with its +own version drift, no risk of the shim and the daemon disagreeing on +the wire format. `prepareRemote` (in `remote.go`) probes the remote's +`nssh --version` at session start and prompts for an upgrade if it's +behind the local — version mismatch is a one-prompt fix. + +The trade-off is that `cmd/nssh/main.go` has to dispatch three roles +based on argv[0] and subcommand. That's why it's split into: + +- `main.go` — argv[0] dispatch + version + usage +- `session.go` — local-side wrapper (the default `nssh ` flow) +- `shim.go` — remote-side persona implementation +- Both sides share `wire`, `ntfy`, `config`, and `log`. + +## Logging + +Every nssh process opens `~/.local/state/nssh/nssh..jsonl` for +appending. Both sides write to it (locally on the laptop, on the +remote it's `$XDG_STATE_HOME/nssh/...`). The schema is the typed +`LogEvent` struct in `log.go`; see [protocol.md](./protocol.md) for +the full event vocabulary. + +`nssh status --tail` follows the active sessions' logs and pretty-prints +events as they arrive — useful for "what happened?" debugging. POSIX +`O_APPEND` writes < `PIPE_BUF` (~4KB) are atomic, so concurrent shim +invocations on the same log don't interleave lines. + +## What we deliberately don't do + +- **Eval received content.** The local side never executes anything + it receives. URLs are passed to `open(1)` after a strict `http(s)://` + prefix check; clipboard payloads go to `pbcopy`/`osascript` as data, + never as commands. +- **Bridge PRIMARY selection.** Only CLIPBOARD. PRIMARY is what gets + set when you select text in an X11 terminal — bridging it would + generate continuous traffic from terminal use. +- **Authenticate the bridge.** Topic secrecy is the only auth. If you + need auth, run a private ntfy server with its own ACLs and pin the + server in `config.toml`. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..9236a68 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,190 @@ +# Protocol & schema reference + +Wire format, log schema, and config precedence — everything you'd need +to interoperate with nssh from another tool. For the "why," see +[internals.md](./internals.md). + +## Wire envelope + +Every message published to the ntfy topic is a JSON object. The Go +type is `wire.Envelope` in `internal/wire/envelope.go`: + +```go +type Envelope struct { + Kind string `json:"kind"` // required, always set + URL string `json:"url,omitempty"` // open + Mime string `json:"mime,omitempty"` // clip-write, clip-read-* + Body string `json:"body,omitempty"` // base64 of inline payload + ID string `json:"id,omitempty"` // clip-read correlation +} +``` + +`Parse` rejects messages with empty `Kind` — anything that round-trips +through `wire.Parse` has at minimum a kind discriminator. + +### Inline vs. attachment + +Payloads ride either inside the envelope's `Body` (base64-encoded) or +outside as ntfy attachments. The choice is made by `wire.Publish`: + +- `len(data) <= InlineThreshold` (3072 bytes) **and** mime doesn't + start with `image/` → **inline**, base64 in `Body`, sent as a + text/plain HTTP POST to the topic. +- Otherwise → **attachment**, raw bytes PUT to the topic, with the + envelope JSON in the `X-Message` header and a filename + (`clip.png` for image PNG, `clip.dat` otherwise) in `Filename`. + +The threshold is conservative: ntfy.sh's free tier caps inline +messages at 4 KB; staying under 3 KB after base64 expansion (which +adds ~33%) keeps us safe. Attachment uploads have a higher size cap +on ntfy.sh and unlimited on self-hosted. + +### Kinds + +| Kind | Direction | Required fields | Notes | +|------|-----------|-----------------|-------| +| `open` | remote → local | `url` | URL to open in the laptop's browser. `handleOpen` filters to `http(s)://` only. | +| `clip-write` | remote → local | `mime`, payload | Write data to the macOS clipboard. Empty payload (`Body == ""` and no attachment) is dropped with a stderr message. | +| `clip-read-request` | remote → local | `id`, `mime` | Ask the local side to read its clipboard. The shim subscribes to the topic with `?since=` for 5s, waiting for a matching response. | +| `clip-read-response` | local → remote | `id`, `mime`, payload | Response to `clip-read-request`. The shim filters by `id` so concurrent reads don't cross paths. Body prefixed with `ERROR: ` indicates failure (e.g. clipboard tools missing). | + +### MIME conventions + +- `text/plain` — default. Inline, no special handling. +- `image/png` — image. Always sent as attachment regardless of size + (the inline-threshold rule excludes any `image/*` mime). On the local + side `pngpaste` reads, `osascript` writes (PNGf class). +- Other mimes: passed through. Wire transport is the same as `text/plain` + (size threshold drives inline-vs-attachment). + +The shim's `xclip -t TARGETS -o` returns a static list (`image/png`, +`text/plain`, `UTF8_STRING`, `STRING`) — Claude Code probes this before +trying Ctrl-V on an image, so we list image/png even though we don't +actually inspect the clipboard until asked. + +## Log schema + +Every nssh process appends JSONL to +`$XDG_STATE_HOME/nssh/nssh..jsonl` (default +`~/.local/state/nssh/nssh..jsonl`). The Go type is `LogEvent` +in `cmd/nssh/log.go`: + +```go +type LogEvent struct { + TS string `json:"ts"` // RFC3339Nano UTC + Event string `json:"event"` // see vocabulary below + Side string `json:"side,omitempty"` // "session" (local) or persona name (remote shim) + PID int `json:"pid,omitempty"` + + // Wire-message details (msg-send / msg-recv). + Kind string `json:"kind,omitempty"` + Mime string `json:"mime,omitempty"` + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` + Size int `json:"size,omitempty"` + + // Session lifecycle. + Target string `json:"target,omitempty"` + Server string `json:"server,omitempty"` + Topic string `json:"topic,omitempty"` + Version string `json:"version,omitempty"` + Exit *int `json:"exit,omitempty"` + Mosh *bool `json:"mosh,omitempty"` + + // Shim invocation. + Persona string `json:"persona,omitempty"` + Args []string `json:"args,omitempty"` + + // Error context. + Err string `json:"err,omitempty"` +} +``` + +### Event vocabulary + +| Event | Emitted by | Fields | Meaning | +|-------|------------|--------|---------| +| `session-open` | local (during `prepareRemote`, written to remote log via SSH heredoc) | `server`, `topic`, `target`, `version` | Local nssh announces itself to the remote at session start. Side is `session-init`. | +| `session-start` | local | `target`, `server` | Local subscriber is starting. | +| `session-end` | local | `exit`, `mosh` | Local interactive session ended. `exit` is `0` on success; `mosh` records which transport was used. | +| `msg-send` | either | `kind`, `mime`, `id`, `url`, `size` | Envelope published to the topic. | +| `msg-recv` | either | `kind`, `mime`, `id`, `url`, `size` | Envelope received from the topic. | +| `msg-unknown` | either | `size` | Topic message that didn't parse as a valid envelope. | +| `shim-start` | remote shim | `persona`, `args` | Shim invocation. | +| `clip-write-empty` | remote shim | `mime` | `xclip -i` / `wl-copy` got empty stdin; nothing to publish. | +| `clip-read-empty` | remote shim | `id` | Local side returned empty clipboard. | +| `clip-read-error` | remote shim | `id`, `err` | Local side returned an `ERROR:` body. | +| `clip-read-timeout` | remote shim | `id` | 5s elapsed without a matching response on the topic. | +| `publish-failed` | remote shim | `kind`, `err` | ntfy publish returned an error (network, 4xx, etc.). | + +`size` is the decoded payload length (bytes), not the wire size. For +attachments, it's the attachment's reported size; for inline payloads, +it's the decoded base64 length. + +`Exit` and `Mosh` are pointer-typed because they need to record an +explicit zero/false meaning — `exit=0` (success) and `mosh=false` +(used ssh) are real values that should appear in the log, not be +silently dropped by `omitempty`. All other zero-valued fields *are* +dropped. + +## Config precedence + +Two sources of truth, plus environment overrides: + +1. **`$NSSH_NTFY_BASE`** — environment variable. Sets `server` only; + wins over everything. +2. **`$XDG_CONFIG_HOME/nssh/config.toml`** (default + `~/.config/nssh/config.toml`) — persistent user config: + ```toml + server = "https://ntfy.example.com" + topic = "my-pinned-topic" + ``` + Wins over the session file. +3. **`$XDG_STATE_HOME/nssh/session`** (default + `~/.local/state/nssh/session`) — written by `nssh ` on the + local side and pushed to the remote at session start (via the + `prepareRemote` SSH heredoc). Same TOML shape, two keys: + `server`, `topic`. The remote shim reads this to know where to + publish. +4. **Defaults** — `server = https://ntfy.sh`, `topic = nssh_` + (12 random bytes of base32, lowercase, prefixed `nssh_`). + +The minimal-TOML reader (`readTOML` in `config.go`) handles only +`key = "value"` lines, blank lines, and `# comments`. No sections, +no arrays, no escaping. That's sufficient for both files. + +## State directory layout + +Local (`~/.local/state/nssh/`): + +``` +nssh..jsonl # per-topic log; one file per session by default +sessions/.json # active-session registry, GC'd on `nssh status` +``` + +Remote (`~/.local/state/nssh/`): + +``` +session # TOML: server, topic. Read by shims. +nssh..jsonl # log file shared with local — both sides append. +``` + +The session file on the remote is **per-host**, not per-session. If you +run two `nssh` sessions to the same remote, the second session's +`prepareRemote` overwrites the first's `session` file with its own +topic. Don't do that, or pin a topic in `config.toml`. + +## Endpoints touched on ntfy + +| Method | Path | Used for | +|--------|------|----------| +| `POST` | `/` | Publish inline message (text/plain body). | +| `PUT` | `/` | Publish attachment (binary body, `Filename` + `X-Message` headers). | +| `GET` | `//json` | Subscribe (long-poll, newline-JSON stream). | +| `GET` | `//json?since=` | Bounded subscribe (used by the shim's clip-read response wait). | +| `GET` | `` | Fetch a published attachment. URL is provided in the message's `attachment.url` field. | + +The local subscriber holds the long-poll `GET //json` open +indefinitely, with a 90-second read deadline (`deadlineConn` in +`session.go`) to detect zombie connections. ntfy sends `event=keepalive` +~every 55s; absence of any data for >90s triggers a reconnect. From d1293544c727aef790db17573cb8424ae23d660f Mon Sep 17 00:00:00 2001 From: Abizer Lokhandwala Date: Tue, 5 May 2026 01:49:49 -0700 Subject: [PATCH 13/13] CLAUDE.md: directive to update docs alongside code changes Spell out exactly which kinds of changes need a docs/ touch (new envelope kinds, new LogEvent fields, config keys, ntfy endpoints, personas) and which don't (pure refactors). Goal: keep internals.md/protocol.md from drifting by treating doc updates as part of the change, not a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c507784..dc4341e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,3 +110,27 @@ Priority: `NSSH_NTFY_BASE` env > config.toml > session file > defaults. - 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/`.