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..68165eadc 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 keeps the ttyd/xterm.js web + terminal, while Zellij uses its built-in browser client. - 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..cc879f515 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 + and uses ttyd/xterm.js, while Zellij uses its native browser client. - 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,46 @@ 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. + +With `zellij`, the Home Assistant panel is served by Zellij's built-in web +server; ttyd and xterm.js are not started. A small ingress adapter restricts the +server to Home Assistant Supervisor traffic and forwards its HTTP and WebSocket +connections. With `tmux`, the existing ttyd/xterm.js Web Terminal remains in +use for Fish, Zsh, and Bash. + +Zellij's token authentication stays enabled. On first startup the app creates a +token named `home-assistant`, prints it once in the app log, and stores the +token in `/data/zellij/web-token` with root-only permissions. Paste it into +the Zellij login screen and select the remember option if desired. + #### 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` @@ -243,8 +272,8 @@ single time this app starts. ## Clipboard: copying and pasting -The Web Terminal is based on xterm.js, which follows X11-style clipboard -conventions that may differ from what you expect: +With the `tmux` backend, the Web Terminal uses xterm.js and follows X11-style +clipboard conventions that may differ from what you expect: - **Copy**: hold `Shift` and select the text with your mouse. The selection is copied to your system clipboard right away (a small scissors icon briefly @@ -252,39 +281,32 @@ conventions that may differ from what you expect: - **Paste**: press `Ctrl+Shift+V`, or right-click and choose paste, depending on your browser. -This applies to the Web Terminal in the Home Assistant frontend. A regular SSH -client uses the clipboard behavior of its own terminal instead. +The `zellij` backend instead uses Zellij's native browser client and its own +keyboard and mouse handling. A regular SSH client always uses the clipboard +behavior of its own terminal. ## 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..92f2a7e34 100644 --- a/ssh/Dockerfile +++ b/ssh/Dockerfile @@ -16,6 +16,9 @@ COPY requirements.txt /tmp/ # Setup base ARG BUILD_ARCH=amd64 ARG HA_CLI_VERSION="5.2.0" +ARG ZELLIJ_VERSION="0.44.3" +ARG ZELLIJ_SHA256_AMD64="0f7c346788627f506c0a28296517768633cff24fc822a739f8264b640ecad751" +ARG ZELLIJ_SHA256_AARCH64="15e6534d42644d66973d136c590c49739dcfd6a1a2a0d3d917973f16c81b45fb" # hadolint ignore=DL3003,DL3042 RUN \ @@ -42,6 +45,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 \ @@ -64,6 +68,7 @@ RUN \ net-tools=2.10-r3 \ nmap=7.99-r0 \ nmap-ncat=7.99-r0 \ + nginx=1.30.4-r1 \ openssh=10.3_p1-r0 \ openssl=3.5.7-r0 \ procps-ng=4.0.6-r0 \ @@ -93,8 +98,29 @@ RUN \ \ && chmod a+x /usr/bin/ha \ && ha completion bash > /usr/share/bash-completion/completions/ha \ + && mkdir -p /usr/share/fish/vendor_completions.d \ + && ha completion fish > /usr/share/fish/vendor_completions.d/ha.fish \ \ - && sed -i -e "s#bin/sh#bin/zsh#" /etc/passwd \ + && case "${BUILD_ARCH}" in \ + amd64) \ + zellij_target="x86_64-unknown-linux-musl"; \ + zellij_sha256="${ZELLIJ_SHA256_AMD64}" \ + ;; \ + aarch64) \ + zellij_target="aarch64-unknown-linux-musl"; \ + zellij_sha256="${ZELLIJ_SHA256_AARCH64}" \ + ;; \ + *) \ + echo "Unsupported architecture for Zellij: ${BUILD_ARCH}" >&2; \ + exit 1 \ + ;; \ + esac \ + && curl -fsSL -o /tmp/zellij.tar.gz \ + "https://github.com/zellij-org/zellij/releases/download/v${ZELLIJ_VERSION}/zellij-${zellij_target}.tar.gz" \ + && echo "${zellij_sha256} /tmp/zellij.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/zellij.tar.gz -C /usr/local/bin zellij \ + && chmod 0755 /usr/local/bin/zellij \ + && zellij --version | grep -Fx "zellij ${ZELLIJ_VERSION}" \ \ && 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..ec9a6cc96 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,74 @@ 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 ZELLIJ_CONFIG_FILE=/etc/zellij/config.kdl +readonly ZELLIJ_STATE_DIR=/data/zellij 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}" < "${ZELLIJ_CONFIG_FILE}.tmp" <> "${HOME_ASSISTANT_PROFILE_D_FILE}" \ @@ -88,17 +178,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 +206,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..a8d70e523 --- /dev/null +++ b/ssh/rootfs/usr/local/bin/terminal-shell @@ -0,0 +1,15 @@ +#!/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}" + +if [ "$#" -gt 0 ]; then + exec "${TERMINAL_SHELL_PATH}" "$@" +fi + +exec "${TERMINAL_SHELL_PATH}" -l diff --git a/ssh/rootfs/usr/local/bin/zellij-web b/ssh/rootfs/usr/local/bin/zellij-web new file mode 100755 index 000000000..9de7d5d39 --- /dev/null +++ b/ssh/rootfs/usr/local/bin/zellij-web @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +config_path="${TERMINAL_CONFIG_PATH:-/etc/terminal-session.conf}" +# shellcheck disable=SC1090 +source "${config_path}" + +: "${INGRESS_ENTRY:?INGRESS_ENTRY is required}" +: "${INGRESS_IP:?INGRESS_IP is required}" +: "${INGRESS_PORT:?INGRESS_PORT is required}" +: "${SUPERVISOR_IP:?SUPERVISOR_IP is required}" + +ingress_entry="${INGRESS_ENTRY%/}" +zellij_config_file="${ZELLIJ_CONFIG_FILE_OVERRIDE:-${ZELLIJ_CONFIG_FILE:-/etc/zellij/config.kdl}}" +zellij_state_dir="${ZELLIJ_STATE_DIR:-/data/zellij}" +zellij_token_file="${ZELLIJ_TOKEN_FILE:-${zellij_state_dir}/web-token}" +nginx_config_file="${NGINX_CONFIG_FILE:-/etc/nginx/zellij.conf}" +zellij_web_port="${ZELLIJ_WEB_PORT:-8082}" +web_pid= +nginx_pid= + +die() { + echo "zellij-web: $*" >&2 + exit 1 +} + +valid_ipv4() { + local address=$1 + local octet + local -a octets + + [[ "${address}" =~ ^[0-9]+(\.[0-9]+){3}$ ]] || return 1 + IFS=. read -r -a octets <<< "${address}" + for octet in "${octets[@]}"; do + (( 10#${octet} <= 255 )) || return 1 + done +} + +valid_ipv4 "${INGRESS_IP}" || die "invalid ingress IP: ${INGRESS_IP}" +valid_ipv4 "${SUPERVISOR_IP}" || die "invalid Supervisor IP: ${SUPERVISOR_IP}" +[[ "${INGRESS_PORT}" =~ ^[0-9]+$ ]] \ + && (( INGRESS_PORT > 0 && INGRESS_PORT <= 65535 )) \ + || die "invalid ingress port: ${INGRESS_PORT}" +[[ "${zellij_web_port}" =~ ^[0-9]+$ ]] \ + && (( zellij_web_port > 0 && zellij_web_port <= 65535 )) \ + || die "invalid Zellij web port: ${zellij_web_port}" +[[ "${ingress_entry}" =~ ^/[A-Za-z0-9_/-]+$ ]] \ + && [[ "${ingress_entry}" != '/' ]] \ + || die "invalid ingress entry: ${INGRESS_ENTRY}" + +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${zellij_state_dir}/cache}" +export XDG_DATA_HOME="${XDG_DATA_HOME:-${zellij_state_dir}/data}" +export ZELLIJ_CONFIG_FILE="${zellij_config_file}" + +mkdir -p \ + "$(dirname "${zellij_config_file}")" \ + "$(dirname "${nginx_config_file}")" \ + "${XDG_CACHE_HOME}" \ + "${XDG_DATA_HOME}" \ + "${zellij_state_dir}" +chmod 0700 "${zellij_state_dir}" "${XDG_CACHE_HOME}" "${XDG_DATA_HOME}" + +cat > "${zellij_config_file}.tmp" < "${nginx_config_file}.tmp" </dev/null 2>&1 || true + + token_output=$(zellij --config "${zellij_config_file}" web \ + --create-token --token-name home-assistant) + token=$(awk -F': ' \ + '$1 == "home-assistant" { print $2; exit }' <<< "${token_output}") + [[ "${token}" =~ ^[0-9a-fA-F-]{36}$ ]] \ + || die 'Zellij returned an invalid authentication token' + + umask 077 + printf '%s\n' "${token}" > "${zellij_token_file}.tmp" + mv -f "${zellij_token_file}.tmp" "${zellij_token_file}" + + echo "Zellij web authentication token: ${token}" +fi +chmod 0600 "${zellij_token_file}" +echo "Zellij web token file: ${zellij_token_file}" + +cleanup() { + trap - EXIT TERM INT HUP + [[ -z "${nginx_pid}" ]] || kill "${nginx_pid}" 2>/dev/null || true + [[ -z "${web_pid}" ]] || kill "${web_pid}" 2>/dev/null || true + [[ -z "${nginx_pid}" ]] || wait "${nginx_pid}" 2>/dev/null || true + [[ -z "${web_pid}" ]] || wait "${web_pid}" 2>/dev/null || true +} +trap cleanup EXIT +trap 'exit 0' TERM INT HUP + +zellij --config "${zellij_config_file}" web \ + --ip 127.0.0.1 --port "${zellij_web_port}" & +web_pid=$! + +ready=false +for _ in {1..100}; do + if curl -fsS "http://127.0.0.1:${zellij_web_port}/info/version" \ + >/dev/null 2>&1; then + ready=true + break + fi + if ! kill -0 "${web_pid}" 2>/dev/null; then + wait "${web_pid}" || true + die 'Zellij web server exited during startup' + fi + sleep 0.1 +done +[[ "${ready}" == true ]] || die 'Timed out waiting for the Zellij web server' + +nginx -c "${nginx_config_file}" -g 'daemon off;' & +nginx_pid=$! + +set +e +wait -n "${web_pid}" "${nginx_pid}" +status=$? +set -e +exit "${status}" 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..846e058ab --- /dev/null +++ b/ssh/tests/test-terminal-backends.sh @@ -0,0 +1,212 @@ +#!/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" +zellij_web="${repo_root}/ssh/rootfs/usr/local/bin/zellij-web" +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' "$*" "$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")" + +# The native web configuration must be ingress-safe and keep ttyd out of the +# Zellij path. +INGRESS_ENTRY=/api/hassio_ingress/test-token \ +INGRESS_IP=172.30.33.10 \ +INGRESS_PORT=12345 \ +SUPERVISOR_IP=172.30.32.2 \ +TERMINAL_CONFIG_PATH="${tmp_dir}/terminal.conf" \ +ZELLIJ_CONFIG_FILE_OVERRIDE="${tmp_dir}/zellij.kdl" \ +NGINX_CONFIG_FILE="${tmp_dir}/nginx.conf" \ +"${zellij_web}" --render-only + +grep -Fq 'base_url "/api/hassio_ingress/test-token"' \ + "${tmp_dir}/zellij.kdl" || fail 'missing Zellij ingress base URL' +grep -Fq 'proxy_hide_header X-Frame-Options;' \ + "${tmp_dir}/nginx.conf" || fail 'Zellij iframe header is not removed' +grep -Fq 'proxy_cookie_path / /api/hassio_ingress/test-token/;' \ + "${tmp_dir}/nginx.conf" || fail 'Zellij cookie is not ingress-scoped' +grep -Fq 'listen 172.30.33.10:12345;' \ + "${tmp_dir}/nginx.conf" || fail 'ingress listener is not isolated' +grep -Fq 'allow 172.30.32.2;' \ + "${tmp_dir}/nginx.conf" || fail 'Supervisor allow-list is missing' + +grep -Fq 'exec /usr/local/bin/zellij-web' \ + "${repo_root}/ssh/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run" \ + || fail 'Zellij does not dispatch to its native web client' +grep -Fq 'exec ttyd' \ + "${repo_root}/ssh/rootfs/etc/s6-overlay/s6-rc.d/ttyd/run" \ + || fail 'tmux no longer dispatches to ttyd' +grep -Fq 'ARG ZELLIJ_VERSION="0.44.3"' \ + "${repo_root}/ssh/Dockerfile" \ + || fail 'web-capable Zellij version is not pinned' + +# 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" \ + "${zellij_web}" \ + "${bash_profile}" +sh -n "${terminal_shell}" "${terminal_session}" "${ssh_login}" + +echo 'All terminal backend tests passed.'