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 .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ body:
options:
- npm (sshub-tui)
- cargo install
- Nix flake (nix run, nix profile, NixOS)
- release binary from GitHub
- built from source (say which commit below)
- other (say which below)
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,26 @@ All notable changes to SSHub are documented in this file.
shrink scrolls the top away also under a scroll region and on the alternate
screen. A resize returns a scrolled-back view to the live screen, as xterm
does. The terminal emulator under the panes moved to vt100 0.18.
- **Startup command runs at login and keeps the session** (issue #171, reported
by [@Frysuni](https://github.com/Frysuni)) - over ssh, a host's startup command
now runs when you log in, and the session goes on in your login shell:
`cd /srv` leaves you in `/srv`, and `cd / && sudo su root` in a root shell in
`/` that drops back to your own shell on exit. It used to replace the shell,
so the session ended as soon as the command did. To keep that, start the
command with `exec`, as in `exec tmux attach`. An ssh_config host's
`RemoteCommand` and a mosh host's command keep their own behaviour.

### Fixed

- **Sessions** - `CSI 3 J` (sent by `clear` in most shells) now also empties the
session's scrollback, and an emoji written with VS16 (`❤️`) takes two cells
instead of one, so the text after it no longer lands on top of it.
- **Startup command** (issue #171, reported by
[@Frysuni](https://github.com/Frysuni)) - the command now runs in a remote
terminal: without one, `sudo su root` printed no prompt and the session stayed
on the connect screen for good. `sshub connect` gets the terminal and the
login shell too. A session that authenticated but prints nothing now shows its
terminal after 6 s instead of keeping the connect screen up.

## [0.20.0] - 2026-10-24

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ The theme picker, with its detail preview next to the list, and the known hosts
- **Session logging** — opt-in capture of PTY session output to `~/.local/share/sshub/profiles/<name>/logs/<host-dir>/` (managed hosts use `{name}-{id}`; pure `~/.ssh/config` aliases without a launcher row may share a directory when sanitized names collide). Enable globally in Settings (`Ctrl+H`) or override per host (`inherit` / `on` / `off`). **Logs capture everything echoed to the terminal, including passwords if they appear on screen.**
- **Session log browser** (`Shift+L`): read those session logs inside the TUI. Pick a host, pick a rotated segment, and view it with terminal escape codes stripped so the transcript reads as plain text. In the viewer, `/` searches (case-insensitive), `n` / `N` step through matches, and `b` bookmarks the current line by name. `m` lists a host's bookmarks to jump straight back to a saved line. Reads are capped so a large transcript is never loaded whole.
- **Mosh transport** — per-host `Transport` field in the host form (`ssh` or `mosh`). Embedded sessions use `mosh` when selected; tunnels and SFTP stay ssh-only.
- **Startup command**: a host's `Startup command` (`--remote-command` headless) runs when you log in, in a remote terminal, and the session goes on in your login shell: `cd /srv/app` starts you in that directory, `cd / && sudo su root` in a root shell in `/` (exit it and you are back in your own shell). Start the command with `exec` to make it the whole session, as in `exec tmux new -A -s main`: the session then ends when it does. This is the ssh transport; an ssh_config host's `RemoteCommand` and a mosh host's command keep their own behaviour
- **Settings overlay** (`Ctrl+H`): pick the theme, let your terminal show through SSHub's own surfaces or through the remote grid (two separate switches, both off by default), toggle OS logos, quit confirmation, the startup animation, session logging, ping probes and the start-up update check, open the profile manager, and control session command history (persist it, the per-host limit of 1 to 2000 commands, and clearing it per session, per host or globally)
- **Headless CLI** — the whole inventory without the TUI: hosts, groups, identities, tunnels, one-shot SFTP, the audit log and themes, plus `sshub exec <host> -- <command>` to run a single command on a saved host and get its output and exit code back (with the stored identity, credential and ProxyJump applied). `--format json` on any listing, stable exit codes, completions for bash/zsh/fish — see [Headless CLI](#headless-cli)
- **Hybrid sources** — hosts from `~/.ssh/config` (read-only) and launcher-managed (full CRUD) merge without duplicates
Expand Down
2 changes: 1 addition & 1 deletion openwiki/security/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ credential key and the error, never the value.

| Path | Option | Behaviour |
|---|---|---|
| Embedded ssh sessions | `StrictHostKeyChecking=ask`, `NumberOfPasswordPrompts=3`, `LogLevel=DEBUG1` (`prepare_session_connect_argv`) | Unknown and changed keys become explicit trust questions in the auth modal; the connect screen shows the server key fingerprint from the debug output |
| Embedded ssh sessions | `StrictHostKeyChecking=ask`, `NumberOfPasswordPrompts=3`, `LogLevel=DEBUG1`, `-t` (`prepare_session_connect_argv`) | Unknown and changed keys become explicit trust questions in the auth modal; the connect screen shows the server key fingerprint from the debug output |
| `sshub host connect`, `sshub exec`, tunnels, key push (each only with a stored secret) | `StrictHostKeyChecking=accept-new` | TOFU so a staged secret is not stuck behind a prompt; `sshub exec` and tunnels without a secret run `BatchMode=yes` |
| Broadcast | `StrictHostKeyChecking=accept-new` (runner default), `ConnectTimeout`, `BatchMode=no` with a secret or hop refs, else `yes` | Never prompts |
| OS detection | `accept-new`, `ConnectTimeout=8`, `BatchMode=no` with a secret, else `yes` | Never prompts |
Expand Down
2 changes: 1 addition & 1 deletion openwiki/workflows/known-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Embedded ssh sessions run with `-o StrictHostKeyChecking=ask` and `LogLevel=DEBU
- **Unknown key**: ssh asks through the askpass channel; the [auth modal](session-tools.md#interactive-authentication-modal) shows the prompt. Only `y` accepts (answers `yes`); `n` or Esc refuses, and Enter does nothing.
- **Changed key**: ssh refuses; `Session::host_key_changed` detects it and the modal shows a warning plus the new fingerprint. Capital `A` removes the stale entry through the same guarded `remove_host` and reconnects; `n`/Esc leaves it alone. The target comes from ssh's own log (`Session::changed_key_target`: the `Host key for <spec> has changed` name and the absolute `Offending … key in <file>:<n>` path) and is used only when the name is one this session connects to (its address, a host argument or `HostKeyAlias`), is a single plain name (no commas or wildcards) and every log line agrees; otherwise no repair is offered.

Other paths keep `accept-new` semantics: `sshub host connect` / `sshub exec` (`prepare_cli_connect_argv`), embedded mosh and tunnels add `StrictHostKeyChecking=accept-new` only when a stored secret is staged, [broadcast](broadcast.md) always adds it, and the [SFTP](sessions-sftp.md#sftp-srcsftp) libssh2 transport appends unknown keys itself and treats a changed key as a hard error.
Other paths keep `accept-new` semantics: `sshub host connect` / `sshub exec` (`prepare_cli_session_argv` / `prepare_cli_connect_argv`), embedded mosh and tunnels add `StrictHostKeyChecking=accept-new` only when a stored secret is staged, [broadcast](broadcast.md) always adds it, and the [SFTP](sessions-sftp.md#sftp-srcsftp) libssh2 transport appends unknown keys itself and treats a changed key as a hard error.

```mermaid
sequenceDiagram
Expand Down
4 changes: 2 additions & 2 deletions openwiki/workflows/sessions-sftp.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ Workspaces (`src/app/workspaces.rs`, table `workspaces`, schema v20) store the l
Key pieces:

- **`pty.rs`**: `PtyRuntime::spawn(argv, rows, cols, env, siphon_stderr)` over `portable-pty`; reader threads send `PtyEvent::{Bytes, Stderr, SshLog, Exited}` to the [event loop](../architecture/overview.md). For ssh the child's stderr is siphoned through a side FIFO (`StderrFifo`: `mkfifo` 0600 inside a private 0700 random-named temp dir, a failure to create it fails the spawn loudly; opened `O_RDWR | O_NONBLOCK`), and for the `ssh` client ssh's own log (`-E`, with `LogLevel=DEBUG1`) goes to a second FIFO as `PtyEvent::SshLog`, via an `sh -c 'p=$1; shift; exec "$p" -E "$SSHUB_LOG_FIFO" "$@" 2>"$SSHUB_STDERR_FIFO"'` wrapper. Only that log channel is parsed for `Authenticated to …`, host-key and changed-key evidence (`Session::push_ssh_log`), because a server's login banner arrives on stderr and could otherwise pose as ssh output. `TERM=xterm-256color` and `COLORTERM=truecolor` are forced. Writes (keys, pastes, query answers) go through one writer thread in order; paste drops ESC and other control bytes. Drop sends SIGHUP, then SIGTERM, then kills the process group.
- **Connect argv** (`prepare_session_connect_argv`, `src/app/util.rs`): embedded ssh runs with `-o StrictHostKeyChecking=ask -o NumberOfPasswordPrompts=3 -o LogLevel=DEBUG1`, so unknown or changed host keys and password prompts surface in the [auth modal](session-tools.md#interactive-authentication-modal) instead of being auto-accepted.
- **Connect argv** (`prepare_session_connect_argv`, `src/app/util.rs`): embedded ssh runs with `-t -o StrictHostKeyChecking=ask -o NumberOfPasswordPrompts=3 -o LogLevel=DEBUG1`, so unknown or changed host keys and password prompts surface in the [auth modal](session-tools.md#interactive-authentication-modal) instead of being auto-accepted. `-t` gives a startup command (`remote_command`, or ssh_config `RemoteCommand`) a remote terminal, which ssh requests on its own only for a login shell. A stored startup command (the trailing `-- <cmd>` from `build_ssh_argv`) gets `\nexec "$SHELL" -l` appended (`keep_login_shell`), so it runs at login and the session goes on in the login shell; a command starting with `exec` replaces that shell and ends the session with it. An ssh_config `RemoteCommand` is left to ssh. `sshub connect` gets the same through `prepare_cli_session_argv`; `sshub exec` drops the startup command instead.
- **Credentials** (`askpass.rs`, `askpass_channel.rs`, `interactive_auth.rs`, `hop_auth.rs`): every interactive ssh session (argv[0] `ssh`, not a key push) gets a private Unix-socket askpass channel: `SSH_ASKPASS=<self exe>` plus `SSHUB_ASKPASS_SOCKET` / `SSHUB_ASKPASS_TOKEN` / `SSHUB_ASKPASS_CHANNEL`. `main.rs` calls `maybe_run_askpass()` first, so the re-executed binary forwards the prompt to the TUI. The destination's stored secret answers only a prompt of its own class (never a 2FA code or a host-key question) from the destination ssh (identified by pid, so a ProxyJump hop never receives it); anything else opens the modal. A ProxyJump hop that asks for a password or passphrase is answered once with that hop host's own stored secret, fetched when it asks (`HopCred`, `hop_auth::find_for`): the prompt must read `user@host's password:` / `Enter passphrase for key '<path>':` for a saved hop AND the asking ssh's last argument must be that hop, so one hop's secret never reaches another hop or a prompt text a hostile server chose. Mosh with a stored secret and key-push sessions use a 0600 staged file under `$XDG_RUNTIME_DIR` (`AskpassSecret`, `SSHUB_ASKPASS_FILE`); hop secrets are never written to it, the file holds references into the credential store that the helper resolves when a hop asks. Only when that file path cannot be staged is the stored secret typed into the PTY, at most once and only when the cursor sits right after a password or passphrase prompt (`maybe_send_pending_secret`, `ends_with_prompt`). OpenSSH older than 8.4 ignores `SSH_ASKPASS_REQUIRE` and gets the prompt in the terminal. Details: [private channel](../security/secrets.md#private-channel-srcsessionaskpass_channelrs) and [secrets](../security/secrets.md).
- **`mod.rs`**: `Session` lifecycle `SessionPhase::{Connecting, Running, Exited}`; the connect animation stays up until a stored secret is delivered or `REVEAL_TIMEOUT` (6 s) passes. `FAILURE_EXPLANATIONS` turns common ssh failures into plain-language reasons on the connect screen. The first `debug1: Server host key:` fingerprint is cached and shown there.
- **`mod.rs`**: `Session` lifecycle `SessionPhase::{Connecting, Running, Exited}`; the connect animation stays up until a stored secret is delivered or `REVEAL_TIMEOUT` (6 s) passes. Past the timeout it gives way only once the host answered: PTY output arrived or ssh authenticated (a startup command may print nothing). `FAILURE_EXPLANATIONS` turns common ssh failures into plain-language reasons on the connect screen. The first `debug1: Server host key:` fingerprint is cached and shown there.
- **`parser.rs`**: `vt100::Parser` with 10 000 rows of scrollback behind `XtermCompat`, which rewrites the xterm sequences `vt100` 0.18 still drops (HPR, VPR, SCOSC/SCORC), caps the REP count, renders DEC Special Graphics line drawing as Unicode, and tracks synchronized output (DEC mode 2026) so the pane keeps showing the last complete frame mid-repaint. It also answers terminal status queries (bounded reply queue) so remote apps do not block on a timeout, and `draw_screen` paints the grid into a ratatui buffer.
- **`render.rs`**: header (tab strip and hints from `session_header_hints`), body, footer, copy toast, ghost text.
- **OSC 52 relay**: applications inside the PTY may request a clipboard write; only the visible session relays non-empty payloads (`App::relay_visible_session_clipboard`). Payloads are capped at 64 KiB and at most 8 writes are queued; background sessions, the dashboard, SFTP and tunnels discard requests immediately. Clipboard reads (`ESC]52;c;?BEL`) stay unanswered. Disable with `[clipboard] relay_from_pty = false`; the raw sequence can still land in enabled session logs.
Expand Down
4 changes: 3 additions & 1 deletion src/app/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ impl App {
.as_secs() as i64;
self.push_ssh_log(crate::ssh::probe::SshLogEntry {
host_name: entry.name().to_string(),
line: format!("$ {}", session_argv.join(" ")),
// A startup command carries a newline (`keep_login_shell`); the
// log is one line, and the panel would drop the break unseen.
line: format!("$ {}", session_argv.join(" ").replace('\n', "\\n")),
level: crate::ssh::probe::LogLevel::Success,
timestamp: now_ts_val,
});
Expand Down
2 changes: 1 addition & 1 deletion src/app/session_spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ impl App {

self.push_ssh_log(crate::ssh::probe::SshLogEntry {
host_name: log_host_name.to_string(),
line: format!("$ {}", argv.join(" ")),
line: format!("$ {}", argv.join(" ").replace('\n', "\\n")),
level: crate::ssh::probe::LogLevel::Success,
timestamp: now_ts(),
});
Expand Down
44 changes: 43 additions & 1 deletion src/app/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,36 @@ pub fn prepare_cli_connect_argv(
}
}

/// Interactive SSH uses explicit host trust and at most three auth attempts.
/// [`prepare_cli_connect_argv`] for an interactive `sshub connect` (and the argv
/// `sshub host resolve` reports for it): ssh also gets `-t` and the startup
/// command keeps the login shell, as in [`prepare_session_connect_argv`].
/// `sshub exec` stays on the plain variant: it drops the startup command and
/// picks `-T` or `-tt` itself.
pub fn prepare_cli_session_argv(
argv: Vec<String>,
has_stored_secret: bool,
verbose: bool,
) -> Vec<String> {
let mut argv = prepare_cli_connect_argv(argv, has_stored_secret, verbose);
if argv.first().map(String::as_str) == Some("ssh") {
argv.insert(1, "-t".into());
keep_login_shell(&mut argv);
}
argv
}

/// Interactive SSH uses explicit host trust, at most three auth attempts, and
/// a remote terminal. ssh asks for a terminal on its own only for a login
/// shell, so a startup command (`remote_command`, or `RemoteCommand` in
/// ssh_config) ran without one: `sudo su root` printed no prompt and the
/// session sat on the connect screen (#171).
pub fn prepare_session_connect_argv(mut argv: Vec<String>, has_stored_secret: bool) -> Vec<String> {
match argv.first().map(String::as_str) {
Some("ssh") => {
argv.splice(
1..1,
[
"-t",
"-o",
"StrictHostKeyChecking=ask",
"-o",
Expand All @@ -311,13 +334,32 @@ pub fn prepare_session_connect_argv(mut argv: Vec<String>, has_stored_secret: bo
.chain(crate::session::SSH_DEBUG_ARGS)
.map(String::from),
);
keep_login_shell(&mut argv);
argv
}
Some("mosh") if has_stored_secret => crate::ssh::inject_mosh_ssh_accept_new(argv),
_ => argv,
}
}

/// A startup command runs at login and the session goes on in the login
/// shell (#171): `cd /srv` leaves a shell in /srv, `sudo su` a root shell
/// that drops back to yours on exit. ssh runs a remote command instead of the
/// shell, so the shell is started after it. A newline rather than `;` ends the
/// command, so a trailing `&` or `# comment` still parses. A command that
/// starts with `exec` replaces the shell that runs the rest, and the session
/// ends with it.
///
/// Only the trailing `-- <command>` pair `build_ssh_argv` adds for a stored
/// startup command is touched; an ssh_config `RemoteCommand` stays ssh's.
fn keep_login_shell(argv: &mut [String]) {
if let [.., sep, command] = argv {
if sep == "--" {
command.push_str("\nexec \"$SHELL\" -l");
}
}
}

/// Build session argv (`ssh` or `mosh`) from the *effective* transport
/// (group inheritance resolved): every connect path must use this, not
/// [`session_argv_for_entry`], so a group-level transport default takes effect.
Expand Down
4 changes: 2 additions & 2 deletions src/cli/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::process::{Command, Stdio};
use anyhow::{bail, Context, Result};

use crate::app::{
optional_field, parse_tags, prepare_cli_connect_argv, resolve_pending_secret,
optional_field, parse_tags, prepare_cli_session_argv, resolve_pending_secret,
resolved_session_argv, session_argv_for_entry, SortMode,
};
use crate::cli::context::CliContext;
Expand Down Expand Up @@ -162,7 +162,7 @@ fn cmd_connect(ctx: &mut CliContext, args: &[String]) -> Result<i32> {
(Some(m), Some(r)) => resolved_session_argv(m, r),
_ => session_argv_for_entry(&entry),
};
let mut argv = prepare_cli_connect_argv(base_argv, pending_secret.is_some(), verbose);
let mut argv = prepare_cli_session_argv(base_argv, pending_secret.is_some(), verbose);

if let Some(cmd) = argv.first() {
// Best-effort pre-flight. If `which` itself cannot run (missing from
Expand Down
10 changes: 7 additions & 3 deletions src/cli/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;

use crate::app::{
prepare_cli_connect_argv, resolve_pending_secret, resolved_session_argv, resolved_to_ssh_host,
prepare_cli_session_argv, resolve_pending_secret, resolved_session_argv, resolved_to_ssh_host,
session_argv_for_entry, HostEntry,
};
use crate::credentials::PasswordStore;
Expand Down Expand Up @@ -163,7 +163,7 @@ pub fn host_resolve_json(
(Some(m), Some(r)) => resolved_session_argv(m, r),
_ => session_argv_for_entry(entry),
};
let argv = prepare_cli_connect_argv(base_argv, pending_secret.is_some(), verbose);
let argv = prepare_cli_session_argv(base_argv, pending_secret.is_some(), verbose);
HostResolveJson {
host: host_record_json(entry, store),
argv,
Expand Down Expand Up @@ -276,7 +276,11 @@ pub fn format_host_list_plain(names: &[&str]) -> String {
pub fn format_resolve_plain(resolve: &HostResolveJson) -> String {
let mut out = format_host_plain(&resolve.host);
out.push_str("\n\nCommand:\n");
out.push_str(&format!(" {}", resolve.argv.join(" ")));
// One line, like the ssh log: a startup command carries a newline.
out.push_str(&format!(
" {}",
resolve.argv.join(" ").replace('\n', "\\n")
));
out.push_str("\n\nHas stored secret: ");
out.push_str(if resolve.has_stored_secret {
"yes"
Expand Down
Loading
Loading