Skip to content
Open
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
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,19 @@ only the last week.

| argv[0] | Mode | Description |
|---------|------|-------------|
| `nssh` (or anything else) | session | SSH/mosh wrapper + ntfy subscriber |
| `nssh` (or anything else) | session | SSH/mosh/et wrapper + ntfy subscriber |
| `xclip` | shim | Clipboard bridge via ntfy |
| `wl-copy` / `wl-paste` | shim | Wayland clipboard bridge via ntfy |
| `xdg-open` / `sensible-browser` | shim | URL forwarding via ntfy |

### Session mode (local)

- Wraps `ssh` or `mosh` with automatic transport selection
- Wraps `ssh`, `mosh`, or `et` (Eternal Terminal) with automatic transport
selection: when no `--ssh`/`--mosh`/`--et` flag is given, prefers `et`
(local `et` + remote `etserver`), then `mosh` (local `mosh` + remote
`mosh-server`), else falls back to plain `ssh`. Detection is a binary
presence check only; if a forced/auto transport's daemon is down, that
transport's own launch fails and the user reruns with another flag.
- Generates a random topic (or reads a pinned one from config), writes it to
the remote's `~/.config/nssh/session`, subscribes to ntfy in a background goroutine
- Dispatches incoming messages by `kind`:
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ For nix/home-manager managed hosts, add the flake input and add a single activat
## Usage

```bash
# Connect (auto-selects mosh if both sides have it)
# Connect (auto-selects et > mosh > ssh based on what both sides have)
nssh devbox
nssh --ssh devbox # force plain SSH
nssh --mosh devbox # force mosh
nssh --et devbox # force Eternal Terminal

# Inside the remote session:

Expand Down Expand Up @@ -151,7 +152,7 @@ The `NSSH_NTFY_BASE` environment variable overrides the server.
- **Local:** macOS, Go 1.25+, [`pngpaste`](https://github.com/jcsalterego/pngpaste) (`brew install pngpaste`)
- **Remote:** Linux with `~/.local/bin` in PATH. Zero runtime deps.
- **Optional:** Self-hosted [ntfy](https://docs.ntfy.sh/install/) for privacy (public ntfy.sh works out of the box).
- **Optional:** `mosh` on both ends for session roaming.
- **Optional:** `mosh` (both ends) or [Eternal Terminal](https://eternalterminal.dev/) (`et` locally + `etserver` on the remote) for session roaming. When available, `et` is auto-selected over `mosh` over plain `ssh`.

## Further reading

Expand Down
20 changes: 10 additions & 10 deletions cmd/nssh/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import (
// LogEvent is the on-disk schema for each JSONL log line. Both the writer
// (logEvent below) and the reader (status.go::formatEvent) marshal against
// this type, so renaming or adding a field is type-checked instead of
// grep-and-pray. Exit and Mosh are pointers so callers can record an
// explicit zero/false without omitempty dropping the field.
// grep-and-pray. Exit is a pointer so callers can record an explicit zero
// (exit=0, success) without omitempty dropping the field.
type LogEvent struct {
TS string `json:"ts"`
Event string `json:"event"`
Expand All @@ -29,14 +29,14 @@ type LogEvent struct {
Size int `json:"size,omitempty"`

// 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"`
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"`
Transport string `json:"transport,omitempty"`
Joined int `json:"joined,omitempty"`

// Shim invocation.
Persona string `json:"persona,omitempty"`
Expand Down
32 changes: 15 additions & 17 deletions cmd/nssh/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ func TestLogEventOmitsZeros(t *testing.T) {

func TestLogEventExitZeroPreserved(t *testing.T) {
zero := 0
yes := true
e := LogEvent{Event: "session-end", Exit: &zero, Mosh: &yes}
e := LogEvent{Event: "session-end", Exit: &zero, Transport: "et"}
data, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
Expand All @@ -35,25 +34,24 @@ func TestLogEventExitZeroPreserved(t *testing.T) {
if !strings.Contains(got, `"exit":0`) {
t.Errorf("exit=0 was dropped: %s", got)
}
if !strings.Contains(got, `"mosh":true`) {
t.Errorf("mosh=true missing: %s", got)
if !strings.Contains(got, `"transport":"et"`) {
t.Errorf("transport=et missing: %s", got)
}
}

func TestLogEventRoundTrip(t *testing.T) {
exit := 42
mosh := false
want := LogEvent{
TS: "2026-05-05T07:43:42Z",
Event: "session-end",
Side: "session",
PID: 12345,
Target: "devbox",
Server: "https://ntfy.sh",
Topic: "nssh_abc",
Version: "v0.1.0",
Exit: &exit,
Mosh: &mosh,
TS: "2026-05-05T07:43:42Z",
Event: "session-end",
Side: "session",
PID: 12345,
Target: "devbox",
Server: "https://ntfy.sh",
Topic: "nssh_abc",
Version: "v0.1.0",
Exit: &exit,
Transport: "mosh",
}
data, err := json.Marshal(want)
if err != nil {
Expand All @@ -69,8 +67,8 @@ func TestLogEventRoundTrip(t *testing.T) {
if got.Exit == nil || *got.Exit != exit {
t.Errorf("Exit not preserved: %v", got.Exit)
}
if got.Mosh == nil || *got.Mosh != mosh {
t.Errorf("Mosh not preserved: %v", got.Mosh)
if got.Transport != want.Transport {
t.Errorf("Transport not preserved: %q", got.Transport)
}
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/nssh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ var buildVersion string

func usage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " nssh [--ssh|--mosh] [--join|--replace|--new] <host> [ssh args...]")
fmt.Fprintln(os.Stderr, " nssh [--ssh|--mosh|--et] [--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")
Expand Down
97 changes: 73 additions & 24 deletions cmd/nssh/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func nsshMain() {
args := os.Args[1:]
forceSSH := false
forceMosh := false
forceET := false
collisionFlag := "" // "join" | "replace" | "new" | ""
parse:
for len(args) > 0 {
Expand All @@ -39,6 +40,8 @@ parse:
forceSSH = true
case "--mosh":
forceMosh = true
case "--et":
forceET = true
case "--join", "--replace", "--new":
collisionFlag = strings.TrimPrefix(args[0], "--")
case "-h", "--help":
Expand All @@ -48,10 +51,25 @@ parse:
}
args = args[1:]
}
if forceSSH && forceMosh {
fmt.Fprintln(os.Stderr, "nssh: --ssh and --mosh are mutually exclusive")
forced := 0
for _, f := range []bool{forceSSH, forceMosh, forceET} {
if f {
forced++
}
}
if forced > 1 {
fmt.Fprintln(os.Stderr, "nssh: --ssh, --mosh, and --et are mutually exclusive")
os.Exit(1)
}
forceTransport := ""
switch {
case forceSSH:
forceTransport = "ssh"
case forceMosh:
forceTransport = "mosh"
case forceET:
forceTransport = "et"
}
if len(args) < 1 {
usage()
}
Expand Down Expand Up @@ -136,14 +154,14 @@ parse:
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)

session, useMosh := selectTransport(forceSSH, forceMosh, sshArgs, sshTarget)
session, transport := selectTransport(forceTransport, sshArgs, sshTarget)
sessErr := runSession(session, sigs)
resetTerminal()
exitCode := 0
if exitErr, ok := sessErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
logEvent(LogEvent{Event: "session-end", Exit: &exitCode, Mosh: &useMosh})
logEvent(LogEvent{Event: "session-end", Exit: &exitCode, Transport: transport})
unregisterSession(sessionFile) // defers don't fire under os.Exit
if exitCode != 0 {
os.Exit(exitCode)
Expand Down Expand Up @@ -263,36 +281,67 @@ func resetTerminal() {
)
}

// remoteHasMosh checks if mosh-server is on the remote's PATH. Used to
// auto-select transport when neither --ssh nor --mosh is given.
func remoteHasMosh(sshTarget string) bool {
cmd := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget, "command -v mosh-server >/dev/null 2>&1")
return cmd.Run() == nil
// remoteHasCommand reports whether `cmd` is on the remote's PATH. Used to
// auto-select a transport (mosh-server / etserver) when none is forced.
func remoteHasCommand(sshTarget, cmd string) bool {
c := exec.Command("ssh", "-o", "BatchMode=yes", sshTarget,
fmt.Sprintf("command -v %s >/dev/null 2>&1", cmd))
return c.Run() == nil
}

// selectTransport picks ssh or mosh based on the user's flags and (if
// neither is forced) whether mosh is installed locally and on the remote.
// Returns the configured exec.Cmd plus a useMosh flag for downstream
// logging. When mosh is selected we force a UTF-8 locale because mosh's
// terminal emulation breaks under POSIX/C locales on minimal images.
func selectTransport(forceSSH, forceMosh bool, sshArgs []string, sshTarget string) (*exec.Cmd, bool) {
useMosh := false
// pickTransport decides which interactive transport to use. force is one of
// "" (auto), "ssh", "mosh", "et"; a forced choice is honored regardless of
// detected availability — the user knows their setup. When auto-selecting,
// preference is et > mosh > ssh, and a transport only wins if both its local
// and remote halves are present.
func pickTransport(force string, localMosh, remoteMosh, localET, remoteET bool) string {
switch force {
case "ssh", "mosh", "et":
return force
}
switch {
case forceSSH:
case forceMosh:
useMosh = true
case localET && remoteET:
return "et"
case localMosh && remoteMosh:
return "mosh"
default:
if _, err := exec.LookPath("mosh"); err == nil && remoteHasMosh(sshTarget) {
useMosh = true
return "ssh"
}
}

// selectTransport resolves the transport (honoring force, else probing local
// binaries and the remote PATH), builds the interactive command, and returns
// it alongside the chosen transport name for downstream logging. Remote probes
// are skipped entirely when a choice is forced or the local binary is absent.
// When mosh is selected we force a UTF-8 locale because mosh's terminal
// emulation breaks under POSIX/C locales on minimal images.
func selectTransport(force string, sshArgs []string, sshTarget string) (*exec.Cmd, string) {
var localMosh, remoteMosh, localET, remoteET bool
if force == "" {
_, errET := exec.LookPath("et")
localET = errET == nil
_, errMosh := exec.LookPath("mosh")
localMosh = errMosh == nil
if localET {
remoteET = remoteHasCommand(sshTarget, "etserver")
}
if localMosh {
remoteMosh = remoteHasCommand(sshTarget, "mosh-server")
}
}
if useMosh {

switch pickTransport(force, localMosh, remoteMosh, localET, remoteET) {
case "et":
fmt.Fprintln(os.Stderr, "nssh: using et for interactive session")
return exec.Command("et", sshTarget), "et"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report missing ET client instead of succeeding

When users force the new --et path on a machine without the local ET client, this branch still returns a command that cannot be started. runSession returns that start error, but nsshMain only converts *exec.ExitError into a non-zero exitCode, so this scenario prints using et, logs transport=et with exit=0, and exits successfully without explaining that et was not found. Please either check exec.LookPath("et") before selecting this forced transport or make start errors propagate as a non-zero failure.

Useful? React with 👍 / 👎.

case "mosh":
fmt.Fprintln(os.Stderr, "nssh: using mosh for interactive session")
cmd := exec.Command("mosh", sshTarget)
cmd.Env = append(os.Environ(), "LC_ALL=C.UTF-8", "LANG=C.UTF-8")
return cmd, true
return cmd, "mosh"
default:
return exec.Command("ssh", sshArgs...), "ssh"
}
return exec.Command("ssh", sshArgs...), false
}

// deadlineConn wraps net.Conn to push the read deadline forward on every Read.
Expand Down
4 changes: 2 additions & 2 deletions cmd/nssh/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,8 @@ func formatEvent(raw, label string) string {
if e.Mime != "" {
fmt.Fprintf(&sb, " mime=%s", e.Mime)
}
if e.Mosh != nil {
fmt.Fprintf(&sb, " mosh=%v", *e.Mosh)
if e.Transport != "" {
fmt.Fprintf(&sb, " transport=%s", e.Transport)
}
if e.Persona != "" {
fmt.Fprintf(&sb, " persona=%s", e.Persona)
Expand Down
39 changes: 39 additions & 0 deletions cmd/nssh/transport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package main

import "testing"

func TestPickTransport(t *testing.T) {
cases := []struct {
name string
force string
localMosh, remoteMosh, localET, remoteET bool
want string
}{
// Forced choices win regardless of detected availability.
{"force ssh ignores availability", "ssh", true, true, true, true, "ssh"},
{"force mosh ignores availability", "mosh", false, false, false, false, "mosh"},
{"force et ignores availability", "et", false, false, false, false, "et"},

// Auto-select precedence: et > mosh > ssh.
{"auto all available picks et", "", true, true, true, true, "et"},
{"auto et beats mosh", "", true, true, true, true, "et"},
{"auto no et falls to mosh", "", true, true, false, false, "mosh"},
{"auto nothing falls to ssh", "", false, false, false, false, "ssh"},

// Partial availability: both halves required for a transport to win.
{"auto local et only -> mosh", "", true, true, true, false, "mosh"},
{"auto remote et only -> mosh", "", true, true, false, true, "mosh"},
{"auto local mosh only -> ssh", "", true, false, false, false, "ssh"},
{"auto remote mosh only -> ssh", "", false, true, false, false, "ssh"},
{"auto local et only, no mosh -> ssh", "", false, false, true, false, "ssh"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := pickTransport(tc.force, tc.localMosh, tc.remoteMosh, tc.localET, tc.remoteET)
if got != tc.want {
t.Errorf("pickTransport(%q, mosh=%v/%v, et=%v/%v) = %q, want %q",
tc.force, tc.localMosh, tc.remoteMosh, tc.localET, tc.remoteET, got, tc.want)
}
})
}
}
22 changes: 20 additions & 2 deletions docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ user who wants a stable topic can pin it in `config.toml`; any password
auth on top is delegated to ntfy's own ACL config (we don't reimplement
it).

### Transport selection (ssh / mosh / et)

The interactive shell can ride `ssh`, `mosh`, or `et` (Eternal Terminal),
chosen in `selectTransport` (`session.go`). With no `--ssh`/`--mosh`/`--et`
flag, auto-selection prefers `et` (local `et` + remote `etserver`), then
`mosh` (local `mosh` + remote `mosh-server`), else `ssh`. Detection is a
binary-presence check only (`command -v` over `ssh -o BatchMode=yes`); the
pure decision lives in `pickTransport` and is unit-tested. A daemon that's
installed but not listening isn't caught here — the transport's own launch
fails and the user reruns with another flag.

Note that `et` *does* support port forwarding, unlike mosh. nssh still
routes the clipboard/URL bridge over ntfy regardless of transport, so the
bridge's behavior is identical no matter how the shell is carried — the
remote shims never have to know which transport is in play. Like the mosh
path, the `et` launch passes only the host target (`et` resolves it via
`~/.ssh/config`); extra ssh flags are not forwarded.

## Why dispatch on argv[0]

Tools like Claude Code, `gh auth login`, and `gcloud auth login` call
Expand Down Expand Up @@ -203,8 +221,8 @@ Browser GETs http://localhost:8585/cb?code=...
Notes on this flow:

- We use a fresh `ssh -W` per callback. No `ControlMaster`, no socket
files. This makes it work whether the outer session is ssh or mosh
— `ssh -W` is its own connection. The user authenticates once when
files. This makes it work whether the outer session is ssh, mosh, or
et — `ssh -W` is its own connection. The user authenticates once when
the session starts (or relies on a key); subsequent `ssh -W` calls
for OAuth callbacks reuse the same auth.
- `ln.Accept()` has a 5-minute deadline (`oauthAcceptTimeout` in
Expand Down
Loading