diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e66862be6..d959ce7f1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,5 +17,16 @@ permissions: security-events: write jobs: + terminal-backends: + name: Terminal backend tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Test shell and session dispatch + run: bash ssh/tests/test-terminal-backends.sh + workflows: uses: hassio-addons/workflows/.github/workflows/app-ci.yaml@383c10d83acbe341acbb35a4a61bfd14827f00f0 # v3.0.0 diff --git a/README.md b/README.md index 33561a965..87e45906d 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,13 @@ well. Additionally, it comes out of the box with the following: your favorite tools, which will be available every single time you log in. - Execute custom commands on app start so that you can customize the shell to your likings. -- [ZSH][zsh] as its default shell. Easier to use for the beginner, more advanced - for the more experienced user. It even comes preloaded with - ["Oh My ZSH"][ohmyzsh], with some plugins enabled as well. +- Selectable interactive shells: Zsh with Oh My Zsh remains the compatible + default, while Fish and Bash are available through the `shell` option. +- Selectable terminal session backends: tmux remains the compatible default, + while Zellij is available through the `session_backend` option. - Contains a sensible set of tools right out of the box: curl, Wget, RSync, GIT, Nmap, Mosquitto client, MariaDB/MySQL client, Awake ("wake on LAN"), Nano, - Neovim, tmux, and a bunch commonly used networking tools. + Neovim, tmux, Zellij, and a bunch commonly used networking tools. ## Support diff --git a/ssh/DOCS.md b/ssh/DOCS.md index d0cc5b6dd..6ca42141a 100644 --- a/ssh/DOCS.md +++ b/ssh/DOCS.md @@ -44,12 +44,13 @@ well. Additionally, it comes out of the box with the following: your favorite tools, which will be available every single time you log in. - Execute custom commands on app start so that you can customize the shell to your likings. -- [ZSH][zsh] as its default shell. Easier to use for the beginner, more advanced - for the more experienced user. It even comes preloaded with - ["Oh My ZSH"][ohmyzsh], with some plugins enabled as well. +- Selectable interactive shells: Zsh with Oh My Zsh remains the compatible + default, while Fish and Bash are available through the `shell` option. +- Selectable terminal session backends: tmux remains the compatible default, + while Zellij is available through the `session_backend` option. - Contains a sensible set of tools right out of the box: curl, Wget, RSync, GIT, Nmap, Mosquitto client, MariaDB/MySQL client, Awake ("wake on LAN"), Nano, - Neovim, tmux, and a bunch commonly used networking tools. + Neovim, tmux, Zellij, and a bunch commonly used networking tools. ## Installation @@ -85,7 +86,8 @@ ssh: allow_agent_forwarding: false allow_remote_port_forwarding: false allow_tcp_forwarding: false -zsh: true +shell: fish +session_backend: zellij share_sessions: true packages: - build-base @@ -197,19 +199,39 @@ Nevertheless, this warning is debatable._ The following options are shared between both the SSH and the Web Terminal. +#### Option: `shell` + +Selects the interactive shell used by SSH and the Web Terminal. Supported values +are `fish`, `zsh`, and `bash`. If this option is omitted, the legacy `zsh` +option remains authoritative so existing installations keep their current shell. + +The root account itself deliberately keeps Bash as its account shell. The +selected interactive shell is started only after login, which keeps remote SSH +commands and tools such as rsync on a POSIX-compatible command shell. + +#### Option: `session_backend` + +Selects the terminal multiplexer used by the Web Terminal and, when session +sharing is enabled, SSH. Supported values are `zellij` and `tmux`. If this +option is omitted, tmux remains the default for compatibility with existing +installations. + +Zellij uses mirrored sessions and its simplified UI in this app so simultaneous +SSH and Web Terminal clients see the same workspace without requiring special +terminal fonts. + #### Option: `zsh` -The app has ZSH pre-installed and configured as the default shell. -However, ZSH might not be your preferred choice. By setting this option to -`false`, you will disable ZSH and the app will fallback to Bash instead. +This is the legacy shell selector. It remains supported for upgrades: `true` +selects Zsh and `false` selects Bash when `shell` is absent. New +configurations should use `shell` instead. #### Option: `share_sessions` -By default, the terminal session between the web client and SSH is shared. -This allows you to pick up where you left your terminal from either of those. - -This option allows you to disable this behavior by setting it to `false`, which -effectively sets SSH to behave as it used to be. +When enabled, interactive SSH clients attach to the same multiplexer session as +the Web Terminal. When disabled, SSH starts the selected shell without attaching +to the Web Terminal session. Non-interactive SSH commands never enter a +multiplexer. #### Option: `packages` @@ -258,33 +280,25 @@ client uses the clipboard behavior of its own terminal instead. ## Known issues and limitations - When SFTP is enabled, the username MUST be set to `root`. -- If you want to use rsync for file transfer, the username MUST be set to - `root`. ## Running the `ha` command or Supervisor API non-interactively -When you log in interactively, the app starts a login shell that sets up the -`SUPERVISOR_TOKEN` environment variable. The `ha` command and the Supervisor -API need that token, so commands like `ha core info` just work. - -Running a command non-interactively does **not** start a login shell, so the -token is not set and the command fails with a `401` error. For example, this -fails: +Non-interactive SSH commands always run under Bash and never enter the selected +multiplexer. With the default non-root SSH username, the login wrapper executes +the command through root's Bash login environment. When logging in directly as +root, OpenSSH invokes root's Bash account shell and imports the +`SUPERVISOR_TOKEN` from the permitted SSH environment: ```shell ssh your-instance "ha core info" ``` -Wrap the command in a login shell so the environment, and with it the token, -is loaded: - -```shell -ssh your-instance 'bash -lc "ha core info"' -``` +The command's output and exit status are returned directly to the SSH client. +Interactive SSH and Web Terminal logins still use the configured `shell` and, +when enabled, the configured shared-session backend. -The same applies when calling the Supervisor API directly or running commands -from automations: invoke them through a login shell (`bash -lc '...'`) so the -`SUPERVISOR_TOKEN` is available. +Mosh bootstraps through non-interactive SSH command mode. It therefore starts +Bash and does not attach to the configured shared-session backend. ## Changelog & Releases diff --git a/ssh/Dockerfile b/ssh/Dockerfile index b7acbd3d6..8b6059231 100644 --- a/ssh/Dockerfile +++ b/ssh/Dockerfile @@ -42,6 +42,7 @@ RUN \ colordiff=1.0.22-r0 \ docker-bash-completion=29.5.3-r0 \ docker-zsh-completion=29.5.3-r0 \ + fish=4.6.0-r1 \ gcompat=1.1.0-r4 \ git=2.54.0-r0 \ htop=3.5.1-r1 \ @@ -80,6 +81,7 @@ RUN \ tmux=3.6b-r0 \ ttyd=1.7.7-r0 \ wget=1.25.0-r3 \ + zellij=0.42.2-r0 \ zip=3.0-r13 \ zsh-autosuggestions=0.7.1-r0 \ zsh-syntax-highlighting=0.8.0-r1 \ @@ -93,8 +95,8 @@ RUN \ \ && chmod a+x /usr/bin/ha \ && ha completion bash > /usr/share/bash-completion/completions/ha \ - \ - && sed -i -e "s#bin/sh#bin/zsh#" /etc/passwd \ + && mkdir -p /usr/share/fish/vendor_completions.d \ + && ha completion fish > /usr/share/fish/vendor_completions.d/ha.fish \ \ && cp /usr/bin/docker /usr/local/bin/.undocked \ \ diff --git a/ssh/config.yaml b/ssh/config.yaml index 13668960a..a1fb3f786 100644 --- a/ssh/config.yaml +++ b/ssh/config.yaml @@ -79,6 +79,8 @@ schema: allow_agent_forwarding: bool allow_remote_port_forwarding: bool allow_tcp_forwarding: bool + shell: list(fish|zsh|bash)? + session_backend: list(zellij|tmux)? zsh: bool share_sessions: bool packages: diff --git a/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-ssh/run b/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-ssh/run index b6fa82fc6..c78575243 100755 --- a/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-ssh/run +++ b/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-ssh/run @@ -8,6 +8,7 @@ readonly SSH_AUTHORIZED_KEYS_PATH=/etc/ssh/authorized_keys readonly SSH_CONFIG_PATH=/etc/ssh/sshd_config readonly SSH_HOST_ED25519_KEY=/data/ssh_host_ed25519_key readonly SSH_HOST_RSA_KEY=/data/ssh_host_rsa_key +readonly SSH_LOGIN_SHELL=/usr/local/bin/ssh-login declare password declare port declare username @@ -98,17 +99,14 @@ username=$(bashio::string.lower "${username}") # Create user account if the user isn't root if [[ "${username}" != "root" ]]; then - # Create an user account - adduser -D "${username}" -s "/bin/zsh" \ + # The wrapper elevates both interactive sessions and remote commands while + # keeping non-interactive commands on root's POSIX-compatible Bash shell. + adduser -D "${username}" -s "${SSH_LOGIN_SHELL}" \ || bashio::exit.nok 'Failed creating the user account' # Add new user to the wheel group adduser "${username}" wheel \ || bashio::exit.nok 'Failed adding user to wheel group' - - # Ensure new user switches to root after login - echo 'exec sudo -i' > "/home/${username}/.zprofile" \ - || bashio::exit.nok 'Failed configuring user profile' fi # We need to set a password for the user account diff --git a/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-user/run b/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-user/run index 113b8e868..154680594 100755 --- a/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-user/run +++ b/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-user/run @@ -7,13 +7,37 @@ readonly -a DIRECTORIES=(addon_configs addons backup homeassistant media share ssl) readonly BASH_HISTORY_FILE=/root/.bash_history readonly BASH_HISTORY_PERSISTENT_FILE=/data/.bash_history +readonly FISH_HISTORY_FILE=/root/.local/share/fish/fish_history +readonly FISH_HISTORY_PERSISTENT_FILE=/data/fish_history readonly GIT_CONFIG=/data/.gitconfig readonly HOME_ASSISTANT_PROFILE_D_FILE=/etc/profile.d/homeassistant.sh readonly SSH_USER_PATH=/data/.ssh +readonly TERMINAL_CONFIG_FILE=/etc/terminal-session.conf +readonly TERMINAL_SHELL_COMMAND=/usr/local/bin/terminal-shell readonly VSCODE_SERVER_PATH=/root/.vscode-server readonly VSCODE_SERVER_PERSISTENT_PATH=/data/.vscode-server readonly ZSH_HISTORY_FILE=/root/.zsh_history readonly ZSH_HISTORY_PERSISTENT_FILE=/data/.zsh_history +declare login_shell +declare session_backend +declare share_sessions=false +declare shell_path + +# shellcheck disable=SC1091 +source /usr/local/lib/terminal-config.sh + +write_terminal_config() { + cat > "${TERMINAL_CONFIG_FILE}" <> "${HOME_ASSISTANT_PROFILE_D_FILE}" \ @@ -88,17 +140,14 @@ if ! bashio::fs.directory_exists "${VSCODE_SERVER_PERSISTENT_PATH}"; then fi ln -s "${VSCODE_SERVER_PERSISTENT_PATH}" "${VSCODE_SERVER_PATH}" -# Disable SSH & Web Terminal session sharing if configured -if ! bashio::config.true 'share_sessions'; then +if [[ "${share_sessions}" == 'false' ]]; then bashio::log.notice 'Session sharing has been disabled!' - rm /root/.bash_profile - rm /root/.zprofile fi # Install user configured/requested packages # # Failures here are intentionally non-fatal: if the package indexes or a -# package cannot be fetched (e.g. broken DNS or no network), we still want +# package cannot be fetched (e.g., broken DNS or no network), we still want # the terminal to come up so the host remains reachable for debugging. if bashio::config.has_value 'packages'; then if apk update; then @@ -119,11 +168,11 @@ if bashio::config.has_value 'init_commands'; then # Use bashio::config to properly iterate over the array, preserving multi-line commands length=$(bashio::config 'init_commands | length') \ || bashio::exit.nok 'Failed to get init_commands array length' - + for (( i=0; i&2 + exit 1 + ;; +esac diff --git a/ssh/rootfs/usr/local/bin/terminal-shell b/ssh/rootfs/usr/local/bin/terminal-shell new file mode 100755 index 000000000..542652a9a --- /dev/null +++ b/ssh/rootfs/usr/local/bin/terminal-shell @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +config_path="${TERMINAL_CONFIG_PATH:-/etc/terminal-session.conf}" +# shellcheck disable=SC1090 +. "${config_path}" + +export APP_SELECTED_SHELL=1 +export SHELL="${TERMINAL_SHELL_PATH}" + +exec "${TERMINAL_SHELL_PATH}" -l diff --git a/ssh/rootfs/usr/local/lib/terminal-config.sh b/ssh/rootfs/usr/local/lib/terminal-config.sh new file mode 100755 index 000000000..096795cd5 --- /dev/null +++ b/ssh/rootfs/usr/local/lib/terminal-config.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# shellcheck shell=bash + +terminal::shell_name() { + if bashio::config.has_value 'shell'; then + bashio::config 'shell' + elif bashio::config.true 'zsh'; then + echo 'zsh' + else + echo 'bash' + fi +} + +terminal::shell_path() { + case "$1" in + fish) + echo '/usr/bin/fish' + ;; + zsh) + echo '/bin/zsh' + ;; + bash) + echo '/bin/bash' + ;; + *) + bashio::exit.nok "Unsupported shell: $1" + ;; + esac +} + +terminal::session_backend() { + local backend + + if bashio::config.has_value 'session_backend'; then + backend=$(bashio::config 'session_backend') + else + backend='tmux' + fi + + case "${backend}" in + zellij|tmux) + echo "${backend}" + ;; + *) + bashio::exit.nok "Unsupported session backend: ${backend}" + ;; + esac +} + +terminal::set_root_account_shell() { + local passwd_file + + if (( $# == 0 )); then + bashio::exit.nok 'No passwd files supplied' + return 1 + fi + + for passwd_file in "$@"; do + sed -i -r -e \ + 's|^(root:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:).*|\1/bin/bash|' \ + "${passwd_file}" \ + || bashio::exit.nok 'Failed setting the root account shell' + done +} diff --git a/ssh/tests/test-terminal-backends.sh b/ssh/tests/test-terminal-backends.sh new file mode 100755 index 000000000..7b85d8069 --- /dev/null +++ b/ssh/tests/test-terminal-backends.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +terminal_lib="${repo_root}/ssh/rootfs/usr/local/lib/terminal-config.sh" +terminal_shell="${repo_root}/ssh/rootfs/usr/local/bin/terminal-shell" +terminal_session="${repo_root}/ssh/rootfs/usr/local/bin/terminal-session" +ssh_login="${repo_root}/ssh/rootfs/usr/local/bin/ssh-login" +bash_profile="${repo_root}/ssh/rootfs/root/.bash_profile" +declare -A config=() + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_eq() { + local expected=$1 + local actual=$2 + + [[ "${actual}" == "${expected}" ]] \ + || fail "expected '${expected}', got '${actual}'" +} + +bashio::config.has_value() { + [[ -n "${config[$1]+set}" && -n "${config[$1]}" ]] +} + +bashio::config.true() { + [[ "${config[$1]-}" == 'true' ]] +} + +bashio::config() { + printf '%s\n' "${config[$1]}" +} + +bashio::exit.nok() { + echo "$*" >&2 + return 1 +} + +# shellcheck disable=SC1090 +source "${terminal_lib}" + +# Existing installations retain their zsh/tmux or bash/tmux behaviour. +config=([zsh]=true) +assert_eq zsh "$(terminal::shell_name)" +assert_eq tmux "$(terminal::session_backend)" + +config=([zsh]=false) +assert_eq bash "$(terminal::shell_name)" +assert_eq tmux "$(terminal::session_backend)" + +# New selectors override the legacy zsh switch only when explicitly configured. +config=([zsh]=true [shell]=fish [session_backend]=zellij) +assert_eq fish "$(terminal::shell_name)" +assert_eq /usr/bin/fish "$(terminal::shell_path fish)" +assert_eq zellij "$(terminal::session_backend)" + +config=([zsh]=false [shell]=zsh [session_backend]=tmux) +assert_eq zsh "$(terminal::shell_name)" +assert_eq /bin/zsh "$(terminal::shell_path zsh)" +assert_eq tmux "$(terminal::session_backend)" + +if terminal::shell_path invalid; then + fail 'invalid shell was accepted' +fi +config=([session_backend]=invalid) +if terminal::session_backend; then + fail 'invalid session backend was accepted' +fi + +tmp_dir=$(mktemp -d) +trap 'rm -r -- "${tmp_dir}"' EXIT +mkdir -p "${tmp_dir}/bin" + +cat > "${tmp_dir}/bin/fake-shell" <<'EOF' +#!/bin/sh +printf '%s|%s|%s\n' "$1" "$SHELL" "$APP_SELECTED_SHELL" +EOF + +cat > "${tmp_dir}/bin/zellij" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" +EOF + +cat > "${tmp_dir}/bin/tmux" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" +EOF + +cat > "${tmp_dir}/bin/sudo" <<'EOF' +#!/bin/sh +set -eu + +case "${1:-}" in + -H) + shift + exec "$@" + ;; + -i) + printf '%s\n' "$*" + ;; + *) + echo "unexpected sudo arguments: $*" >&2 + exit 64 + ;; +esac +EOF + +chmod +x "${tmp_dir}/bin/"* + +cat > "${tmp_dir}/terminal.conf" < "${tmp_dir}/passwd" <<'EOF' +root:x:0:0:root:/root:/bin/ash +daemon:x:2:2:daemon:/sbin:/sbin/nologin +EOF +terminal::set_root_account_shell "${tmp_dir}/passwd" +assert_eq 'root:x:0:0:root:/root:/bin/bash' \ + "$(sed -n '1p' "${tmp_dir}/passwd")" +assert_eq 'daemon:x:2:2:daemon:/sbin:/sbin/nologin' \ + "$(sed -n '2p' "${tmp_dir}/passwd")" + +# A non-interactive login shell must not be redirected into a multiplexer. +output=$(bash --noprofile --norc -c "source '${bash_profile}'; printf unaffected") +assert_eq unaffected "${output}" + +bash -n \ + "${terminal_lib}" \ + "${repo_root}/ssh/rootfs/etc/s6-overlay/s6-rc.d/init-user/run" \ + "${repo_root}/ssh/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run" \ + "${bash_profile}" +sh -n "${terminal_shell}" "${terminal_session}" "${ssh_login}" + +echo 'All terminal backend tests passed.'