-
Notifications
You must be signed in to change notification settings - Fork 0
nssh: ntfy read deadlines + one-shot remote prep #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
|
@@ -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' | ||
|
|
@@ -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 "" | ||
| } | ||
|
|
||
| func resolveShortHost(sshArgs []string) string { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Replacing the default client transport with a custom Useful? React with 👍 / 👎. |
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return &deadlineConn{Conn: conn, period: 90 * time.Second}, nil | ||
| }, | ||
| ResponseHeaderTimeout: 30 * time.Second, | ||
| }, | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Custom transport loses DefaultTransport's 30s dial timeoutMedium Severity The Reviewed by Cursor Bugbot for commit 9af7c7e. Configure here. |
||
|
|
||
| for { | ||
| if ctx.Err() != nil { | ||
|
|
@@ -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 { | ||
|
|
@@ -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() | ||
|
|
@@ -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() | ||
|
|
||


There was a problem hiding this comment.
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 -eand echoesNSSH_VERSION:beforemkdir/cat. If the version probe succeeds but session-file writing fails (permissions, disk full), the script exits non-zero. Go'scmd.Output()still returns the captured stdout, but theerr != nilcheck 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)
cmd/nssh/main.go#L65-L69Reviewed by Cursor Bugbot for commit 9af7c7e. Configure here.