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