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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# nssh

_Written by [Claude Opus 4.7](https://www.anthropic.com/news/claude-opus-4-7) via Claude Code_
_Built with [Claude Opus 4.7](https://www.anthropic.com/news/claude-opus-4-7) via Claude Code_

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

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.

## The problem
Expand Down
43 changes: 0 additions & 43 deletions cmd/nssh/infect.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,26 +217,6 @@ func downloadBinary(tag, goos, goarch string) (string, error) {
}
}

// probeRemoteVersion SSHes in (login shell for PATH) and runs `nssh --version`
// on the remote. Returns the version string and whether nssh is installed.
func probeRemoteVersion(sshTarget string) (ver string, installed bool) {
out, err := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget,
`bash -l -c 'command -v nssh >/dev/null 2>&1 && nssh --version 2>&1 | head -1'`,
).Output()
if err != nil {
return "", false
}
line := strings.TrimSpace(string(out))
if line == "" {
return "", false
}
parts := strings.Fields(line)
if len(parts) < 2 {
return "", false
}
return parts[1], true
}

// promptYes returns true if stdin is a TTY and the user answers yes.
func promptYes(msg string) bool {
stat, err := os.Stdin.Stat()
Expand All @@ -250,29 +230,6 @@ func promptYes(msg string) bool {
return resp == "y" || resp == "yes"
}

// checkRemoteVersion probes the remote's nssh version and warns if missing
// or mismatched. Prompts to infect if on a TTY. Non-fatal on any error.
func checkRemoteVersion(sshTarget string) {
localVer := version()
if !looksLikeSemver(localVer) {
return
}
remoteVer, installed := probeRemoteVersion(sshTarget)
if !installed {
fmt.Fprintln(os.Stderr, "nssh: not installed on remote — clipboard bridge will not work")
if promptYes(" install it now?") {
infectRemote(sshTarget, false)
}
return
}
if remoteVer != localVer {
fmt.Fprintf(os.Stderr, "nssh: remote version %s, local %s\n", remoteVer, localVer)
if promptYes(" update remote to " + localVer + "?") {
infectRemote(sshTarget, false)
}
}
}

// infectSelf sets up the local machine: creates persona symlinks in
// ~/.local/bin pointing to the currently running nssh binary. Refuses on
// desktop systems (unless force=true) since symlinking xclip/xdg-open/etc
Expand Down
97 changes: 80 additions & 17 deletions cmd/nssh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ import (

var localhostRe = regexp.MustCompile(`(?:localhost|127\.0\.0\.1):(\d+)`)

// writeRemoteSession writes the session file and seeds the log on the remote
// host so the shim knows which ntfy server/topic to use, and so there's a
// canonical "session opened" event before any shim fires. Runs one SSH command
// before the interactive session starts.
func writeRemoteSession(sshTarget string, cfg nsshConfig) {
// 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",
Expand All @@ -40,9 +40,15 @@ func writeRemoteSession(sshTarget string, cfg nsshConfig) {
}
eventJSON, _ := json.Marshal(event)

// Heredocs with quoted delimiters ('EOF') prevent any shell expansion
// inside, so TOML and JSON go through verbatim regardless of contents.
// 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'
Expand All @@ -54,12 +60,26 @@ 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", "-s")
cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash", "-l", "-s")
cmd.Stdin = strings.NewReader(script)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "nssh: failed to write session config on remote: %v\n", err)
// Non-fatal — shim may still work if remote has a pinned config.toml.
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 ""
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Version output discarded when session write fails

Low Severity

The script uses set -e and echoes NSSH_VERSION: before mkdir/cat. If the version probe succeeds but session-file writing fails (permissions, disk full), the script exits non-zero. Go's cmd.Output() still returns the captured stdout, but the err != nil check discards it and returns "". This causes a misleading "not installed on remote" prompt when nssh IS installed but the session directory is unwritable.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9af7c7e. Configure here.

}

func resolveShortHost(sshArgs []string) string {
Expand Down Expand Up @@ -177,10 +197,38 @@ func handleOpen(rawURL, sshTarget string) {
}
}

// 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"
client := &http.Client{}

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)
Comment on lines +221 to +223
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore proxy support in ntfy HTTP transport

Replacing the default client transport with a custom http.Transport here drops Proxy: http.ProxyFromEnvironment, and in Go a nil Proxy means no proxy is used. That regresses connectivity for environments that require HTTP_PROXY/HTTPS_PROXY to reach ntfy (corporate networks, CI runners, bastion setups), where previous behavior worked via the default transport.

Useful? React with 👍 / 👎.

if err != nil {
return nil, err
}
return &deadlineConn{Conn: conn, period: 90 * time.Second}, nil
},
ResponseHeaderTimeout: 30 * time.Second,
},
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Custom transport loses DefaultTransport's 30s dial timeout

Medium Severity

The net.Dialer only sets KeepAlive but omits Timeout. The previous &http.Client{} used http.DefaultTransport, which dials with a 30s timeout. With Timeout defaulting to zero, TCP connects to an unreachable ntfy server can hang for minutes (OS-level TCP timeout). This directly undermines the deadlineConn improvement — after detecting a dead socket in ~90s, the reconnect dial itself can stall for far longer. The dialer also drops Proxy: http.ProxyFromEnvironment and TLSHandshakeTimeout, which http.DefaultTransport provides.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9af7c7e. Configure here.


for {
if ctx.Err() != nil {
Expand Down Expand Up @@ -214,6 +262,9 @@ func subscribeNtfy(ctx context.Context, cfg nsshConfig, sshTarget string) {
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 {
Expand Down Expand Up @@ -385,10 +436,6 @@ func nsshMain() {
return
}

// Version check before session starts — warns if the remote's nssh is
// missing or mismatched, offers to re-infect on TTY.
checkRemoteVersion(sshTarget)

cfg := loadConfig()
if cfg.Topic == "" {
cfg.Topic = generateTopic()
Expand All @@ -401,7 +448,23 @@ func nsshMain() {
"server": cfg.Server,
})

writeRemoteSession(sshTarget, cfg)
// 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()
Expand Down