NanoSSH: a 13 MB Windows SSH client, and the twelve boundaries that shaped it

2026-11-05 · 41 min · Stack Frame

Who this is for: developers building lightweight desktop tools with Go and a webview, or anyone working with terminal byte streams and SSH protocol details.

Bottom line first: the point of this project is not feature count but a handful of boundaries that are easy to get wrong — how IPC serialises, terminal backpressure, PTY parameter order, the host-key trust model and Windows platform details.

Reading time ~30 minutes · Level: advanced

Contents
  • A deliberately narrow Windows SSH client
  • Architecture: put the risk in the replaceable layer
  • Problem 1: the README says binary WebSocket, the code is a base64 event bridge
  • Problem 2: multi-byte characters split across chunks become U+FFFD
  • Problem 3: the ack loop and its three traps
  • Problem 4: PTY width and height order
  • Problem 5: known_hosts and the hard edges of TOFU
  • Problem 6: the Windows ssh-agent named pipe
  • Problem 7: the tray icon clicks do nothing
  • Problem 8: the 14 MB size ceiling
  • Problem 9: high-DPI flicker and zero-sized terminals
  • Problem 10: SFTP and forwarding share a connection, not a lifetime
  • Problem 11: passwords, stale passwords and credential self-healing
  • Problem 12: three edges of key management
  • Closing

A deliberately narrow Windows SSH client

The positioning first: NanoSSH is a Windows SSH client that does five things — server list, terminal, SFTP, local port forwarding and key management. No accounts, no cloud sync, no plugin market, no AI assistant, no auto-update. The narrow scope is the product thesis, not an unfinished roadmap.

The stack is Wails v2 plus WebView2. The Go backend splits into three: internal/sshcore is a pure SSH core with no UI dependency; internal/shell is the Wails binding facade owning sessions, files, forwards, keys and the tray; internal/store handles persistence. The frontend is framework-free TypeScript, DOM built by a hand-written h(), state via @preact/signals-core, terminal via xterm.js 6. Seven runtime dependencies in total, six of them xterm and its addons.

The artefact is a single executable, 13.23 MB on x64, with no external runtime. build.ps1 sets 14 MB as a hard ceiling and fails the build past it. Five direct Go dependencies, twenty-seven indirect. Those numbers are not marketing: they constrain many later decisions — no SQLite, a hand-written tray, no third-party systray, as few new modules as possible.

Because the constraints are hard, the problems here are concrete: not "should we support X" but "why did this Chinese character become a question mark", "why does the tray icon ignore clicks", "why does vim open misaligned". Each problem below has symptom, cause, fix and cost.

Architecture: put the risk in the replaceable layer

The most important decision is separating SSH protocol logic from the UI shell completely. sshcore does not know Wails, WebView2 or xterm exist; it exposes connections, sessions, SFTP, forwarding and key parsing. shell translates core capabilities into callable bindings and moves byte streams into event channels. The frontend reaches the backend through two layers, ipc/api.ts and ipc/transport.ts.

The benefit is that risk sits in the replaceable layer. Wails is the shell; swapping it for bare WebView2 or another window container means changing main.go, the bindings in internal/shell and two frontend IPC files — sshcore untouched. Conversely, protocol tests need no window: an in-process test sshd exercises connections, PTYs, SFTP and forwarding.

The backend's event outlet is a function type rather than a hard-coded Wails call:

type emitFunc func(ctx context.Context, eventName string, data ...any)

App and shellSession both hold it: production assigns runtime.EventsEmit, tests assign a collector. That small indirection is what later makes backpressure, host-key prompts and transfer progress testable.

Persistence follows the same narrow rule: servers.json is the only database, versioned at 1, with pure-function migration on read, a .bak before saving, then temp file, fsync and rename. known_hosts uses OpenSSH format so it can be exchanged with the system file; private keys are standard OpenSSH; logs live under logs. The data root defaults to %APPDATA%\NanoSSH, overridden by NANOSSH_DATA_DIR for tests and portable mode. No hidden binary formats — everything opens in a text editor when something goes wrong.

The window layer has some non-business plumbing that still shapes the experience: the frameless title bar is drawn by the frontend while Wails keeps resize hot zones and shadow; a single-instance lock focuses the existing window instead of starting a second process; Mica background reads CurrentBuildNumber from the registry and only enables translucency from build 22621, falling back to opaque on older systems so you never get a transparent window with no background. The frontend reads MicaEnabled() and decides whether the title bar and sidebar become translucent surfaces.

The frontend has one seam too: views, term and components never touch window.go; every binding call and event subscription goes through api.ts. Byte transport lives alone in transport.ts, the only frontend module strongly tied to the shell. Swapping Wails later rewrites a deliberately bounded area.

Problem 1: the README says binary WebSocket, the implementation is a base64 event bridge

Symptom. The README mentions "binary WebSocket IPC + ack backpressure", yet no WebSocket server or binary framing exists anywhere. Terminal data actually travels over Wails runtime.EventsEmit with event names like term:data:<sessionId>.

Cause. The README described the original intent; the implementation settled on Wails' own event bridge. Another part of the same README says "a Worker decodes base64", which contradicts the first claim. Wails binding calls are JSON-serialised, and so are event payloads: a Go []byte arrives in the frontend as a base64 string. The dependency tree does contain gorilla/websocket, but as a transitive Wails dependency, not this IPC.

Fix. Rather than build a WebSocket to match the document, the project accepted the bridge's limits and compensated: the frontend decodes base64 in a Web Worker and transfers the ArrayBuffer to the main thread via Transferable so decoding never blocks rendering, then hands raw bytes to xterm's write without string concatenation.

function decodeBase64(input: string): Uint8Array {
  const bin = atob(input);
  const len = bin.length;
  const out = new Uint8Array(len);
  for (let i = 0; i < len; i++) out[i] = bin.charCodeAt(i);
  return out;
}

ctx.onmessage = (e: MessageEvent<string>) => {
  const buf = decodeBase64(e.data);
  ctx.postMessage(buf, [buf.buffer]);
};

Cost. Base64 inflates bytes by roughly 33%, so a 16 KiB terminal chunk becomes a ~21.8 KiB string; Go also serialises JSON and the webview decodes base64. What it buys: no WebSocket service to maintain, no connection auth, no extra port outside Wails. That trade is acceptable for a local SSH client, but the README was inaccurate. The fix was to document "Wails event bridge + base64 decoding" rather than keep a faster-sounding noun. Engineering documents should describe the implementation, not the wish. Reverting to binary WebSocket would also lose single-instance, same-origin event dispatch and no-extra-port, and require solving local authentication and a new attack surface — which is why the implementation was not changed for the document.

When a Worker is unavailable (some restricted webview environments), createDecoder falls back to synchronous atob on the main thread: decoding then occupies the render thread and long output stutters, but nothing is missing. Each terminal session owns a Worker, kept across tab switches and terminated only when the session closes; many sessions mean a few more Worker instances in memory.

Problem 2: multi-byte characters split across chunks become U+FFFD

Symptom. An echo containing Chinese, Korean or emoji occasionally shows � mid-output. Intermittent, and more likely with longer output or a slower machine.

Cause. The backend initially did string(buf[:n]) after each read and split on lines before sending. SSH channel read boundaries have nothing to do with character boundaries: a three-byte character can straddle two Read calls. Converting half a character to a string forces Go to emit U+FFFD, and once a UTF-8 decode has happened the pieces can never be rejoined.

Fix. []byte end to end in the backend. A fixed 32 KiB read buffer; each read is copied immediately into a chunks channel of depth 8; pumpSession only merges, batches and sends — no line splitting, no decoding. stdout and stderr have separate reader goroutines writing into the same channel, consumed by a single writer. xterm receives raw bytes and does its own decoding and wide-character handling.

Two paths must be distinguished: an interactive shell streams through pumpSession, while non-interactive RunCommand collects output in a limitedBuffer capped at 8 MiB, silently dropping the excess without blocking the remote. The latter is for probes and batch work only; large output must go through the shell or the SFTP streaming API.

buf := make([]byte, 32<<10)
for {
    n, err := rd.Read(buf)
    if n > 0 {
        cp := make([]byte, n)
        copy(cp, buf[:n])
        select {
        case chunks <- cp:
        case <-sess.done:
            return
        }
    }
    if err != nil {
        return
    }
}

TestShellUTF8Passthrough covers this: the remote echoes 你好,世界 한국어 日本語 emoji🙂 and the test asserts the line is byte-identical with no U+FFFD. Another test pushes a 40,000-byte single line past the 16 KiB chunk cap to force multiple flush/ack rounds and confirms nothing is dropped. The non-interactive path has an 8 MiB flood command to confirm large output reads continuously.

Cost. The backend gave up line-oriented processing, so keyword filtering, log writing or command auditing must happen in the frontend or a separate parser. Each chunk is copied because the read buffer is reused; a small memory cost for correct ownership. stdout and stderr ordering is not strictly guaranteed, though a PTY merges them and the result usually looks right.

Problem 3: the ack loop and its three traps

Symptom. With a plain "read and send", running cat on a large local file makes the webview event queue grow without bound: the terminal lags, then the window freezes. Worse, once the frontend is destroyed the Go reader waits forever for an ack that will never arrive, so the session can be neither read nor closed.

Cause. Wails event emission offers no "the frontend has processed this" signal. xterm's write is asynchronous; pushing more data before the buffer renders accumulates latency without limit. The SSH channel has a window, but that window covers Go's reader, not the webview renderer. The middle has no feedback loop, so pressure piles up in process memory.

Fix. Make ack an explicit end-to-end backpressure signal: the backend sends one chunk at a time and waits for the frontend to ack inside xterm's write callback. Constants: at most 16 KiB per send, at most 8 ms of batching, at most 30 s waiting for an ack. ackCh has capacity 1, and AckTermData drops duplicates with select/default.

flush := func() {
    if len(pending) == 0 {
        return
    }
    data := pending
    pending = nil
    sess.emit(sess.ctx, "term:data:"+sess.id, data)
    select {
    case <-sess.ackCh:
    case <-sess.done:
    case <-time.After(termAckTimeout):
        wruntime.LogWarning(sess.ctx, "terminal: ack timeout, resuming reads")
    }
}

The frontend chains write and ack through a promise so slow rendering cannot reorder acks:

this.chain = this.chain
  .then(() => this.handlers.onData(bytes))
  .then(() => (this.disposed ? undefined : backend.ackTermData(this.sessionId)))
  .catch((err) => console.error("[nanossh] terminal write failed:", err));

Three traps. First, the frontend can ack before the backend starts waiting, so ackCh needs capacity 1 or the first ack is lost. Second, dispose must send one final ack, or the reader hangs until the 30-second timeout. Third, duplicate acks must be discarded safely, because only one chunk is in flight at any moment.

One easily missed detail in flush: if chunks is drained while waiting, the timer may already have fired, so the next loop iteration must Stop and drain it or a second flush happens immediately. The 8 ms batch is not about reducing event count but about sending small packets like keystroke echoes promptly; batching only at 16 KiB makes interaction feel dull.

In tests, the collector acks synchronously inside the event callback — equivalent to the fastest possible "already rendered". A real browser also goes through Worker decoding, Transferable transfer and xterm rendering, so production chunk intervals are longer.

Cost. Throughput is capped by rendering speed, deliberately: terminals want low latency, not maximum bandwidth. Acks carry no chunk sequence number, so the implementation guarantees only one chunk in flight; parallel chunks would need IDs. The 30-second timeout is a safety net, not a normal path — it converts "frontend hung" into "backend keeps reading", which can mask a real rendering fault, so it must be visible in logs.

Problem 4: PTY width and height order

Symptom. vim opens fine locally but the whole screen misaligns through NanoSSH, and htop columns do not line up. Resizing sometimes makes it "accidentally" correct. The initial suspicion was xterm's fit; the bug was in the backend.

Cause. Two contradictory naming conventions. RFC 4254's pty-req and window-change encode width first, then height; golang.org/x/crypto/ssh's RequestPty(term, h, w, modes) and WindowChange(h, w) take height first, then width. Following the RFC field order into x/crypto swaps the two, so vim receives 80 rows and 24 columns.

Fix. The project's own API is uniformly (rows, cols): sshcore.ShellOptions, Shell.Resize, shell.StartShellRequest and the frontend resizeShell all follow it, with comments explaining the RFC versus x/crypto difference. A test pins it: TestStartShellPTYParamOrder has the test sshd parse pty-req exactly per RFC and asserts rows=30, cols=100.

if err := sess.RequestPty(term, rows, cols, opts.Modes); err != nil {
    sess.Close()
    return nil, fmt.Errorf("sshcore: request PTY (%dx%d) failed: %w", cols, rows, err)
}

Note that the error text still says "(cols x rows)" for humans while the real argument order is rows, cols; both perspectives must coexist or readers get confused.

Defaults exist too: rows or cols of zero or less fall back to 24×80 so a frontend that has not laid out yet cannot send zeros. Resize errors on invalid sizes and never writes 0×0 to the remote. The frontend's fit() can throw before layout; the wrapper swallows it and returns the current term.rows and term.cols, deferring to the next ResizeObserver.

Cost. fit() returns { rows, cols } and callers must pass (rows, cols), which the code comments repeat. window-change is fire-and-forget with wantReply=false: returning only means bytes were written, not applied, so tests must poll the server. Dragging a window generates many resizes, debounced at 150 ms, at the cost of a slightly delayed remote size.

Problem 5: known_hosts and the hard edges of TOFU

Symptom. First connection to an unknown host should show a fingerprint card; a changed key should block clearly, not offer an "ignore once" button people click reflexively. In practice the error types returned by knownhosts get lumped together, mixing "unknown host" with "fingerprint changed".

Cause. In the KeyError returned by knownhosts.New, an empty Want means "no record for this host", while a non-empty one means "record exists but the presented key does not match". The former is TOFU; the latter may signal a man in the middle. There is also a distinct RevokedError for @revoked lines, which must block unconditionally. Mixing the three produces the classic "fingerprint changed, allow once" vulnerability.

Fix. Handle RevokedError first and return an error; then check whether KeyError.Want is non-empty. Unknown hosts offer three choices: once, trust and save, cancel. A changed key accepts only an explicit replacement; HostKeyAcceptOnce and refusal both yield ErrHostKeyRejected. Replacement matches the old entry by address and writes a new public-key line atomically (temp file, fsync, rename) at mode 0600. knownhosts.New requires the file to exist, so first connections call ensureFile to create the parent directory and an empty file, otherwise the fingerprint prompt never appears and parsing fails first.

if len(keyErr.Want) > 0 {
    change := &HostKeyChange{Info: info}
    for _, wk := range keyErr.Want {
        change.OldFingerprints = append(change.OldFingerprints,
            ssh.FingerprintSHA256(wk.Key))
    }
    decision, perr := prompt(info, change)
    if perr != nil {
        return perr
    }
    if decision != HostKeyAcceptAndSave {
        return fmt.Errorf("%w (fingerprint changed)", ErrHostKeyRejected)
    }
    return writeKnownHost(knownHostsPath, hostname, key, true)
}

Tests cover three behaviours: first prompt then save, no second prompt, and a server key rotation that must block while the callback still receives the old fingerprint; another test confirms "once" is not persisted and both connections prompt again.

Cost. known_hosts supports hashed hostnames (|1|...) and the replacement logic cannot reverse a hash, so such lines are skipped and a new line appended. Old hashed entries remain and may keep reporting changes until the user cleans them by hand — a documented edge, with a comment saying hashed forms are treated as misses and preserved. On Windows, mode 0600 is only intent; real access control comes from ACLs inherited from %APPDATA%.

Problem 6: the Windows ssh-agent named pipe

Symptom. Windows OpenSSH is installed and ssh-add lists keys, but agent auth in NanoSSH always fails; with Git Bash installed, SSH_AUTH_SOCK exists yet dialling it also fails.

Cause. Windows has no Unix domain sockets; Windows OpenSSH's agent listens on the fixed named pipe \\.\pipe\openssh-ssh-agent. SSH_AUTH_SOCK, even when present, may be a Cygwin or MSYS private format that Go's net.Dial("unix", ...) cannot reach. Also, when the pipe exists but all instances are busy, CreateFile returns ERROR_PIPE_BUSY; reporting that directly tells users "agent unavailable".

Fix. Hand-written CreateFile plus WaitNamedPipe-equivalent logic, no winio. Fixed pipe name, a 3-second deadline, retry every 50 ms on ERROR_PIPE_BUSY. On success wrap with agent.NewClient and call List() once before authenticating; an empty list says "run ssh-add first" rather than surfacing no supported methods remain. authMethods returns an io.Closer, closed on handshake failure and otherwise attached to the Client so a failed handshake cannot leak the pipe.

deadline := time.Now().Add(agentDialTimeout)
for {
    h, err := windows.CreateFile(name, windows.GENERIC_READ|windows.GENERIC_WRITE,
        0, nil, windows.OPEN_EXISTING, 0, 0)
    if err == nil {
        return os.NewFile(uintptr(h), agentPipeName), nil
    }
    if err != windows.ERROR_PIPE_BUSY || time.Now().After(deadline) {
        return nil, fmt.Errorf("sshcore: cannot reach ssh-agent (%s): %w; "+
            "check that the Windows OpenSSH Authentication Agent service is running",
            agentPipeName, err)
    }
    time.Sleep(50 * time.Millisecond)
}

A Windows-only test asserts the path really enters the pipe namespace: with no agent, the error must be ERROR_FILE_NOT_FOUND or ERROR_PIPE_BUSY, never ERROR_INVALID_NAME. If Go or x/sys changes device-path handling, the test fails first.

Cost. Only the Windows OpenSSH agent is supported — no PuTTY Pageant, and no third-party protocol dependency to support it. Three seconds is a hard cap: better a clear error than an endless spinner. Multiple SSH clients cause pipe contention and failures inside the retry window are normal; only past three seconds is it truly unavailable. That is shorter than the 15-second TCP connect timeout because the agent is local and should answer in milliseconds.

Problem 7: the tray icon clicks do nothing

Symptom. After closing the window to the tray, the icon is visible but double-clicking does nothing and the context menu never opens. No errors in the log, process still alive.

Cause. Win32 window messages are delivered per thread, so the message window and the GetMessage loop must live on the same OS thread. Go goroutines can migrate between OS threads around blocking calls; create the window on one thread and pump messages on another and messages never arrive. The symptom looks exactly like "the icon did not attach".

Fix. The tray thread calls runtime.LockOSThread() on entry and unlocks on exit; both the message window creation and the GetMessage loop run on that locked thread. The window is an HWND_MESSAGE window that takes no screen space; callbacks, balloons and the context menu are delivered via custom messages. Creation is a synchronous handshake: newTray blocks until ready or 3 seconds, and on failure returns an error so the caller can shut down normally — never leaving "window hidden, icon not attached".

func (t *tray) run(done chan<- error) {
    runtime.LockOSThread()
    defer runtime.UnlockOSThread()
    // register class, create message window, Shell_NotifyIcon, then GetMessage loop
}

The tray is created lazily: no notification-area slot while the window is open, an icon only when actually minimising. That also means a failed tray creation still lets the user exit instead of being trapped in a background process with no exit. The balloon shows once (a trayTipShown flag); the context menu uses TPM_RETURNCMD to read the selection directly instead of routing WM_COMMAND; quitting destroys the icon via a custom message and ends the loop.

Cost. One OS thread is locked for the whole tray lifetime and must not block; callbacks only send messages and flip state, with heavy work pushed back to the business layer. The wndProc defends every branch against panics, menu commands travel over a channel, and the tray thread never touches the database or network. Only one tray icon is supported, so wndProc is bound via a package variable rather than SetWindowLongPtr. The handwritten Win32 code is about two hundred lines — less maintainable than a library, but zero new dependencies and a smaller binary.

Problem 8: the 14 MB size ceiling

Symptom. As features accumulated the artefact grew past 13.23 MB, leaving under 0.8 MB before the 14 MB limit. At that point any casual "just add a library" decision can fail the build.

Cause. The Wails runtime is not small, and the SSH implementation from x/crypto, the WebView2 loader and the frontend bundle all live inside. The dependency graph also carries a batch of Wails transitive dependencies — echo, gorilla/websocket, string and template helpers — not all reachable but all in the module graph. Add SQLite or a third-party tray library and size runs away.

Fix. Turn size from a verbal agreement into a build assertion. build.ps1 fixes GOARCH=amd64 and CGO_ENABLED=0, builds with -ldflags "-s -w", checks that the PE header machine field is 0x8664, then rounds the size and throws past the threshold.

$sizeMB = [math]::Round((Get-Item $exe).Length / 1MB, 2)
if ($sizeMB -gt $MaxSizeMB) {
    throw "size exceeded: $sizeMB MB > $MaxSizeMB MB. The goal is 'extremely light' — check dependency growth before raising the limit."
}

The specific choices: no SQLite (server data is dozens of rows; modernc.org/sqlite would add 4–6 MB for nothing) — JSON with atomic writes and a .bak instead, which is small and easy to repair by hand; no third-party tray, just Shell_NotifyIconW; seven frontend runtime dependencies and no UI framework; keys, known_hosts and servers.json in standard formats with no bespoke binary containers; the frontend embedded via go:embed all:frontend/dist with no external asset directory. wails.json drives the frontend build while build.ps1 owns the Go side and the assertions. CI also runs govulncheck, because advisories in x/crypto need continuous discovery rather than after-the-fact checks.

Cost. JSON storage rewrites the whole file on save — O(n), acceptable for dozens of servers, and no SQL for complex queries later. Handwritten Win32 and a hand-rolled DOM builder add maintenance. The size guard itself is brittle: a Go, Wails or frontend dependency bump can move the number, so thresholds need re-checking after upgrades. The assertion runs after the Wails build, so the failing artefact is kept for analysis but CI fails. One easily missed point: a 32-bit Go toolchain produces 386 by default, so the script must pin amd64 or you get a working binary of the wrong architecture.

Problem 9: high-DPI flicker and zero-sized terminals

Symptom. Dragging the window between a 100% and a 150% display makes WebView2 repaint repeatedly and flicker. Switching from the terminal tab to files and back leaves the xterm cursor misaligned to the old column count, sometimes collapsing the terminal to a thin line.

Cause. Two problems overlap. First, WebView2 receives dense resize events on high DPI or multi-monitor switches; reflowing on every one flickers. Second, in-session view switching uses display:none, so the hidden .term-host measures zero and ResizeObserver reports 0×0; calling fit() then computes a tiny grid, and switching back leaves it wrong.

Fix. The window layer sets ResizeDebounceMS=120 so Wails debounces WebView2 repaints; the frontend debounces resize another 150 ms, skips observations below 4 pixels in either dimension, and re-fits plus syncs size inside requestAnimationFrame. Returning to the terminal view also goes through rAF so layout finishes first. Switching from the keys page back to servers calls refitAll() so every visible terminal re-fits its container.

const ro = new ResizeObserver(() => {
  for (const [id, handle] of terminals) {
    const host = hosts.get(id);
    if (!host || panes.get(id)?.hidden) continue;
    if (host.clientWidth < 4 || host.clientHeight < 4) continue;
    handle.fit();
    handle.syncSize();
  }
});

WebGL is downgraded at the same layer: new WebglAddon() sits in a try, initialisation failure keeps xterm's DOM renderer, and context loss calls dispose() and falls back the same way. Nothing breaks; long output just costs more CPU. The window also declares PerMonitorV2 DPI awareness in the manifest, otherwise WebView2 gets bitmap-stretched across displays and text blurs. Light/dark follows the system, and Mica only enables on build 22621+, with older systems staying opaque so a transparent window never lacks a background.

Cost. The two debounces stack (120 ms then 150 ms), so the remote PTY receives the final size a moment after dragging ends — perceptible but not painful. Skipping sub-4-pixel sizes avoids 0×0 but also means tiny layout changes do not recompute. The WebGL downgrade is silent; users just notice scrolling got slower, a trade made to keep the main flow undisturbed.

Problem 10: SFTP and forwarding share a connection, not a lifetime

Symptom. Initially every file panel opened a new SSH connection and each forwarding rule dialled its own, so opening files added a handshake and another login record on the server. Worse, closing a session tab left the local listening port occupied because forwards were not released with the session.

Cause. SFTP and port forwarding are just extra channels on the same SSH connection: an sftp subsystem channel and a direct-tcpip channel per forwarded connection. Neither needs a new dial, handshake or auth. What needs designing is lifetime: channels belong to the session and must be torn down with it, along with listeners and rules.

Fix. SFTP is created lazily and cached: the first SftpOpen starts the subsystem on the existing ssh.Client and later calls reuse the same sftp.Client; a test asserts two opens produce one SSH connection and one SFTP channel. Forwarding keeps a rule table per session: validate port and host, listen on 127.0.0.1, open a direct-tcpip channel per incoming local connection via ssh.Dial, shuttle both directions through 32 KiB buffers, and count active, total and per-direction bytes. On session close, dropForwards closes listeners, cuts live connections and pushes a removed state.

func (f *Forward) handle(local net.Conn) {
    remote, err := f.ssh.Dial("tcp", f.remoteAddr)
    if err != nil {
        local.Close()
        return
    }
    // register the pair and splice both directions; either side closing ends both
}

Smaller patches matter too. Rename prefers PosixRename and falls back to standard Rename; symlinks only trigger a Stat when the entry is a link and not a directory, avoiding a round trip per entry in big directories; delete only removes empty directories, never recursively, because a wrong path in recursion is irreversible. Forwarding binds 127.0.0.1 only — exposing a service to the LAN must be an explicit decision — and a port conflict errors instead of leaving half a rule. Transfer progress is rate-limited to 120 ms so every 32 KiB does not flood the webview; cancellation travels through context, shuttle checks ctx.Err() before each block, and the terminal state is cancelled rather than done. Forwarding stats share the same interval and only push on change, so idle rules emit nothing.

Cost. All channels share one TCP connection, so terminal, SFTP and forwarding contend for the same bandwidth and connection-level resources; a single large transfer can slow terminal echo. Remote forwarding (-R) and dynamic forwarding (-D) are not implemented — only local -L. Downloads open the remote file once to get its size and reopen for the transfer, costing a round trip to avoid holding two handles. Recursive upload and recursive delete are explicitly unsupported.

Problem 11: passwords, stale passwords and credential self-healing

Symptom. After changing the remote password, every connection attempt fails and there is no way to enter a new one; conversely a mistyped password in the dialog gets saved, and later attempts keep failing.

Cause. If "the user typed a password" and "save the password" are the same call, a failed auth still persists; if a stale saved password is never removed, the frontend sees HasPassword true, never prompts, and the user is stuck. Deleting a server while leaving its credential behind also orphans a password pointing at a host that no longer exists.

Fix. Save only after successful authentication; if auth fails and the user supplied no password this time, delete the saved one so the next attempt prompts. Deleting a server deletes the NanoSSH/server/<id> credential. HasPassword answers only "is there one", never returns the secret, and drives whether the frontend connects directly or prompts; ForgetPassword is idempotent. The frontend asks HasPassword first when auth is password-based and no password was supplied, so normal use is not prompted every time. Key passphrases work the same way: on failure with a stored passphrase, delete it and re-prompt with a retry flag, at most three times.

if srv.Auth == sshcore.AuthPassword && req.Password != "" {
    if serr := a.secrets.Set(store.PasswordTarget(srv.ID), req.Password); serr != nil {
        wruntime.LogWarning(a.ctx, "failed to save server password: "+serr.Error())
    }
}

Credentials live in Windows Credential Manager as generic credentials with PersistLocalMachine, target NanoSSH/server/<id> or NanoSSH/key/<id>. No bespoke encrypted file, because that is the classic "roll your own crypto" trap; the credential manager uses DPAPI bound to the current user and does not travel with the data directory.

Cost. Self-healing depends on strings.Contains(err.Error(), "unable to authenticate"), a string match against x/crypto's message; change the wording and healing stops. Detecting a typed auth error would be sturdier, but the simpler path was chosen. Credentials bound to the Windows account cannot be decrypted on another machine — correct behaviour — so copying servers.json and keys means re-entering passwords. Passwords are still plaintext in process memory; there is no master password or memory encryption, which is the usual desktop SSH boundary. Other programs running as the same user can enumerate the targets: that is the Windows credential model, not something NanoSSH can fix alone. It guarantees only that secrets are not in a plaintext JSON file and do not travel with the data directory.

Problem 12: three edges of key management

Symptom. Importing a FIDO security key reports "failed to parse private key", looking like corruption; generating RSA defaults to 2048, which the baseline rejects; deleting a key still referenced by a server can quietly change that server's auth and break the next connection.

Cause. A FIDO key's private handle never leaves the hardware, Go's ssh library does not support it, and users must be told "the algorithm is unsupported", not "the file is broken". RSA 2048 is no longer enough and 4096 takes seconds to generate. Deleting a referenced key creates a dangling reference, and silently rewriting server config is harder to diagnose than an error.

Fix. Only ed25519, RSA 3072 and ECDSA P256, generated in native OpenSSH format, with public lines that paste straight into authorized_keys and fingerprints matching ssh-keygen -lf. FIDO detection must inspect the decoded PEM payload, because OpenSSH private keys store the algorithm name as a plain string inside the binary body; searching the PEM text for sk- finds nothing.

if block, _ := pem.Decode(pemBytes); block != nil {
    if bytes.Contains(block.Bytes, []byte("sk-ssh-ed25519")) ||
        bytes.Contains(block.Bytes, []byte("sk-ecdsa-sha2")) {
        return nil, ErrKeyFIDOUnsupported
    }
}

Key files are named by ULID rather than the user's name: no collisions, no path traversal, and renaming is free. Import parses before writing, so success proves the key is usable and yields the exact public key and fingerprint; failure leaves no half-record with an empty fingerprint. Three failure paths are distinguished deliberately: passphrase needed returns ErrKeyEncrypted, hardware keys return ErrKeyFIDOUnsupported, anything else means the file itself is bad. Without a name, import uses the source filename minus extension; a passphrase-protected import stores the passphrase so the first connection does not ask again. Deleting scans the server list and refuses while referenced, naming the servers. The order is record first, file second, so no "record exists, file gone" dangling state.

Cost. No FIDO keys and no agent forwarding — both stated in the error text rather than failing silently. RSA 3072 generation is slower than 2048 but far better than 4096, a middle ground. Mode 0600 on Windows is again only intent; protection comes from the %APPDATA% ACL. No rotation, no multi-key binding — the surface stays deliberately narrow.

Closing

The value of NanoSSH is not how many features it has but that it pins down a few boundaries that are easy to get wrong. IPC chose Wails' base64 event bridge, accepted the 33% inflation and offset it with Worker decoding and Transferable; terminal bytes stay []byte end to end with no line splitting or decoding, using 16 KiB chunks, 8 ms batching and an ack loop to keep latency at the renderer; PTY parameters are uniformly (rows, cols) with a test preventing the RFC/x-crypto swap from returning; host keys distinguish unknown, changed and revoked, with changed keys allowing only explicit replacement; the Windows details land in named pipes, LockOSThread, resize debouncing and a 14 MB size guard.

Each decision has a cost: throughput is bound to rendering, hashed known_hosts entries cannot be replaced automatically, JSON cannot be queried, handwritten Win32 costs maintenance, and stale-password healing depends on error text. They are written into comments, tests and error messages rather than hidden.

Testing is part of the architecture: an in-process testsshd provides a real SSH server, an eventCollector replaces the event outlet and acks immediately on receipt, and the credential backend is swapped for an in-memory implementation so tests never write to the real credential manager. Conclusions like "terminal bytes are never lost", "PTY order is correct", "forwarding reuses one connection" and "closing a session releases the port" are repeatable without a window. What transfers to similar tools is not a function but the order of operations: write the boundaries down first, then pick the implementation.

Further reading