Skip to content

Commit bfa3180

Browse files
authored
nssh: harden session recovery (#11)
Hardens the per-host session-recovery mechanism added in the original checkpoint. - Skip `prepareRemote` SSH when joining an existing host session — the remote state was already set up by the first connect. - Replay missed messages on reconnect via ntfy `?since=<id>`; track lastID across drops. Solves the laptop-closed-overnight case directly. - New `subscribe-up` / `subscribe-down` LogEvent entries with reconnect gap, rendered by `nssh status --tail`. - New `ping` / `pong` wire kinds + `pingTopic` helper to distinguish "alive" from "wedged" peers. - Collision prompt when an existing session is found for the host: `[J]oin / [R]eplace (SIGTERM then SIGKILL) / [N]ew / [C]ancel`; default depends on liveness ping. Non-interactive shells silently join with a stderr warning. Flags: `--join` / `--replace` / `--new`. - New `nssh sweep [--all|--older <dur>] <host>` subcommand to clean up orphan `mosh-server` processes on a remote. Safe with tmux-inside-mosh. Follow-ups: #10 (auto-sweep on connect, spare-yourself in --all), #12 (per-session env-injection design — properly fixes the single-session-file rendezvous limitation).
1 parent f7f4147 commit bfa3180

11 files changed

Lines changed: 719 additions & 43 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/nssh
22
/nssh-linux
33
.remember/
4+
/.claude/

CLAUDE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ to `infect self`. `infect self` creates symlinks in `~/.local/bin`
4141
pointing at the running nssh binary (darwin: no-op; desktop linux:
4242
refuses without --force to avoid shadowing real xclip/xdg-open).
4343

44+
`nssh sweep <host>` lists `mosh-server` processes owned by $USER on
45+
the remote and offers to kill them. Safe when running tmux-inside-mosh:
46+
killing mosh-server doesn't kill the tmux server, so detached sessions
47+
survive. Use `--all` for unattended cleanup or `--older 168h` to keep
48+
only the last week.
49+
4450
## Architecture
4551

4652
### Dispatch on argv[0]
@@ -82,6 +88,8 @@ JSON envelopes on the ntfy topic. Every message has a `kind` field:
8288
| `clip-write` | remote → local | Write data to the Mac clipboard |
8389
| `clip-read-request` | remote → local | Request the Mac clipboard contents |
8490
| `clip-read-response` | local → remote | Response with clipboard data |
91+
| `ping` | local ↔ local | Liveness probe between two nssh processes sharing a topic |
92+
| `pong` | local ↔ local | Ack for `ping`, echoing the same correlation id |
8593

8694
Small text (≤3KB) is base64-encoded inline in the `body` field. Larger payloads
8795
and images are sent as ntfy attachments (PUT with `Filename` + `X-Message` headers).
@@ -93,6 +101,24 @@ no config required. nssh writes the server + topic to `~/.local/state/nssh/sessi
93101
on the remote before launching the shell (and seeds a `session-open` event into
94102
the JSONL log). The shim reads this file.
95103

104+
### Session collisions
105+
106+
A pidfile per live local nssh is kept at `~/.local/state/nssh/sessions/<pid>.json`.
107+
On startup, nssh looks up the host (canonical short name from `ssh -G`) in that
108+
registry. When an existing session is found nssh sends a `ping` on the topic and
109+
waits ~1.5s for a `pong`, then prompts (in an interactive shell) for one of:
110+
111+
| Choice | Effect |
112+
|--------|--------|
113+
| join | adopt the existing topic; both subscribers see every message |
114+
| replace | SIGTERM the existing PID, then SIGKILL after 1s if it's still up; fresh topic |
115+
| new | fresh topic; existing PID is left running but the remote bridge will follow the new topic |
116+
117+
Default in the prompt is `join` if the peer answered the ping, `replace` if it
118+
didn't. Non-interactive shells silently join (with a warning on the stderr if the
119+
peer was unresponsive). Override with `--join` / `--replace` / `--new` on the
120+
command line.
121+
96122
Optional `~/.config/nssh/config.toml` on either side to pin values:
97123
```toml
98124
server = "https://ntfy.example.com" # default: https://ntfy.sh

cmd/nssh/config.go

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,24 +62,37 @@ func readTOML(path string) map[string]string {
6262
return m
6363
}
6464

65-
// loadConfig resolves the ntfy server and topic from (in priority order):
66-
// 1. Environment variables (NSSH_NTFY_BASE)
67-
// 2. ~/.config/nssh/config.toml (server, topic) — persistent user config
68-
// 3. ~/.local/state/nssh/session (server, topic) — written by nssh at connect time
69-
// 4. Defaults: server=https://ntfy.sh, topic=<generated>
65+
// loadConfig resolves the ntfy server and topic for shim mode (xclip, xdg-open
66+
// etc. running on a remote shell). Priority (highest first):
67+
// 1. NSSH_NTFY_BASE env var (server only)
68+
// 2. ~/.config/nssh/config.toml — persistent user config
69+
// 3. ~/.local/state/nssh/session — written by `nssh <host>` at connect time
70+
// 4. Defaults: server=https://ntfy.sh
7071
func loadConfig() nsshConfig {
72+
return resolveConfig(true)
73+
}
74+
75+
// loadSessionConfig is loadConfig minus the remote-style session file. Used by
76+
// `nssh <host>` on the local Mac, where the session file is a remote
77+
// convention; reading it locally would mean every new local nssh inherits the
78+
// topic of the last remote shell that was prepared, defeating per-host reuse.
79+
func loadSessionConfig() nsshConfig {
80+
return resolveConfig(false)
81+
}
82+
83+
func resolveConfig(includeSessionFile bool) nsshConfig {
7184
cfg := nsshConfig{Server: defaultServer}
7285

73-
// Session file (written by nssh session mode at connect time).
74-
session := readTOML(filepath.Join(stateDir(), "session"))
75-
if session["server"] != "" {
76-
cfg.Server = session["server"]
77-
}
78-
if session["topic"] != "" {
79-
cfg.Topic = session["topic"]
86+
if includeSessionFile {
87+
session := readTOML(filepath.Join(stateDir(), "session"))
88+
if session["server"] != "" {
89+
cfg.Server = session["server"]
90+
}
91+
if session["topic"] != "" {
92+
cfg.Topic = session["topic"]
93+
}
8094
}
8195

82-
// Permanent config overrides session.
8396
config := readTOML(filepath.Join(configDir(), "config.toml"))
8497
if config["server"] != "" {
8598
cfg.Server = config["server"]
@@ -88,7 +101,6 @@ func loadConfig() nsshConfig {
88101
cfg.Topic = config["topic"]
89102
}
90103

91-
// Env var overrides everything for server.
92104
if v := os.Getenv("NSSH_NTFY_BASE"); v != "" {
93105
cfg.Server = v
94106
}

cmd/nssh/log.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,23 @@ type LogEvent struct {
3030

3131
// Session lifecycle.
3232
Target string `json:"target,omitempty"`
33+
Host string `json:"host,omitempty"`
3334
Server string `json:"server,omitempty"`
3435
Topic string `json:"topic,omitempty"`
3536
Version string `json:"version,omitempty"`
3637
Exit *int `json:"exit,omitempty"`
3738
Mosh *bool `json:"mosh,omitempty"`
39+
Joined int `json:"joined,omitempty"`
3840

3941
// Shim invocation.
4042
Persona string `json:"persona,omitempty"`
4143
Args []string `json:"args,omitempty"`
4244

45+
// Subscriber resilience (subscribe-up / subscribe-down).
46+
Reconnect bool `json:"reconnect,omitempty"`
47+
Gap string `json:"gap,omitempty"`
48+
Since string `json:"since,omitempty"`
49+
4350
// Error context.
4451
Err string `json:"err,omitempty"`
4552
}

cmd/nssh/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ var buildVersion string
1414

1515
func usage() {
1616
fmt.Fprintln(os.Stderr, "usage:")
17-
fmt.Fprintln(os.Stderr, " nssh [--ssh|--mosh] <host> [ssh args...] open a session")
17+
fmt.Fprintln(os.Stderr, " nssh [--ssh|--mosh] [--join|--replace|--new] <host> [ssh args...]")
18+
fmt.Fprintln(os.Stderr, " open a session")
1819
fmt.Fprintln(os.Stderr, " nssh infect [--force] <host> install on a remote host")
1920
fmt.Fprintln(os.Stderr, " nssh infect [--force] self symlink personas on this machine")
2021
fmt.Fprintln(os.Stderr, " nssh status [--tail] show active sessions")
22+
fmt.Fprintln(os.Stderr, " nssh sweep [--all|--older <dur>] <host> kill orphan mosh-servers on a host")
2123
fmt.Fprintln(os.Stderr, " nssh --version print version info")
24+
fmt.Fprintln(os.Stderr, "")
25+
fmt.Fprintln(os.Stderr, "session collision flags (when another nssh is already attached to <host>):")
26+
fmt.Fprintln(os.Stderr, " --join share the existing ntfy topic (no prompt)")
27+
fmt.Fprintln(os.Stderr, " --replace kill the existing nssh, take over with a fresh topic")
28+
fmt.Fprintln(os.Stderr, " --new generate a fresh topic without killing the existing")
2229
os.Exit(1)
2330
}
2431

@@ -71,6 +78,9 @@ func main() {
7178
case "status":
7279
statusCmd(os.Args[2:])
7380
return
81+
case "sweep":
82+
sweepCmd(os.Args[2:])
83+
return
7484
case "-v", "--version":
7585
printVersion()
7686
return

0 commit comments

Comments
 (0)