├── debian ├── source │ ├── format │ └── options ├── install ├── rules ├── control └── changelog ├── shpool ├── tests │ ├── data │ │ ├── motd_env_test_script.sh │ │ ├── echo_stop.sh │ │ ├── norc.toml │ │ ├── restore_lines.toml │ │ ├── restore_many_lines.toml │ │ ├── restore_screen.toml │ │ ├── prompt_prefix_fish.toml │ │ ├── prompt_prefix_zsh.toml │ │ ├── user_env.toml │ │ ├── tmp_default_dir.toml │ │ ├── dynamic_config.toml.tmpl │ │ ├── forward_env.toml │ │ ├── no_etc_environment.toml │ │ ├── prompt_prefix_bash.toml │ │ ├── disable_symlink_ssh_auth_sock.toml │ │ ├── motd_dump.toml.tmpl │ │ ├── motd_pager.toml.tmpl │ │ ├── long_noop_keybinding.toml │ │ ├── custom_detach_keybinding.toml │ │ ├── motd_pager_env_test.toml.tmpl │ │ ├── motd_pager_1d_debounce.toml.tmpl │ │ └── motd_pager_1s_debounce.toml.tmpl │ ├── support │ │ ├── attach.rs │ │ ├── mod.rs │ │ ├── events.rs │ │ └── line_matcher.rs │ ├── list.rs │ ├── detach.rs │ └── daemon.rs ├── src │ └── main.rs └── Cargo.toml ├── .cargo └── config.toml ├── Cargo.toml ├── rust-toolchain.toml ├── systemd ├── shpool.socket └── shpool.service ├── rustfmt.toml ├── .gitignore ├── .github ├── workflows │ ├── nightly.yml │ ├── publish.yml │ ├── build_binaries.yml │ └── presubmit.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── dependabot.yml ├── deny.toml ├── shpool-protocol ├── Cargo.toml ├── CHANGELOG └── src │ └── lib.rs ├── libshpool ├── README.md ├── src │ ├── common.rs │ ├── set_log_level.rs │ ├── consts.rs │ ├── list.rs │ ├── kill.rs │ ├── daemon │ │ ├── systemd.rs │ │ ├── exit_notify.rs │ │ ├── mod.rs │ │ ├── signals.rs │ │ ├── etc_environment.rs │ │ ├── trie.rs │ │ ├── ttl_reaper.rs │ │ ├── show_motd.rs │ │ └── prompt.rs │ ├── detach.rs │ ├── user.rs │ ├── hooks.rs │ ├── daemonize.rs │ ├── duration.rs │ ├── tty.rs │ ├── session_restore │ │ └── mod.rs │ ├── test_hooks.rs │ └── attach.rs └── Cargo.toml ├── CONTRIBUTING.md ├── release-plz.toml ├── CONFIG.md ├── HACKING.md └── LICENSE /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /shpool/tests/data/motd_env_test_script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "TERM=${TERM}" 4 | 5 | sleep 5 6 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # use MSRV aware resolver 2 | [resolver] 3 | incompatible-rust-versions = "fallback" 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | members = [ 5 | "libshpool", 6 | "shpool", 7 | "shpool-protocol", 8 | ] 9 | -------------------------------------------------------------------------------- /debian/install: -------------------------------------------------------------------------------- 1 | target/release/shpool usr/bin 2 | systemd/shpool.service usr/lib/systemd/user 3 | systemd/shpool.socket usr/lib/systemd/user 4 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.74.0" 3 | components = ["rust-docs", "rust-src", "rust-analyzer"] 4 | profile = "minimal" 5 | -------------------------------------------------------------------------------- /shpool/tests/data/echo_stop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "$1" 4 | 5 | echo "$0" 6 | 7 | while read -r line; 8 | do 9 | if [[ "$line" == "stop" ]] ; then 10 | exit 11 | fi 12 | done 13 | -------------------------------------------------------------------------------- /shpool/tests/data/norc.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix = "" 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | -------------------------------------------------------------------------------- /systemd/shpool.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Shpool Shell Session Pooler 3 | 4 | [Socket] 5 | ListenStream=%t/shpool/shpool.socket 6 | SocketMode=0600 7 | 8 | [Install] 9 | WantedBy=sockets.target 10 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Use: `cargo +nightly fmt` 2 | 3 | edition = "2021" 4 | style_edition = "2021" 5 | use_small_heuristics = "Max" 6 | newline_style = "Unix" 7 | wrap_comments = true 8 | format_generated_files = false 9 | -------------------------------------------------------------------------------- /shpool/tests/data/restore_lines.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = { lines = 2 } 5 | prompt_prefix = "" 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | debian/.debhelper 4 | debian/debhelper-build-stamp 5 | debian/files 6 | debian/shpool.substvars 7 | debian/shpool 8 | debian/shpool.postinst.debhelper 9 | debian/shpool.postrm.debhelper 10 | -------------------------------------------------------------------------------- /shpool/tests/data/restore_many_lines.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = { lines = 5000 } 5 | prompt_prefix = "" 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | -------------------------------------------------------------------------------- /shpool/tests/data/restore_screen.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "screen" 5 | prompt_prefix = "someprefix " 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | -------------------------------------------------------------------------------- /shpool/tests/data/prompt_prefix_fish.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/usr/bin/fish" 4 | session_restore_mode = "simple" 5 | prompt_prefix="session_name=$SHPOOL_SESSION_NAME " 6 | 7 | [env] 8 | TERM = "" 9 | 10 | -------------------------------------------------------------------------------- /shpool/tests/data/prompt_prefix_zsh.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/usr/bin/zsh" 4 | session_restore_mode = "simple" 5 | prompt_prefix="session_name=$SHPOOL_SESSION_NAME " 6 | 7 | [env] 8 | TERM = "" 9 | 10 | -------------------------------------------------------------------------------- /shpool/tests/data/user_env.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix = "" 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | SOME_CUSTOM_ENV_VAR = "customvalue" 10 | -------------------------------------------------------------------------------- /shpool/tests/data/tmp_default_dir.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix = "" 6 | default_dir = "/tmp" 7 | 8 | [env] 9 | PS1 = "prompt> " 10 | TERM = "" 11 | -------------------------------------------------------------------------------- /shpool/tests/data/dynamic_config.toml.tmpl: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix = "" 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | CHANGING_VAR = "REPLACE_ME" 11 | -------------------------------------------------------------------------------- /shpool/tests/data/forward_env.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix = "" 6 | 7 | forward_env = ["FOO", "BAR"] 8 | 9 | [env] 10 | PS1 = "prompt> " 11 | TERM = "" 12 | -------------------------------------------------------------------------------- /shpool/tests/data/no_etc_environment.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | noread_etc_environment = true 4 | shell = "/bin/bash" 5 | session_restore_mode = "simple" 6 | prompt_prefix = "" 7 | 8 | [env] 9 | PS1 = "prompt> " 10 | TERM = "" 11 | -------------------------------------------------------------------------------- /shpool/tests/data/prompt_prefix_bash.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix="session_name=$SHPOOL_SESSION_NAME " 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | -------------------------------------------------------------------------------- /shpool/tests/data/disable_symlink_ssh_auth_sock.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | nosymlink_ssh_auth_sock = true 5 | session_restore_mode = "simple" 6 | prompt_prefix = "" 7 | 8 | [env] 9 | PS1 = "prompt> " 10 | TERM = "" 11 | -------------------------------------------------------------------------------- /shpool/tests/data/motd_dump.toml.tmpl: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | 6 | motd = "dump" 7 | motd_args = ["motd=TMP_MOTD_MSG_FILE"] 8 | 9 | [env] 10 | PS1 = "prompt> " 11 | TERM = "xterm" 12 | -------------------------------------------------------------------------------- /debian/source/options: -------------------------------------------------------------------------------- 1 | compression = xz 2 | compression-level = fast 3 | tar-ignore = "placeholder_to_exclude_the_defaults" 4 | tar-ignore = "**/android-rust/darwin-x86" 5 | tar-ignore = "**/android-rust/linux-musl-x86" 6 | tar-ignore = "**/android-rust/windows-x86" 7 | -------------------------------------------------------------------------------- /shpool/tests/data/motd_pager.toml.tmpl: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | 6 | motd_args = ["motd=TMP_MOTD_MSG_FILE"] 7 | 8 | [motd.pager] 9 | bin = "less" 10 | 11 | [env] 12 | PS1 = "prompt> " 13 | TERM = "xterm" 14 | -------------------------------------------------------------------------------- /shpool/tests/data/long_noop_keybinding.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix = "" 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | 11 | [[keybinding]] 12 | binding = "a a a a a" 13 | action = "noop" 14 | -------------------------------------------------------------------------------- /systemd/shpool.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Shpool - Shell Session Pool 3 | Requires=shpool.socket 4 | 5 | [Service] 6 | Type=simple 7 | ExecStart=/usr/bin/shpool daemon 8 | KillMode=mixed 9 | TimeoutStopSec=2s 10 | SendSIGHUP=yes 11 | 12 | [Install] 13 | WantedBy=default.target 14 | -------------------------------------------------------------------------------- /shpool/tests/data/custom_detach_keybinding.toml: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | prompt_prefix = "" 6 | 7 | [env] 8 | PS1 = "prompt> " 9 | TERM = "" 10 | 11 | [[keybinding]] 12 | binding = "Ctrl-v Ctrl-w Ctrl-g" 13 | action = "detach" 14 | -------------------------------------------------------------------------------- /shpool/tests/data/motd_pager_env_test.toml.tmpl: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | 6 | [motd.pager] 7 | # this gets replaced by motd_env_test_script.sh 8 | bin = "MOTD_ENV_TEST_SCRIPT" 9 | 10 | [env] 11 | PS1 = "prompt> " 12 | TERM = "testval" 13 | -------------------------------------------------------------------------------- /shpool/tests/data/motd_pager_1d_debounce.toml.tmpl: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | 6 | motd_args = ["motd=TMP_MOTD_MSG_FILE"] 7 | 8 | [motd.pager] 9 | bin = "less" 10 | show_every = "1d" 11 | 12 | [env] 13 | PS1 = "prompt> " 14 | TERM = "xterm" 15 | -------------------------------------------------------------------------------- /shpool/tests/data/motd_pager_1s_debounce.toml.tmpl: -------------------------------------------------------------------------------- 1 | norc = true 2 | noecho = true 3 | shell = "/bin/bash" 4 | session_restore_mode = "simple" 5 | 6 | motd_args = ["motd=TMP_MOTD_MSG_FILE"] 7 | 8 | [motd.pager] 9 | bin = "less" 10 | show_every = "1s" 11 | 12 | [env] 13 | PS1 = "prompt> " 14 | TERM = "xterm" 15 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | on: 3 | schedule: 4 | - cron: '04 05 * * *' 5 | 6 | jobs: 7 | deny: 8 | name: cargo deny --all-features check 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 12 | - uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 13 | with: 14 | inherit-toolchain: true 15 | bins: cargo-deny 16 | - run: sudo apt-get install libpam0g-dev 17 | - run: cargo deny --all-features check 18 | 19 | postsubmit: 20 | uses: ./.github/workflows/presubmit.yml 21 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # 3 | # You can test this locally with 4 | # ``` 5 | # dpkg-buildpackage --sanitize-env -us -uc -b -d -rfakeroot 6 | # ``` 7 | # 8 | # `dh clean` will remove any dangling files afterwards 9 | 10 | export RUST_VERSION := $(shell ls ./android-rust/linux-x86 | sort -n | tail -n 1) 11 | export PATH := $(shell pwd)/android-rust/linux-x86/${RUST_VERSION}/bin:${PATH} 12 | export LD_LIBRARY_PATH := $(shell pwd)/android-rust/linux-x86/${RUST_VERSION}/lib:${LD_LIBRARY_PATH} 13 | export DH_VERBOSE = 1 14 | 15 | %: 16 | dh $@ 17 | 18 | override_dh_auto_build: 19 | CARGO_HOME=/tmp/cargo cargo build --release 20 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [graph] 2 | all-features = true 3 | 4 | [advisories] 5 | version = 2 6 | db-path = "~/.cargo/advisory-db" 7 | db-urls = ["https://github.com/rustsec/advisory-db"] 8 | yanked = "deny" 9 | 10 | # The instant crate is unmaintained, but we need to depend on 11 | # it to avoid bumping our MSRV. This should be fixed once our 12 | # MSRV is at least 1.77. 13 | [[advisories.ignore]] 14 | id = "RUSTSEC-2024-0384" 15 | 16 | [licenses] 17 | allow = [ 18 | "CC0-1.0", # unencumbered 19 | "ISC", # notice 20 | "Apache-2.0", 21 | "MIT", 22 | "Unicode-DFS-2016", 23 | "BSD-3-Clause", # notice 24 | ] 25 | confidence-threshold = 1.0 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What happened** 11 | Please explain what went wrong with your usage of shpool. 12 | 13 | **What I expected to happen** 14 | Please explain what you think should have happened instead. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 19 | **Version info** 20 | Run `shpool version` and paste the output here 21 | 22 | **Logs** 23 | Run `journalctl --output cat --user -u shpool` to pull logs related to shpool, then attach the file 24 | to this issue. You may need to trim the output with `tail`. 25 | -------------------------------------------------------------------------------- /shpool-protocol/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shpool-protocol" 3 | version = "0.3.2" 4 | edition = "2021" 5 | authors = ["Ethan Pailes "] 6 | repository = "https://github.com/shell-pool/shpool" 7 | readme = "../README.md" 8 | description = '''shpool-protocol defines the internal protocol 9 | shpool uses to talk between its client and daemon processes. 10 | You almost certainly don't need to use it directly. 11 | ''' 12 | license = "Apache-2.0" 13 | keywords = ["tmux", "tty", "terminal", "shell", "persistence"] 14 | rust-version = "1.74" 15 | 16 | [dependencies] 17 | anyhow = "1" 18 | serde = "1" 19 | serde_derive = "1" 20 | clap = { version = "4", features = ["derive"] } # cli parsing 21 | -------------------------------------------------------------------------------- /shpool-protocol/CHANGELOG: -------------------------------------------------------------------------------- 1 | 2 | shpool-protocol (0.3.2) unstable; urgency=low 3 | 4 | Added 5 | 6 | * add -d/--dir flag ([#271](https://github.com/shell-pool/shpool/pull/271)) 7 | 8 | -- Shpool Authors Tue, 18 Nov 2025 17:11:59 +0000 9 | 10 | shpool-protocol (0.3.1) unstable; urgency=low 11 | 12 | Other 13 | 14 | * fix typo in README ([#252](https://github.com/shell-pool/shpool/pull/252)) 15 | 16 | -- Shpool Authors Mon, 08 Sep 2025 19:52:50 +0000 17 | 18 | shpool-protocol (0.2.1) unstable; urgency=low 19 | 20 | Other 21 | 22 | * Better ssh config example 23 | 24 | -- Shpool Authors Mon, 16 Sep 2024 14:51:04 +0000 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | # Check for updates every Monday 6 | schedule: 7 | interval: "weekly" 8 | commit-message: 9 | prefix: "ci" 10 | groups: 11 | github-actions: 12 | patterns: ["*"] 13 | - package-ecosystem: "cargo" 14 | directory: "/" 15 | # Check for updates every Monday 16 | schedule: 17 | interval: "weekly" 18 | commit-message: 19 | prefix: "chore" 20 | ignore: 21 | # Ignore all patch updates to reduce toil for importing into internal 22 | # google monorepo. 23 | # Security updates are not affected. 24 | - dependency-name: "*" 25 | update-types: ["version-update:semver-patch"] 26 | -------------------------------------------------------------------------------- /libshpool/README.md: -------------------------------------------------------------------------------- 1 | # libshpool 2 | 3 | libshpool contains the meat of the implementation for the 4 | shpool command line tool. You almost certainly don't want to 5 | be using it directly, but with it you can create a wrapper 6 | binary. It mostly exists because we want to add monitoring 7 | to an internal google version of the tool, but don't believe 8 | that telemetry belongs in an open-source tool. Other potential 9 | use-cases such as incorporating a shpool daemon into an 10 | IDE that hosts remote terminals could be imagined though. 11 | 12 | ## Integrating 13 | 14 | In order to call libshpool, you must keep a few things in mind. 15 | In spirit, you just need to call `libshpool::run(libshpoo::Args::parse())`, 16 | but you need to take care of a few things manually. 17 | 18 | 1. Handle the `version` subcommand. Since libshpool is a library, the output 19 | will not be very good if the library handles the versioning. 20 | 2. Depend on the `motd` crate and call `motd::handle_reexec()` in your `main` 21 | function. 22 | -------------------------------------------------------------------------------- /shpool/src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | /// Shpool is a session persistence tool that works simillarly to tmux, but 15 | /// aims to provide a simpler user experience. See [the 16 | /// README](https://github.com/shell-pool/shpool) for more 17 | /// info. 18 | use clap::Parser; 19 | 20 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 21 | 22 | fn main() -> anyhow::Result<()> { 23 | let args = libshpool::Args::parse(); 24 | 25 | if args.version() { 26 | println!("shpool {VERSION}"); 27 | return Ok(()); 28 | } 29 | 30 | libshpool::run(args, None) 31 | } 32 | -------------------------------------------------------------------------------- /shpool/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shpool" 3 | version = "0.9.3" 4 | edition = "2021" 5 | authors = ["Ethan Pailes "] 6 | repository = "https://github.com/shell-pool/shpool" 7 | readme = "../README.md" 8 | description = ''' 9 | shpool is a mechanism for establishing lightweight persistant shell 10 | sessions to gracefully handle network disconnects. 11 | ''' 12 | license = "Apache-2.0" 13 | keywords = ["tmux", "tty", "terminal", "shell", "persistence"] 14 | rust-version = "1.74" 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [dependencies] 19 | clap = { version = "4", features = ["derive"] } # cli parsing 20 | anyhow = "1" # dynamic, unstructured errors 21 | libshpool = { version = "0.9.3", path = "../libshpool" } 22 | 23 | [dev-dependencies] 24 | lazy_static = "1" # globals 25 | crossbeam-channel = "0.5" # channels 26 | tempfile = "3" # keeping tests hermetic 27 | regex = "1" # test assertions 28 | serde_json = "1" # json parsing 29 | ntest = "0.9" # test timeouts 30 | 31 | # rusty wrapper for unix apis 32 | [dependencies.nix] 33 | version = "0.30" 34 | features = ["poll", "ioctl", "process", "signal", "fs"] 35 | -------------------------------------------------------------------------------- /libshpool/src/common.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //! The common module is a grab bag of shared utility functions. 16 | 17 | use std::env; 18 | 19 | use anyhow::anyhow; 20 | 21 | pub fn resolve_sessions(sessions: &mut Vec, action: &str) -> anyhow::Result<()> { 22 | if sessions.is_empty() { 23 | if let Ok(current_session) = env::var("SHPOOL_SESSION_NAME") { 24 | sessions.push(current_session); 25 | } 26 | } 27 | 28 | if sessions.is_empty() { 29 | eprintln!("no session to {action}"); 30 | return Err(anyhow!("no session to {action}")); 31 | } 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. 4 | 5 | ## Before you begin 6 | 7 | ### Sign our Contributor License Agreement 8 | 9 | Contributions to this project must be accompanied by a 10 | [Contributor License Agreement](https://cla.developers.google.com/about) (CLA). 11 | You (or your employer) retain the copyright to your contribution; this simply 12 | gives us permission to use and redistribute your contributions as part of the 13 | project. 14 | 15 | If you or your current employer have already signed the Google CLA (even if it 16 | was for a different project), you probably don't need to do it again. 17 | 18 | Visit to see your current agreements or to 19 | sign a new one. 20 | 21 | ### Review our community guidelines 22 | 23 | This project follows 24 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 25 | 26 | ## Contribution process 27 | 28 | ### Code reviews 29 | 30 | All submissions, including submissions by project members, require review. We 31 | use GitHub pull requests for this purpose. Consult 32 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 33 | information on using pull requests. 34 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: shpool 2 | Section: unknown 3 | Priority: optional 4 | Maintainer: Ethan Pailes 5 | Standards-Version: 4.0.0 6 | Build-Depends: cargo (>= 0.70.0), rustc (>= 1.70.0), python3, debhelper-compat (= 13) 7 | Vcs-browser: https://github.com/shell-pool/shpool 8 | Homepage: https://github.com/shell-pool/shpool 9 | 10 | Package: shpool 11 | Architecture: any 12 | Depends: ${misc:Depends}, ${shlibs:Depends} 13 | Description: think tmux... then aim lower 14 | shpool is a shell pooler, which allows for persistant named 15 | shell sessions. This is useful when you are connecting to 16 | a remote server over a connection which might drop. It is 17 | similar to tmux and GNU screen in that it allows you to 18 | create a named shell session, then re-attach to it later, 19 | but it differs in that it does not do any multiplexing. 20 | This means that your local terminal emulater does all the 21 | work to render the terminal output, which preserves the 22 | typical terminal user experience more faithfully than tmux. 23 | In particular, while tmux and GNU screen break scrollback 24 | and copy-paste, with shpool they both just work. Additionally, 25 | shpool is much simpler than a full terminal multiplexer, and 26 | therefore easier to learn. 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release-plz 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | release-plz: 14 | name: Release-plz 15 | runs-on: ubuntu-latest 16 | steps: 17 | # Generating a GitHub token, so that PRs and tags created by 18 | # the release-plz-action can trigger actions workflows. 19 | - name: Generate GitHub token 20 | uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf 21 | id: generate-token 22 | with: 23 | app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} # <-- GitHub App ID secret name 24 | private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} # <-- GitHub App private key secret name 25 | - name: Checkout repository 26 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 27 | with: 28 | fetch-depth: 0 29 | - name: Install Rust toolchain 30 | uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 31 | with: 32 | bins: cross 33 | - name: Run release-plz 34 | uses: MarcoIeni/release-plz-action@487eb7b5c085a664d5c5ca05f4159bd9b591182a 35 | env: 36 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 37 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 38 | -------------------------------------------------------------------------------- /release-plz.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | # disable the changelog for all packages, will only enable for shpool 3 | changelog_update = false 4 | # disable creating release by default, will only enable for shpool 5 | git_release_enable = false 6 | git_release_type = "auto" 7 | 8 | [changelog] 9 | header = "" 10 | body = """ 11 | {{ package }} ({{ version | trim_start_matches(pat="v") }}) unstable; urgency=low 12 | {% for group, commits in commits | group_by(attribute="group") %} 13 | {{ group | upper_first }} 14 | {% for commit in commits -%} 15 | {%- if commit.scope %} 16 | * *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %} 17 | {%- else %} 18 | * {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }} 19 | {%- endif %} 20 | {%- endfor %} 21 | {% endfor %} 22 | -- Shpool Authors {{ now() | date(format="%a, %d %b %Y %T %z") }} 23 | """ 24 | trim = false 25 | 26 | [[package]] 27 | name = "shpool" 28 | changelog_update = true 29 | changelog_path = "./debian/changelog" 30 | # Also include changes in files in libshpool 31 | changelog_include = ["libshpool"] 32 | # Use bare vx.y.z version tag for shpool, other packages will use the default 33 | # packagename-vx.y.z tag 34 | git_tag_name = "v{{ version }}" 35 | # GitHub release will only be created for the overall shpool binary 36 | git_release_enable = true 37 | git_release_name = "v{{ version }}" 38 | 39 | [[package]] 40 | name = "libshpool" 41 | # libshpool doesn't get its own tag since it's always the same version as shpool 42 | git_tag_enable = false 43 | 44 | [[package]] 45 | name = "shpool-protocol" 46 | changelog_update = true 47 | changelog_path = "./shpool-protocol/CHANGELOG" 48 | -------------------------------------------------------------------------------- /libshpool/src/set_log_level.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{io, path::PathBuf}; 16 | 17 | use anyhow::Context; 18 | use shpool_protocol::{ConnectHeader, LogLevel, SetLogLevelReply, SetLogLevelRequest}; 19 | 20 | use crate::{protocol, protocol::ClientResult}; 21 | 22 | pub fn run(level: LogLevel, socket: PathBuf) -> anyhow::Result<()> { 23 | let mut client = match protocol::Client::new(socket) { 24 | Ok(ClientResult::JustClient(c)) => c, 25 | Ok(ClientResult::VersionMismatch { warning, client }) => { 26 | eprintln!("warning: {warning}, try restarting your daemon"); 27 | client 28 | } 29 | Err(err) => { 30 | let io_err = err.downcast::()?; 31 | if io_err.kind() == io::ErrorKind::NotFound { 32 | eprintln!("could not connect to daemon"); 33 | } 34 | return Err(io_err).context("connecting to daemon"); 35 | } 36 | }; 37 | 38 | client 39 | .write_connect_header(ConnectHeader::SetLogLevel(SetLogLevelRequest { level })) 40 | .context("sending set-log-level header")?; 41 | let _reply: SetLogLevelReply = client.read_reply().context("reading reply")?; 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /libshpool/src/consts.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::time; 16 | 17 | pub const SOCK_STREAM_TIMEOUT: time::Duration = time::Duration::from_millis(200); 18 | pub const JOIN_POLL_DURATION: time::Duration = time::Duration::from_millis(100); 19 | 20 | pub const BUF_SIZE: usize = 1024 * 16; 21 | 22 | pub const HEARTBEAT_DURATION: time::Duration = time::Duration::from_millis(500); 23 | 24 | pub const STDIN_FD: i32 = 0; 25 | pub const STDERR_FD: i32 = 2; 26 | 27 | // Used to determine when the shell has started up so we can attempt to sniff 28 | // what type of shell it is based on /proc//exe. 29 | pub const STARTUP_SENTINEL: &str = "SHPOOL_STARTUP_SENTINEL"; 30 | 31 | // Used to flag when prompt setup is complete and we can stop 32 | // dropping the output. 33 | pub const PROMPT_SENTINEL: &str = "SHPOOL_PROMPT_SETUP_SENTINEL"; 34 | 35 | // A magic env var which indicates that a `shpool daemon` invocation should 36 | // actually just print the given sentinel then exit. We do this because 37 | // using `echo` will cause the sentinel value to appear multiple times 38 | // in the output stream. For the same reason, we don't set the value 39 | // to an actual sentianl, but instead either "startup" or "prompt". 40 | pub const SENTINEL_FLAG_VAR: &str = "SHPOOL__INTERNAL__PRINT_SENTINEL"; 41 | 42 | // If set to "true", the daemon will autodaemonize after launch. 43 | pub const AUTODAEMONIZE_VAR: &str = "SHPOOL__INTERNAL__AUTODAEMONIZE"; 44 | -------------------------------------------------------------------------------- /libshpool/src/list.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{io, path::PathBuf, time}; 16 | 17 | use anyhow::Context; 18 | use shpool_protocol::{ConnectHeader, ListReply}; 19 | 20 | use crate::{protocol, protocol::ClientResult}; 21 | 22 | pub fn run(socket: PathBuf) -> anyhow::Result<()> { 23 | let mut client = match protocol::Client::new(socket) { 24 | Ok(ClientResult::JustClient(c)) => c, 25 | Ok(ClientResult::VersionMismatch { warning, client }) => { 26 | eprintln!("warning: {warning}, try restarting your daemon"); 27 | client 28 | } 29 | Err(err) => { 30 | let io_err = err.downcast::()?; 31 | if io_err.kind() == io::ErrorKind::NotFound { 32 | eprintln!("could not connect to daemon"); 33 | } 34 | return Err(io_err).context("connecting to daemon"); 35 | } 36 | }; 37 | 38 | client.write_connect_header(ConnectHeader::List).context("sending list connect header")?; 39 | let reply: ListReply = client.read_reply().context("reading reply")?; 40 | 41 | println!("NAME\tSTARTED_AT\tSTATUS"); 42 | for session in reply.sessions.iter() { 43 | let started_at = 44 | time::UNIX_EPOCH + time::Duration::from_millis(session.started_at_unix_ms as u64); 45 | let started_at = chrono::DateTime::::from(started_at); 46 | println!("{}\t{}\t{}", session.name, started_at.to_rfc3339(), session.status); 47 | } 48 | 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /libshpool/src/kill.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{io, path::Path}; 16 | 17 | use anyhow::{anyhow, Context}; 18 | use shpool_protocol::{ConnectHeader, KillReply, KillRequest}; 19 | 20 | use crate::{common, protocol, protocol::ClientResult}; 21 | 22 | pub fn run

(mut sessions: Vec, socket: P) -> anyhow::Result<()> 23 | where 24 | P: AsRef, 25 | { 26 | let mut client = match protocol::Client::new(socket) { 27 | Ok(ClientResult::JustClient(c)) => c, 28 | Ok(ClientResult::VersionMismatch { warning, client }) => { 29 | eprintln!("warning: {warning}, try restarting your daemon"); 30 | client 31 | } 32 | Err(err) => { 33 | let io_err = err.downcast::()?; 34 | if io_err.kind() == io::ErrorKind::NotFound { 35 | eprintln!("could not connect to daemon"); 36 | } 37 | return Err(io_err).context("connecting to daemon"); 38 | } 39 | }; 40 | 41 | common::resolve_sessions(&mut sessions, "kill")?; 42 | 43 | client 44 | .write_connect_header(ConnectHeader::Kill(KillRequest { sessions })) 45 | .context("writing detach request header")?; 46 | 47 | let reply: KillReply = client.read_reply().context("reading reply")?; 48 | 49 | if !reply.not_found_sessions.is_empty() { 50 | eprintln!("not found: {}", reply.not_found_sessions.join(" ")); 51 | return Err(anyhow!("not found: {}", reply.not_found_sessions.join(" "))); 52 | } 53 | 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /libshpool/src/daemon/systemd.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{ 16 | env, 17 | os::{ 18 | fd::{OwnedFd, RawFd}, 19 | unix::{io::FromRawFd, net::UnixListener}, 20 | }, 21 | }; 22 | 23 | use anyhow::{anyhow, Context}; 24 | use nix::sys::stat; 25 | 26 | // the fd that systemd uses for the first activation socket 27 | // (0 through 2 are for the std streams) 28 | // Reference https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html# 29 | const FIRST_ACTIVATION_SOCKET_FD: RawFd = 3; 30 | 31 | /// activation_socket converts the systemd activation socket 32 | /// to a usable UnixStream. 33 | pub fn activation_socket() -> anyhow::Result { 34 | let num_activation_socks = env::var("LISTEN_FDS") 35 | .context("fetching LISTEN_FDS env var")? 36 | .parse::() 37 | .context("parsing LISTEN_FDS as int")?; 38 | if num_activation_socks != 1 { 39 | return Err(anyhow!("expected exactly 1 activation fd, got {}", num_activation_socks)); 40 | } 41 | 42 | // Safety: we have just checked that there is 1 activation fd, which starts at 43 | // FIRST_ACTIVATION_SOCKET_FD. This FD can be closed by us. 44 | let fd = unsafe { OwnedFd::from_raw_fd(FIRST_ACTIVATION_SOCKET_FD) }; 45 | 46 | let sock_stat = stat::fstat(&fd).context("stating activation sock")?; 47 | if !stat::SFlag::from_bits_truncate(sock_stat.st_mode).contains(stat::SFlag::S_IFSOCK) { 48 | return Err(anyhow!("expected to be passed a unix socket")); 49 | } 50 | 51 | Ok(fd.into()) 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/build_binaries.yml: -------------------------------------------------------------------------------- 1 | name: Build Binaries # Continuous Deployment 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | env: 11 | CARGO_INCREMENTAL: 0 12 | CARGO_NET_GIT_FETCH_WITH_CLI: true 13 | CARGO_NET_RETRY: 10 14 | CARGO_TERM_COLOR: always 15 | RUST_BACKTRACE: 1 16 | RUSTFLAGS: -D warnings 17 | RUSTUP_MAX_RETRIES: 10 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | jobs: 24 | upload-assets: 25 | name: ${{ matrix.target }} 26 | if: github.repository_owner == 'shell-pool' && startsWith(github.event.release.name, 'shpool') 27 | runs-on: ${{ matrix.os }} 28 | strategy: 29 | matrix: 30 | include: 31 | - target: aarch64-unknown-linux-gnu 32 | os: ubuntu-22.04 33 | - target: aarch64-unknown-linux-musl 34 | os: ubuntu-22.04 35 | - target: x86_64-unknown-linux-gnu 36 | os: ubuntu-22.04 37 | - target: x86_64-unknown-linux-musl 38 | os: ubuntu-22.04 39 | #- target: aarch64-apple-darwin 40 | # os: macos-12 41 | #- target: x86_64-apple-darwin 42 | # os: macos-12 43 | #- target: x86_64-unknown-freebsd 44 | # os: ubuntu-22.04 45 | timeout-minutes: 60 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 49 | - name: Install Rust toolchain 50 | uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 51 | with: 52 | inherit-toolchain: true 53 | bins: cross 54 | - uses: taiki-e/setup-cross-toolchain-action@84e58a47fc2bcd3821a2aa8c153595bbffb0e10f 55 | with: 56 | target: ${{ matrix.target }} 57 | if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, '-musl') 58 | - run: echo "RUSTFLAGS=${RUSTFLAGS} -C target-feature=+crt-static" >> "${GITHUB_ENV}" 59 | if: endsWith(matrix.target, 'windows-msvc') 60 | - uses: taiki-e/upload-rust-binary-action@3962470d6e7f1993108411bc3f75a135ec67fc8c 61 | with: 62 | bin: shpool 63 | target: ${{ matrix.target }} 64 | tar: all 65 | zip: windows 66 | token: ${{ secrets.GITHUB_TOKEN }} 67 | -------------------------------------------------------------------------------- /libshpool/src/detach.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{io, path::Path}; 16 | 17 | use anyhow::{anyhow, Context}; 18 | use shpool_protocol::{ConnectHeader, DetachReply, DetachRequest}; 19 | 20 | use crate::{common, protocol, protocol::ClientResult}; 21 | 22 | pub fn run

(mut sessions: Vec, socket: P) -> anyhow::Result<()> 23 | where 24 | P: AsRef, 25 | { 26 | let mut client = match protocol::Client::new(socket) { 27 | Ok(ClientResult::JustClient(c)) => c, 28 | Ok(ClientResult::VersionMismatch { warning, client }) => { 29 | eprintln!("warning: {warning}, try restarting your daemon"); 30 | client 31 | } 32 | Err(err) => { 33 | let io_err = err.downcast::()?; 34 | if io_err.kind() == io::ErrorKind::NotFound { 35 | eprintln!("could not connect to daemon"); 36 | } 37 | return Err(io_err).context("connecting to daemon"); 38 | } 39 | }; 40 | 41 | common::resolve_sessions(&mut sessions, "detach")?; 42 | 43 | client 44 | .write_connect_header(ConnectHeader::Detach(DetachRequest { sessions })) 45 | .context("writing detach request header")?; 46 | 47 | let reply: DetachReply = client.read_reply().context("reading reply")?; 48 | 49 | if !reply.not_found_sessions.is_empty() { 50 | eprintln!("not found: {}", reply.not_found_sessions.join(" ")); 51 | return Err(anyhow!("not found: {}", reply.not_found_sessions.join(" "))); 52 | } 53 | if !reply.not_attached_sessions.is_empty() { 54 | eprintln!("not attached: {}", reply.not_attached_sessions.join(" ")); 55 | return Err(anyhow!("not attached: {}", reply.not_attached_sessions.join(" "))); 56 | } 57 | 58 | Ok(()) 59 | } 60 | -------------------------------------------------------------------------------- /libshpool/src/daemon/exit_notify.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{ 16 | sync::{Condvar, Mutex}, 17 | time::Duration, 18 | }; 19 | 20 | #[derive(Debug)] 21 | pub struct ExitNotifier { 22 | slot: Mutex>, 23 | cond: Condvar, 24 | } 25 | 26 | impl ExitNotifier { 27 | pub fn new() -> Self { 28 | ExitNotifier { slot: Mutex::new(None), cond: Condvar::new() } 29 | } 30 | 31 | /// Notify all waiters that the process has exited. 32 | pub fn notify_exit(&self, status: i32) { 33 | let mut slot = self.slot.lock().unwrap(); 34 | *slot = Some(status); 35 | self.cond.notify_all(); 36 | } 37 | 38 | /// Wait for the process to exit, with an optional timeout 39 | /// to allow the caller to wake up periodically. 40 | pub fn wait(&self, timeout: Option) -> Option { 41 | let slot = self.slot.lock().unwrap(); 42 | 43 | // If a thread waits on the exit status when the child has already 44 | // exited, we just want to immediately return. 45 | if slot.is_some() { 46 | return *slot; 47 | } 48 | 49 | match timeout { 50 | Some(t) => { 51 | // returns a lock result, so we want to unwrap 52 | // to propagate the lock poisoning 53 | let (exit_status, wait_res) = self 54 | .cond 55 | .wait_timeout_while(slot, t, |exit_status| exit_status.is_none()) 56 | .unwrap(); 57 | if wait_res.timed_out() { 58 | None 59 | } else { 60 | *exit_status 61 | } 62 | } 63 | None => *self.cond.wait_while(slot, |exit_status| exit_status.is_none()).unwrap(), 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /libshpool/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "libshpool" 3 | version = "0.9.3" 4 | edition = "2021" 5 | repository = "https://github.com/shell-pool/shpool" 6 | authors = ["Ethan Pailes "] 7 | readme = "README.md" 8 | description = ''' 9 | libshpool contains the implementation of the shpool tool, 10 | which provides a mechanism for establishing lightweight 11 | persistant shell sessions to gracefully handle network 12 | disconnects. 13 | ''' 14 | license = "Apache-2.0" 15 | keywords = ["tmux", "tty", "terminal", "shell", "persistence"] 16 | rust-version = "1.74" 17 | 18 | [features] 19 | test_hooks = [] # for internal testing only, don't enable this feature 20 | 21 | [dependencies] 22 | clap = { version = "4", features = ["derive"] } # cli parsing 23 | anyhow = "1" # dynamic, unstructured errors 24 | chrono = "0.4" # getting current time and formatting it 25 | serde = "1" # config parsing, connection header formatting 26 | serde_derive = "1" # config parsing, connection header formatting 27 | toml = "0.8" # config parsing 28 | byteorder = "1" # endianness 29 | signal-hook = "0.3" # signal handling 30 | shpool_pty = "0.3.1" # spawning shells in ptys 31 | lazy_static = "1" # globals 32 | crossbeam-channel = "0.5" # channels 33 | libc = "0.2" # basic libc types 34 | log = "0.4" # logging facade (not used directly, but required if we have tracing-log enabled) 35 | tracing = "0.1" # logging and performance monitoring facade 36 | rmp-serde = "1" # serialization for the control protocol 37 | shpool_vt100 = "0.1.3" # terminal emulation for the scrollback buffer 38 | shell-words = "1" # parsing the -c/--cmd argument 39 | motd = { version = "0.2.2", default-features = false, features = [] } # getting the message-of-the-day 40 | termini = "1.0.0" # terminfo database 41 | tempfile = "3" # RAII tmp files 42 | strip-ansi-escapes = "0.2.0" # cleaning up strings for pager display 43 | notify = { version = "7", features = ["crossbeam-channel"] } # watch config file for updates 44 | libproc = "0.14.8" # sniffing shells by examining the subprocess 45 | daemonize = "0.5" # autodaemonization 46 | shpool-protocol = { version = "0.3.2", path = "../shpool-protocol" } # client-server protocol 47 | 48 | # rusty wrapper for unix apis 49 | [dependencies.nix] 50 | version = "0.30" 51 | features = ["poll", "ioctl", "socket", "user", "process", "signal", "term", "fs"] 52 | 53 | [dependencies.tracing-subscriber] 54 | version = "0.3.19" 55 | default-features = false 56 | features = ["std", "fmt", "tracing-log", "smallvec"] 57 | 58 | [dev-dependencies] 59 | ntest = "0.9" # test timeouts 60 | assert_matches = "1.5" # assert_matches macro 61 | -------------------------------------------------------------------------------- /libshpool/src/user.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{ffi::CStr, io, ptr}; 16 | 17 | use anyhow::anyhow; 18 | 19 | #[derive(Debug)] 20 | pub struct Info { 21 | pub default_shell: String, 22 | pub home_dir: String, 23 | pub user: String, 24 | } 25 | 26 | pub fn info() -> anyhow::Result { 27 | let mut passwd_str_buf: [libc::c_char; 1024 * 4] = [0; 1024 * 4]; 28 | let mut passwd = libc::passwd { 29 | pw_name: ptr::null_mut(), 30 | pw_passwd: ptr::null_mut(), 31 | pw_uid: 0, 32 | pw_gid: 0, 33 | pw_gecos: ptr::null_mut(), 34 | pw_dir: ptr::null_mut(), 35 | pw_shell: ptr::null_mut(), 36 | }; 37 | let mut passwd_res_ptr: *mut libc::passwd = ptr::null_mut(); 38 | unsafe { 39 | // Safety: pretty much pure ffi, passwd and passwd_str_buf correctly 40 | // have memory backing them. 41 | let errno = libc::getpwuid_r( 42 | libc::getuid(), 43 | &mut passwd, 44 | passwd_str_buf.as_mut_ptr(), 45 | passwd_str_buf.len(), 46 | &mut passwd_res_ptr as *mut *mut libc::passwd, 47 | ); 48 | if passwd_res_ptr.is_null() { 49 | if errno == 0 { 50 | return Err(anyhow!("could not find current user, should be impossible")); 51 | } else { 52 | return Err(anyhow!( 53 | "error resolving user info: {}", 54 | io::Error::from_raw_os_error(errno) 55 | )); 56 | } 57 | } 58 | 59 | // Safety: these pointers are all cstrings 60 | Ok(Info { 61 | default_shell: String::from(String::from_utf8_lossy( 62 | CStr::from_ptr(passwd.pw_shell).to_bytes(), 63 | )), 64 | home_dir: String::from(String::from_utf8_lossy( 65 | CStr::from_ptr(passwd.pw_dir).to_bytes(), 66 | )), 67 | user: String::from(String::from_utf8_lossy(CStr::from_ptr(passwd.pw_name).to_bytes())), 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /libshpool/src/hooks.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | /// Callbacks that the wrapping binary can implement. 16 | /// 17 | /// These allow you to do stuff like inject telemetry into the daemon 18 | /// or trigger background processes based on a particular session 19 | /// name (for example you could update and re-build a repository n 20 | /// minutes after your `devserver` session disconnects on the assumption 21 | /// that the user is done for the day). 22 | /// 23 | /// Hooks are invoked inline within the daemon's control flow, so 24 | /// you MUST NOT block for extended periods of time. If you need to 25 | /// do work that could block for a while, you should spin up a worker 26 | /// thread and enqueue events so the hooks can be processed async. 27 | /// 28 | /// It would be nicer if the hooks took `&mut self`, but they are called 29 | /// from an immutable context and it is nice to avoid the syncronization 30 | /// / interior mutability unless it is required. Users can always get 31 | /// mutable state with a cell / mutex. 32 | /// 33 | /// Any errors returned will simply be logged. 34 | /// 35 | /// All hooks do nothing by default. 36 | pub trait Hooks { 37 | /// Triggered when a fresh session is created. 38 | fn on_new_session(&self, _session_name: &str) -> anyhow::Result<()> { 39 | Ok(()) 40 | } 41 | 42 | /// Triggered when a user connects to an existing session. 43 | fn on_reattach(&self, _session_name: &str) -> anyhow::Result<()> { 44 | Ok(()) 45 | } 46 | 47 | /// Triggered when a user tries connects to a session but can't because 48 | /// there is already a connected client. 49 | fn on_busy(&self, _session_name: &str) -> anyhow::Result<()> { 50 | Ok(()) 51 | } 52 | 53 | /// Triggered when the `shpool attach` process hangs up. 54 | fn on_client_disconnect(&self, _session_name: &str) -> anyhow::Result<()> { 55 | Ok(()) 56 | } 57 | 58 | /// Triggered when a session closes due to some event on the daemon such 59 | /// as the shell exiting. 60 | fn on_shell_disconnect(&self, _session_name: &str) -> anyhow::Result<()> { 61 | Ok(()) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/presubmit.yml: -------------------------------------------------------------------------------- 1 | name: presubmit 2 | on: [pull_request, workflow_call, workflow_dispatch] 3 | 4 | jobs: 5 | test: 6 | name: cargo test --all-features 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 10 | - uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 11 | with: 12 | inherit-toolchain: true 13 | - run: sudo apt-get install zsh fish libpam0g-dev 14 | - run: SHPOOL_LEAVE_TEST_LOGS=true cargo test --all-features 15 | - name: Archive Logs 16 | if: always() 17 | uses: actions/upload-artifact@v5 18 | id: artifact-upload-step 19 | with: 20 | name: test-logs 21 | path: /tmp/shpool-test*/*.log 22 | 23 | # miri does not handle all the IO we do, disabled for now. 24 | # 25 | # miri: 26 | # name: cargo +nightly miri test 27 | # runs-on: ubuntu-22.04 28 | # steps: 29 | # - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 30 | # - uses: moonrepo/setup-rust@b8edcc56aab474d90c7cf0bb8beeaf8334c15e9f 31 | # with: 32 | # components: miri 33 | # channel: nightly 34 | # - run: sudo apt-get install zsh fish 35 | # - run: MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test 36 | 37 | rustfmt: 38 | name: cargo +nightly fmt -- --check 39 | runs-on: ubuntu-22.04 40 | steps: 41 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 42 | - uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 43 | with: 44 | components: rustfmt 45 | channel: nightly 46 | - run: sudo apt-get install libpam0g-dev 47 | - run: cargo +nightly fmt -- --check 48 | 49 | cranky: 50 | name: cargo +nightly cranky --all-targets -- -D warnings 51 | runs-on: ubuntu-22.04 52 | steps: 53 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 54 | - uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 55 | with: 56 | components: clippy 57 | bins: cargo-cranky@0.3.0 58 | channel: nightly 59 | - run: sudo apt-get install zsh fish libpam0g-dev 60 | - run: cargo +nightly cranky --all-targets -- -D warnings 61 | 62 | deny: 63 | name: cargo deny --all-features check licenses 64 | runs-on: ubuntu-22.04 65 | steps: 66 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 67 | - name: Install Rust toolchain 68 | uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 69 | with: 70 | inherit-toolchain: true 71 | bins: cargo-deny 72 | - run: sudo apt-get install libpam0g-dev 73 | - run: cargo deny --all-features check licenses 74 | -------------------------------------------------------------------------------- /libshpool/src/daemon/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{env, os::unix::net::UnixListener, path::PathBuf}; 16 | 17 | use anyhow::Context; 18 | use tracing::{info, instrument}; 19 | 20 | use crate::{config, consts, hooks}; 21 | 22 | mod etc_environment; 23 | mod exit_notify; 24 | pub mod keybindings; 25 | mod pager; 26 | mod prompt; 27 | mod server; 28 | mod shell; 29 | mod show_motd; 30 | mod signals; 31 | mod systemd; 32 | mod trie; 33 | mod ttl_reaper; 34 | 35 | #[instrument(skip_all)] 36 | pub fn run( 37 | config_manager: config::Manager, 38 | runtime_dir: PathBuf, 39 | hooks: Box, 40 | log_level_handle: tracing_subscriber::reload::Handle< 41 | tracing_subscriber::filter::LevelFilter, 42 | tracing_subscriber::registry::Registry, 43 | >, 44 | socket: PathBuf, 45 | ) -> anyhow::Result<()> { 46 | if let Ok(daemonize) = env::var(consts::AUTODAEMONIZE_VAR) { 47 | if daemonize == "true" { 48 | env::remove_var(consts::AUTODAEMONIZE_VAR); // avoid looping 49 | 50 | let pid_file = socket.with_file_name("daemonized-shpool.pid"); 51 | 52 | info!("daemonizing with pid_file={:?}", pid_file); 53 | daemonize::Daemonize::new().pid_file(pid_file).start().context("daemonizing")?; 54 | } 55 | } 56 | 57 | info!("\n\n======================== STARTING DAEMON ============================\n\n"); 58 | 59 | let server = server::Server::new(config_manager, hooks, runtime_dir, log_level_handle)?; 60 | 61 | let (cleanup_socket, listener) = match systemd::activation_socket() { 62 | Ok(l) => { 63 | info!("using systemd activation socket"); 64 | (None, l) 65 | } 66 | Err(e) => { 67 | info!("no systemd activation socket: {:?}", e); 68 | (Some(socket.clone()), UnixListener::bind(&socket).context("binding to socket")?) 69 | } 70 | }; 71 | // spawn the signal handler thread in the background 72 | signals::Handler::new(cleanup_socket.clone()).spawn()?; 73 | 74 | server::Server::serve(server, listener)?; 75 | 76 | if let Some(sock) = cleanup_socket { 77 | std::fs::remove_file(sock).context("cleaning up socket on exit")?; 78 | } else { 79 | info!("systemd manages the socket, so not cleaning it up"); 80 | } 81 | 82 | Ok(()) 83 | } 84 | -------------------------------------------------------------------------------- /shpool/tests/support/attach.rs: -------------------------------------------------------------------------------- 1 | use std::{io, io::Write, path::PathBuf, process}; 2 | 3 | use anyhow::{anyhow, Context}; 4 | 5 | use super::{events::Events, line_matcher::LineMatcher}; 6 | 7 | /// Proc is a handle for a `shpool attach` subprocess 8 | /// spawned for testing 9 | pub struct Proc { 10 | pub proc: process::Child, 11 | pub log_file: PathBuf, 12 | pub events: Option, 13 | } 14 | 15 | impl Proc { 16 | pub fn run_raw(&mut self, cmd: Vec) -> anyhow::Result<()> { 17 | let stdin = self.proc.stdin.as_mut().ok_or(anyhow!("missing stdin"))?; 18 | 19 | stdin.write_all(&cmd).context("writing cmd into attach proc")?; 20 | stdin.flush().context("flushing cmd")?; 21 | 22 | Ok(()) 23 | } 24 | 25 | pub fn run_raw_cmd(&mut self, mut cmd: Vec) -> anyhow::Result<()> { 26 | cmd.push("\n".as_bytes()[0]); 27 | self.run_raw(cmd) 28 | } 29 | 30 | pub fn run_cmd(&mut self, cmd: &str) -> anyhow::Result<()> { 31 | eprintln!("running cmd '{cmd}'"); 32 | let stdin = self.proc.stdin.as_mut().ok_or(anyhow!("missing stdin"))?; 33 | 34 | let full_cmd = format!("{cmd}\n"); 35 | stdin.write_all(full_cmd.as_bytes()).context("writing cmd into attach proc")?; 36 | stdin.flush().context("flushing cmd")?; 37 | 38 | Ok(()) 39 | } 40 | 41 | /// Create a handle for asserting about stdout output lines. 42 | /// 43 | /// For some reason we can't just create the Lines iterator as soon 44 | /// as we spawn the subcommand. Attempts to do so result in 45 | /// `Resource temporarily unavailable` (EAGAIN) errors. 46 | pub fn line_matcher(&mut self) -> anyhow::Result> { 47 | let r = self.proc.stdout.take().ok_or(anyhow!("missing stdout"))?; 48 | 49 | nix::fcntl::fcntl(&r, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_NONBLOCK)) 50 | .context("setting stdout nonblocking")?; 51 | 52 | Ok(LineMatcher { out: io::BufReader::new(r), never_match_regex: vec![] }) 53 | } 54 | 55 | /// Create a handle for asserting about stderr output lines. 56 | pub fn stderr_line_matcher(&mut self) -> anyhow::Result> { 57 | let r = self.proc.stderr.take().ok_or(anyhow!("missing stderr"))?; 58 | 59 | nix::fcntl::fcntl(&r, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_NONBLOCK)) 60 | .context("setting stderr nonblocking")?; 61 | 62 | Ok(LineMatcher { out: io::BufReader::new(r), never_match_regex: vec![] }) 63 | } 64 | 65 | pub fn await_event(&mut self, event: &str) -> anyhow::Result<()> { 66 | if let Some(events) = &mut self.events { 67 | events.await_event(event) 68 | } else { 69 | Err(anyhow!("no events stream")) 70 | } 71 | } 72 | } 73 | 74 | impl std::ops::Drop for Proc { 75 | fn drop(&mut self) { 76 | if let Err(e) = self.proc.kill() { 77 | eprintln!("err killing attach proc: {e:?}"); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /libshpool/src/daemon/signals.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{ 16 | path::PathBuf, 17 | sync::{atomic::AtomicBool, Arc}, 18 | thread, 19 | }; 20 | 21 | use anyhow::Context; 22 | use signal_hook::{consts::TERM_SIGNALS, flag, iterator::Signals}; 23 | use tracing::{error, info}; 24 | 25 | pub struct Handler { 26 | sock: Option, 27 | } 28 | impl Handler { 29 | pub fn new(sock: Option) -> Self { 30 | Handler { sock } 31 | } 32 | 33 | pub fn spawn(self) -> anyhow::Result<()> { 34 | info!("spawning signal handler thread"); 35 | 36 | // This sets us up to shutdown immediately if someone 37 | // mashes ^C so we don't get stuck attempting a graceful 38 | // shutdown. 39 | let term_now = Arc::new(AtomicBool::new(false)); 40 | for sig in TERM_SIGNALS { 41 | // When terminated by a second term signal, exit with exit code 1. 42 | // This will do nothing the first time (because term_now is false). 43 | flag::register_conditional_shutdown(*sig, 1, Arc::clone(&term_now))?; 44 | // But this will "arm" the above for the second time, by setting it to true. 45 | // The order of registering these is important, if you put this one first, it 46 | // will first arm and then terminate ‒ all in the first round. 47 | flag::register(*sig, Arc::clone(&term_now))?; 48 | } 49 | 50 | let mut signals = Signals::new(TERM_SIGNALS).context("creating signal iterator")?; 51 | thread::spawn(move || { 52 | // Signals are exposed via an iterator so this loop is just to consume 53 | // that by blocking until the first value is emitted. Clippy thinks we 54 | // are looping over a collection and is confused about why we always 55 | // exit in the loop body. 56 | #[allow(clippy::never_loop)] 57 | for signal in &mut signals { 58 | assert!(TERM_SIGNALS.contains(&signal)); 59 | 60 | info!("term sig handler: cleaning up socket"); 61 | if let Some(sock) = self.sock { 62 | if let Err(e) = std::fs::remove_file(sock).context("cleaning up socket") { 63 | error!("error cleaning up socket file: {}", e); 64 | } 65 | } 66 | 67 | info!("term sig handler: exiting"); 68 | std::process::exit(0); 69 | } 70 | }); 71 | 72 | Ok(()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /libshpool/src/daemonize.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{ffi::OsStr, os::unix::net::UnixStream, path::Path, process, thread, time::Duration}; 16 | 17 | use crate::{config, consts, Args}; 18 | 19 | use anyhow::{anyhow, Context}; 20 | use tracing::info; 21 | 22 | /// Check if we can connect to the control socket, and if we 23 | /// can't, fork the daemon in the background. 24 | pub fn maybe_fork_daemon( 25 | config_manager: &config::Manager, 26 | args: &Args, 27 | shpool_bin: B, 28 | control_sock: P, 29 | ) -> anyhow::Result<()> 30 | where 31 | B: AsRef, 32 | P: AsRef, 33 | { 34 | let control_sock = control_sock.as_ref(); 35 | 36 | if UnixStream::connect(control_sock).is_ok() { 37 | info!("daemon already running on {:?}, no need to autodaemonize", control_sock); 38 | // There is already a daemon listening on the control socket, we 39 | // don't need to do anything. 40 | return Ok(()); 41 | } 42 | info!("no daemon running on {:?}, autodaemonizing", control_sock); 43 | 44 | let log_file = control_sock.with_file_name("daemonized-shpool.log"); 45 | 46 | let mut cmd = process::Command::new(shpool_bin); 47 | if let Some(config_file) = &args.config_file { 48 | cmd.arg("--config-file").arg(config_file); 49 | } 50 | cmd.arg("--log-file") 51 | .arg(log_file) 52 | .arg("--socket") 53 | .arg(control_sock.as_os_str()) 54 | .arg("daemon") 55 | .env(consts::AUTODAEMONIZE_VAR, "true") 56 | .stdout(process::Stdio::null()) 57 | .stderr(process::Stdio::null()) 58 | .spawn() 59 | .context("launching background daemon")?; 60 | info!("launched background daemon"); 61 | 62 | // Now poll with exponential backoff until we can dial the control socket. 63 | if config_manager.get().nodaemonize_timeout.unwrap_or(false) { 64 | info!("waiting for daemon to come up with no timeout"); 65 | let mut sleep_ms = 10; 66 | let max_sleep_ms = 2000; 67 | loop { 68 | if UnixStream::connect(control_sock).is_ok() { 69 | info!("connected to freshly launched background daemon"); 70 | return Ok(()); 71 | } 72 | 73 | thread::sleep(Duration::from_millis(sleep_ms)); 74 | sleep_ms *= 2; 75 | if sleep_ms > max_sleep_ms { 76 | sleep_ms = max_sleep_ms; 77 | } 78 | } 79 | } else { 80 | info!("waiting for daemon to come up with timeout"); 81 | // `sum(10*(2**x) for x in range(9))` = 5110 ms = ~5 s 82 | let mut sleep_ms = 10; 83 | for _ in 0..9 { 84 | if UnixStream::connect(control_sock).is_ok() { 85 | info!("connected to freshly launched background daemon"); 86 | return Ok(()); 87 | } 88 | 89 | thread::sleep(Duration::from_millis(sleep_ms)); 90 | sleep_ms *= 2; 91 | } 92 | } 93 | 94 | Err(anyhow!("daemonizing: launched daemon, but control socket never came up")) 95 | } 96 | -------------------------------------------------------------------------------- /shpool/tests/support/mod.rs: -------------------------------------------------------------------------------- 1 | // This module is used from multiple different test files, each of which 2 | // gets compiled into its own binary. Not all the binaries use all the 3 | // stuff here. 4 | #![allow(dead_code)] 5 | 6 | use std::{ 7 | env, io, 8 | io::BufRead, 9 | path::{Path, PathBuf}, 10 | process::Command, 11 | sync::Mutex, 12 | time, 13 | }; 14 | 15 | use anyhow::{anyhow, Context}; 16 | 17 | pub mod attach; 18 | pub mod daemon; 19 | pub mod events; 20 | pub mod line_matcher; 21 | 22 | pub fn dump_err(f: fn() -> anyhow::Result<()>) -> anyhow::Result<()> { 23 | let res = f(); 24 | if let Err(e) = res.as_ref() { 25 | eprintln!("top level error: {e:?}"); 26 | } 27 | res 28 | } 29 | 30 | pub fn testdata_file>(file: P) -> PathBuf { 31 | let mut dir = cargo_dir(); 32 | dir.pop(); 33 | dir.pop(); 34 | dir.join("shpool").join("tests").join("data").join(file) 35 | } 36 | 37 | lazy_static::lazy_static! { 38 | // cache the result and make sure we only ever compile once 39 | static ref SHPOOL_BIN_PATH: Mutex> = Mutex::new(None); 40 | } 41 | 42 | pub fn wait_until

(mut pred: P) -> anyhow::Result<()> 43 | where 44 | P: FnMut() -> anyhow::Result, 45 | { 46 | let mut sleep_dur = time::Duration::from_millis(5); 47 | for _ in 0..12 { 48 | if pred()? { 49 | return Ok(()); 50 | } else { 51 | std::thread::sleep(sleep_dur); 52 | sleep_dur *= 2; 53 | } 54 | } 55 | 56 | Err(anyhow!("pred never became true")) 57 | } 58 | 59 | pub fn shpool_bin() -> anyhow::Result { 60 | let mut cached = SHPOOL_BIN_PATH.lock().unwrap(); 61 | if let Some(path) = &*cached { 62 | return Ok(path.to_path_buf()); 63 | } 64 | 65 | let mut project_dir = cargo_dir(); 66 | project_dir.pop(); 67 | project_dir.pop(); 68 | 69 | let out = Command::new("cargo") 70 | .arg("build") 71 | .arg("--features=test_hooks") 72 | .arg("--message-format=json") 73 | .current_dir(project_dir) 74 | .output() 75 | .context("scraping cargo test binaries")?; 76 | 77 | if !out.status.success() { 78 | return Err(anyhow!("cargo invocation failed")); 79 | } 80 | 81 | let line_reader = io::BufReader::new(&out.stdout[..]); 82 | for line in line_reader.lines() { 83 | let line = line.context("reading line from stdout")?; 84 | let entry: serde_json::Value = 85 | serde_json::from_str(&line).context("parsing an output line from cargo")?; 86 | 87 | let src_path = entry.get("target").and_then(|v| v.get("src_path")).and_then(|v| v.as_str()); 88 | let exe = entry.get("executable").and_then(|v| v.as_str()); 89 | let kind = entry 90 | .get("target") 91 | .and_then(|v| v.get("kind")) 92 | .and_then(|v| v.get(0)) 93 | .and_then(|v| v.as_str()); 94 | let crate_type = entry 95 | .get("target") 96 | .and_then(|v| v.get("crate_types")) 97 | .and_then(|v| v.get(0)) 98 | .and_then(|v| v.as_str()); 99 | 100 | if let (Some(src_path), Some(exe), Some(kind), Some(crate_type)) = 101 | (src_path, exe, kind, crate_type) 102 | { 103 | if !src_path.ends_with("src/main.rs") { 104 | continue; 105 | } 106 | 107 | if kind != "bin" { 108 | continue; 109 | } 110 | 111 | if crate_type != "bin" { 112 | continue; 113 | } 114 | 115 | if let Some(exe_basename) = Path::new(&exe).file_name() { 116 | if exe_basename.to_os_string().into_string().unwrap() != "shpool" { 117 | eprintln!("shpool bin 5"); 118 | continue; 119 | } 120 | } else { 121 | continue; 122 | } 123 | 124 | let path = PathBuf::from(exe); 125 | *cached = Some(path.clone()); 126 | return Ok(path); 127 | } 128 | } 129 | 130 | Err(anyhow!("could not find shpool bin name")) 131 | } 132 | 133 | pub fn cargo_dir() -> PathBuf { 134 | env::var_os("CARGO_BIN_PATH") 135 | .map(PathBuf::from) 136 | .or_else(|| { 137 | env::current_exe().ok().map(|mut path| { 138 | path.pop(); 139 | if path.ends_with("deps") { 140 | path.pop(); 141 | } 142 | path 143 | }) 144 | }) 145 | .unwrap_or_else(|| panic!("CARGO_BIN_PATH wasn't set. Cannot continue running test")) 146 | } 147 | -------------------------------------------------------------------------------- /libshpool/src/daemon/etc_environment.rs: -------------------------------------------------------------------------------- 1 | use std::io::{BufRead, BufReader, Read}; 2 | 3 | use tracing::{debug, warn}; 4 | 5 | // The syntax for /etc/environment is ill defined. The 6 | // file is parsed by pam_env, so this is an attempt at 7 | // porting the parsing logic from that 8 | // https://github.com/linux-pam/linux-pam/blob/1fbf123d982b90d41463df7b6b59a4e544263358/modules/pam_env/pam_env.c#L906 9 | // 10 | // N.B. The logic from pam_env is horrifically, traumatically broken. We 11 | // have to be bug compatible, but please, for the love of god, never 12 | // write anything like this if you have a choice. 13 | pub fn parse_compat(file: R) -> anyhow::Result> { 14 | let mut pairs = vec![]; 15 | let mut etc_env = BufReader::new(file); 16 | let mut line = String::new(); 17 | loop { 18 | line.clear(); 19 | match etc_env.read_line(&mut line) { 20 | Ok(0) => break, // EOF 21 | Ok(_) => { 22 | let mut line = line.trim(); 23 | if line.is_empty() || line.starts_with('#') { 24 | debug!("parsing /etc/environment: blank or comment line"); 25 | continue; 26 | } 27 | 28 | // This requires exactly one space after the 29 | // export, or it doesn't count. Bonkers. Absolutely 30 | // bonkers. 31 | line = line.strip_prefix("export ").unwrap_or(line); 32 | 33 | // Scan through the line looking for a # that starts a 34 | // trailing comment. What if the # is in the middle of 35 | // quotes? Lol, who cares about edge cases, certainly not 36 | // us! 37 | let line: String = line.chars().take_while(|c| *c != '#').collect(); 38 | 39 | let parts: Vec<_> = line.splitn(2, '=').collect(); 40 | if parts.len() != 2 { 41 | warn!("parsing /etc/environment: split failed (should be impossible)"); 42 | continue; 43 | } 44 | let (key, mut val) = (parts[0], parts[1]); 45 | if key.is_empty() { 46 | warn!("parsing /etc/environment: empty key"); 47 | continue; 48 | } 49 | if !key.chars().all(char::is_alphanumeric) { 50 | warn!("parsing /etc/environment: non alphanum key"); 51 | continue; 52 | } 53 | 54 | // Strip quotes. Yes, you're reading it right, this will match 55 | // single quotes with double quotes and strip unmatched leading 56 | // quotes while doing nothing for unmatched trailing quotes. 57 | let has_leading_quote = val.starts_with('\'') || val.starts_with('"'); 58 | val = val.strip_prefix('\'').unwrap_or(val); 59 | val = val.strip_prefix('"').unwrap_or(val); 60 | if has_leading_quote { 61 | val = val.strip_suffix('\'').unwrap_or(val); 62 | val = val.strip_suffix('"').unwrap_or(val); 63 | } 64 | pairs.push((String::from(key), String::from(val))); 65 | } 66 | Err(e) => return Err(e)?, 67 | } 68 | } 69 | 70 | Ok(pairs) 71 | } 72 | 73 | #[cfg(test)] 74 | mod test { 75 | use super::*; 76 | 77 | #[test] 78 | fn parse_file() -> anyhow::Result<()> { 79 | let pairs = parse_compat(std::io::Cursor::new( 80 | r#" 81 | BASIC=foo 82 | LEADINGWS=foo 83 | QUOTEDCOMMENT='surely a # in the middle of a quoted value won't count as a comment' 84 | LEADINGUNTERM='wut is going on 85 | TRAILINGUNTERM=wut is going on' 86 | export EXPORTED1SPACE=foo 87 | export EXPORTED2SPACE=foo 88 | MISMATCHQUOTE='wut is going on" 89 | DOUBLEEQUALS=foo=bar 90 | DOUBLEEQUALSQUOTE='foo=bar' 91 | "#, 92 | ))?; 93 | assert_eq!( 94 | pairs, 95 | vec![ 96 | (String::from("BASIC"), String::from("foo")), 97 | (String::from("LEADINGWS"), String::from("foo")), 98 | (String::from("QUOTEDCOMMENT"), String::from("surely a ")), 99 | (String::from("LEADINGUNTERM"), String::from("wut is going on")), 100 | (String::from("TRAILINGUNTERM"), String::from("wut is going on'")), 101 | (String::from("EXPORTED1SPACE"), String::from("foo")), 102 | (String::from("MISMATCHQUOTE"), String::from("wut is going on")), 103 | (String::from("DOUBLEEQUALS"), String::from("foo=bar")), 104 | (String::from("DOUBLEEQUALSQUOTE"), String::from("foo=bar")), 105 | ] 106 | ); 107 | 108 | Ok(()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /libshpool/src/duration.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | /*! A parser for the duration format supported by the 16 | attach --ttl flag. 17 | */ 18 | 19 | use anyhow::{anyhow, bail, Context}; 20 | use std::time; 21 | 22 | pub fn parse(src: &str) -> anyhow::Result { 23 | if src.contains(':') { 24 | parse_colon_duration(src) 25 | } else if src.chars().last().map(|c| c.is_alphabetic()).unwrap_or(false) { 26 | parse_suffix_duration(src) 27 | } else { 28 | bail!("could not parse '{}' as duration", src); 29 | } 30 | } 31 | 32 | /// Parses dd:hh:mm:ss or any suffix 33 | fn parse_colon_duration(src: &str) -> anyhow::Result { 34 | let mut parts = src.split(':').collect::>(); 35 | parts.reverse(); 36 | if parts.is_empty() { 37 | bail!("'{}' must have at least one part", src); 38 | } 39 | let mut secs = parts[0].parse::().context("parsing seconds part")?; 40 | dbg!(secs); 41 | if parts.len() == 1 { 42 | return Ok(time::Duration::from_secs(secs)); 43 | } 44 | secs += parts[1].parse::().context("parsing minutes part")? * 60; 45 | dbg!(secs); 46 | if parts.len() == 2 { 47 | return Ok(time::Duration::from_secs(secs)); 48 | } 49 | secs += parts[2].parse::().context("parsing hours part")? * 60 * 60; 50 | dbg!(secs); 51 | if parts.len() == 3 { 52 | return Ok(time::Duration::from_secs(secs)); 53 | } 54 | secs += parts[3].parse::().context("parsing days part")? * 60 * 60 * 24; 55 | dbg!(secs); 56 | if parts.len() != 4 { 57 | bail!("colon duration cannot have more than 4 parts"); 58 | } 59 | 60 | Ok(time::Duration::from_secs(secs)) 61 | } 62 | 63 | /// Parses 20d, 3h, 14m ect 64 | fn parse_suffix_duration(src: &str) -> anyhow::Result { 65 | let num: String = src.chars().take_while(|c| c.is_numeric()).collect(); 66 | let c = src.chars().last().ok_or(anyhow!("internal error: no suffix"))?; 67 | make_suffix_duration(num.parse::().context("parsing num part of duration")?, c) 68 | .ok_or(anyhow!("unknown time unit '{}'", c)) 69 | } 70 | 71 | fn make_suffix_duration(n: u64, c: char) -> Option { 72 | match c { 73 | 's' => Some(time::Duration::from_secs(n)), 74 | 'm' => Some(time::Duration::from_secs(n * 60)), 75 | 'h' => Some(time::Duration::from_secs(n * 60 * 60)), 76 | 'd' => Some(time::Duration::from_secs(n * 60 * 60 * 24)), 77 | _ => None, 78 | } 79 | } 80 | 81 | #[cfg(test)] 82 | mod test { 83 | use super::*; 84 | 85 | #[test] 86 | fn successes() { 87 | let cases = vec![ 88 | ("10:30", time::Duration::from_secs(10 * 60 + 30)), 89 | ("3:10:30", time::Duration::from_secs(3 * 60 * 60 + 10 * 60 + 30)), 90 | ("1:3:10:30", time::Duration::from_secs(60 * 60 * 24 + 3 * 60 * 60 + 10 * 60 + 30)), 91 | ("5s", time::Duration::from_secs(5)), 92 | ("5m", time::Duration::from_secs(5 * 60)), 93 | ("5h", time::Duration::from_secs(5 * 60 * 60)), 94 | ("5d", time::Duration::from_secs(5 * 60 * 60 * 24)), 95 | ]; 96 | 97 | for (src, dur) in cases.into_iter() { 98 | match parse(src) { 99 | Ok(parsed_dur) => { 100 | assert_eq!(dur, parsed_dur); 101 | } 102 | Err(e) => { 103 | assert_eq!("", e.to_string()); 104 | } 105 | } 106 | } 107 | } 108 | 109 | #[test] 110 | fn errors() { 111 | let cases = vec![ 112 | ("12", "could not parse"), 113 | ("12x", "unknown time unit"), 114 | (":1", "parsing minutes part"), 115 | ("1:1:1:1:1", "cannot have more than 4"), 116 | ]; 117 | 118 | for (src, err_substring) in cases.into_iter() { 119 | if let Err(e) = parse(src) { 120 | eprintln!("ERR: {e}"); 121 | eprintln!("err_substring: {err_substring}"); 122 | assert!(e.to_string().contains(err_substring)); 123 | } else { 124 | assert_eq!("", "expected err, but got none"); 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /libshpool/src/daemon/trie.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, hash}; 2 | 3 | #[derive(Debug)] 4 | pub struct Trie { 5 | // The nodes which form the tree. The first node is the root 6 | // node, afterwards the order is undefined. 7 | nodes: Vec>, 8 | } 9 | 10 | #[derive(Eq, PartialEq, Copy, Clone, Debug)] 11 | pub enum TrieCursor { 12 | /// A cursor to use to start a char-wise match 13 | Start, 14 | /// Represents a state in the middle or end of a match 15 | Match { idx: usize, is_partial: bool }, 16 | /// A terminal state indicating a failure to match 17 | NoMatch, 18 | } 19 | 20 | #[derive(Debug)] 21 | pub struct TrieNode { 22 | // We need to store a phantom symbol here so we can have the 23 | // Sym type parameter available for the TrieTab trait constraint 24 | // in the impl block. Apologies for the type tetris. 25 | phantom: std::marker::PhantomData, 26 | value: Option, 27 | tab: TT, 28 | } 29 | 30 | impl Trie 31 | where 32 | TT: TrieTab, 33 | Sym: Copy, 34 | { 35 | pub fn new() -> Self { 36 | Trie { nodes: vec![TrieNode::new(None)] } 37 | } 38 | 39 | /// Insert a seq, value pair into the trie 40 | pub fn insert>(&mut self, seq: Seq, value: V) { 41 | let mut current_node = 0; 42 | for sym in seq { 43 | current_node = if let Some(next_node) = self.nodes[current_node].tab.get(sym) { 44 | *next_node 45 | } else { 46 | let idx = self.nodes.len(); 47 | self.nodes.push(TrieNode::new(None)); 48 | self.nodes[current_node].tab.set(sym, idx); 49 | idx 50 | }; 51 | } 52 | self.nodes[current_node].value = Some(value); 53 | } 54 | 55 | /// Check if the given sequence exists in the trie, used by tests. 56 | #[allow(dead_code)] 57 | pub fn contains>(&self, seq: Seq) -> bool { 58 | let mut match_state = TrieCursor::Start; 59 | for sym in seq { 60 | match_state = self.advance(match_state, sym); 61 | if let TrieCursor::NoMatch = match_state { 62 | return false; 63 | } 64 | } 65 | if let TrieCursor::Start = match_state { 66 | return self.nodes[0].value.is_some(); 67 | } 68 | 69 | if let TrieCursor::Match { is_partial, .. } = match_state { 70 | !is_partial 71 | } else { 72 | false 73 | } 74 | } 75 | 76 | /// Process a single token of input, returning the current state. 77 | /// To start a new match, use TrieCursor::Start. 78 | pub fn advance(&self, cursor: TrieCursor, sym: Sym) -> TrieCursor { 79 | let node = match cursor { 80 | TrieCursor::Start => &self.nodes[0], 81 | TrieCursor::Match { idx, .. } => &self.nodes[idx], 82 | TrieCursor::NoMatch => return TrieCursor::NoMatch, 83 | }; 84 | 85 | if let Some(idx) = node.tab.get(sym) { 86 | TrieCursor::Match { idx: *idx, is_partial: self.nodes[*idx].value.is_none() } 87 | } else { 88 | TrieCursor::NoMatch 89 | } 90 | } 91 | 92 | /// Get the value for a match cursor. 93 | pub fn get(&self, cursor: TrieCursor) -> Option<&V> { 94 | if let TrieCursor::Match { idx, .. } = cursor { 95 | self.nodes[idx].value.as_ref() 96 | } else { 97 | None 98 | } 99 | } 100 | } 101 | 102 | impl TrieNode 103 | where 104 | TT: TrieTab, 105 | { 106 | fn new(value: Option) -> Self { 107 | TrieNode { phantom: std::marker::PhantomData, value, tab: TT::new() } 108 | } 109 | } 110 | 111 | /// The backing table the trie uses to associate symbols with state 112 | /// indexes. This is basically `std::ops::IndexMut` plus a `new` function. 113 | /// We can't just make this a sub-trait of `IndexMut` because u8 does 114 | /// not implement IndexMut for vectors. 115 | pub trait TrieTab { 116 | fn new() -> Self; 117 | fn get(&self, index: Idx) -> Option<&usize>; 118 | fn set(&mut self, index: Idx, elem: usize); 119 | } 120 | 121 | impl TrieTab for HashMap 122 | where 123 | Sym: hash::Hash + Eq + PartialEq, 124 | { 125 | fn new() -> Self { 126 | HashMap::new() 127 | } 128 | 129 | fn get(&self, index: Sym) -> Option<&usize> { 130 | self.get(&index) 131 | } 132 | 133 | fn set(&mut self, index: Sym, elem: usize) { 134 | self.insert(index, elem); 135 | } 136 | } 137 | 138 | impl TrieTab for Vec> { 139 | fn new() -> Self { 140 | vec![None; u8::MAX as usize] 141 | } 142 | 143 | fn get(&self, index: u8) -> Option<&usize> { 144 | self[index as usize].as_ref() 145 | } 146 | 147 | fn set(&mut self, index: u8, elem: usize) { 148 | self[index as usize] = Some(elem) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /libshpool/src/tty.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{ 16 | io, 17 | os::{fd::BorrowedFd, unix::io::RawFd}, 18 | }; 19 | 20 | use anyhow::Context; 21 | use nix::{ 22 | sys::{ 23 | termios, 24 | termios::{ControlFlags, InputFlags, LocalFlags, OutputFlags, SetArg}, 25 | }, 26 | unistd::isatty, 27 | }; 28 | use shpool_protocol::TtySize; 29 | use tracing::error; 30 | 31 | use crate::consts; 32 | 33 | // see `man ioctl_tty` for info on these ioctl commands 34 | nix::ioctl_read_bad!(tiocgwinsz, libc::TIOCGWINSZ, libc::winsize); 35 | nix::ioctl_write_ptr_bad!(tiocswinsz, libc::TIOCSWINSZ, libc::winsize); 36 | 37 | /// Methods for the TtySize protocol struct. Protocol structs 38 | /// are always bare structs, so we use ext traits to mix in methods. 39 | pub trait TtySizeExt { 40 | fn from_fd(fd: RawFd) -> anyhow::Result; 41 | fn set_fd(&self, fd: RawFd) -> anyhow::Result<()>; 42 | } 43 | 44 | impl TtySizeExt for TtySize { 45 | /// from_fd returns the terminal size for the given terminal. 46 | fn from_fd(fd: RawFd) -> anyhow::Result { 47 | let mut term_size = libc::winsize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0 }; 48 | 49 | // Safety: term_size is stack allocated and live for the whole 50 | // call. 51 | unsafe { 52 | tiocgwinsz(fd, &mut term_size).context("fetching term size")?; 53 | } 54 | 55 | Ok(TtySize { 56 | rows: term_size.ws_row, 57 | cols: term_size.ws_col, 58 | xpixel: term_size.ws_xpixel, 59 | ypixel: term_size.ws_ypixel, 60 | }) 61 | } 62 | 63 | /// set_fd sets the tty indicated by the given file descriptor 64 | /// to have this size. 65 | fn set_fd(&self, fd: RawFd) -> anyhow::Result<()> { 66 | let term_size = libc::winsize { 67 | ws_row: self.rows, 68 | ws_col: self.cols, 69 | ws_xpixel: self.xpixel, 70 | ws_ypixel: self.ypixel, 71 | }; 72 | 73 | // Safety: term_size is live for the whole call. 74 | unsafe { 75 | tiocswinsz(fd, &term_size).context("setting term size")?; 76 | } 77 | 78 | Ok(()) 79 | } 80 | } 81 | 82 | pub fn disable_echo(fd: BorrowedFd<'_>) -> anyhow::Result<()> { 83 | let mut term = termios::tcgetattr(fd).context("grabbing term flags")?; 84 | term.local_flags &= !LocalFlags::ECHO; 85 | 86 | termios::tcsetattr(fd, SetArg::TCSANOW, &term)?; 87 | 88 | Ok(()) 89 | } 90 | 91 | pub fn set_attach_flags() -> anyhow::Result> { 92 | // Safety: stdin is live for the whole program duration 93 | let fd = unsafe { BorrowedFd::borrow_raw(consts::STDIN_FD) }; 94 | 95 | if !isatty(io::stdin())? || !isatty(io::stdout())? || !isatty(io::stderr())? { 96 | // We are not attached to a terminal, so don't futz with its flags. 97 | return Ok(AttachFlagsGuard { fd, old: None }); 98 | } 99 | 100 | // grab settings from the stdin terminal 101 | let old = termios::tcgetattr(fd).context("grabbing term flags")?; 102 | 103 | // Set the input terminal to raw mode so we immediately get the input chars. 104 | // The terminal for the remote shell is the one that will apply all the logic. 105 | let mut new = old.clone(); 106 | new.input_flags &= !(InputFlags::IGNBRK 107 | | InputFlags::BRKINT 108 | | InputFlags::PARMRK 109 | | InputFlags::ISTRIP 110 | | InputFlags::INLCR 111 | | InputFlags::IGNCR 112 | | InputFlags::ICRNL 113 | | InputFlags::IXON); 114 | new.output_flags &= !OutputFlags::OPOST; 115 | new.local_flags &= !(LocalFlags::ECHO 116 | | LocalFlags::ECHONL 117 | | LocalFlags::ICANON 118 | | LocalFlags::ISIG 119 | | LocalFlags::IEXTEN); 120 | new.control_flags &= !(ControlFlags::CSIZE | ControlFlags::PARENB); 121 | new.control_flags |= ControlFlags::CS8; 122 | termios::tcsetattr(fd, SetArg::TCSANOW, &new)?; 123 | 124 | Ok(AttachFlagsGuard { fd, old: Some(old) }) 125 | } 126 | 127 | pub struct AttachFlagsGuard<'fd> { 128 | fd: BorrowedFd<'fd>, 129 | old: Option, 130 | } 131 | 132 | impl std::ops::Drop for AttachFlagsGuard<'_> { 133 | fn drop(&mut self) { 134 | if let Some(old) = &self.old { 135 | if let Err(e) = termios::tcsetattr(self.fd, SetArg::TCSANOW, old) { 136 | error!("error restoring terminal settings: {:?}", e); 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /libshpool/src/session_restore/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use shpool_protocol::TtySize; 16 | use tracing::info; 17 | 18 | use crate::config::{self, SessionRestoreMode}; 19 | 20 | // To prevent data getting dropped, we set this to be large, but we don't want 21 | // to use u16::MAX, since the vt100 crate eagerly fills in its rows, and doing 22 | // so is very memory intensive. The right fix is to get the vt100 crate to 23 | // lazily initialize its rows, but that is likely a bunch of work. 24 | const VTERM_WIDTH: u16 = 1024; 25 | 26 | /// Some session shpool specific config getters 27 | trait ConfigExt { 28 | /// Effective vterm width. 29 | /// 30 | /// See also `VTERM_WIDTH`. 31 | fn vterm_width(&self) -> u16; 32 | } 33 | 34 | impl ConfigExt for config::Manager { 35 | fn vterm_width(&self) -> u16 { 36 | let config = self.get(); 37 | config.vt100_output_spool_width.unwrap_or(VTERM_WIDTH) 38 | } 39 | } 40 | 41 | pub trait SessionSpool { 42 | /// Resizes the internal representation to new tty size. 43 | fn resize(&mut self, size: TtySize); 44 | 45 | /// Gets a byte sequence to restore the on-screen session content. 46 | /// 47 | /// The returned sequence is expected to be able to restore the screen 48 | /// content regardless of any prior screen state. It thus mostly likely 49 | /// includes some terminal control codes to reset the screen from any 50 | /// state back to a known good state. 51 | /// 52 | /// Note that what exactly is restored is determined by the implementation, 53 | /// and thus can vary from do nothing to a few lines to a full screen, 54 | /// etc. 55 | fn restore_buffer(&self) -> Vec; 56 | 57 | /// Process bytes from pty master. 58 | fn process(&mut self, bytes: &[u8]); 59 | } 60 | 61 | /// A spool that doesn't do anything. 62 | pub struct NullSpool; 63 | impl SessionSpool for NullSpool { 64 | fn resize(&mut self, _: TtySize) {} 65 | 66 | fn restore_buffer(&self) -> Vec { 67 | vec![] 68 | } 69 | 70 | fn process(&mut self, _: &[u8]) {} 71 | } 72 | 73 | /// A spool that restores the last screenful of content using shpool_vt100. 74 | pub struct Vt100Screen { 75 | parser: shpool_vt100::Parser, 76 | /// Other options will be read dynamically from config. 77 | config: config::Manager, 78 | } 79 | 80 | impl SessionSpool for Vt100Screen { 81 | fn resize(&mut self, size: TtySize) { 82 | self.parser.screen_mut().set_size(size.rows, self.config.vterm_width()); 83 | } 84 | 85 | fn restore_buffer(&self) -> Vec { 86 | let (rows, cols) = self.parser.screen().size(); 87 | info!("computing screen restore buf with (rows={}, cols={})", rows, cols); 88 | self.parser.screen().contents_formatted() 89 | } 90 | 91 | fn process(&mut self, bytes: &[u8]) { 92 | self.parser.process(bytes) 93 | } 94 | } 95 | 96 | /// A spool that restores the last n lines of content using shpool_vt100. 97 | pub struct Vt100Lines { 98 | parser: shpool_vt100::Parser, 99 | /// How many lines to restore 100 | nlines: u16, 101 | /// Other options will be read dynamically from config. 102 | config: config::Manager, 103 | } 104 | 105 | impl SessionSpool for Vt100Lines { 106 | fn resize(&mut self, size: TtySize) { 107 | self.parser.screen_mut().set_size(size.rows, self.config.vterm_width()); 108 | } 109 | 110 | fn restore_buffer(&self) -> Vec { 111 | let (rows, cols) = self.parser.screen().size(); 112 | info!("computing lines({}) restore buf with (rows={}, cols={})", self.nlines, rows, cols); 113 | self.parser.screen().last_n_rows_contents_formatted(self.nlines) 114 | } 115 | 116 | fn process(&mut self, bytes: &[u8]) { 117 | self.parser.process(bytes) 118 | } 119 | } 120 | 121 | /// Creates a spool given a `mode`. 122 | pub fn new( 123 | config: config::Manager, 124 | mode: &SessionRestoreMode, 125 | size: &TtySize, 126 | scrollback_lines: usize, 127 | ) -> Box { 128 | let vterm_width = config.vterm_width(); 129 | match mode { 130 | SessionRestoreMode::Simple => Box::new(NullSpool), 131 | SessionRestoreMode::Screen => Box::new(Vt100Screen { 132 | parser: shpool_vt100::Parser::new(size.rows, vterm_width, scrollback_lines), 133 | config, 134 | }), 135 | SessionRestoreMode::Lines(nlines) => Box::new(Vt100Lines { 136 | parser: shpool_vt100::Parser::new(size.rows, vterm_width, scrollback_lines), 137 | nlines: *nlines, 138 | config, 139 | }), 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /shpool/tests/support/events.rs: -------------------------------------------------------------------------------- 1 | use std::{io, io::BufRead, os::unix::net::UnixStream, path::Path, time}; 2 | 3 | use anyhow::anyhow; 4 | 5 | /// Event represents a stream of events you can wait for. 6 | /// 7 | /// To actually wait for a particular event, you should create 8 | /// an EventWaiter with the `waiter` or `await_event` routines. 9 | pub struct Events { 10 | lines: io::Lines>, 11 | } 12 | 13 | impl Events { 14 | pub fn new>(sock: P) -> anyhow::Result { 15 | let mut sleep_dur = time::Duration::from_millis(5); 16 | for _ in 0..12 { 17 | if let Ok(s) = UnixStream::connect(&sock) { 18 | return Ok(Events { lines: io::BufReader::new(s).lines() }); 19 | } else { 20 | std::thread::sleep(sleep_dur); 21 | sleep_dur *= 2; 22 | } 23 | } 24 | 25 | Err(anyhow!("timed out waiting for connection to event sock")) 26 | } 27 | 28 | /// waiter creates an event waiter that can later be used to 29 | /// block until the event occurs. You should generally call waiter 30 | /// before you take the action that will trigger the event in order 31 | /// to avoid race conditions. 32 | /// 33 | /// `events` should be a list of events to listen for, in order. 34 | /// You can wait for the events by calling methods on the EventWaiter, 35 | /// and you should make sure to use `wait_final_event` to get the 36 | /// Events struct back at the last event. 37 | pub fn waiter(mut self, events: SI) -> EventWaiter 38 | where 39 | S: Into, 40 | SI: IntoIterator, 41 | { 42 | let events: Vec = events.into_iter().map(|s| s.into()).collect(); 43 | assert!(!events.is_empty()); 44 | 45 | let (tx, rx) = crossbeam_channel::bounded(events.len()); 46 | let waiter = EventWaiter { matched: rx }; 47 | std::thread::spawn(move || { 48 | let mut return_lines = false; 49 | let mut offset = 0; 50 | 51 | 'LINELOOP: for line in &mut self.lines { 52 | match line { 53 | Ok(l) => { 54 | if events[offset] == l { 55 | if offset == events.len() - 1 { 56 | // this is the last event 57 | return_lines = true; 58 | break 'LINELOOP; 59 | } else { 60 | tx.send(WaiterEvent::Event(l)).unwrap(); 61 | } 62 | offset += 1; 63 | } 64 | } 65 | Err(e) => { 66 | eprintln!("error scanning for event '{}': {:?}", events[offset], e); 67 | } 68 | } 69 | } 70 | 71 | if return_lines { 72 | tx.send(WaiterEvent::Done((events[offset].clone(), self.lines))).unwrap(); 73 | } 74 | }); 75 | 76 | waiter 77 | } 78 | 79 | /// await_events waits for a given event on the stream. 80 | /// Prefer `waiter` since it is less prone to race conditions. 81 | /// `await_event` might be approriate for startup events where 82 | /// it is not possible to use `waiter`. 83 | pub fn await_event(&mut self, event: &str) -> anyhow::Result<()> { 84 | for line in &mut self.lines { 85 | let line = line?; 86 | if line == event { 87 | return Ok(()); 88 | } 89 | } 90 | 91 | Ok(()) 92 | } 93 | } 94 | 95 | /// EventWaiter represents waiting for a particular event. 96 | /// It should be converted back into an Events struct with 97 | /// the wait() routine. 98 | pub struct EventWaiter { 99 | matched: crossbeam_channel::Receiver, 100 | } 101 | 102 | enum WaiterEvent { 103 | Event(String), 104 | Done((String, io::Lines>)), 105 | } 106 | 107 | impl EventWaiter { 108 | pub fn wait_event(&mut self, event: &str) -> anyhow::Result<()> { 109 | eprintln!("waiting for event '{event}'"); 110 | match self.matched.recv()? { 111 | WaiterEvent::Event(e) => { 112 | if e == event { 113 | Ok(()) 114 | } else { 115 | Err(anyhow!("Got '{}' event, want '{}'", e, event)) 116 | } 117 | } 118 | WaiterEvent::Done((e, _)) => { 119 | if e == event { 120 | Ok(()) 121 | } else { 122 | Err(anyhow!("Got '{}' event, want '{}'", e, event)) 123 | } 124 | } 125 | } 126 | } 127 | 128 | pub fn wait_final_event(self, event: &str) -> anyhow::Result { 129 | eprintln!("waiting for final event '{event}'"); 130 | match self.matched.recv()? { 131 | WaiterEvent::Event(e) => { 132 | Err(anyhow!("Got non-fianl '{}' event, want final '{}'", e, event)) 133 | } 134 | WaiterEvent::Done((e, lines)) => { 135 | if e == event { 136 | Ok(Events { lines }) 137 | } else { 138 | Err(anyhow!("Got '{}' event, want '{}'", e, event)) 139 | } 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /libshpool/src/test_hooks.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // tooling gets confused by the conditional compilation 16 | #![allow(dead_code)] 17 | 18 | // The test_hooks module provides a mechanism for exposing events to 19 | // the test harness so that it does not have to rely on buggy and slow 20 | // sleeps in order to test various scenarios. The basic idea is that 21 | // we publish a unix socket and then clients can listen for specific 22 | // named events in order to block until they have occurred. 23 | use std::{ 24 | io::Write, 25 | os::unix::net::{UnixListener, UnixStream}, 26 | sync::Mutex, 27 | time, 28 | }; 29 | 30 | use anyhow::{anyhow, Context}; 31 | use tracing::{error, info}; 32 | 33 | #[cfg(feature = "test_hooks")] 34 | pub fn emit(event: &str) { 35 | let sock_path = TEST_HOOK_SERVER.sock_path.lock().unwrap(); 36 | if sock_path.is_some() { 37 | TEST_HOOK_SERVER.emit_event(event); 38 | } 39 | } 40 | 41 | #[cfg(not(feature = "test_hooks"))] 42 | pub fn emit(_event: &str) { 43 | // a no-op normally 44 | } 45 | 46 | #[cfg(feature = "test_hooks")] 47 | pub fn scoped(event: &str) -> ScopedEvent { 48 | ScopedEvent::new(event) 49 | } 50 | 51 | #[cfg(not(feature = "test_hooks"))] 52 | pub fn scoped(_event: &str) {} 53 | 54 | /// ScopedEvent emits an event when it goes out of scope 55 | pub struct ScopedEvent<'a> { 56 | event: &'a str, 57 | } 58 | 59 | impl<'a> ScopedEvent<'a> { 60 | pub fn new(event: &'a str) -> Self { 61 | ScopedEvent { event } 62 | } 63 | } 64 | 65 | impl std::ops::Drop for ScopedEvent<'_> { 66 | fn drop(&mut self) { 67 | emit(self.event); 68 | } 69 | } 70 | 71 | lazy_static::lazy_static! { 72 | pub static ref TEST_HOOK_SERVER: TestHookServer = TestHookServer::new(); 73 | } 74 | 75 | pub struct TestHookServer { 76 | sock_path: Mutex>, 77 | clients: Mutex>, 78 | } 79 | 80 | impl TestHookServer { 81 | fn new() -> Self { 82 | TestHookServer { sock_path: Mutex::new(None), clients: Mutex::new(vec![]) } 83 | } 84 | 85 | pub fn set_socket_path(&self, path: String) { 86 | let mut sock_path = self.sock_path.lock().unwrap(); 87 | *sock_path = Some(path); 88 | } 89 | 90 | pub fn wait_for_connect(&self) -> anyhow::Result<()> { 91 | let mut sleep_dur = time::Duration::from_millis(5); 92 | for _ in 0..12 { 93 | { 94 | let clients = self.clients.lock().unwrap(); 95 | if !clients.is_empty() { 96 | return Ok(()); 97 | } 98 | } 99 | 100 | std::thread::sleep(sleep_dur); 101 | sleep_dur *= 2; 102 | } 103 | 104 | Err(anyhow!("no connection to test hook server")) 105 | } 106 | 107 | /// start is the background thread to listen on a unix socket 108 | /// for a test harness to dial in so it can wait for events. 109 | /// The caller is responsible for spawning the worker thread. 110 | /// Events are pushed to everyone who has dialed in as a 111 | /// newline delimited stream of event tags. 112 | pub fn start(&self) { 113 | let sock_path: String; 114 | { 115 | let sock_path_m = self.sock_path.lock().unwrap(); 116 | match &*sock_path_m { 117 | Some(s) => { 118 | sock_path = String::from(s); 119 | } 120 | None => { 121 | error!("you must call set_socket_path before calling start"); 122 | return; 123 | } 124 | }; 125 | } 126 | 127 | let listener = match UnixListener::bind(&sock_path).context("binding to socket") { 128 | Ok(l) => l, 129 | Err(e) => { 130 | error!("error binding to test hook socket: {:?}", e); 131 | return; 132 | } 133 | }; 134 | info!("listening for test hook connections on {}", &sock_path); 135 | for stream in listener.incoming() { 136 | info!("accepted new test hook client"); 137 | let stream = match stream { 138 | Ok(s) => s, 139 | Err(e) => { 140 | error!("error accepting connection to test hook server: {:?}", e); 141 | continue; 142 | } 143 | }; 144 | let mut clients = self.clients.lock().unwrap(); 145 | clients.push(stream); 146 | } 147 | } 148 | 149 | fn emit_event(&self, event: &str) { 150 | info!("emitting event '{}'", event); 151 | let event_line = format!("{event}\n"); 152 | let clients = self.clients.lock().unwrap(); 153 | for mut client in clients.iter() { 154 | if let Err(e) = client.write_all(event_line.as_bytes()) { 155 | error!("error emitting '{}' event: {:?}", event, e); 156 | } 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /CONFIG.md: -------------------------------------------------------------------------------- 1 | # config 2 | 3 | The canonical documentation of shpool's config is the comments 4 | on the `Config` struct defined in `libshpool/src/config.rs`, but 5 | this document aims to provide some high level explanations of 6 | some common configuration options. 7 | 8 | You can specify the path to your config file by passing a 9 | `-c /path/to/config.toml` flag, or by creating and 10 | editing `~/.config/shpool/config.toml`. 11 | 12 | ## Prompt Prefix 13 | 14 | By default, `shpool` will detect when you are using a shell it knows 15 | how to inject a prompt into. Currently, those shells include `bash`, 16 | `zsh` and `fish`, but more may be added in the future. If it noticed 17 | you are using one such shell, it will inject the prompt prefix 18 | `shpool:$SHPOOL_SESSION_NAME` at the beginning of your prompt 19 | in order to hint to you when you are inside of a `shpool` session. 20 | 21 | You can customize this prompt prefix by setting a new value in 22 | your config. For example, to show the `shpool` session name 23 | inside square brackets, you can put 24 | 25 | ``` 26 | prompt_prefix = "[$SHPOOL_SESSION_NAME]" 27 | ``` 28 | 29 | in your config file. If you want to instead completely suppress 30 | the prompt injection, you can just set a blank `prompt_prefix` 31 | with 32 | 33 | ``` 34 | prompt_prefix = "" 35 | ``` 36 | 37 | this allows you to write a custom prompt hook in your .rc files 38 | that examines the `$SHPOOL_SESSION_NAME` environment variable 39 | directly, or eschew a `shpool` prompt customization entirely. 40 | 41 | ## Session Restore Mode 42 | 43 | `shpool` can do a few different things when you re-attach to an existing 44 | session. You can choose what you want it to do with the `session_restore_mode` 45 | configuration option. 46 | 47 | ### `"screen"` (default) - restore a screenful of history 48 | 49 | The `"screen"` option causes `shpool` to re-draw sufficient output to fill the 50 | entire screen of the client terminal as well as using the SIGWINCH trick 51 | described in the `"simple"` section below. This will help restore 52 | context for interactive terminal sessions that are not full blown ncurses 53 | apps. `"screen"` is the default reattach behavior for `shpool`. 54 | You can choose this option explicitly by adding 55 | 56 | ``` 57 | session_restore_mode = "screen" 58 | ``` 59 | 60 | to your `~/.config/shpool/config.toml`. 61 | 62 | ### `"simple"` - only ask child processes to redraw 63 | 64 | The `"simple"` option avoids restoring any output. In this reconnect mode, `shpool` will 65 | issue some SIGWINCH signals to try to convince full screen ncurses apps 66 | such as vim or emacs to re-draw the screen, but will otherwise do nothing. 67 | Any shell output produced when there was no client connected to the session 68 | will be lost. You can choose this connection mode by adding 69 | 70 | ``` 71 | session_restore_mode = "simple" 72 | ``` 73 | 74 | to your `~/.config/shpool/config.toml`. 75 | 76 | ### `{ lines = n }` - restore the last n lines of history 77 | 78 | The lines option is much like the `"screen"` option, except that rather 79 | than just a screenful of text, it restores the last n lines of text 80 | from the terminal being re-attached to. This could be useful if you 81 | wish to have more context than a single screenful of text. Note that 82 | n cannot exceed the value of the `output_spool_lines` configuration 83 | option, but it defaults to the value of the lines option, so you likely 84 | won't need to change it. 85 | 86 | ``` 87 | session_restore_mode = { lines = n } 88 | ``` 89 | 90 | where n is a number to your `~/.config/shpool/config.toml`. 91 | 92 | ## Detach Keybinding 93 | 94 | You may wish to configure your detach keybinding. 95 | By default, `shpool` will detach from the current user session when you 96 | press the sequence `Ctrl-Space Ctrl-q` (press `Ctrl-Space` then release 97 | it and press `Ctrl-q`, don't try to hold down all three keys at once), 98 | but you can configure a different binding by adding an entry 99 | like 100 | 101 | ``` 102 | [[keybinding]] 103 | binding = "Ctrl-a d" 104 | action = "detach" 105 | ``` 106 | 107 | to your `~/.config/shpool/config.toml`. 108 | 109 | For the moment, control is the only modifier key supported, but the keybinding 110 | engine is designed to be able to handle more, so if you want a different one, 111 | you can file a bug with your feature request. 112 | 113 | ## motd 114 | 115 | `shpool` has support for displaying the message of the day (the message `sshd` 116 | shows you when you first log into a system). This is most relevant to users 117 | in institutional settings where important information gets communicated 118 | via the message of the day. 119 | 120 | ### never mode 121 | 122 | ``` 123 | motd = "never" 124 | ``` 125 | 126 | currently, this is the default mode. In this mode, the message of the day will 127 | not be shown by `shpool`. 128 | 129 | ### dump mode 130 | 131 | ``` 132 | motd = "dump" 133 | ``` 134 | 135 | in dump mode, `shpool` will dump out the motd inline the first time you 136 | start a new session, but you will not see it when you re-attach to an 137 | existing session. 138 | 139 | ### pager mode 140 | 141 | ``` 142 | [motd.pager] 143 | bin = "less" 144 | ``` 145 | 146 | in pager mode, `shpool` will display the message of the day in a configurable 147 | pager program. The pager must accept a file name to display as its first argument. 148 | `shpool` will launch the pager in a pty and wait until it exits before moving 149 | on to the actual terminal session. Pager mode is more disruptive than 150 | dump mode, but it allows shpool to show you the motd even if you have a single 151 | long running session you keep around for months and continually reattach to. 152 | -------------------------------------------------------------------------------- /libshpool/src/daemon/ttl_reaper.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | /*! The ttl reaper is responsible to reaping sessions which 16 | have a ttl set. It uses a channel for a mailbox to listen 17 | for newly woken threads, adds a generation id to session 18 | names to avoid clobbering fresh session with the same 19 | session name as a previous session, and uses a min heap 20 | to schedule wakeups in order to reap threads on time. 21 | */ 22 | 23 | use std::{ 24 | cmp, 25 | collections::{BinaryHeap, HashMap}, 26 | sync::{Arc, Mutex}, 27 | time::Instant, 28 | }; 29 | 30 | use tracing::{info, span, warn, Level}; 31 | 32 | use super::shell; 33 | 34 | /// Run the reaper thread loop. Should be invoked in a dedicated 35 | /// thread. 36 | pub fn run( 37 | new_sess: crossbeam_channel::Receiver<(String, Instant)>, 38 | shells: Arc>>>, 39 | ) -> anyhow::Result<()> { 40 | let _s = span!(Level::INFO, "ttl_reaper").entered(); 41 | 42 | let mut heap = BinaryHeap::new(); 43 | let mut gen_ids = HashMap::new(); 44 | 45 | loop { 46 | // empty heap loop, just waiting for new sessions to watch 47 | while heap.is_empty() { 48 | match new_sess.recv() { 49 | Ok((session_name, reap_at)) => { 50 | let gen_id = gen_ids.entry(session_name.clone()).or_insert(0); 51 | *gen_id += 1; 52 | info!( 53 | "scheduling first sess {}:{} to be reaped at {:?}", 54 | &session_name, *gen_id, reap_at 55 | ); 56 | heap.push(Reapable { session_name, gen_id: *gen_id, reap_at }); 57 | } 58 | Err(crossbeam_channel::RecvError) => { 59 | info!("bailing due to RecvError in empty heap loop"); 60 | return Ok(()); 61 | } 62 | } 63 | } 64 | 65 | while !heap.is_empty() { 66 | let wake_at = if let Some(reapable) = heap.peek() { 67 | reapable.reap_at 68 | } else { 69 | warn!("no reapable even with heap len {}, should be impossible", heap.len()); 70 | continue; 71 | }; 72 | 73 | crossbeam_channel::select! { 74 | recv(new_sess) -> new_sess_msg => { 75 | match new_sess_msg { 76 | Ok((session_name, reap_at)) => { 77 | let gen_id = gen_ids.entry(session_name.clone()).or_insert(0); 78 | *gen_id += 1; 79 | info!("scheduling {}:{} to be reaped at {:?}", 80 | &session_name, *gen_id, reap_at); 81 | heap.push(Reapable { 82 | session_name, 83 | gen_id: *gen_id, 84 | reap_at, 85 | }); 86 | } 87 | Err(crossbeam_channel::RecvError) => { 88 | info!("bailing due to RecvError"); 89 | return Ok(()) 90 | }, 91 | } 92 | } 93 | recv(crossbeam_channel::at(wake_at)) -> _ => { 94 | let reapable = heap.pop() 95 | .expect("there to be an entry in a non-empty heap"); 96 | info!("waking up to reap {:?}", reapable); 97 | let current_gen = gen_ids.get(&reapable.session_name) 98 | .copied().unwrap_or(0); 99 | if current_gen != reapable.gen_id { 100 | info!("ignoring {}:{} because current gen is {:?}", 101 | &reapable.session_name, reapable.gen_id, current_gen); 102 | continue; 103 | } 104 | 105 | let _s = span!(Level::INFO, "lock(shells)").entered(); 106 | let mut shells = shells.lock().unwrap(); 107 | if let Some(sess) = shells.get(&reapable.session_name) { 108 | if let Err(e) = sess.kill() { 109 | warn!("error trying to kill '{}': {:?}", 110 | reapable.session_name, e); 111 | } 112 | } else { 113 | warn!("tried to kill '{}' but it wasn't in the shells tab", 114 | reapable.session_name); 115 | continue; 116 | } 117 | shells.remove(&reapable.session_name); 118 | } 119 | } 120 | } 121 | } 122 | } 123 | 124 | /// A record in the min heap that we use to track the 125 | /// sessions that need to be cleaned up. 126 | #[derive(Debug)] 127 | struct Reapable { 128 | session_name: String, 129 | gen_id: usize, 130 | reap_at: Instant, 131 | } 132 | 133 | impl cmp::PartialEq for Reapable { 134 | fn eq(&self, rhs: &Reapable) -> bool { 135 | self.reap_at == rhs.reap_at 136 | } 137 | } 138 | impl cmp::Eq for Reapable {} 139 | 140 | impl cmp::PartialOrd for Reapable { 141 | fn partial_cmp(&self, other: &Reapable) -> Option { 142 | Some(self.cmp(other)) 143 | } 144 | } 145 | 146 | impl cmp::Ord for Reapable { 147 | fn cmp(&self, other: &Reapable) -> cmp::Ordering { 148 | // flip the ordering to make a min heap 149 | other.reap_at.cmp(&self.reap_at) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /shpool/tests/list.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | use anyhow::Context; 4 | use ntest::timeout; 5 | use regex::Regex; 6 | 7 | mod support; 8 | 9 | use crate::support::daemon::DaemonArgs; 10 | 11 | #[test] 12 | #[timeout(30000)] 13 | fn empty() -> anyhow::Result<()> { 14 | support::dump_err(|| { 15 | let mut daemon_proc = support::daemon::Proc::new( 16 | "norc.toml", 17 | DaemonArgs { listen_events: false, ..DaemonArgs::default() }, 18 | ) 19 | .context("starting daemon proc")?; 20 | let out = daemon_proc.list()?; 21 | assert!(out.status.success(), "list proc did not exit successfully"); 22 | 23 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 24 | assert_eq!(stderr.len(), 0, "expected no stderr"); 25 | 26 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 27 | assert!(stdout.contains("NAME")); 28 | assert!(stdout.contains("STARTED_AT")); 29 | 30 | Ok(()) 31 | }) 32 | } 33 | 34 | #[test] 35 | #[timeout(30000)] 36 | fn version_mismatch_client_newer() -> anyhow::Result<()> { 37 | support::dump_err(|| { 38 | let mut daemon_proc = support::daemon::Proc::new( 39 | "norc.toml", 40 | DaemonArgs { 41 | listen_events: false, 42 | extra_env: vec![( 43 | String::from("SHPOOL_TEST__OVERRIDE_VERSION"), 44 | String::from("0.0.0"), 45 | )], 46 | ..Default::default() 47 | }, 48 | ) 49 | .context("starting daemon proc")?; 50 | let out = daemon_proc.list()?; 51 | assert!(out.status.success(), "list proc did not exit successfully"); 52 | 53 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 54 | assert!(stderr.contains("is newer")); 55 | assert!(stderr.contains("try restarting")); 56 | 57 | Ok(()) 58 | }) 59 | } 60 | 61 | #[test] 62 | #[timeout(30000)] 63 | fn version_mismatch_client_older() -> anyhow::Result<()> { 64 | support::dump_err(|| { 65 | let mut daemon_proc = support::daemon::Proc::new( 66 | "norc.toml", 67 | DaemonArgs { 68 | listen_events: false, 69 | extra_env: vec![( 70 | String::from("SHPOOL_TEST__OVERRIDE_VERSION"), 71 | String::from("99999.0.0"), 72 | )], 73 | ..Default::default() 74 | }, 75 | ) 76 | .context("starting daemon proc")?; 77 | let out = daemon_proc.list()?; 78 | assert!(out.status.success(), "list proc did not exit successfully"); 79 | 80 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 81 | assert!(stderr.contains("is older")); 82 | assert!(stderr.contains("try restarting")); 83 | 84 | Ok(()) 85 | }) 86 | } 87 | 88 | #[test] 89 | #[timeout(30000)] 90 | fn no_daemon() -> anyhow::Result<()> { 91 | support::dump_err(|| { 92 | let out = Command::new(support::shpool_bin()?) 93 | .arg("--socket") 94 | .arg("/fake/does/not/exist/shpool.socket") 95 | .arg("--no-daemonize") 96 | .arg("list") 97 | .output() 98 | .context("spawning list proc")?; 99 | 100 | assert!(!out.status.success(), "list proc exited successfully"); 101 | 102 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 103 | assert!(stderr.contains("could not connect to daemon")); 104 | 105 | Ok(()) 106 | }) 107 | } 108 | 109 | #[test] 110 | #[timeout(30000)] 111 | fn one_session() -> anyhow::Result<()> { 112 | support::dump_err(|| { 113 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 114 | .context("starting daemon proc")?; 115 | let bidi_enter_w = daemon_proc.events.take().unwrap().waiter(["daemon-bidi-stream-enter"]); 116 | 117 | let _sess1 = daemon_proc.attach("sh1", Default::default())?; 118 | 119 | daemon_proc.events = Some(bidi_enter_w.wait_final_event("daemon-bidi-stream-enter")?); 120 | 121 | let out = daemon_proc.list()?; 122 | assert!(out.status.success(), "list proc did not exit successfully"); 123 | 124 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 125 | assert_eq!(stderr.len(), 0, "expected no stderr"); 126 | 127 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 128 | assert!(stdout.contains("sh1")); 129 | 130 | Ok(()) 131 | }) 132 | } 133 | 134 | #[test] 135 | #[timeout(30000)] 136 | fn two_sessions() -> anyhow::Result<()> { 137 | support::dump_err(|| { 138 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 139 | .context("starting daemon proc")?; 140 | let mut bidi_enter_w = daemon_proc.events.take().unwrap().waiter([ 141 | "daemon-bidi-stream-enter", 142 | "daemon-bidi-stream-enter", 143 | "daemon-bidi-stream-done", 144 | ]); 145 | 146 | let _sess1 = daemon_proc.attach("sh1", Default::default())?; 147 | 148 | bidi_enter_w.wait_event("daemon-bidi-stream-enter")?; 149 | 150 | { 151 | let _sess2 = daemon_proc.attach("sh2", Default::default())?; 152 | 153 | bidi_enter_w.wait_event("daemon-bidi-stream-enter")?; 154 | 155 | let out = daemon_proc.list()?; 156 | assert!(out.status.success(), "list proc did not exit successfully"); 157 | 158 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 159 | assert_eq!(stderr.len(), 0, "expected no stderr"); 160 | 161 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 162 | assert!(stdout.contains("sh1")); 163 | assert!(stdout.contains("sh2")); 164 | } 165 | 166 | // wait for the hangup to complete 167 | bidi_enter_w.wait_event("daemon-bidi-stream-done")?; 168 | 169 | let out = daemon_proc.list()?; 170 | assert!(out.status.success(), "list proc did not exit successfully"); 171 | 172 | let sh1_re = Regex::new("sh1.*attached")?; 173 | let sh2_re = Regex::new("sh2.*disconnected")?; 174 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 175 | dbg!(&stdout); 176 | assert!(sh1_re.is_match(&stdout)); 177 | assert!(sh2_re.is_match(&stdout)); 178 | 179 | Ok(()) 180 | }) 181 | } 182 | -------------------------------------------------------------------------------- /shpool/tests/support/line_matcher.rs: -------------------------------------------------------------------------------- 1 | use std::{io, io::BufRead, time}; 2 | 3 | use anyhow::{anyhow, Context}; 4 | use regex::Regex; 5 | 6 | const CMD_READ_TIMEOUT: time::Duration = time::Duration::from_secs(3); 7 | const CMD_READ_SLEEP_DUR: time::Duration = time::Duration::from_millis(20); 8 | 9 | pub struct LineMatcher { 10 | pub out: io::BufReader, 11 | /// A list of regular expressions which should never match. 12 | pub never_match_regex: Vec, 13 | } 14 | 15 | impl LineMatcher 16 | where 17 | R: std::io::Read, 18 | { 19 | /// Add a pattern to check to ensure that it never matches. 20 | /// 21 | /// NOTE: this will cause the line matcher to consume the whole 22 | /// output rather than stopping reading at the last match. 23 | pub fn never_matches(&mut self, re: &str) -> anyhow::Result<()> { 24 | let compiled_re = Regex::new(re)?; 25 | self.never_match_regex.push(compiled_re); 26 | 27 | Ok(()) 28 | } 29 | 30 | /// Scan lines until one matches the given regex 31 | pub fn scan_until_re(&mut self, re: &str) -> anyhow::Result<()> { 32 | let compiled_re = Regex::new(re)?; 33 | let start = time::Instant::now(); 34 | loop { 35 | let mut line = String::new(); 36 | match self.out.read_line(&mut line) { 37 | Ok(0) => { 38 | return Err(anyhow!("LineMatcher: EOF")); 39 | } 40 | Err(e) => { 41 | if e.kind() == io::ErrorKind::WouldBlock { 42 | if start.elapsed() > CMD_READ_TIMEOUT { 43 | return Err(io::Error::new( 44 | io::ErrorKind::TimedOut, 45 | "timed out reading line", 46 | ))?; 47 | } 48 | 49 | std::thread::sleep(CMD_READ_SLEEP_DUR); 50 | continue; 51 | } 52 | 53 | return Err(e).context("reading line from shell output")?; 54 | } 55 | Ok(_) => { 56 | if line.ends_with('\n') { 57 | line.pop(); 58 | if line.ends_with('\r') { 59 | line.pop(); 60 | } 61 | } 62 | } 63 | } 64 | 65 | self.check_persistant_assertions(&line)?; 66 | 67 | eprint!("scanning for /{re}/... "); 68 | if compiled_re.is_match(&line) { 69 | eprintln!(" match"); 70 | return Ok(()); 71 | } else { 72 | eprintln!(" no match"); 73 | } 74 | } 75 | } 76 | 77 | pub fn match_re(&mut self, re: &str) -> anyhow::Result<()> { 78 | match self.capture_re(re) { 79 | Ok(_) => Ok(()), 80 | Err(e) => Err(e), 81 | } 82 | } 83 | 84 | pub fn capture_re(&mut self, re: &str) -> anyhow::Result>> { 85 | let start = time::Instant::now(); 86 | loop { 87 | let mut line = String::new(); 88 | match self.out.read_line(&mut line) { 89 | Ok(0) => { 90 | return Err(anyhow!("LineMatcher: EOF")); 91 | } 92 | Err(e) => { 93 | if e.kind() == io::ErrorKind::WouldBlock { 94 | if start.elapsed() > CMD_READ_TIMEOUT { 95 | return Err(io::Error::new( 96 | io::ErrorKind::TimedOut, 97 | "timed out reading line", 98 | ))?; 99 | } 100 | 101 | std::thread::sleep(CMD_READ_SLEEP_DUR); 102 | continue; 103 | } 104 | 105 | return Err(e).context("reading line from shell output")?; 106 | } 107 | Ok(_) => { 108 | if line.ends_with('\n') { 109 | line.pop(); 110 | if line.ends_with('\r') { 111 | line.pop(); 112 | } 113 | } 114 | } 115 | } 116 | 117 | self.check_persistant_assertions(&line)?; 118 | 119 | // Don't print the whole line so we don't include any control codes. 120 | // eprintln!("testing /{}/ against '{}'", re, &line); 121 | eprintln!("testing /{re}/ against line"); 122 | return match Regex::new(re)?.captures(&line) { 123 | Some(caps) => Ok(caps 124 | .iter() 125 | .map(|maybe_match| maybe_match.map(|m| String::from(m.as_str()))) 126 | .collect()), 127 | None => Err(anyhow!("expected /{}/ to match '{}'", re, &line)), 128 | }; 129 | } 130 | } 131 | 132 | /// Scan through all the remaining lines and ensure that no persistant 133 | /// assertions fail (the never match regex). 134 | pub fn drain(&mut self) -> anyhow::Result<()> { 135 | let start = time::Instant::now(); 136 | loop { 137 | let mut line = String::new(); 138 | match self.out.read_line(&mut line) { 139 | Ok(0) => { 140 | return Ok(()); 141 | } 142 | Err(e) => { 143 | if e.kind() == io::ErrorKind::WouldBlock { 144 | if start.elapsed() > CMD_READ_TIMEOUT { 145 | return Err(io::Error::new( 146 | io::ErrorKind::TimedOut, 147 | "timed out reading line", 148 | ))?; 149 | } 150 | 151 | std::thread::sleep(CMD_READ_SLEEP_DUR); 152 | continue; 153 | } 154 | 155 | return Err(e).context("reading line from shell output")?; 156 | } 157 | Ok(_) => { 158 | if line.ends_with('\n') { 159 | line.pop(); 160 | if line.ends_with('\r') { 161 | line.pop(); 162 | } 163 | } 164 | } 165 | } 166 | 167 | self.check_persistant_assertions(&line)?; 168 | } 169 | } 170 | 171 | fn check_persistant_assertions(&self, line: &str) -> anyhow::Result<()> { 172 | for nomatch_re in self.never_match_regex.iter() { 173 | if nomatch_re.is_match(line) { 174 | return Err(anyhow!("expected /{}/ never to match, but it did", nomatch_re)); 175 | } 176 | } 177 | 178 | Ok(()) 179 | } 180 | } 181 | 182 | impl std::ops::Drop for LineMatcher 183 | where 184 | R: std::io::Read, 185 | { 186 | fn drop(&mut self) { 187 | if !self.never_match_regex.is_empty() { 188 | if let Err(e) = self.drain() { 189 | panic!("assertion failure during drain: {e:?}"); 190 | } 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /HACKING.md: -------------------------------------------------------------------------------- 1 | 2 | # Hacking 3 | 4 | Some tips for working on shpool. 5 | 6 | ## Installing From Source 7 | 8 | ### Install a rust toolchain 9 | 10 | If you have not already done so, install a rust toolchain. 11 | The minimum rust version for shpool is `1.74.0`, so make sure that 12 | `cargo --version` reports that version or higher before attempting 13 | to build shpool. The easiest way to install an up to date 14 | rust toolchain is with [`rustup`](https://rustup.rs/), 15 | a nice tool maintained by the rust project that allows 16 | you to easily use different toolchain versions. 17 | 18 | Make sure that `~/.cargo/bin` is on you `PATH` so you can use 19 | binaries installed with cargo. An entry like 20 | 21 | ``` 22 | $ source "$HOME/.cargo/env" 23 | ``` 24 | 25 | in your `.profile` file should do the trick. 26 | 27 | ### Build `shpool` 28 | 29 | To build and install `shpool` run 30 | 31 | ``` 32 | $ cargo build --release 33 | $ cp target/release/shpool ~/.cargo/bin/shpool 34 | ``` 35 | 36 | ### Install the systemd user service unit file 37 | 38 | A convenient way to run the shpool daemon is to use systemd 39 | to start and run it as a user-level systemd service. You 40 | can use the `systemd/shpool.{service,socket}` files 41 | to do this. Install it by running 42 | 43 | ``` 44 | $ mkdir -p ~/.config/systemd/user 45 | $ cp systemd/* ~/.config/systemd/user 46 | ``` 47 | 48 | enable and start it up with 49 | 50 | ``` 51 | $ systemctl --user enable shpool 52 | $ systemctl --user start shpool 53 | ``` 54 | 55 | ## Formatting 56 | 57 | Run `cargo +nightly fmt` to ensure that the code matches the expected 58 | style. 59 | 60 | ## Version Policy 61 | 62 | Like most rust projects, shpool follows semver. The libshpool and 63 | shpool crates are always kept in version lockstep, while other 64 | supporting crates in the shpool project evolve independently 65 | depending on how their own APIs change. 66 | 67 | It can be a bit murky what counts as a breaking change given the fact 68 | that shpool is a binary where most of the logic lives inside a library. 69 | This policy lays out the API surfaces that we consider public for the 70 | purposes of libshpool/shpool version. 71 | 72 | ### Public interfaces 73 | 74 | - The `libshpool` crate's rust API 75 | - The command line interface for the `shpool` binary 76 | - The config file format (any changes in default values for config entries is 77 | considered breaking) 78 | 79 | ### Non-public interfaces 80 | 81 | This list is non-exhaustive, but is meant to provide some examples of 82 | places where changes are not considered breaking for the purposes 83 | of semver. 84 | 85 | - The attach process to daemon process protocol (the shpool-protocol crate) 86 | - Specifics about how the prompt hook works 87 | - Specifics about how the session restore engine works 88 | - MSRV 89 | 90 | ## MSRV Policy 91 | 92 | We aim to maintain a significant lag behind the latest rust stable for our 93 | MSRV (minimum supported rust version). This is to enable users with older 94 | toolchains who don't want to use rustup to build and package shpool. We 95 | target the [debian stable rust version](https://tracker.debian.org/pkg/rustc) 96 | (though as of this writing we are 11 versions ahead and will take some time 97 | to get there). This target is not set in stone and may need to change 98 | due to unforeseen circumstances. 99 | 100 | ## Commit message style 101 | 102 | https://www.conventionalcommits.org/ is used to facilitate changelog generation. 103 | 104 | ## Release 105 | 106 | [release-plz](https://release-plz.ieni.dev/) is used to manage the release 107 | process. It will create a release PR and keep it updated with any commits to 108 | the `master` branch. When the PR is merged, `release-plz` creates the tag and 109 | the release on GitHub and publishes the creates to creates.io. 110 | 111 | See https://release-plz.ieni.dev/ for more details. 112 | 113 | ## Measuring Latency 114 | 115 | To check e2e latency, you can use the 116 | [sshping](https://github.com/spook/sshping) tool to compare latency 117 | between a raw ssh connection and one using shpool. First, get the 118 | baseline measurement by running 119 | 120 | ``` 121 | sshping -H $REMOTE_HOST 122 | ``` 123 | 124 | on your local machine. Now get a comparison by shelling into your 125 | remote host and starting a shpool session called `sshping` with 126 | `shpool attach sshping`. In this session, run `cat > /dev/null` 127 | to set up a tty that will just echo back chars. Now on your local 128 | machine, run 129 | 130 | ``` 131 | sshping -H -e '/path/to/shpool attach -f sshping' $REMOTE_HOST 132 | ``` 133 | 134 | to collect latency measurements with shpool in the loop. 135 | 136 | Some latency measurements I collected this way are: 137 | 138 | ``` 139 | $ sshping -H $REMOTE_HOST 140 | ssh-Login-Time: 3.99 s 141 | Minimum-Latency: 24.2 ms 142 | Median-Latency: 26.6 ms 143 | Average-Latency: 27.1 ms 144 | Average-Deviation: 7.85 ms 145 | Maximum-Latency: 180 ms 146 | Echo-Count: 1.00 kB 147 | Upload-Size: 8.00 MB 148 | Upload-Rate: 5.41 MB/s 149 | Download-Size: 8.00 MB 150 | Download-Rate: 7.06 MB/s 151 | $ sshping -H -e '/path/to/shpool attach -f sshping' $REMOTE_HOST 152 | ssh-Login-Time: 5.17 s 153 | Minimum-Latency: 24.4 ms 154 | Median-Latency: 25.7 ms 155 | Average-Latency: 25.9 ms 156 | Average-Deviation: 1.19 ms 157 | Maximum-Latency: 50.8 ms 158 | Echo-Count: 1.00 kB 159 | Upload-Size: 8.00 MB 160 | Upload-Rate: 5.48 MB/s 161 | Download-Size: 8.00 MB 162 | Download-Rate: 7.31 MB/s 163 | ``` 164 | 165 | pretty good. 166 | 167 | ## Debugging with `rr` 168 | 169 | The `rr` tool allows you to record and replay executions under a debugger, 170 | which allows you to do fun stuff like step backwards. Additionally, when 171 | `rr` records a trace, it records the trace for the whole process tree, so 172 | you can debug events that happen in subprocesses. `rr` only works on Linux, 173 | and requires certain performance counters, so it does not work well in 174 | many virtualized environments. 175 | 176 | To record a test under `rr`, build the test binary with 177 | 178 | ``` 179 | $ cargo test --test --no-run 180 | ``` 181 | 182 | then run 183 | 184 | ``` 185 | $ SHPOOL_LEAVE_TEST_LOGS=true rr ./path/to/test/exe --nocapture 186 | ``` 187 | 188 | to replay, inspecting a subprocess, first run 189 | 190 | ``` 191 | $ rr ps 192 | ``` 193 | 194 | to view all the various processes that got launched, then run 195 | 196 | ``` 197 | $ rr replay --debugger=rust-gdb --onprocess= 198 | ``` 199 | 200 | where `` is taken from the output of `rr ps`. 201 | 202 | ## Preserving Logs in Tests 203 | 204 | By default, tests will clean up log files emitted by the various 205 | shpool subprocesses they spawn. In order get the tests to leave 206 | log files around for later inspection, you can set the 207 | `SHPOOL_LEAVE_TEST_LOGS` environment variable to `true`. 208 | 209 | For example to run `happy_path` from the `attach` suite and 210 | leave log files in place you might run 211 | 212 | ``` 213 | $ SHPOOL_LEAVE_TEST_LOGS=true cargo test --test attach happy_path -- --nocapture 214 | ``` 215 | -------------------------------------------------------------------------------- /libshpool/src/daemon/show_motd.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{ 16 | ffi::OsString, 17 | io, 18 | os::unix::net::UnixStream, 19 | sync::{Arc, Mutex}, 20 | time, 21 | }; 22 | 23 | use anyhow::{anyhow, Context}; 24 | use shpool_protocol::{Chunk, ChunkKind, TtySize}; 25 | use tracing::{info, instrument}; 26 | 27 | use crate::{ 28 | config, 29 | daemon::pager::{Pager, PagerCtl}, 30 | duration, 31 | protocol::ChunkExt as _, 32 | }; 33 | 34 | /// Showers know how to show the message of the day. 35 | #[derive(Debug, Clone)] 36 | pub struct DailyMessenger { 37 | motd_resolver: motd::Resolver, 38 | config: config::Manager, 39 | debouncer: Option, 40 | } 41 | 42 | impl DailyMessenger { 43 | /// Make a new DailyMessenger. 44 | pub fn new(config: config::Manager) -> anyhow::Result { 45 | let debouncer = { 46 | let config_ref = config.get(); 47 | match config_ref.motd.clone().unwrap_or_default() { 48 | config::MotdDisplayMode::Pager { show_every: Some(dur), .. } => { 49 | Some(Debouncer::new(duration::parse(&dur).context("parsing debounce dur")?)) 50 | } 51 | _ => None, 52 | } 53 | }; 54 | 55 | Ok(DailyMessenger { 56 | motd_resolver: motd::Resolver::new().context("creating motd resolver")?, 57 | config, 58 | debouncer, 59 | }) 60 | } 61 | 62 | #[instrument(skip_all)] 63 | pub fn dump( 64 | &self, 65 | mut stream: W, 66 | term_db: &termini::TermInfo, 67 | ) -> anyhow::Result<()> { 68 | assert!(matches!( 69 | self.config.get().motd.clone().unwrap_or_default(), 70 | config::MotdDisplayMode::Dump 71 | )); 72 | 73 | let raw_motd_value = self.raw_motd_value(term_db)?; 74 | 75 | let chunk = Chunk { kind: ChunkKind::Data, buf: raw_motd_value.as_slice() }; 76 | 77 | chunk.write_to(&mut stream).context("dumping motd") 78 | } 79 | 80 | /// Display the motd in a pager. Callers should do a downcast error 81 | /// check for PagerError::ClientHangup as if they had called 82 | /// Pager::display directly. 83 | /// 84 | /// # Returns 85 | /// 86 | /// `Ok(Some(...))` indicates that a pager has been shown, 87 | /// while `Ok(None)` indicates that it is not time to show the 88 | /// pager yet. An error is an error. 89 | #[instrument(skip_all)] 90 | pub fn display_in_pager( 91 | &self, 92 | // The client connection on which to display the pager. 93 | client_stream: &mut UnixStream, 94 | // The session to associate this pager with for SIGWINCH purposes. 95 | ctl_slot: Arc>>, 96 | // The size of the tty to start off with 97 | init_tty_size: TtySize, 98 | // The env that the shell will be launched with, we want to use 99 | // the same env for the pager program (mostly because we want 100 | // to pass TERM along correctly). 101 | shell_env: &[(OsString, OsString)], 102 | ) -> anyhow::Result> { 103 | if let Some(debouncer) = &self.debouncer { 104 | if !debouncer.should_fire()? { 105 | return Ok(None); 106 | } 107 | } 108 | 109 | let pager_bin = if let config::MotdDisplayMode::Pager { bin, .. } = 110 | self.config.get().motd.clone().unwrap_or_default() 111 | { 112 | bin 113 | } else { 114 | return Err(anyhow!("internal error: wrong mode to display in pager")); 115 | }; 116 | 117 | info!("displaying motd in pager '{}'", pager_bin); 118 | 119 | let motd_value = self.motd_value()?; 120 | 121 | let pager = Pager::new(pager_bin.to_string()); 122 | 123 | let final_size = pager.display( 124 | client_stream, 125 | ctl_slot, 126 | init_tty_size, 127 | motd_value.as_str(), 128 | shell_env, 129 | )?; 130 | Ok(Some(final_size)) 131 | } 132 | 133 | fn motd_value(&self) -> anyhow::Result { 134 | self.motd_resolver 135 | .value(match &self.config.get().motd_args { 136 | Some(args) => { 137 | let mut args = args.clone(); 138 | // On debian based systems we need to set noupdate in order to get 139 | // the motd from userspace. It should be ignored on non-debian systems. 140 | if !args.iter().any(|a| a == "noupdate") { 141 | args.push(String::from("noupdate")); 142 | } 143 | motd::ArgResolutionStrategy::Exact(args) 144 | } 145 | None => motd::ArgResolutionStrategy::Auto, 146 | }) 147 | .context("resolving motd") 148 | } 149 | 150 | fn raw_motd_value(&self, term_db: &termini::TermInfo) -> anyhow::Result> { 151 | let motd_value = self.motd_value()?; 152 | Self::convert_to_raw(term_db, &motd_value) 153 | } 154 | 155 | /// Convert the given motd into a byte buffer suitable to be written to the 156 | /// terminal. The only real transformation we perform is injecting carrage 157 | /// returns after newlines. 158 | fn convert_to_raw(term_db: &termini::TermInfo, motd: &str) -> anyhow::Result> { 159 | let carrage_return_code = term_db 160 | .raw_string_cap(termini::StringCapability::CarriageReturn) 161 | .ok_or(anyhow!("no carrage return code"))?; 162 | 163 | let mut buf: Vec = vec![]; 164 | 165 | let lines = motd.split('\n'); 166 | for line in lines { 167 | buf.extend(line.as_bytes()); 168 | buf.push(b'\n'); 169 | buf.extend(carrage_return_code); 170 | } 171 | 172 | Ok(buf) 173 | } 174 | } 175 | 176 | #[derive(Debug, Clone)] 177 | struct Debouncer { 178 | last_fired: Arc>, 179 | dur: time::Duration, 180 | } 181 | 182 | impl Debouncer { 183 | fn new(dur: time::Duration) -> Self { 184 | Debouncer { last_fired: Arc::new(Mutex::new(time::SystemTime::now() - (dur * 2))), dur } 185 | } 186 | 187 | #[instrument(skip_all)] 188 | fn should_fire(&self) -> anyhow::Result { 189 | let mut last_fired = self.last_fired.lock().unwrap(); 190 | if last_fired.elapsed()? >= self.dur { 191 | let old_ts: chrono::DateTime = (*last_fired).into(); 192 | *last_fired = time::SystemTime::now(); 193 | let new_ts: chrono::DateTime = (*last_fired).into(); 194 | info!("last_fired: old = {}, new = {}", old_ts, new_ts); 195 | Ok(true) 196 | } else { 197 | let ts: chrono::DateTime = (*last_fired).into(); 198 | info!("not firing yet (last_fired = {})", ts); 199 | Ok(false) 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /libshpool/src/daemon/prompt.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This file contains the logic for injecting the `prompt_annotation` 16 | // config option into a user's prompt for known shells. 17 | 18 | use std::io::{Read, Write}; 19 | 20 | use anyhow::{anyhow, Context}; 21 | use tracing::{debug, info, instrument, warn}; 22 | 23 | use crate::{ 24 | consts::{SENTINEL_FLAG_VAR, STARTUP_SENTINEL}, 25 | daemon::trie::{Trie, TrieCursor}, 26 | }; 27 | 28 | #[derive(Debug, Clone)] 29 | enum KnownShell { 30 | Bash, 31 | Zsh, 32 | Fish, 33 | } 34 | 35 | /// Inject the given prefix into the given shell subprocess, using 36 | /// the shell path in `shell` to decide the right way to go about 37 | /// injecting the prefix. 38 | /// 39 | /// If the prefix is blank, this is a noop. 40 | #[instrument(skip_all)] 41 | pub fn maybe_inject_prefix( 42 | pty_master: &mut shpool_pty::fork::Fork, 43 | prompt_prefix: &str, 44 | session_name: &str, 45 | ) -> anyhow::Result<()> { 46 | let shell_pid = pty_master.child_pid().ok_or(anyhow!("no child pid"))?; 47 | // scan for the startup sentinel so we know it is safe to sniff the shell 48 | let mut pty_master = pty_master.is_parent().context("expected parent")?; 49 | wait_for_startup(&mut pty_master)?; 50 | 51 | let shell_type = sniff_shell(shell_pid); 52 | debug!("sniffed shell type: {:?}", shell_type); 53 | 54 | // now actually inject the prompt 55 | let prompt_prefix = prompt_prefix.replace("$SHPOOL_SESSION_NAME", session_name); 56 | 57 | let mut script = match (prompt_prefix.as_str(), shell_type) { 58 | (_, Ok(KnownShell::Bash)) => format!( 59 | r#" 60 | if [[ -z "${{PROMPT_COMMAND+x}}" ]]; then 61 | PS1="{prompt_prefix}${{PS1}}" 62 | else 63 | SHPOOL__OLD_PROMPT_COMMAND=("${{PROMPT_COMMAND[@]}}") 64 | SHPOOL__OLD_PS1="${{PS1}}" 65 | function __shpool__prompt_command() {{ 66 | PS1="${{SHPOOL__OLD_PS1}}" 67 | for prompt_hook in "${{SHPOOL__OLD_PROMPT_COMMAND[@]}}" 68 | do 69 | eval "${{prompt_hook}}" 70 | done 71 | PS1="{prompt_prefix}${{PS1}}" 72 | }} 73 | PROMPT_COMMAND=__shpool__prompt_command 74 | fi 75 | "# 76 | ), 77 | (_, Ok(KnownShell::Zsh)) => format!( 78 | r#" 79 | typeset -a precmd_functions 80 | SHPOOL__OLD_PROMPT="${{PROMPT}}" 81 | function __shpool__reset_rprompt() {{ 82 | PROMPT="${{SHPOOL__OLD_PROMPT}}" 83 | }} 84 | precmd_functions[1,0]=(__shpool__reset_rprompt) 85 | function __shpool__prompt_command() {{ 86 | PROMPT="{prompt_prefix}${{PROMPT}}" 87 | }} 88 | precmd_functions+=(__shpool__prompt_command) 89 | "# 90 | ), 91 | (_, Ok(KnownShell::Fish)) => format!( 92 | r#" 93 | functions --copy fish_prompt shpool__old_prompt 94 | function fish_prompt; echo -n "{prompt_prefix}"; shpool__old_prompt; end 95 | "# 96 | ), 97 | (_, Err(e)) => { 98 | warn!("could not sniff shell: {}", e); 99 | 100 | // not the end of the world, we will just not inject a prompt prefix 101 | String::new() 102 | } 103 | }; 104 | 105 | // With this magic env var set, `shpool daemon` will just 106 | // print the prompt sentinel and immediately exit. We do 107 | // this rather than `echo $PROMPT_SENTINEL` because different 108 | // shells have subtly different echo behavior which makes it 109 | // hard to make the scanner work right. 110 | // TODO(julien): this will probably not work on mac 111 | let sentinel_cmd = 112 | format!("\n {}=prompt /proc/{}/exe daemon\n", SENTINEL_FLAG_VAR, std::process::id()); 113 | script.push_str(sentinel_cmd.as_str()); 114 | 115 | debug!("injecting prefix script '{}'", script); 116 | pty_master.write_all(script.as_bytes()).context("running prefix script")?; 117 | 118 | Ok(()) 119 | } 120 | 121 | #[instrument(skip_all)] 122 | fn wait_for_startup(pty_master: &mut shpool_pty::fork::Master) -> anyhow::Result<()> { 123 | let mut startup_sentinel_scanner = SentinelScanner::new(STARTUP_SENTINEL); 124 | let startup_sentinel_cmd = 125 | format!("\n {}=startup /proc/{}/exe daemon\n", SENTINEL_FLAG_VAR, std::process::id()); 126 | 127 | pty_master 128 | .write_all(startup_sentinel_cmd.as_bytes()) 129 | .context("running startup sentinel script")?; 130 | 131 | let mut buf: [u8; 2048] = [0; 2048]; 132 | loop { 133 | let len = pty_master.read(&mut buf).context("reading chunk to scan for startup")?; 134 | if len == 0 { 135 | continue; 136 | } 137 | let buf = &buf[..len]; 138 | debug!("buf='{}'", String::from_utf8_lossy(buf)); 139 | for byte in buf.iter() { 140 | if startup_sentinel_scanner.transition(*byte) { 141 | // This might drop trailing data from the chunk we just read, but 142 | // it should be fine since we are about to inject the prompt setup 143 | // stuff anyway, and shell.rs will scan for the prompt setup sentinel 144 | // in order to handle the smooth handoff. 145 | return Ok(()); 146 | } 147 | } 148 | } 149 | } 150 | 151 | /// Determine the shell process running under the given pid by examining 152 | /// `/proc//exe`. 153 | #[instrument(skip_all)] 154 | fn sniff_shell(pid: libc::pid_t) -> anyhow::Result { 155 | let shell_proc_name = 156 | libproc::proc_pid::name(pid).map_err(|e| anyhow!("determining subproc name: {:?}", e))?; 157 | info!("shell_proc_name: {}", shell_proc_name); 158 | 159 | if shell_proc_name.ends_with("bash") { 160 | Ok(KnownShell::Bash) 161 | } else if shell_proc_name.ends_with("zsh") { 162 | Ok(KnownShell::Zsh) 163 | } else if shell_proc_name.ends_with("fish") { 164 | Ok(KnownShell::Fish) 165 | } else { 166 | Err(anyhow!("unknown shell: {:?}", shell_proc_name)) 167 | } 168 | } 169 | 170 | /// A trie for scanning through shell output to look for the sentinel. 171 | pub struct SentinelScanner { 172 | scanner: Trie>>, 173 | cursor: TrieCursor, 174 | num_matches: usize, 175 | } 176 | 177 | impl SentinelScanner { 178 | /// Create a new sentinel scanner. 179 | pub fn new(sentinel: &str) -> Self { 180 | let mut scanner = Trie::new(); 181 | scanner.insert(sentinel.bytes(), ()); 182 | 183 | SentinelScanner { scanner, cursor: TrieCursor::Start, num_matches: 0 } 184 | } 185 | 186 | // Pump the given byte through the scanner, returning true if the underlying 187 | // shell has finished printing the sentinel value. 188 | pub fn transition(&mut self, byte: u8) -> bool { 189 | self.cursor = self.scanner.advance(self.cursor, byte); 190 | match self.cursor { 191 | TrieCursor::NoMatch => { 192 | self.cursor = TrieCursor::Start; 193 | false 194 | } 195 | TrieCursor::Match { is_partial, .. } if !is_partial => { 196 | self.cursor = TrieCursor::Start; 197 | self.num_matches += 1; 198 | debug!("got prompt sentinel match #{}", self.num_matches); 199 | self.num_matches == 1 200 | } 201 | _ => false, 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | 2 | shpool (0.9.3) unstable; urgency=low 3 | 4 | Added 5 | 6 | * add -d/--dir flag ([#271](https://github.com/shell-pool/shpool/pull/271)) 7 | 8 | -- Shpool Authors Tue, 18 Nov 2025 17:11:59 +0000 9 | 10 | shpool (0.9.2) unstable; urgency=low 11 | 12 | Fixed 13 | 14 | * fmt lints ([#230](https://github.com/shell-pool/shpool/pull/230)) 15 | 16 | Other 17 | 18 | * fix typo in README ([#252](https://github.com/shell-pool/shpool/pull/252)) 19 | * bump nix from 0.29.0 to 0.30.1 ([#229](https://github.com/shell-pool/shpool/pull/229)) 20 | * bump tempfile from 3.17.1 to 3.20.0 ([#231](https://github.com/shell-pool/shpool/pull/231)) 21 | * Support special characters ([#254](https://github.com/shell-pool/shpool/pull/254)) 22 | * extract session restore related code behind a trait ([#160](https://github.com/shell-pool/shpool/pull/160)) 23 | 24 | -- Shpool Authors Mon, 08 Sep 2025 19:52:50 +0000 25 | 26 | shpool (0.9.1) unstable; urgency=low 27 | 28 | Added 29 | 30 | * dump forward_env in file (#223) 31 | 32 | Fixed 33 | 34 | * eval SHPOOL__OLD_PROMPT_COMMAND rather than iterate sub-components (#213) 35 | 36 | Other 37 | 38 | * 0.1.2 -> 0.1.3 (#215) 39 | 40 | -- Shpool Authors Tue, 25 Mar 2025 14:40:49 +0000 41 | 42 | shpool (0.9.0) unstable; urgency=low 43 | 44 | Added 45 | 46 | * [**breaking**] allow daemon log level to dynamically change (#207) 47 | * add dynamic log level msg to shpool-protocol (#206) 48 | 49 | Other 50 | 51 | * upgrade nix to 0.29 (#192) 52 | 53 | -- Shpool Authors Fri, 21 Feb 2025 20:52:30 +0000 54 | 55 | shpool (0.8.2) unstable; urgency=low 56 | 57 | Fixed 58 | 59 | * remove ansi color codes from logs (#202) 60 | * reconnect hangup due to long session restore (#199) 61 | * vterm width (#201) 62 | * lints (#198) 63 | 64 | Other 65 | 66 | * update Cargo.lock dependencies 67 | * differentiate error contexts (#200) 68 | * upgrade notify crate (#190) 69 | 70 | -- Shpool Authors Thu, 13 Feb 2025 20:04:17 +0000 71 | 72 | shpool (0.8.1) unstable; urgency=low 73 | 74 | Fixed 75 | 76 | * reduce deadlock potential in shell->client 77 | 78 | -- Shpool Authors Wed, 22 Jan 2025 16:54:09 +0000 79 | 80 | shpool (0.8.0) unstable; urgency=low 81 | 82 | Fixed 83 | 84 | * style 85 | * handle dyn config updates to motd settings 86 | * [**breaking**] exit success for graceful detaches 87 | * deadlock when shell->client thread stops 88 | * new lifetime lints 89 | * add space to keybindings grammar 90 | * suppress prompt sentinels from history 91 | 92 | -- Shpool Authors Mon, 14 Oct 2024 20:39:40 +0000 93 | 94 | shpool (0.7.1) unstable; urgency=low 95 | 96 | Added 97 | 98 | * add span traces at lock() points 99 | 100 | Fixed 101 | 102 | * forward shell env to pager 103 | * suppress prompt setup in screen restore 104 | 105 | Other 106 | 107 | * Better ssh config example 108 | * reader thread -> shell_to_client thread 109 | 110 | -- Shpool Authors Mon, 16 Sep 2024 14:51:04 +0000 111 | 112 | shpool (0.7.0) unstable; urgency=low 113 | 114 | Added 115 | 116 | * [**breaking**] add autodaemonization support 117 | 118 | Fixed 119 | 120 | * add version negotiation warnings 121 | * migrate to new shpool-protocol crate 122 | * protocol forward compat 123 | * tune vt100 memory usage 124 | 125 | Other 126 | 127 | * rip out directories dep 128 | * bump toml from 0.7.8 to 0.8.12 ([#78](https://github.com/shell-pool/shpool/pull/78)) 129 | 130 | -- Shpool Authors Mon, 26 Aug 2024 20:30:00 +0000 131 | shpool (0.6.3) unstable; urgency=low 132 | 133 | * Add debounce option to motd pager mode 134 | * Ban whitespace in session names 135 | * Ban blank session names 136 | * Fix terminfo resolution fallback 137 | * Fully disable prompt code for blank prompt prefix 138 | * Add system level configuration 139 | * Fix config change watcher to pick up new files 140 | * Sniff shells rather than just keying off of binary name 141 | 142 | -- Ethan Pailes Tue, 09 Jul 2024 08:32:00 -0400 143 | shpool (0.6.2) unstable; urgency=low 144 | 145 | * Fix bash prompt prefix injection 146 | * Hide prompt prefix setup code from users 147 | 148 | -- Ethan Pailes Wed, 03 Jun 2024 08:46:00 -0400 149 | shpool (0.6.1) unstable; urgency=low 150 | 151 | * Start automatically reloading config file 152 | * Fix motd = "dump" mode to stop mangling initial prompt 153 | * Add timeouts to prevent session message deadlocks 154 | * Start correctly forwarding {x,y}pixel in term size 155 | 156 | -- Ethan Pailes Wed, 15 May 2024 12:07:00 -0400 157 | shpool (0.6.0) unstable; urgency=low 158 | 159 | * Add new 'motd' config option for displaying the motd 160 | * Add 'dump' motd mode 161 | * Add 'pager' motd mode 162 | * [BREAKING] Add requirment to register a motd reexec handler in main 163 | * [BREAKING] Set default prompt prefix 164 | * Upgrade deps 165 | * README fixes 166 | * Fix how user info is collected 167 | * Github migration toil 168 | 169 | -- Ethan Pailes Thu, 03 Apr 2024 09:02:00 -0400 170 | shpool (0.5.0) unstable; urgency=low 171 | 172 | * Add fish support to prompt_prefix 173 | * [BREAKING] add new hooks API to libshpool 174 | * Add session status to list output 175 | 176 | -- Ethan Pailes Thu, 16 Feb 2023 10:02:00 -0400 177 | shpool (0.4.0) unstable; urgency=low 178 | 179 | * Add new default-disabled prompt_prefix option 180 | supporting both bash and zsh 181 | * [BREAKING] remove -c/--config_file daemon flag 182 | * [BREAKING] remove old version switch 183 | * Add version subcommand 184 | * Remove "binary differs ..." warning 185 | * Update docs about automatic connection methods 186 | 187 | -- Ethan Pailes Thu, 01 Feb 2023 14:14:00 -0400 188 | shpool (0.3.5) unstable; urgency=low 189 | 190 | * Add -c/--cmd flag to attach subcommand 191 | * Add forward_env config option 192 | * Deprecate -c/--config_file flag to daemon subcommand 193 | * Make -c/--config_file a top level flag 194 | 195 | -- Ethan Pailes Mon, 18 Dec 2023 13:16:00 -0400 196 | shpool (0.3.4) unstable; urgency=low 197 | 198 | * Bump shpool_pty 199 | * Correctly set up SHELL variable for all shells 200 | 201 | -- Ethan Pailes Wed, 22 Nov 2023 14:52:00 -0400 202 | shpool (0.3.3) unstable; urgency=low 203 | 204 | * Forward LANG env var from client to daemon 205 | * Update dependencies 206 | 207 | -- Ethan Pailes Tue, 08 Nov 2023 12:48:00 -0400 208 | shpool (0.3.2) unstable; urgency=low 209 | 210 | * Start correctly parsing /etc/environment 211 | * Update dependencies 212 | 213 | -- Ethan Pailes Tue, 31 Oct 2023 11:11:00 -0400 214 | shpool (0.3.1) unstable; urgency=low 215 | 216 | * Disable output spool in "simple" session restore mode 217 | * Fix bug where shpool was failing to forward $DISPLAY 218 | * Fix output shpool out of bounds cursor restore bug 219 | 220 | -- Ethan Pailes Tue, 10 Oct 2023 11:51:00 -0400 221 | shpool (0.3.0) unstable; urgency=low 222 | 223 | * [BREAKING] Make "screen" the default reattach mode 224 | 225 | -- Ethan Pailes Tue, 03 Oct 2023 09:26:00 -0400 226 | shpool (0.2.5) unstable; urgency=low 227 | 228 | * Add --ttl flag to attach subcommand 229 | * Fix exit status threading 230 | 231 | -- Ethan Pailes Thu, 21 Sep 2023 14:39:00 -0400 232 | shpool (0.2.4) unstable; urgency=low 233 | 234 | * Fix stuck session bug 235 | * Fix session restore resize trim bug 236 | 237 | -- Ethan Pailes Thu, 14 Sep 2023 14:49:00 -0400 238 | shpool (0.2.3) unstable; urgency=low 239 | 240 | * Fix long chunk bug in session restore 241 | 242 | -- Ethan Pailes Wed, 02 Aug 2023 13:38:00 -0400 243 | shpool (0.2.2) unstable; urgency=low 244 | 245 | * Fix bug in lines reattach mode 246 | 247 | -- Ethan Pailes Fri, 28 Jul 2023 14:14:00 -0400 248 | shpool (0.2.1) unstable; urgency=low 249 | 250 | * Fix bug where initial tty size was not set correctly 251 | 252 | -- Ethan Pailes Wed, 19 Jul 2023 13:32:00 -0400 253 | shpool (0.2.0) unstable; urgency=low 254 | 255 | * Add support for session_restore_mode option 256 | * Loosen exe mismatch restriction 257 | * [BREAKING] Change config identifiers to be lower case 258 | 259 | -- Ethan Pailes Tue, 18 Jul 2023 16:02:00 -0400 260 | shpool (0.1.2) unstable; urgency=low 261 | 262 | * Fix bug in how PATH is set in new shells 263 | * Add initial_path config option 264 | 265 | -- Ethan Pailes Mon, 05 Jun 2023 14:59:25 -0400 266 | shpool (0.1.1) unstable; urgency=low 267 | 268 | * Fix systemd unit file. 269 | 270 | -- Ethan Pailes Wed, 03 May 2023 14:07:35 -0400 271 | shpool (0.1.0) unstable; urgency=low 272 | 273 | * Initial release. 274 | 275 | -- Ethan Pailes Wed, 03 May 2023 14:07:35 -0400 276 | -------------------------------------------------------------------------------- /libshpool/src/attach.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{env, fmt, io, path::PathBuf, thread, time}; 16 | 17 | use anyhow::{anyhow, bail, Context}; 18 | use shpool_protocol::{ 19 | AttachHeader, AttachReplyHeader, ConnectHeader, DetachReply, DetachRequest, ResizeReply, 20 | ResizeRequest, SessionMessageReply, SessionMessageRequest, SessionMessageRequestPayload, 21 | TtySize, 22 | }; 23 | use tracing::{error, info, warn}; 24 | 25 | use super::{config, duration, protocol, protocol::ClientResult, test_hooks, tty::TtySizeExt as _}; 26 | 27 | const MAX_FORCE_RETRIES: usize = 20; 28 | 29 | pub fn run( 30 | config_manager: config::Manager, 31 | name: String, 32 | force: bool, 33 | ttl: Option, 34 | cmd: Option, 35 | dir: Option, 36 | socket: PathBuf, 37 | ) -> anyhow::Result<()> { 38 | info!("\n\n======================== STARTING ATTACH ============================\n\n"); 39 | test_hooks::emit("attach-startup"); 40 | 41 | if name.is_empty() { 42 | eprintln!("blank session names are not allowed"); 43 | return Ok(()); 44 | } 45 | if name.contains(char::is_whitespace) { 46 | eprintln!("whitespace is not allowed in session names"); 47 | return Ok(()); 48 | } 49 | 50 | SignalHandler::new(name.clone(), socket.clone()).spawn()?; 51 | 52 | let ttl = match &ttl { 53 | Some(src) => match duration::parse(src.as_str()) { 54 | Ok(d) => Some(d), 55 | Err(e) => { 56 | bail!("could not parse ttl: {:?}", e); 57 | } 58 | }, 59 | None => None, 60 | }; 61 | 62 | let mut detached = false; 63 | let mut tries = 0; 64 | while let Err(err) = do_attach(&config_manager, name.as_str(), &ttl, &cmd, &dir, &socket) { 65 | match err.downcast() { 66 | Ok(BusyError) if !force => { 67 | eprintln!("session '{name}' already has a terminal attached"); 68 | return Ok(()); 69 | } 70 | Ok(BusyError) => { 71 | if !detached { 72 | let mut client = dial_client(&socket)?; 73 | client 74 | .write_connect_header(ConnectHeader::Detach(DetachRequest { 75 | sessions: vec![name.clone()], 76 | })) 77 | .context("writing detach request header")?; 78 | let detach_reply: DetachReply = client.read_reply().context("reading reply")?; 79 | if !detach_reply.not_found_sessions.is_empty() { 80 | warn!("could not find session '{}' to detach it", name); 81 | } 82 | 83 | detached = true; 84 | } 85 | thread::sleep(time::Duration::from_millis(100)); 86 | 87 | if tries > MAX_FORCE_RETRIES { 88 | eprintln!("session '{name}' already has a terminal which remains attached even after attempting to detach it"); 89 | return Err(anyhow!("could not detach session, forced attach failed")); 90 | } 91 | tries += 1; 92 | } 93 | Err(err) => return Err(err), 94 | } 95 | } 96 | 97 | Ok(()) 98 | } 99 | 100 | #[derive(Debug)] 101 | struct BusyError; 102 | impl fmt::Display for BusyError { 103 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 104 | write!(f, "BusyError") 105 | } 106 | } 107 | impl std::error::Error for BusyError {} 108 | 109 | fn do_attach( 110 | config: &config::Manager, 111 | name: &str, 112 | ttl: &Option, 113 | cmd: &Option, 114 | dir: &Option, 115 | socket: &PathBuf, 116 | ) -> anyhow::Result<()> { 117 | let mut client = dial_client(socket)?; 118 | 119 | let tty_size = match TtySize::from_fd(0) { 120 | Ok(s) => s, 121 | Err(e) => { 122 | warn!("stdin is not a tty, using default size (err: {e:?})"); 123 | TtySize { rows: 24, cols: 80, xpixel: 0, ypixel: 0 } 124 | } 125 | }; 126 | 127 | let forward_env = config.get().forward_env.clone(); 128 | let mut local_env_keys = vec!["TERM", "DISPLAY", "LANG", "SSH_AUTH_SOCK"]; 129 | if let Some(fenv) = &forward_env { 130 | for var in fenv.iter() { 131 | local_env_keys.push(var); 132 | } 133 | } 134 | 135 | let cwd = String::from(env::current_dir().context("getting cwd")?.to_string_lossy()); 136 | let default_dir = config.get().default_dir.clone().unwrap_or(String::from("$HOME")); 137 | let start_dir = match (default_dir.as_str(), dir.as_deref()) { 138 | (".", None) => Some(cwd), 139 | ("$HOME", None) => None, 140 | (d, None) => Some(String::from(d)), 141 | (_, Some(".")) => Some(cwd), 142 | (_, Some(d)) => Some(String::from(d)), 143 | }; 144 | 145 | client 146 | .write_connect_header(ConnectHeader::Attach(AttachHeader { 147 | name: String::from(name), 148 | local_tty_size: tty_size, 149 | local_env: local_env_keys 150 | .into_iter() 151 | .filter_map(|var| { 152 | let val = env::var(var).context("resolving var").ok()?; 153 | Some((String::from(var), val)) 154 | }) 155 | .collect::>(), 156 | ttl_secs: ttl.map(|d| d.as_secs()), 157 | cmd: cmd.clone(), 158 | dir: start_dir, 159 | })) 160 | .context("writing attach header")?; 161 | 162 | let attach_resp: AttachReplyHeader = client.read_reply().context("reading attach reply")?; 163 | info!("attach_resp.status={:?}", attach_resp.status); 164 | 165 | { 166 | use shpool_protocol::AttachStatus::*; 167 | match attach_resp.status { 168 | Busy => { 169 | return Err(BusyError.into()); 170 | } 171 | Forbidden(reason) => { 172 | eprintln!("forbidden: {reason}"); 173 | return Err(anyhow!("forbidden: {reason}")); 174 | } 175 | Attached { warnings } => { 176 | for warning in warnings.into_iter() { 177 | eprintln!("shpool: warn: {warning}"); 178 | } 179 | info!("attached to an existing session: '{}'", name); 180 | } 181 | Created { warnings } => { 182 | for warning in warnings.into_iter() { 183 | eprintln!("shpool: warn: {warning}"); 184 | } 185 | info!("created a new session: '{}'", name); 186 | } 187 | UnexpectedError(err) => { 188 | return Err(anyhow!("BUG: unexpected error attaching to '{}': {}", name, err)); 189 | } 190 | } 191 | } 192 | 193 | match client.pipe_bytes() { 194 | Ok(exit_status) => std::process::exit(exit_status), 195 | Err(e) => Err(e), 196 | } 197 | } 198 | 199 | fn dial_client(socket: &PathBuf) -> anyhow::Result { 200 | match protocol::Client::new(socket) { 201 | Ok(ClientResult::JustClient(c)) => Ok(c), 202 | Ok(ClientResult::VersionMismatch { warning, client }) => { 203 | eprintln!("warning: {warning}, try restarting your daemon"); 204 | eprintln!("hit enter to continue anyway or ^C to exit"); 205 | 206 | let _ = io::stdin() 207 | .lines() 208 | .next() 209 | .context("waiting for a continue through a version mismatch")?; 210 | 211 | Ok(client) 212 | } 213 | Err(err) => { 214 | let io_err = err.downcast::()?; 215 | if io_err.kind() == io::ErrorKind::NotFound { 216 | eprintln!("could not connect to daemon"); 217 | } 218 | Err(io_err).context("connecting to daemon") 219 | } 220 | } 221 | } 222 | 223 | // 224 | // Signal Handling 225 | // 226 | 227 | struct SignalHandler { 228 | session_name: String, 229 | socket: PathBuf, 230 | } 231 | 232 | impl SignalHandler { 233 | fn new(session_name: String, socket: PathBuf) -> Self { 234 | SignalHandler { session_name, socket } 235 | } 236 | 237 | fn spawn(self) -> anyhow::Result<()> { 238 | use signal_hook::{consts::*, iterator::*}; 239 | 240 | let sigs = vec![SIGWINCH]; 241 | let mut signals = Signals::new(sigs).context("creating signal iterator")?; 242 | 243 | thread::spawn(move || { 244 | for signal in &mut signals { 245 | let res = match signal { 246 | SIGWINCH => self.handle_sigwinch(), 247 | sig => { 248 | error!("unknown signal: {}", sig); 249 | panic!("unknown signal: {sig}"); 250 | } 251 | }; 252 | if let Err(e) = res { 253 | error!("signal handler error: {:?}", e); 254 | } 255 | } 256 | }); 257 | 258 | Ok(()) 259 | } 260 | 261 | fn handle_sigwinch(&self) -> anyhow::Result<()> { 262 | info!("handle_sigwinch: enter"); 263 | let mut client = match protocol::Client::new(&self.socket)? { 264 | ClientResult::JustClient(c) => c, 265 | // At this point, we've already warned the user and they 266 | // chose to continue anyway, so we shouldn't bother them 267 | // again. 268 | ClientResult::VersionMismatch { client, .. } => client, 269 | }; 270 | 271 | let tty_size = TtySize::from_fd(0).context("getting tty size")?; 272 | info!("handle_sigwinch: tty_size={:?}", tty_size); 273 | 274 | // write the request on a new, seperate connection 275 | client 276 | .write_connect_header(ConnectHeader::SessionMessage(SessionMessageRequest { 277 | session_name: self.session_name.clone(), 278 | payload: SessionMessageRequestPayload::Resize(ResizeRequest { 279 | tty_size: tty_size.clone(), 280 | }), 281 | })) 282 | .context("writing resize request")?; 283 | 284 | let reply: SessionMessageReply = 285 | client.read_reply().context("reading session message reply")?; 286 | match reply { 287 | SessionMessageReply::NotFound => { 288 | warn!( 289 | "handle_sigwinch: sent resize for session '{}', but the daemon has no record of that session", 290 | self.session_name 291 | ); 292 | } 293 | SessionMessageReply::Resize(ResizeReply::Ok) => { 294 | info!("handle_sigwinch: resized session '{}' to {:?}", self.session_name, tty_size); 295 | } 296 | reply => { 297 | warn!("handle_sigwinch: unexpected resize reply: {:?}", reply); 298 | } 299 | } 300 | 301 | Ok(()) 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /shpool/tests/detach.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::literal_string_with_formatting_args)] 2 | 3 | use std::process::Command; 4 | 5 | use anyhow::Context; 6 | use ntest::timeout; 7 | 8 | mod support; 9 | 10 | use crate::support::daemon::DaemonArgs; 11 | 12 | #[test] 13 | #[timeout(30000)] 14 | fn single_running() -> anyhow::Result<()> { 15 | support::dump_err(|| { 16 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 17 | .context("starting daemon proc")?; 18 | 19 | let mut waiter = daemon_proc 20 | .events 21 | .take() 22 | .unwrap() 23 | .waiter(["daemon-bidi-stream-enter", "daemon-bidi-stream-done"]); 24 | let mut attach_proc = 25 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 26 | waiter.wait_event("daemon-bidi-stream-enter")?; 27 | 28 | let out = daemon_proc.detach(vec![String::from("sh1")])?; 29 | assert!(out.status.success(), "not successful"); 30 | 31 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 32 | assert_eq!(stderr.len(), 0, "expected no stderr"); 33 | 34 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 35 | assert_eq!(stdout.len(), 0, "expected no stdout"); 36 | 37 | daemon_proc.events = Some(waiter.wait_final_event("daemon-bidi-stream-done")?); 38 | 39 | let attach_exit_status = attach_proc.proc.wait()?; 40 | assert!(attach_exit_status.success()); 41 | 42 | Ok(()) 43 | }) 44 | } 45 | 46 | #[test] 47 | #[timeout(30000)] 48 | fn version_mismatch_client_newer() -> anyhow::Result<()> { 49 | support::dump_err(|| { 50 | let mut daemon_proc = support::daemon::Proc::new( 51 | "norc.toml", 52 | DaemonArgs { 53 | extra_env: vec![( 54 | String::from("SHPOOL_TEST__OVERRIDE_VERSION"), 55 | String::from("0.0.0"), 56 | )], 57 | ..DaemonArgs::default() 58 | }, 59 | ) 60 | .context("starting daemon proc")?; 61 | 62 | let waiter = daemon_proc.events.take().unwrap().waiter(["daemon-bidi-stream-enter"]); 63 | let mut attach_proc = 64 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 65 | 66 | // get past the version mismatch prompt 67 | attach_proc.run_cmd("")?; 68 | 69 | daemon_proc.events = Some(waiter.wait_final_event("daemon-bidi-stream-enter")?); 70 | 71 | let out = daemon_proc.detach(vec![String::from("sh1")])?; 72 | assert!(out.status.success()); 73 | 74 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 75 | assert!(stderr.contains("is newer")); 76 | assert!(stderr.contains("try restarting")); 77 | 78 | Ok(()) 79 | }) 80 | } 81 | 82 | #[test] 83 | #[timeout(30000)] 84 | fn single_not_running() -> anyhow::Result<()> { 85 | support::dump_err(|| { 86 | let mut daemon_proc = support::daemon::Proc::new( 87 | "norc.toml", 88 | DaemonArgs { listen_events: false, ..DaemonArgs::default() }, 89 | ) 90 | .context("starting daemon proc")?; 91 | 92 | let out = daemon_proc.detach(vec![String::from("sh1")])?; 93 | assert!(!out.status.success(), "successful"); 94 | 95 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 96 | assert!(stderr.contains("not found: sh1")); 97 | 98 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 99 | assert_eq!(stdout.len(), 0, "expected no stderr"); 100 | 101 | Ok(()) 102 | }) 103 | } 104 | 105 | #[test] 106 | #[timeout(30000)] 107 | fn no_daemon() -> anyhow::Result<()> { 108 | support::dump_err(|| { 109 | let out = Command::new(support::shpool_bin()?) 110 | .arg("--socket") 111 | .arg("/fake/does/not/exist/shpool.socket") 112 | .arg("--no-daemonize") 113 | .arg("detach") 114 | .output() 115 | .context("spawning detach proc")?; 116 | 117 | assert!(!out.status.success(), "detach proc exited successfully"); 118 | 119 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 120 | assert!(stderr.contains("could not connect to daemon")); 121 | 122 | Ok(()) 123 | }) 124 | } 125 | 126 | #[test] 127 | #[timeout(30000)] 128 | fn running_env_var() -> anyhow::Result<()> { 129 | support::dump_err(|| { 130 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 131 | .context("starting daemon proc")?; 132 | 133 | let mut waiter = daemon_proc 134 | .events 135 | .take() 136 | .unwrap() 137 | .waiter(["daemon-bidi-stream-enter", "daemon-bidi-stream-done"]); 138 | let _attach_proc = 139 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 140 | waiter.wait_event("daemon-bidi-stream-enter")?; 141 | 142 | let out = Command::new(support::shpool_bin()?) 143 | .arg("--socket") 144 | .arg(&daemon_proc.socket_path) 145 | .arg("detach") 146 | .env("SHPOOL_SESSION_NAME", "sh1") 147 | .output() 148 | .context("spawning detach cmd")?; 149 | assert!(out.status.success(), "not successful"); 150 | 151 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 152 | assert_eq!(stderr.len(), 0, "expected no stderr"); 153 | 154 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 155 | assert_eq!(stdout.len(), 0, "expected no stdout"); 156 | 157 | daemon_proc.events = Some(waiter.wait_final_event("daemon-bidi-stream-done")?); 158 | 159 | Ok(()) 160 | }) 161 | } 162 | 163 | #[test] 164 | #[timeout(30000)] 165 | fn reattach() -> anyhow::Result<()> { 166 | support::dump_err(|| { 167 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 168 | .context("starting daemon proc")?; 169 | 170 | let bidi_done_w = daemon_proc.events.take().unwrap().waiter(["daemon-bidi-stream-done"]); 171 | let mut sess1 = 172 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 173 | 174 | let mut lm1 = sess1.line_matcher()?; 175 | sess1.run_cmd("export MYVAR=first ; echo hi")?; 176 | lm1.scan_until_re("hi$")?; 177 | 178 | let out = daemon_proc.detach(vec![String::from("sh1")])?; 179 | assert!(out.status.success(), "not successful"); 180 | 181 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 182 | assert_eq!(stderr.len(), 0, "expected no stderr"); 183 | 184 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 185 | assert_eq!(stdout.len(), 0, "expected no stdout"); 186 | 187 | daemon_proc.events = Some(bidi_done_w.wait_final_event("daemon-bidi-stream-done")?); 188 | 189 | let mut sess2 = 190 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 191 | let mut lm2 = sess2.line_matcher()?; 192 | sess2.run_cmd("echo ${MYVAR:-second}")?; 193 | lm2.match_re("first$")?; 194 | 195 | Ok(()) 196 | }) 197 | } 198 | 199 | #[test] 200 | #[timeout(30000)] 201 | fn multiple_running() -> anyhow::Result<()> { 202 | support::dump_err(|| { 203 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 204 | .context("starting daemon proc")?; 205 | 206 | let mut waiter = daemon_proc.events.take().unwrap().waiter([ 207 | "daemon-bidi-stream-enter", 208 | "daemon-bidi-stream-enter", 209 | "daemon-bidi-stream-done", 210 | "daemon-bidi-stream-done", 211 | ]); 212 | let _sess1 = 213 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 214 | waiter.wait_event("daemon-bidi-stream-enter")?; 215 | 216 | let _sess2 = 217 | daemon_proc.attach("sh2", Default::default()).context("starting attach proc")?; 218 | waiter.wait_event("daemon-bidi-stream-enter")?; 219 | 220 | let out = daemon_proc.detach(vec![String::from("sh1"), String::from("sh2")])?; 221 | assert!(out.status.success(), "not successful"); 222 | 223 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 224 | assert_eq!(stderr.len(), 0, "expected no stderr"); 225 | 226 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 227 | assert_eq!(stdout.len(), 0, "expected no stdout"); 228 | 229 | waiter.wait_event("daemon-bidi-stream-done")?; 230 | daemon_proc.events = Some(waiter.wait_final_event("daemon-bidi-stream-done")?); 231 | 232 | Ok(()) 233 | }) 234 | } 235 | 236 | #[test] 237 | fn multiple_mixed() -> anyhow::Result<()> { 238 | support::dump_err(|| { 239 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 240 | .context("starting daemon proc")?; 241 | 242 | let mut waiter = daemon_proc 243 | .events 244 | .take() 245 | .unwrap() 246 | .waiter(["daemon-bidi-stream-enter", "daemon-bidi-stream-done"]); 247 | let _attach_proc = 248 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 249 | waiter.wait_event("daemon-bidi-stream-enter")?; 250 | 251 | let out = daemon_proc.detach(vec![String::from("sh1"), String::from("sh2")])?; 252 | assert!(!out.status.success(), "unexpectedly successful"); 253 | 254 | let stdout = String::from_utf8_lossy(&out.stdout[..]); 255 | assert_eq!(stdout.len(), 0, "expected no stdout"); 256 | 257 | let stderr = String::from_utf8_lossy(&out.stderr[..]); 258 | assert!(stderr.contains("not found: sh2"), "expected not found"); 259 | 260 | daemon_proc.events = Some(waiter.wait_final_event("daemon-bidi-stream-done")?); 261 | 262 | Ok(()) 263 | }) 264 | } 265 | 266 | #[test] 267 | fn double_tap() -> anyhow::Result<()> { 268 | support::dump_err(|| { 269 | let mut daemon_proc = support::daemon::Proc::new("norc.toml", DaemonArgs::default()) 270 | .context("starting daemon proc")?; 271 | 272 | let mut waiter = daemon_proc 273 | .events 274 | .take() 275 | .unwrap() 276 | .waiter(["daemon-bidi-stream-enter", "daemon-bidi-stream-done"]); 277 | let _attach_proc = 278 | daemon_proc.attach("sh1", Default::default()).context("starting attach proc")?; 279 | waiter.wait_event("daemon-bidi-stream-enter")?; 280 | 281 | let out1 = daemon_proc.detach(vec![String::from("sh1")])?; 282 | assert!(out1.status.success(), "not successful"); 283 | 284 | let stdout1 = String::from_utf8_lossy(&out1.stdout[..]); 285 | assert_eq!(stdout1.len(), 0, "expected no stdout"); 286 | 287 | let stderr1 = String::from_utf8_lossy(&out1.stderr[..]); 288 | assert_eq!(stderr1.len(), 0); 289 | 290 | daemon_proc.events = Some(waiter.wait_final_event("daemon-bidi-stream-done")?); 291 | 292 | let out2 = daemon_proc.detach(vec![String::from("sh1")])?; 293 | assert!(!out2.status.success(), "unexpectedly successful"); 294 | 295 | let stdout2 = String::from_utf8_lossy(&out2.stdout[..]); 296 | assert_eq!(stdout2.len(), 0, "expected no stdout"); 297 | 298 | let stderr2 = String::from_utf8_lossy(&out2.stderr[..]); 299 | assert!(stderr2.contains("not attached: sh1"), "expected not attached"); 300 | 301 | Ok(()) 302 | }) 303 | } 304 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /shpool-protocol/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | use std::{default::Default, fmt}; 16 | 17 | use anyhow::anyhow; 18 | use clap::ValueEnum; 19 | use serde_derive::{Deserialize, Serialize}; 20 | 21 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 22 | 23 | /// The header used to advertize daemon version. 24 | /// 25 | /// This header gets written by the daemon to every stream as 26 | /// soon as it is opened, which allows the client to compare 27 | /// version strings for protocol negotiation (basically just 28 | /// deciding if the user ought to be warned about mismatched 29 | /// versions). 30 | #[derive(Serialize, Deserialize, Debug)] 31 | pub struct VersionHeader { 32 | pub version: String, 33 | } 34 | 35 | /// The blob of metadata that a client transmits when it 36 | /// first connects. 37 | /// 38 | /// It uses an enum to allow different connection types 39 | /// to be initiated on the same socket. The ConnectHeader is always prefixed 40 | /// with a 4 byte little endian unsigned word to indicate length. 41 | #[derive(Serialize, Deserialize, Debug)] 42 | pub enum ConnectHeader { 43 | /// Attach to the named session indicated by the given header. 44 | /// 45 | /// Responds with an AttachReplyHeader. 46 | Attach(AttachHeader), 47 | /// List all of the currently active sessions. 48 | List, 49 | /// A message for a named, running sessions. This 50 | /// provides a mechanism for RPC-like calls to be 51 | /// made to running sessions. Messages are only 52 | /// delivered if there is currently a client attached 53 | /// to the session because we need a servicing thread 54 | /// with access to the SessionInner to respond to requests 55 | /// (we could implement a mailbox system or something 56 | /// for detached threads, but so far we have not needed to). 57 | SessionMessage(SessionMessageRequest), 58 | /// A message to request that a list of running 59 | /// sessions get detached from. 60 | Detach(DetachRequest), 61 | /// A message to request that a list of running 62 | /// sessions get killed. 63 | Kill(KillRequest), 64 | // A request to set the log level to a new value. 65 | SetLogLevel(SetLogLevelRequest), 66 | } 67 | 68 | /// KillRequest represents a request to kill 69 | /// the given named sessions. 70 | #[derive(Serialize, Deserialize, Debug)] 71 | pub struct KillRequest { 72 | /// The sessions to detach 73 | #[serde(default)] 74 | pub sessions: Vec, 75 | } 76 | 77 | #[derive(Serialize, Deserialize, Debug)] 78 | pub struct KillReply { 79 | #[serde(default)] 80 | pub not_found_sessions: Vec, 81 | } 82 | 83 | /// DetachRequest represents a request to detach 84 | /// from the given named sessions. 85 | #[derive(Serialize, Deserialize, Debug)] 86 | pub struct DetachRequest { 87 | /// The sessions to detach 88 | #[serde(default)] 89 | pub sessions: Vec, 90 | } 91 | 92 | #[derive(Serialize, Deserialize, Debug)] 93 | pub struct DetachReply { 94 | /// sessions that are not even in the session table 95 | #[serde(default)] 96 | pub not_found_sessions: Vec, 97 | /// sessions that are in the session table, but have no 98 | /// tty attached 99 | #[serde(default)] 100 | pub not_attached_sessions: Vec, 101 | } 102 | 103 | #[derive(Serialize, Deserialize, Debug, Default, ValueEnum, Clone)] 104 | pub enum LogLevel { 105 | #[default] 106 | Off, 107 | Error, 108 | Warn, 109 | Info, 110 | Debug, 111 | Trace, 112 | } 113 | 114 | // SetLogLevelRequest contains a request to set a new 115 | // log level 116 | #[derive(Serialize, Deserialize, Debug)] 117 | pub struct SetLogLevelRequest { 118 | #[serde(default)] 119 | pub level: LogLevel, 120 | } 121 | 122 | #[derive(Serialize, Deserialize, Debug)] 123 | pub struct SetLogLevelReply {} 124 | 125 | /// SessionMessageRequest represents a request that 126 | /// ought to be routed to the session indicated by 127 | /// `session_name`. 128 | #[derive(Serialize, Deserialize, Debug)] 129 | pub struct SessionMessageRequest { 130 | /// The session to route this request to. 131 | #[serde(default)] 132 | pub session_name: String, 133 | /// The actual message to send to the session. 134 | #[serde(default)] 135 | pub payload: SessionMessageRequestPayload, 136 | } 137 | 138 | /// SessionMessageRequestPayload contains a request for 139 | /// a running session. 140 | #[derive(Serialize, Deserialize, Debug, Default)] 141 | pub enum SessionMessageRequestPayload { 142 | /// Resize a named session's pty. Generated when 143 | /// a `shpool attach` process receives a SIGWINCH. 144 | Resize(ResizeRequest), 145 | /// Detach the given session. Generated internally 146 | /// by the server from a batch detach request. 147 | #[default] 148 | Detach, 149 | } 150 | 151 | /// ResizeRequest resizes the pty for a named session. 152 | /// 153 | /// We use an out-of-band request rather than doing this 154 | /// in the input stream because we don't want to have to 155 | /// introduce a framing protocol for the input stream. 156 | #[derive(Serialize, Deserialize, Debug)] 157 | pub struct ResizeRequest { 158 | /// The size of the client's tty 159 | #[serde(default)] 160 | pub tty_size: TtySize, 161 | } 162 | 163 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 164 | pub enum SessionMessageReply { 165 | /// The session was not found in the session table 166 | NotFound, 167 | /// There is not terminal attached to the session so 168 | /// it can't handle messages right now. 169 | NotAttached, 170 | /// The response to a resize message 171 | Resize(ResizeReply), 172 | /// The response to a detach message 173 | Detach(SessionMessageDetachReply), 174 | } 175 | 176 | /// A reply to a detach message 177 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 178 | pub enum SessionMessageDetachReply { 179 | Ok, 180 | } 181 | 182 | /// A reply to a resize message 183 | #[derive(Serialize, Deserialize, Debug, PartialEq)] 184 | pub enum ResizeReply { 185 | Ok, 186 | } 187 | 188 | /// AttachHeader is the blob of metadata that a client transmits when it 189 | /// first dials into the shpool daemon indicating which shell it wants 190 | /// to attach to. 191 | #[derive(Serialize, Deserialize, Debug, Default)] 192 | pub struct AttachHeader { 193 | /// The name of the session to create or attach to. 194 | #[serde(default)] 195 | pub name: String, 196 | /// The size of the local tty. Passed along so that the remote 197 | /// pty can be kept in sync (important so curses applications look 198 | /// right). 199 | #[serde(default)] 200 | pub local_tty_size: TtySize, 201 | /// A subset of the environment of the shell that `shpool attach` is run 202 | /// in. Contains only some variables needed to set up the shell when 203 | /// shpool forks off a process. For now the list is just `SSH_AUTH_SOCK` 204 | /// and `TERM`. 205 | #[serde(default)] 206 | pub local_env: Vec<(String, String)>, 207 | /// If specified, sets a time limit on how long the shell will be open 208 | /// when the shell is first created (does nothing in the case of a 209 | /// reattach). The daemon is responsible for automatically killing the 210 | /// session once the ttl is over. 211 | #[serde(default)] 212 | pub ttl_secs: Option, 213 | /// If specified, a command to run instead of the users default shell. 214 | #[serde(default)] 215 | pub cmd: Option, 216 | /// If specified, the directory to start the shell in. If not, $HOME 217 | /// should be used. 218 | #[serde(default)] 219 | pub dir: Option, 220 | } 221 | 222 | impl AttachHeader { 223 | pub fn local_env_get(&self, var: &str) -> Option<&str> { 224 | self.local_env.iter().find(|(k, _)| k == var).map(|(_, v)| v.as_str()) 225 | } 226 | } 227 | 228 | /// AttachReplyHeader is the blob of metadata that the shpool service prefixes 229 | /// the data stream with after an attach. In can be used to indicate a 230 | /// connection error. 231 | #[derive(Serialize, Deserialize, Debug)] 232 | pub struct AttachReplyHeader { 233 | #[serde(default)] 234 | pub status: AttachStatus, 235 | } 236 | 237 | /// ListReply is contains a list of active sessions to be displayed to the user. 238 | #[derive(Serialize, Deserialize, Debug)] 239 | pub struct ListReply { 240 | #[serde(default)] 241 | pub sessions: Vec, 242 | } 243 | 244 | /// Session describes an active session. 245 | #[derive(Serialize, Deserialize, Debug)] 246 | pub struct Session { 247 | #[serde(default)] 248 | pub name: String, 249 | #[serde(default)] 250 | pub started_at_unix_ms: i64, 251 | #[serde(default)] 252 | pub status: SessionStatus, 253 | } 254 | 255 | /// Indicates if a shpool session currently has a client attached. 256 | #[derive(Serialize, Deserialize, Debug, Default)] 257 | pub enum SessionStatus { 258 | #[default] 259 | Attached, 260 | Disconnected, 261 | } 262 | 263 | impl fmt::Display for SessionStatus { 264 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 265 | match self { 266 | SessionStatus::Attached => write!(f, "attached"), 267 | SessionStatus::Disconnected => write!(f, "disconnected"), 268 | } 269 | } 270 | } 271 | 272 | /// AttachStatus indicates what happened during an attach attempt. 273 | #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] 274 | pub enum AttachStatus { 275 | /// Attached indicates that there was an existing shell session with 276 | /// the given name, and `shpool attach` successfully connected to it. 277 | /// 278 | /// NOTE: warnings is not currently used, but it used to be, and we 279 | /// might want it in the future, so it is not worth breaking the protocol 280 | /// over. 281 | Attached { warnings: Vec }, 282 | /// Created indicates that there was no existing shell session with the 283 | /// given name, so `shpool` created a new one. 284 | /// 285 | /// NOTE: warnings is not currently used, see above. 286 | Created { warnings: Vec }, 287 | /// Busy indicates that there is an existing shell session with the given 288 | /// name, but another shpool session is currently connected to 289 | /// it, so the connection attempt was rejected. 290 | Busy, 291 | /// Forbidden indicates that the daemon has rejected the connection 292 | /// attempt for security reasons. 293 | Forbidden(String), 294 | /// Some unexpected error 295 | UnexpectedError(String), 296 | } 297 | 298 | impl Default for AttachStatus { 299 | fn default() -> Self { 300 | AttachStatus::UnexpectedError(String::from("default")) 301 | } 302 | } 303 | 304 | #[derive(Serialize, Deserialize, Debug, Default, Clone)] 305 | pub struct TtySize { 306 | pub rows: u16, 307 | pub cols: u16, 308 | pub xpixel: u16, 309 | pub ypixel: u16, 310 | } 311 | 312 | /// ChunkKind is a tag that indicates what type of frame is being transmitted 313 | /// through the socket. 314 | #[derive(Copy, Clone, Debug, PartialEq)] 315 | pub enum ChunkKind { 316 | /// After the kind tag, the chunk will have a 4 byte little endian length 317 | /// prefix followed by the actual data. 318 | Data = 0, 319 | /// An empty chunk sent so that the daemon can check to make sure the attach 320 | /// process is still listening. 321 | Heartbeat = 1, 322 | /// The child shell has exited. After the kind tag, the chunk will 323 | /// have exactly 4 bytes of data, which will contain a little endian 324 | /// code indicating the child's exit status. 325 | ExitStatus = 2, 326 | } 327 | 328 | impl TryFrom for ChunkKind { 329 | type Error = anyhow::Error; 330 | 331 | fn try_from(v: u8) -> anyhow::Result { 332 | match v { 333 | 0 => Ok(ChunkKind::Data), 334 | 1 => Ok(ChunkKind::Heartbeat), 335 | 2 => Ok(ChunkKind::ExitStatus), 336 | _ => Err(anyhow!("unknown ChunkKind {}", v)), 337 | } 338 | } 339 | } 340 | 341 | /// Chunk represents of a chunk of data in the output stream 342 | /// 343 | /// format: 344 | /// 345 | /// ```text 346 | /// 1 byte: kind tag 347 | /// little endian 4 byte word: length prefix 348 | /// N bytes: data 349 | /// ``` 350 | #[derive(Debug, PartialEq)] 351 | pub struct Chunk<'data> { 352 | pub kind: ChunkKind, 353 | pub buf: &'data [u8], 354 | } 355 | -------------------------------------------------------------------------------- /shpool/tests/daemon.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Write, 3 | io::Read, 4 | os::unix::{net::UnixListener, process::CommandExt as _}, 5 | path, 6 | process::{Command, Stdio}, 7 | time, 8 | }; 9 | 10 | use anyhow::{anyhow, Context}; 11 | use nix::{ 12 | sys::signal::{self, Signal}, 13 | unistd::{ForkResult, Pid}, 14 | }; 15 | use ntest::timeout; 16 | use regex::Regex; 17 | 18 | mod support; 19 | 20 | use crate::support::daemon::{AttachArgs, DaemonArgs}; 21 | 22 | #[test] 23 | #[timeout(30000)] 24 | fn start() -> anyhow::Result<()> { 25 | support::dump_err(|| { 26 | let tmp_dir = tempfile::Builder::new() 27 | .prefix("shpool-test") 28 | .rand_bytes(20) 29 | .tempdir() 30 | .context("creating tmp dir")?; 31 | 32 | let mut child = Command::new(support::shpool_bin()?) 33 | .stdout(Stdio::piped()) 34 | .stderr(Stdio::piped()) 35 | .arg("--socket") 36 | .arg(tmp_dir.path().join("shpool.socket")) 37 | .arg("daemon") 38 | .spawn() 39 | .context("spawning daemon process")?; 40 | 41 | // The server should start up and run without incident for 42 | // half a second. 43 | std::thread::sleep(time::Duration::from_millis(500)); 44 | 45 | child.kill().context("killing child")?; 46 | 47 | let mut stdout = child.stdout.take().context("missing stdout")?; 48 | let mut stdout_str = String::from(""); 49 | stdout.read_to_string(&mut stdout_str).context("slurping stdout")?; 50 | 51 | if !stdout_str.is_empty() { 52 | println!("{stdout_str}"); 53 | return Err(anyhow!("unexpected stdout output")); 54 | } 55 | 56 | let mut stderr = child.stderr.take().context("missing stderr")?; 57 | let mut stderr_str = String::from(""); 58 | stderr.read_to_string(&mut stderr_str).context("slurping stderr")?; 59 | assert!(stderr_str.contains("STARTING DAEMON")); 60 | 61 | Ok(()) 62 | }) 63 | } 64 | 65 | #[test] 66 | #[timeout(30000)] 67 | fn systemd_activation() -> anyhow::Result<()> { 68 | support::dump_err(|| { 69 | let tmp_dir = tempfile::Builder::new() 70 | .prefix("shpool-test") 71 | .rand_bytes(20) 72 | .tempdir() 73 | .context("creating tmp dir")?; 74 | let sock_path = tmp_dir.path().join("shpool.socket"); 75 | let activation_sock = UnixListener::bind(&sock_path)?; 76 | 77 | let (parent_stderr, child_stderr) = 78 | nix::unistd::pipe().context("creating pipe to collect stderr")?; 79 | let child_stderr_pipe = Stdio::from(child_stderr); 80 | let mut cmd = Command::new(support::shpool_bin()?); 81 | cmd.stdout(Stdio::piped()) 82 | .stderr(child_stderr_pipe) 83 | .env("LISTEN_FDS", "1") 84 | .env("LISTEN_FDNAMES", sock_path) 85 | .arg("daemon"); 86 | 87 | let mut pid_buf = String::with_capacity(128); 88 | 89 | // We use fork both so we can correctly set LISTEN_PID and so 90 | // that the daemon will inherit the socket fd the way that we 91 | // want. 92 | // 93 | // We have to manually fork rather than using pre_exec because 94 | // there does not appear to be a way to set an environment 95 | // variable the child will inherit in the pre_exec callback. 96 | // 97 | // Safety: it's a test, get off my back. I try to avoid allocating. 98 | let child_pid = match unsafe { nix::unistd::fork() } { 99 | Ok(ForkResult::Parent { child, .. }) => child, 100 | Ok(ForkResult::Child) => { 101 | // place the unix socket file descriptor in the right place 102 | // Safety: We are sure that FD 3 is not open, and the returned OwnedFd will be 103 | // its only owner. 104 | let fdarg = match unsafe { nix::unistd::dup2_raw(activation_sock, 3) } { 105 | Ok(newfd) => newfd, 106 | Err(e) => { 107 | eprintln!("dup err: {e}"); 108 | std::process::exit(1) 109 | } 110 | }; 111 | 112 | // unset the fd_cloexec flag on the file descriptor so 113 | // we can actuall pass it down to the child 114 | let fdflags = nix::fcntl::fcntl(&fdarg, nix::fcntl::FcntlArg::F_GETFD) 115 | .expect("getfd flags to work"); 116 | let mut newflags = nix::fcntl::FdFlag::from_bits(fdflags).unwrap(); 117 | newflags.remove(nix::fcntl::FdFlag::FD_CLOEXEC); 118 | nix::fcntl::fcntl(&fdarg, nix::fcntl::FcntlArg::F_SETFD(newflags)) 119 | .expect("FD_CLOEXEC to be unset"); 120 | 121 | // set the LISTEN_PID environment variable without 122 | // allocating 123 | write!(&mut pid_buf, "{}", std::process::id()) 124 | .expect("to be able to format the pid"); 125 | cmd.env("LISTEN_PID", pid_buf); 126 | 127 | let err = cmd.exec(); 128 | eprintln!("exec err: {err:?}"); 129 | std::process::exit(1); 130 | } 131 | Err(e) => { 132 | return Err(e).context("forking daemon proc"); 133 | } 134 | }; 135 | 136 | // The server should start up and run without incident for 137 | // half a second. 138 | std::thread::sleep(time::Duration::from_millis(500)); 139 | 140 | // kill the daemon proc and reap the return code 141 | nix::sys::signal::kill(child_pid, Some(nix::sys::signal::Signal::SIGKILL)) 142 | .context("killing daemon")?; 143 | nix::sys::wait::waitpid(child_pid, None).context("reaping daemon")?; 144 | 145 | let mut stderr_buf: Vec = vec![0; 1024 * 8]; 146 | let len = 147 | nix::unistd::read(&parent_stderr, &mut stderr_buf[..]).context("reading stderr")?; 148 | let stderr = String::from_utf8_lossy(&stderr_buf[..len]); 149 | assert!(stderr.contains("using systemd activation socket")); 150 | 151 | Ok(()) 152 | }) 153 | } 154 | 155 | #[test] 156 | #[timeout(30000)] 157 | fn config() -> anyhow::Result<()> { 158 | support::dump_err(|| { 159 | let tmp_dir = tempfile::Builder::new() 160 | .prefix("shpool-test") 161 | .rand_bytes(20) 162 | .tempdir() 163 | .context("creating tmp dir")?; 164 | 165 | let mut child = Command::new(support::shpool_bin()?) 166 | .stdout(Stdio::piped()) 167 | .stderr(Stdio::piped()) 168 | .arg("--socket") 169 | .arg(tmp_dir.path().join("shpool.socket")) 170 | .arg("--config-file") 171 | .arg(support::testdata_file("empty.toml")) 172 | .arg("daemon") 173 | .spawn() 174 | .context("spawning daemon process")?; 175 | 176 | // The server should start up and run without incident for 177 | // half a second. 178 | std::thread::sleep(time::Duration::from_millis(500)); 179 | 180 | child.kill().context("killing child")?; 181 | 182 | let mut stdout = child.stdout.take().context("missing stdout")?; 183 | let mut stdout_str = String::from(""); 184 | stdout.read_to_string(&mut stdout_str).context("slurping stdout")?; 185 | 186 | if !stdout_str.is_empty() { 187 | println!("{stdout_str}"); 188 | return Err(anyhow!("unexpected stdout output")); 189 | } 190 | 191 | let mut stderr = child.stderr.take().context("missing stderr")?; 192 | let mut stderr_str = String::from(""); 193 | stderr.read_to_string(&mut stderr_str).context("slurping stderr")?; 194 | assert!(stderr_str.contains("STARTING DAEMON")); 195 | 196 | Ok(()) 197 | }) 198 | } 199 | 200 | #[test] 201 | #[timeout(30000)] 202 | fn hooks() -> anyhow::Result<()> { 203 | support::dump_err(|| { 204 | let mut daemon_proc = 205 | support::daemon::Proc::new_instrumented("norc.toml").context("starting daemon proc")?; 206 | let sh1_detached_re = Regex::new("sh1.*disconnected")?; 207 | 208 | { 209 | // 1 new session 210 | let mut sh1_proc = daemon_proc 211 | .attach( 212 | "sh1", 213 | AttachArgs { cmd: Some(String::from("/bin/bash")), ..Default::default() }, 214 | ) 215 | .context("starting attach proc")?; 216 | 217 | // sequencing 218 | let mut sh1_matcher = sh1_proc.line_matcher()?; 219 | sh1_proc.run_cmd("echo hi")?; 220 | sh1_matcher.scan_until_re("hi$")?; 221 | 222 | // 1 busy 223 | let mut busy_proc = daemon_proc 224 | .attach( 225 | "sh1", 226 | AttachArgs { cmd: Some(String::from("/bin/bash")), ..Default::default() }, 227 | ) 228 | .context("starting attach proc")?; 229 | busy_proc.proc.wait()?; 230 | } // 1 client disconnect 231 | 232 | // spin until sh1 disconnects 233 | daemon_proc.wait_until_list_matches(|listout| sh1_detached_re.is_match(listout))?; 234 | 235 | // 1 reattach 236 | let mut sh1_proc = daemon_proc 237 | .attach( 238 | "sh1", 239 | AttachArgs { cmd: Some(String::from("/bin/bash")), ..Default::default() }, 240 | ) 241 | .context("starting attach proc")?; 242 | sh1_proc.run_cmd("exit")?; // 1 shell disconnect 243 | 244 | support::wait_until(|| { 245 | let hook_records = daemon_proc.hook_records.as_ref().unwrap().lock().unwrap(); 246 | Ok(!hook_records.shell_disconnects.is_empty()) 247 | })?; 248 | 249 | let hook_records = daemon_proc.hook_records.as_ref().unwrap().lock().unwrap(); 250 | eprintln!("hook_records: {hook_records:?}"); 251 | assert_eq!(hook_records.new_sessions[0], "sh1"); 252 | assert_eq!(hook_records.reattaches[0], "sh1"); 253 | assert_eq!(hook_records.busys[0], "sh1"); 254 | assert_eq!(hook_records.client_disconnects[0], "sh1"); 255 | assert_eq!(hook_records.shell_disconnects[0], "sh1"); 256 | 257 | Ok(()) 258 | }) 259 | } 260 | 261 | #[test] 262 | #[timeout(30000)] 263 | fn cleanup_socket() -> anyhow::Result<()> { 264 | support::dump_err(|| { 265 | let mut daemon_proc = support::daemon::Proc::new( 266 | "norc.toml", 267 | DaemonArgs { listen_events: false, ..DaemonArgs::default() }, 268 | ) 269 | .context("starting daemon proc")?; 270 | 271 | signal::kill( 272 | Pid::from_raw(daemon_proc.proc.as_ref().unwrap().id() as i32), 273 | Signal::SIGINT, 274 | )?; 275 | 276 | daemon_proc.proc_wait()?; 277 | 278 | assert!(!path::Path::new(&daemon_proc.socket_path).exists()); 279 | Ok(()) 280 | }) 281 | } 282 | 283 | #[test] 284 | #[timeout(30000)] 285 | fn echo_sentinel() -> anyhow::Result<()> { 286 | support::dump_err(|| { 287 | let output = Command::new(support::shpool_bin()?) 288 | .stdout(Stdio::piped()) 289 | .stderr(Stdio::piped()) 290 | .env("SHPOOL__INTERNAL__PRINT_SENTINEL", "prompt") 291 | .arg("daemon") 292 | .output()?; 293 | 294 | assert!(output.status.success()); 295 | let stdout = String::from_utf8_lossy(&output.stdout); 296 | assert!(stdout.contains("SHPOOL_PROMPT_SETUP_SENTINEL")); 297 | 298 | Ok(()) 299 | }) 300 | } 301 | 302 | #[test] 303 | #[timeout(30000)] 304 | fn allows_dynamic_log_adjustments() -> anyhow::Result<()> { 305 | support::dump_err(|| { 306 | let mut daemon_proc = support::daemon::Proc::new( 307 | "norc.toml", 308 | DaemonArgs { verbosity: 0, ..Default::default() }, 309 | ) 310 | .context("starting daemon proc")?; 311 | 312 | daemon_proc.set_log_level("trace")?; 313 | 314 | // Loop because the data might not get flushed the first time through. 315 | loop { 316 | let mut sh1 = daemon_proc.attach("sh1", AttachArgs::default())?; 317 | // let mut line_matcher1 = sh1.line_matcher()?; 318 | sh1.run_cmd("echo hi")?; 319 | // line_matcher1.scan_until_re("hi$")?; 320 | 321 | // Make sure trace level data landed in the log despite the fact 322 | // that we started the daemon with a verbosity level of 0. 323 | let log_data = std::fs::read_to_string(&daemon_proc.log_file)?; 324 | if log_data.contains("echo hi") { 325 | break; 326 | } 327 | 328 | std::thread::sleep(time::Duration::from_millis(300)); 329 | } 330 | 331 | Ok(()) 332 | }) 333 | } 334 | --------------------------------------------------------------------------------