Skip to content

Commit a28fd7d

Browse files
abizerclaude
andauthored
nssh: ntfy read deadlines + one-shot remote prep (#3)
## Summary - Fix ntfy subscriber hanging on dead TCP sockets (overnight/sleep/NAT rebind). Wrap the dialed `net.Conn` so every `Read` resets a 90s deadline; ntfy's ~55s keepalives keep a healthy stream inside the window, silence trips `i/o timeout` → reconnect. - Collapse `checkRemoteVersion` + `writeRemoteSession` into a single `bash -l -s` invocation (`prepareRemote`). Startup goes from 2 SSH round-trips to 1 before the interactive session. - Drop the now-unused `probeRemoteVersion`/`checkRemoteVersion` from `infect.go`. ## Test plan - [ ] `go build ./...` and `go test ./...` pass locally - [ ] `nssh <host>` opens a session and the ntfy subscriber logs a reconnect line if the network is bounced - [ ] Leave a session idle > 2 minutes with no activity; clipboard still works after - [ ] Version-mismatch prompt still fires when the remote `nssh` is outdated - [ ] Missing-remote prompt still fires when `nssh` isn't installed on the remote 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes the ntfy streaming transport behavior and refactors remote session initialization/version probing into a new single SSH invocation, which could affect connection reliability and upgrade prompts. > > **Overview** > Improves reliability of the ntfy subscriber by dialing with TCP keepalive, enforcing per-read deadlines (to detect zombie sockets), and logging scanner errors before reconnecting. > > Refactors remote startup so version probing, session file write, and remote JSONL log seeding happen in one `bash -l` SSH call (`prepareRemote`), and removes the now-redundant remote version-check helpers from `infect.go`. Also updates README wording to better describe `nssh`’s purpose. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9af7c7e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cc0d7bc commit a28fd7d

3 files changed

Lines changed: 84 additions & 61 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# nssh
22

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

5+
`nssh` bridges your local machine (macOS, primarily) to a headless Linux VM to
6+
let you use tools like `xdg-open` or `xclip` that otherwise require X and a display to work.
7+
58
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.
69

710
## The problem

cmd/nssh/infect.go

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -217,26 +217,6 @@ func downloadBinary(tag, goos, goarch string) (string, error) {
217217
}
218218
}
219219

220-
// probeRemoteVersion SSHes in (login shell for PATH) and runs `nssh --version`
221-
// on the remote. Returns the version string and whether nssh is installed.
222-
func probeRemoteVersion(sshTarget string) (ver string, installed bool) {
223-
out, err := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget,
224-
`bash -l -c 'command -v nssh >/dev/null 2>&1 && nssh --version 2>&1 | head -1'`,
225-
).Output()
226-
if err != nil {
227-
return "", false
228-
}
229-
line := strings.TrimSpace(string(out))
230-
if line == "" {
231-
return "", false
232-
}
233-
parts := strings.Fields(line)
234-
if len(parts) < 2 {
235-
return "", false
236-
}
237-
return parts[1], true
238-
}
239-
240220
// promptYes returns true if stdin is a TTY and the user answers yes.
241221
func promptYes(msg string) bool {
242222
stat, err := os.Stdin.Stat()
@@ -250,29 +230,6 @@ func promptYes(msg string) bool {
250230
return resp == "y" || resp == "yes"
251231
}
252232

253-
// checkRemoteVersion probes the remote's nssh version and warns if missing
254-
// or mismatched. Prompts to infect if on a TTY. Non-fatal on any error.
255-
func checkRemoteVersion(sshTarget string) {
256-
localVer := version()
257-
if !looksLikeSemver(localVer) {
258-
return
259-
}
260-
remoteVer, installed := probeRemoteVersion(sshTarget)
261-
if !installed {
262-
fmt.Fprintln(os.Stderr, "nssh: not installed on remote — clipboard bridge will not work")
263-
if promptYes(" install it now?") {
264-
infectRemote(sshTarget, false)
265-
}
266-
return
267-
}
268-
if remoteVer != localVer {
269-
fmt.Fprintf(os.Stderr, "nssh: remote version %s, local %s\n", remoteVer, localVer)
270-
if promptYes(" update remote to " + localVer + "?") {
271-
infectRemote(sshTarget, false)
272-
}
273-
}
274-
}
275-
276233
// infectSelf sets up the local machine: creates persona symlinks in
277234
// ~/.local/bin pointing to the currently running nssh binary. Refuses on
278235
// desktop systems (unless force=true) since symlinking xclip/xdg-open/etc

cmd/nssh/main.go

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ import (
2424

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

27-
// writeRemoteSession writes the session file and seeds the log on the remote
28-
// host so the shim knows which ntfy server/topic to use, and so there's a
29-
// canonical "session opened" event before any shim fires. Runs one SSH command
30-
// before the interactive session starts.
31-
func writeRemoteSession(sshTarget string, cfg nsshConfig) {
27+
// prepareRemote probes the remote's nssh version and writes the session file +
28+
// seeds the JSONL log in a single SSH login-shell invocation. Returns the
29+
// remote nssh version, or "" if not installed / unreadable. Non-fatal on
30+
// errors — shim may still work with a pinned config.toml or no log at all.
31+
func prepareRemote(sshTarget string, cfg nsshConfig) string {
3232
event := map[string]any{
3333
"ts": time.Now().UTC().Format(time.RFC3339Nano),
3434
"event": "session-open",
@@ -40,9 +40,15 @@ func writeRemoteSession(sshTarget string, cfg nsshConfig) {
4040
}
4141
eventJSON, _ := json.Marshal(event)
4242

43-
// Heredocs with quoted delimiters ('EOF') prevent any shell expansion
44-
// inside, so TOML and JSON go through verbatim regardless of contents.
43+
// bash -l so PATH includes ~/.local/bin even for non-interactive sessions.
44+
// Heredocs with quoted delimiters ('EOF') prevent shell expansion inside,
45+
// so TOML and JSON pass through verbatim regardless of contents.
4546
script := fmt.Sprintf(`set -e
47+
if command -v nssh >/dev/null 2>&1; then
48+
echo "NSSH_VERSION: $(nssh --version 2>/dev/null | head -1 | awk '{print $2}')"
49+
else
50+
echo "NSSH_VERSION: none"
51+
fi
4652
dir="${XDG_STATE_HOME:-$HOME/.local/state}/nssh"
4753
mkdir -p "$dir"
4854
cat > "$dir/session" <<'NSSH_SESSION_EOF'
@@ -54,12 +60,26 @@ cat >> "$dir/nssh.%s.jsonl" <<'NSSH_LOG_EOF'
5460
NSSH_LOG_EOF
5561
`, cfg.Server, cfg.Topic, cfg.Topic, string(eventJSON))
5662

57-
cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash", "-s")
63+
cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "bash", "-l", "-s")
5864
cmd.Stdin = strings.NewReader(script)
59-
if err := cmd.Run(); err != nil {
60-
fmt.Fprintf(os.Stderr, "nssh: failed to write session config on remote: %v\n", err)
61-
// Non-fatal — shim may still work if remote has a pinned config.toml.
65+
cmd.Stderr = os.Stderr
66+
out, err := cmd.Output()
67+
if err != nil {
68+
fmt.Fprintf(os.Stderr, "nssh: remote prepare: %v\n", err)
69+
return ""
6270
}
71+
for _, line := range strings.Split(string(out), "\n") {
72+
v, ok := strings.CutPrefix(line, "NSSH_VERSION: ")
73+
if !ok {
74+
continue
75+
}
76+
v = strings.TrimSpace(v)
77+
if v == "" || v == "none" {
78+
return ""
79+
}
80+
return v
81+
}
82+
return ""
6383
}
6484

6585
func resolveShortHost(sshArgs []string) string {
@@ -177,10 +197,38 @@ func handleOpen(rawURL, sshTarget string) {
177197
}
178198
}
179199

200+
// deadlineConn wraps net.Conn to push the read deadline forward on every Read.
201+
// The ntfy server sends keepalive events every ~55s, so if no bytes arrive
202+
// for well past that window the connection is silently dead (laptop sleep, NAT
203+
// rebind, proxy drop) — the next Read returns i/o timeout and the subscriber
204+
// reconnects. Without this, Read can block forever on a zombie TCP socket.
205+
type deadlineConn struct {
206+
net.Conn
207+
period time.Duration
208+
}
209+
210+
func (c *deadlineConn) Read(p []byte) (int, error) {
211+
_ = c.Conn.SetReadDeadline(time.Now().Add(c.period))
212+
return c.Conn.Read(p)
213+
}
214+
180215
func subscribeNtfy(ctx context.Context, cfg nsshConfig, sshTarget string) {
181216
topicURL := cfg.topicURL()
182217
endpoint := topicURL + "/json"
183-
client := &http.Client{}
218+
219+
dialer := &net.Dialer{KeepAlive: 15 * time.Second}
220+
client := &http.Client{
221+
Transport: &http.Transport{
222+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
223+
conn, err := dialer.DialContext(ctx, network, addr)
224+
if err != nil {
225+
return nil, err
226+
}
227+
return &deadlineConn{Conn: conn, period: 90 * time.Second}, nil
228+
},
229+
ResponseHeaderTimeout: 30 * time.Second,
230+
},
231+
}
184232

185233
for {
186234
if ctx.Err() != nil {
@@ -214,6 +262,9 @@ func subscribeNtfy(ctx context.Context, cfg nsshConfig, sshTarget string) {
214262
go handleMessage(msg, topicURL, sshTarget)
215263
}
216264
}
265+
if err := scanner.Err(); err != nil && ctx.Err() == nil {
266+
fmt.Fprintf(os.Stderr, "nssh: ntfy stream ended (%v) — reconnecting\n", err)
267+
}
217268
resp.Body.Close()
218269

219270
select {
@@ -385,10 +436,6 @@ func nsshMain() {
385436
return
386437
}
387438

388-
// Version check before session starts — warns if the remote's nssh is
389-
// missing or mismatched, offers to re-infect on TTY.
390-
checkRemoteVersion(sshTarget)
391-
392439
cfg := loadConfig()
393440
if cfg.Topic == "" {
394441
cfg.Topic = generateTopic()
@@ -401,7 +448,23 @@ func nsshMain() {
401448
"server": cfg.Server,
402449
})
403450

404-
writeRemoteSession(sshTarget, cfg)
451+
// One SSH login-shell to probe version, write the session file, and seed
452+
// the remote JSONL log before the interactive session starts.
453+
remoteVer := prepareRemote(sshTarget, cfg)
454+
if localVer := version(); looksLikeSemver(localVer) {
455+
switch {
456+
case remoteVer == "":
457+
fmt.Fprintln(os.Stderr, "nssh: not installed on remote — clipboard bridge will not work")
458+
if promptYes(" install it now?") {
459+
infectRemote(sshTarget, false)
460+
}
461+
case remoteVer != localVer:
462+
fmt.Fprintf(os.Stderr, "nssh: remote version %s, local %s\n", remoteVer, localVer)
463+
if promptYes(" update remote to " + localVer + "?") {
464+
infectRemote(sshTarget, false)
465+
}
466+
}
467+
}
405468

406469
ctx, cancel := context.WithCancel(context.Background())
407470
defer cancel()

0 commit comments

Comments
 (0)