Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/nssh
/nssh-linux
.remember/
/.claude/
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ to `infect self`. `infect self` creates symlinks in `~/.local/bin`
pointing at the running nssh binary (darwin: no-op; desktop linux:
refuses without --force to avoid shadowing real xclip/xdg-open).

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

## Architecture

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

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

### Session collisions

A pidfile per live local nssh is kept at `~/.local/state/nssh/sessions/<pid>.json`.
On startup, nssh looks up the host (canonical short name from `ssh -G`) in that
registry. When an existing session is found nssh sends a `ping` on the topic and
waits ~1.5s for a `pong`, then prompts (in an interactive shell) for one of:

| Choice | Effect |
|--------|--------|
| join | adopt the existing topic; both subscribers see every message |
| replace | SIGTERM the existing PID, then SIGKILL after 1s if it's still up; fresh topic |
| new | fresh topic; existing PID is left running but the remote bridge will follow the new topic |

Default in the prompt is `join` if the peer answered the ping, `replace` if it
didn't. Non-interactive shells silently join (with a warning on the stderr if the
peer was unresponsive). Override with `--join` / `--replace` / `--new` on the
command line.

Optional `~/.config/nssh/config.toml` on either side to pin values:
```toml
server = "https://ntfy.example.com" # default: https://ntfy.sh
Expand Down
40 changes: 26 additions & 14 deletions cmd/nssh/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,24 +62,37 @@ func readTOML(path string) map[string]string {
return m
}

// loadConfig resolves the ntfy server and topic from (in priority order):
// 1. Environment variables (NSSH_NTFY_BASE)
// 2. ~/.config/nssh/config.toml (server, topic) — persistent user config
// 3. ~/.local/state/nssh/session (server, topic) — written by nssh at connect time
// 4. Defaults: server=https://ntfy.sh, topic=<generated>
// loadConfig resolves the ntfy server and topic for shim mode (xclip, xdg-open
// etc. running on a remote shell). Priority (highest first):
// 1. NSSH_NTFY_BASE env var (server only)
// 2. ~/.config/nssh/config.toml — persistent user config
// 3. ~/.local/state/nssh/session — written by `nssh <host>` at connect time
// 4. Defaults: server=https://ntfy.sh
func loadConfig() nsshConfig {
return resolveConfig(true)
}

// loadSessionConfig is loadConfig minus the remote-style session file. Used by
// `nssh <host>` on the local Mac, where the session file is a remote
// convention; reading it locally would mean every new local nssh inherits the
// topic of the last remote shell that was prepared, defeating per-host reuse.
func loadSessionConfig() nsshConfig {
return resolveConfig(false)
}

func resolveConfig(includeSessionFile bool) nsshConfig {
cfg := nsshConfig{Server: defaultServer}

// Session file (written by nssh session mode at connect time).
session := readTOML(filepath.Join(stateDir(), "session"))
if session["server"] != "" {
cfg.Server = session["server"]
}
if session["topic"] != "" {
cfg.Topic = session["topic"]
if includeSessionFile {
session := readTOML(filepath.Join(stateDir(), "session"))
if session["server"] != "" {
cfg.Server = session["server"]
}
if session["topic"] != "" {
cfg.Topic = session["topic"]
}
}

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

// Env var overrides everything for server.
if v := os.Getenv("NSSH_NTFY_BASE"); v != "" {
cfg.Server = v
}
Expand Down
7 changes: 7 additions & 0 deletions cmd/nssh/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,23 @@ type LogEvent struct {

// Session lifecycle.
Target string `json:"target,omitempty"`
Host string `json:"host,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"`
Joined int `json:"joined,omitempty"`

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

// Subscriber resilience (subscribe-up / subscribe-down).
Reconnect bool `json:"reconnect,omitempty"`
Gap string `json:"gap,omitempty"`
Since string `json:"since,omitempty"`
Comment thread
cursor[bot] marked this conversation as resolved.

// Error context.
Err string `json:"err,omitempty"`
}
Expand Down
12 changes: 11 additions & 1 deletion cmd/nssh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ var buildVersion string

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

Expand Down Expand Up @@ -71,6 +78,9 @@ func main() {
case "status":
statusCmd(os.Args[2:])
return
case "sweep":
sweepCmd(os.Args[2:])
return
case "-v", "--version":
printVersion()
return
Expand Down
Loading