From fee04a2ff13b4527c288c3b281f671af77aa447d Mon Sep 17 00:00:00 2001 From: petruha Date: Tue, 27 Oct 2026 12:11:39 +0400 Subject: [PATCH 1/3] fix: run the startup command at login, in a terminal (#171) A host's startup command reached ssh as its remote command, which runs in place of the login shell and without a remote terminal: `cd /` ended the session at once, and `sudo su root` started a root shell that printed no prompt, so the connect screen stayed up for good. Interactive sessions (the TUI, `sshub connect` and the argv `sshub host resolve` reports) now pass -t and append `exec "$SHELL" -l` on a new line, so the command runs at login and the session goes on in the login shell. A command that starts with `exec` still ends the session with it. `sshub exec`, broadcast and the other one-shot paths drop the command as before, and an ssh_config RemoteCommand is left to ssh. --- README.md | 1 + openwiki/security/secrets.md | 2 +- openwiki/workflows/known-hosts.md | 2 +- openwiki/workflows/sessions-sftp.md | 2 +- src/app/connect.rs | 4 +- src/app/session_spawn.rs | 2 +- src/app/util.rs | 44 ++++++++++++- src/cli/host.rs | 4 +- src/cli/output.rs | 10 ++- src/session/mod.rs | 99 +++++++++++++++++++++++++++++ src/tui/screens/host_form.rs | 5 ++ 11 files changed, 164 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 81dcc2dd..173e5af5 100644 --- a/README.md +++ b/README.md @@ -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//logs//` (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 -- ` 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 diff --git a/openwiki/security/secrets.md b/openwiki/security/secrets.md index 8f1de554..933765e7 100644 --- a/openwiki/security/secrets.md +++ b/openwiki/security/secrets.md @@ -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 | diff --git a/openwiki/workflows/known-hosts.md b/openwiki/workflows/known-hosts.md index 9d73b2a8..fa1bbe0b 100644 --- a/openwiki/workflows/known-hosts.md +++ b/openwiki/workflows/known-hosts.md @@ -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 has changed` name and the absolute `Offending … key in :` 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 diff --git a/openwiki/workflows/sessions-sftp.md b/openwiki/workflows/sessions-sftp.md index cf81b219..40e24c7e 100644 --- a/openwiki/workflows/sessions-sftp.md +++ b/openwiki/workflows/sessions-sftp.md @@ -50,7 +50,7 @@ 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 `-- ` 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=` 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 '':` 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. - **`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. diff --git a/src/app/connect.rs b/src/app/connect.rs index adc5c52d..ae5f3df5 100644 --- a/src/app/connect.rs +++ b/src/app/connect.rs @@ -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, }); diff --git a/src/app/session_spawn.rs b/src/app/session_spawn.rs index 61cb29e9..32e4b7cb 100644 --- a/src/app/session_spawn.rs +++ b/src/app/session_spawn.rs @@ -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(), }); diff --git a/src/app/util.rs b/src/app/util.rs index acef604e..d3f7e9bb 100644 --- a/src/app/util.rs +++ b/src/app/util.rs @@ -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, + has_stored_secret: bool, + verbose: bool, +) -> Vec { + 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, has_stored_secret: bool) -> Vec { match argv.first().map(String::as_str) { Some("ssh") => { argv.splice( 1..1, [ + "-t", "-o", "StrictHostKeyChecking=ask", "-o", @@ -311,6 +334,7 @@ pub fn prepare_session_connect_argv(mut argv: Vec, 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), @@ -318,6 +342,24 @@ pub fn prepare_session_connect_argv(mut argv: Vec, has_stored_secret: bo } } +/// 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 `-- ` 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. diff --git a/src/cli/host.rs b/src/cli/host.rs index 0eae5d54..425791d8 100644 --- a/src/cli/host.rs +++ b/src/cli/host.rs @@ -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; @@ -162,7 +162,7 @@ fn cmd_connect(ctx: &mut CliContext, args: &[String]) -> Result { (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 diff --git a/src/cli/output.rs b/src/cli/output.rs index acc9e2cf..10829165 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -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; @@ -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, @@ -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" diff --git a/src/session/mod.rs b/src/session/mod.rs index f8b5b2a1..1bfb3fbd 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -2495,6 +2495,105 @@ mod prompt_tests { assert!(log.contains("Invalid user sshub-no-such-user"), "{log}"); } + /// A session to the tempdir sshd built the way the TUI builds one for a + /// host whose startup command is `command`: `build_ssh_argv`, then + /// `prepare_session_connect_argv`, with a client config for the test key. + fn startup_command_session(sshd: &crate::test_support::TempdirSshd, command: &str) -> Session { + let dir = sshd.dir.path(); + let known = dir.join("known_hosts"); + let host_key = std::fs::read_to_string(sshd.host_key.with_extension("pub")).unwrap(); + std::fs::write(&known, format!("[127.0.0.1]:{} {host_key}", sshd.port)).unwrap(); + let client_config = dir.join("ssh_config"); + std::fs::write( + &client_config, + format!( + "IdentitiesOnly yes\nUserKnownHostsFile {}\nBatchMode yes\n", + known.display(), + ), + ) + .unwrap(); + let mut host = crate::ssh::SshHost::new("oracle"); + host.hostname = Some("127.0.0.1".into()); + host.port = Some(sshd.port); + host.user = Some(crate::test_support::current_user()); + host.identity_file = Some(sshd.user_key.to_str().unwrap().into()); + host.remote_command = Some(command.into()); + let mut argv = crate::ssh::build_ssh_argv(&host); + argv.splice(1..1, ["-F".into(), client_config.to_str().unwrap().into()]); + let config = SessionConfig { + hops: Vec::new(), + argv: crate::app::prepare_session_connect_argv(argv, false), + display_name: "oracle".into(), + meta: SessionMeta::default(), + pending_secret: None, + key_push_identity: None, + host_name: "oracle".into(), + }; + Session::spawn(config, (24, 80), None).unwrap() + } + + #[test] + fn a_startup_command_runs_in_a_remote_terminal() { + // Oracle: real OpenSSH against a tempdir sshd (#171). ssh asks for a + // remote terminal on its own only for a login shell, so a startup + // command ran without one: `sudo su root` printed no prompt. + let Some(sshd) = tempdir_sshd() else { + return; + }; + let mut s = startup_command_session(&sshd, "tty; exec sleep 60"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let text = loop { + s.drain(); + let text = s.parser.screen().contents(); + if text.contains("/dev/") || text.contains("not a tty") { + break text; + } + assert!( + std::time::Instant::now() < deadline, + "no output from the startup command (phase={:?}): {:?}", + s.phase, + s.debug_log() + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + assert!(!text.contains("not a tty"), "{text:?}"); + } + + #[test] + fn a_startup_command_leaves_the_login_shell() { + // Oracle: real OpenSSH against a tempdir sshd (#171). A startup + // command runs at login and the session goes on: `cd /` used to be + // the whole session, which ended as soon as it returned. + let Some(sshd) = tempdir_sshd() else { + return; + }; + let mut s = startup_command_session(&sshd, "cd /"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !s.is_connected() { + s.drain(); + assert!( + std::time::Instant::now() < deadline, + "never authenticated: {:?}", + s.debug_log() + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + s.write(b"echo \"pwd=$PWD\"\r").unwrap(); + loop { + s.drain(); + let text = s.parser.screen().contents(); + if text.lines().any(|l| l.trim_end() == "pwd=/") { + break; + } + assert!( + !s.phase.is_terminal() && std::time::Instant::now() < deadline, + "no shell in / after the startup command (phase={:?}): {text:?}", + s.phase + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + #[test] fn clipboard_writes_from_the_pty_are_relayed_and_announced() { // An app inside the PTY (herdr, tmux, an editor with clipboard=osc52) diff --git a/src/tui/screens/host_form.rs b/src/tui/screens/host_form.rs index 4a948677..915f44a8 100644 --- a/src/tui/screens/host_form.rs +++ b/src/tui/screens/host_form.rs @@ -157,6 +157,10 @@ pub fn render_host_form( "Startup command", if editing { text_input::with_cursor(&form.remote_command, form.cursor) + } else if form.remote_command.trim().is_empty() { + // What the field does, said where it is set (#171). + // Short enough for one row of the form at 80 columns. + "runs at login, then your shell".to_string() } else { display_text(&form.remote_command) }, @@ -245,6 +249,7 @@ pub fn render_host_form( HostFormField::ProxyJump => form.proxy_jump.trim().is_empty(), HostFormField::Username => form.username.trim().is_empty(), HostFormField::SecretLookup => form.secret_lookup.trim().is_empty(), + HostFormField::RemoteCommand => form.remote_command.trim().is_empty(), HostFormField::ForwardAgent => form.forward_agent.is_none(), HostFormField::Transport => form.transport.is_none(), HostFormField::Identity => form.identity_index == 0, From f500cf051b3031b2b693598e02ac8345aaa453e3 Mon Sep 17 00:00:00 2001 From: petruha Date: Tue, 27 Oct 2026 12:11:39 +0400 Subject: [PATCH 2/3] fix(session): show an authenticated session that prints nothing Past the reveal timeout the connect screen gave way only to PTY output, so a startup command that prints nothing kept an authenticated session behind it for good. ssh's "Authenticated to" marker now counts as an answer too. --- CHANGELOG.md | 14 ++++++++ openwiki/workflows/sessions-sftp.md | 2 +- src/session/mod.rs | 51 +++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48fa7f54..a086c646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/openwiki/workflows/sessions-sftp.md b/openwiki/workflows/sessions-sftp.md index 40e24c7e..1b1e401c 100644 --- a/openwiki/workflows/sessions-sftp.md +++ b/openwiki/workflows/sessions-sftp.md @@ -52,7 +52,7 @@ 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 `-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 `-- ` 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=` 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 '':` 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. diff --git a/src/session/mod.rs b/src/session/mod.rs index 1bfb3fbd..b15adcfb 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -846,14 +846,17 @@ impl Session { } /// Reveal the live terminal once the connect timeout elapses — but only if - /// real PTY content has arrived (a prompt/banner/host-key question we might - /// have failed to auto-answer). With `-v` debug now siphoned off the PTY, a - /// host that never answered has a blank grid, so failing open there would - /// just hide the spinner + debug tail behind emptiness. Those sessions stay - /// on the spinner until the child exits. + /// the host is there: real PTY content arrived (a prompt/banner/host-key + /// question we might have failed to auto-answer), or ssh authenticated. A + /// startup command may print nothing at all, and an authenticated session + /// held behind the spinner looked like a connect that never finished + /// (#171). With `-v` debug siphoned off the PTY, a host that never answered + /// has a blank grid, so failing open there would just hide the spinner + + /// debug tail behind emptiness. Those sessions stay on the spinner until + /// the child exits. fn reveal_on_timeout(&mut self) { if let SessionPhase::Connecting { started_at } = self.phase { - if started_at.elapsed() >= REVEAL_TIMEOUT && self.saw_pty_bytes { + if started_at.elapsed() >= REVEAL_TIMEOUT && (self.saw_pty_bytes || self.connected) { self.diagnostics .push("auth: connect timed out — showing live terminal".into()); self.phase = SessionPhase::Running { @@ -2594,6 +2597,42 @@ mod prompt_tests { } } + #[test] + fn an_authenticated_session_that_prints_nothing_still_reveals() { + // Oracle: real OpenSSH against a tempdir sshd (#171). The connect + // screen gave way only to output, so a startup command that prints + // nothing kept an authenticated session behind the spinner for good. + let Some(sshd) = tempdir_sshd() else { + return; + }; + let mut s = startup_command_session(&sshd, "exec sleep 60"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !s.is_connected() { + s.drain(); + assert!( + std::time::Instant::now() < deadline, + "never authenticated: {:?}", + s.debug_log() + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!( + !s.saw_pty_bytes && matches!(s.phase, SessionPhase::Connecting { .. }), + "setup: authenticated and silent (phase={:?})", + s.phase + ); + // Past the reveal timeout, without waiting it out. + s.phase = SessionPhase::Connecting { + started_at: Instant::now() - REVEAL_TIMEOUT, + }; + s.drain(); + assert!( + matches!(s.phase, SessionPhase::Running { .. }), + "{:?}", + s.phase + ); + } + #[test] fn clipboard_writes_from_the_pty_are_relayed_and_announced() { // An app inside the PTY (herdr, tmux, an editor with clipboard=osc52) From 4ee5d083adc0763878317b72db95be3a5ca3da5d Mon Sep 17 00:00:00 2001 From: petruha Date: Tue, 27 Oct 2026 12:11:40 +0400 Subject: [PATCH 3/3] docs: offer the Nix flake in the bug report template --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f858b65f..a6688262 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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)