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/CLAUDE.md b/CLAUDE.md index c2e0ffb..dc4341e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ cmd/nssh/ The single binary (session wrapper + shim + --infect, dis internal/wire/ Shared envelope type and parser internal/ntfy/ Shared ntfy HTTP helpers (publish, attach, fetch) internal/clipboard/ macOS pasteboard helpers (pbcopy, pbpaste, pngpaste, osascript) -docs/ Design docs +docs/ internals.md (architecture, flows) + protocol.md (wire/log schema) .github/workflows/ CI (cachix.yaml for nix, release.yml for tagged releases) justfile Build recipes flake.nix Nix package @@ -103,7 +103,34 @@ Priority: `NSSH_NTFY_BASE` env > config.toml > session file > defaults. ## Key constraints -- stdlib only — no external Go dependencies +- Minimize external Go dependencies — prefer stdlib + `golang.org/x/*` + modules over third-party. Pull in a dep only when it replaces a + hand-rolled re-implementation that's a real correctness or + ergonomics liability (current set: `golang.org/x/mod/semver`). - Single binary cross-compiles for macOS and Linux with zero runtime deps - Never eval or execute received content on local side - Only bridge CLIPBOARD selection, not PRIMARY + +## Maintaining docs + +`docs/internals.md` and `docs/protocol.md` are the precise current-state +references. Update them in the same change that makes them stale — +don't ship the code change and document later. Things that require a +docs touch: + +- New or removed envelope `kind` → update the kinds table in + `protocol.md` and any flow diagrams in `internals.md` that mention it. +- New, renamed, or removed `LogEvent` field or event name → update + the schema and event vocabulary in `protocol.md`. +- New config key, env var, or precedence change → update the config + section in `protocol.md`. +- New ntfy endpoint or change to inline-vs-attachment rules → update + the endpoints / transport sections in `protocol.md`. +- New shim persona, transport (e.g. ssh/mosh siblings), or + cross-cutting design choice → update the relevant `internals.md` + section and the persona table here in CLAUDE.md. + +Pure refactors (file splits, helper extractions) usually don't need +doc updates unless they change a name the docs reference. The README +is for the pitch and the install path; everything precise lives under +`docs/`. 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/cmd/nssh/clipboard.go b/cmd/nssh/clipboard.go index 929cdb5..6ca8a37 100644 --- a/cmd/nssh/clipboard.go +++ b/cmd/nssh/clipboard.go @@ -1,8 +1,6 @@ package main import ( - "encoding/base64" - "encoding/json" "fmt" "os" "strings" @@ -12,28 +10,22 @@ import ( "github.com/abizer/nssh/v2/internal/wire" ) -const inlineThreshold = 3 * 1024 - -func handleClipWrite(env wire.Envelope, att *ntfy.Attachment) { - var data []byte - var err error - - switch { - case env.Body != "": - data, err = base64.StdEncoding.DecodeString(env.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "nssh: clip-write: base64 decode: %v\n", err) +// handleClipWrite writes incoming clipboard data to the local clipboard. +// body is the pre-decoded inline payload (nil when none); when nil and an +// attachment is present, the attachment is fetched. +func handleClipWrite(env wire.Envelope, att *ntfy.Attachment, body []byte) { + data := body + if data == nil { + if att == nil || att.URL == "" { + fmt.Fprintln(os.Stderr, "nssh: clip-write: no data (empty body and no attachment)") return } - case att != nil && att.URL != "": + var err error data, err = ntfy.FetchAttachment(att.URL) if err != nil { fmt.Fprintf(os.Stderr, "nssh: clip-write: %v\n", err) return } - default: - fmt.Fprintln(os.Stderr, "nssh: clip-write: no data (empty body and no attachment)") - return } mime := env.Mime @@ -68,9 +60,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 +68,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/infect.go b/cmd/nssh/infect.go index 8716ecb..900519b 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" @@ -12,6 +13,8 @@ import ( "runtime" "runtime/debug" "strings" + + "golang.org/x/mod/semver" ) // personas are the argv[0] names nssh answers to when symlinked. @@ -42,43 +45,24 @@ func latestReleaseTag() (string, error) { if resp.StatusCode != 200 { return "", fmt.Errorf("github api: %s", resp.Status) } - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err + var rel struct { + TagName string `json:"tag_name"` } - const key = `"tag_name":"` - i := strings.Index(string(body), key) - if i < 0 { - return "", fmt.Errorf("no tag_name in github response") + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return "", fmt.Errorf("github api: decode: %w", err) } - rest := string(body[i+len(key):]) - j := strings.Index(rest, `"`) - if j < 0 { - return "", fmt.Errorf("malformed tag_name") + if rel.TagName == "" { + return "", fmt.Errorf("no tag_name in github response") } - return rest[:j], nil + return rel.TagName, nil } -// looksLikeSemver reports whether v is a clean "vX.Y.Z" tag (no +dirty etc). -func looksLikeSemver(v string) bool { - if !strings.HasPrefix(v, "v") || strings.ContainsAny(v, "+ ") { - return false - } - parts := strings.Split(v[1:], ".") - if len(parts) != 3 { - return false - } - for _, p := range parts { - if p == "" { - return false - } - for _, r := range p { - if r < '0' || r > '9' { - return false - } - } - } - return true +// isReleaseVersion reports whether v is a clean release tag — valid semver +// with no prerelease (-rc1) and no build metadata (+dirty). Used to gate +// the version-mismatch nag at session start: dev builds shouldn't prompt +// the user to overwrite the remote with an in-flight build. +func isReleaseVersion(v string) bool { + return semver.IsValid(v) && semver.Prerelease(v) == "" && semver.Build(v) == "" } // detectLocalDesktop returns (true, reason) if a desktop session appears to be @@ -110,9 +94,7 @@ ls /tmp/.X11-unix/ 2>/dev/null | head -1 | grep -q . && { ls /tmp/.X11-unix/ | h ls /run/user/*/wayland-* 2>/dev/null | head -1 | grep -q . && { ls /run/user/*/wayland-* | head -1; exit 0; } exit 1 ` - cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash -l -s") - cmd.Stdin = strings.NewReader(script) - out, err := cmd.Output() + out, err := runRemoteScript(sshTarget, script) if err != nil { // Exit 1 from our script = no desktop. ssh errors also end up here. return false, "" @@ -301,7 +283,7 @@ func infectRemote(sshTarget string, force bool) { fmt.Fprintf(os.Stderr, "nssh: remote is %s/%s\n", goos, goarch) tag := version() - if !looksLikeSemver(tag) { + if !isReleaseVersion(tag) { t, err := latestReleaseTag() if err != nil { fmt.Fprintf(os.Stderr, "nssh: couldn't resolve release tag: %v\n", err) @@ -318,7 +300,7 @@ func infectRemote(sshTarget string, force bool) { } fmt.Fprintf(os.Stderr, "nssh: copying to %s:~/.local/bin/nssh\n", sshTarget) - if err := exec.Command("ssh", sshTarget, "mkdir -p ~/.local/bin").Run(); err != nil { + if _, err := runRemoteScript(sshTarget, "mkdir -p ~/.local/bin\n"); err != nil { fmt.Fprintf(os.Stderr, "nssh: mkdir: %v\n", err) os.Exit(1) } @@ -331,16 +313,15 @@ func infectRemote(sshTarget string, force bool) { // Let the freshly-installed nssh infect the remote itself — this keeps // the symlink list in one place (personas var here) and means nssh always - // owns its own symlinks. - cmd := exec.Command("ssh", sshTarget, "bash -l -c '~/.local/bin/nssh infect self --force'") - cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr - if err := cmd.Run(); err != nil { + // owns its own symlinks. infectSelf writes to stderr (which passes + // through), so we discard the captured stdout. + if _, err := runRemoteScript(sshTarget, "~/.local/bin/nssh infect self --force\n"); err != nil { fmt.Fprintf(os.Stderr, "nssh: remote infect self: %v\n", err) os.Exit(1) } // Sanity-check PATH ordering. - out, _ := exec.Command("ssh", sshTarget, `bash -l -c 'command -v xclip'`).Output() + out, _ := runRemoteScript(sshTarget, "command -v xclip\n") resolved := strings.TrimSpace(string(out)) if !strings.Contains(resolved, ".local/bin/xclip") { fmt.Fprintln(os.Stderr, "nssh: WARNING: ~/.local/bin/xclip is not first in PATH on the remote") @@ -352,3 +333,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/log.go b/cmd/nssh/log.go index bedb973..2868460 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" @@ -11,11 +10,44 @@ 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 - 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,25 +66,21 @@ func openLog(topic, side string) { return } logFile = f - logTopic = topic 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 } @@ -61,34 +89,20 @@ 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. -// 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 bb932ab..d525452 100644 --- a/cmd/nssh/main.go +++ b/cmd/nssh/main.go @@ -1,297 +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 := 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, - } - 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)) - - cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash", "-l", "-s") - cmd.Stdin = strings.NewReader(script) - cmd.Stderr = os.Stderr - out, err := cmd.Output() - 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("msg-unknown", map[string]any{"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:") @@ -303,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 { @@ -366,148 +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("session-start", map[string]any{ - "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("session-end", map[string]any{"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..89c3a5b --- /dev/null +++ b/cmd/nssh/oauth.go @@ -0,0 +1,98 @@ +package main + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "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 +// 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) + 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() + 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 new file mode 100644 index 0000000..4eed0ba --- /dev/null +++ b/cmd/nssh/remote.go @@ -0,0 +1,97 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// 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() +} + +// 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..566e1c9 --- /dev/null +++ b/cmd/nssh/session.go @@ -0,0 +1,305 @@ +package main + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "golang.org/x/mod/semver" + + "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(); 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 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) + } + } + } + + 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) + + session, useMosh := selectTransport(forceSSH, forceMosh, sshArgs, sshTarget) + 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 +} + +// 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 +// 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 + } + + // 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) + } + logMessage("in", env, size) + + switch env.Kind { + case "open": + handleOpen(env.URL, sshTarget) + case "clip-write": + handleClipWrite(env, msg.Attachment, body) + 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) + } +} diff --git a/cmd/nssh/shim.go b/cmd/nssh/shim.go index f2282f3..8405d73 100644 --- a/cmd/nssh/shim.go +++ b/cmd/nssh/shim.go @@ -34,28 +34,14 @@ 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 } 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)) } @@ -105,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)) @@ -122,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) } @@ -159,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) @@ -256,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 cb1ef8f..09d1f60 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" @@ -280,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) } - for _, k := range keys { - fmt.Fprintf(&sb, " %s=%v", k, m[k]) + if e.Kind != "" { + fmt.Fprintf(&sb, " kind=%s", e.Kind) + } + if e.Mime != "" { + fmt.Fprintf(&sb, " mime=%s", e.Mime) + } + 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 { 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. diff --git a/flake.nix b/flake.nix index 25f2e7f..4b08ff4 100644 --- a/flake.nix +++ b/flake.nix @@ -15,8 +15,9 @@ 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; meta = { description = "SSH/mosh wrapper with clipboard bridge and xdg-open forwarding via ntfy"; 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= 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..71ae9dd --- /dev/null +++ b/internal/clipboard/clipboard_other.go @@ -0,0 +1,16 @@ +//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. A Linux client backend (xclip / wl-clipboard) is +// the natural follow-up. +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 } 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) + } +}