├── .github ├── .ruby-version ├── linters │ ├── .yamllint.yml │ └── .markdown-lint.yml ├── Gemfile ├── dependabot.yml ├── workflows │ ├── ci.yml │ ├── bump-version.yml │ ├── lint.yml │ └── tagged-release.yml └── Gemfile.lock ├── .gitignore ├── tests └── unit │ ├── fixtures │ ├── os │ │ ├── alpine-32-os-release │ │ ├── arch-os-release │ │ ├── debian-jessie-os-release │ │ ├── amzn-2-os-release │ │ └── ubuntu-1404-os-release │ └── authorized_keys │ │ ├── sshcommand_list_expected_output │ │ ├── sshcommand_list_expected_json_output_md5_filtered │ │ ├── input_variants │ │ └── sshcommand_list_expected_json_output │ ├── functions.bats │ ├── test_helper.bash │ └── core.bats ├── package.json ├── Dockerfile ├── LICENSE ├── README.md ├── Makefile └── sshcommand /.github/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | sshcommand.bak 3 | test-results 4 | -------------------------------------------------------------------------------- /.github/linters/.yamllint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | line-length: disable 6 | -------------------------------------------------------------------------------- /.github/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby file: ".ruby-version" 4 | 5 | gem "fpm" 6 | gem "package_cloud" 7 | -------------------------------------------------------------------------------- /tests/unit/fixtures/os/alpine-32-os-release: -------------------------------------------------------------------------------- 1 | NAME="Alpine Linux" 2 | ID=alpine 3 | VERSION_ID=3.2.3 4 | PRETTY_NAME="Alpine Linux v3.2" 5 | HOME_URL="http://alpinelinux.org" 6 | BUG_REPORT_URL="http://bugs.alpinelinux.org" 7 | -------------------------------------------------------------------------------- /tests/unit/fixtures/os/arch-os-release: -------------------------------------------------------------------------------- 1 | NAME="Arch Linux" 2 | PRETTY_NAME="Arch Linux" 3 | ID=arch 4 | ID_LIKE=archlinux 5 | ANSI_COLOR="0;36" 6 | HOME_URL="https://www.archlinux.org/" 7 | SUPPORT_URL="https://bbs.archlinux.org/" 8 | BUG_REPORT_URL="https://bugs.archlinux.org/" 9 | -------------------------------------------------------------------------------- /tests/unit/fixtures/os/debian-jessie-os-release: -------------------------------------------------------------------------------- 1 | PRETTY_NAME="Debian GNU/Linux jessie/sid" 2 | NAME="Debian GNU/Linux" 3 | ID=debian 4 | ANSI_COLOR="1;31" 5 | HOME_URL="http://www.debian.org/" 6 | SUPPORT_URL="http://www.debian.org/support/" 7 | BUG_REPORT_URL="http://bugs.debian.org/" 8 | -------------------------------------------------------------------------------- /tests/unit/fixtures/os/amzn-2-os-release: -------------------------------------------------------------------------------- 1 | NAME="Amazon Linux" 2 | VERSION="2" 3 | ID="amzn" 4 | ID_LIKE="centos rhel fedora" 5 | VERSION_ID="2" 6 | PRETTY_NAME="Amazon Linux 2" 7 | ANSI_COLOR="0;33" 8 | CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2" 9 | HOME_URL="https://amazonlinux.com/" 10 | -------------------------------------------------------------------------------- /.github/linters/.markdown-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | default: true 3 | 4 | # Line length 5 | # https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md#md013 6 | MD013: false 7 | 8 | # Inline HTML 9 | # https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md#md033 10 | MD033: false 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sshcommand", 3 | "version": "0.17.1", 4 | "description": "Turn SSH into a thin client specifically for your app", 5 | "global": "true", 6 | "install": "cp sshcommand /usr/local/bin && chmod +x /usr/local/bin/sshcommand", 7 | "scripts": [ 8 | "sshcommand" 9 | ] 10 | } -------------------------------------------------------------------------------- /tests/unit/fixtures/os/ubuntu-1404-os-release: -------------------------------------------------------------------------------- 1 | NAME="Ubuntu" 2 | VERSION="14.04.3 LTS, Trusty Tahr" 3 | ID=ubuntu 4 | ID_LIKE=debian 5 | PRETTY_NAME="Ubuntu 14.04.3 LTS" 6 | VERSION_ID="14.04" 7 | HOME_URL="http://www.ubuntu.com/" 8 | SUPPORT_URL="http://help.ubuntu.com/" 9 | BUG_REPORT_URL="http://bugs.launchpad.net/ubuntu/" 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.1-bookworm 2 | 3 | # hadolint ignore=DL3027 4 | RUN apt-get update \ 5 | && apt install apt-transport-https build-essential curl gnupg2 jq lintian rsync rubygems-integration ruby-dev ruby software-properties-common sudo -qy \ 6 | && apt-get clean \ 7 | && rm -rf /var/lib/apt/lists/* 8 | 9 | # hadolint ignore=DL3028 10 | RUN gem install --quiet rake fpm package_cloud 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "bundler" 5 | directory: "/.github" 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "docker" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | labels: 13 | - "type: dependencies" 14 | - package-ecosystem: github-actions 15 | directory: "/" 16 | schedule: 17 | interval: daily 18 | open-pull-requests-limit: 10 19 | labels: 20 | - "type: dependencies" 21 | -------------------------------------------------------------------------------- /tests/unit/fixtures/authorized_keys/sshcommand_list_expected_output: -------------------------------------------------------------------------------- 1 | 2a:f7:39:1c:63:80:c4:9e:a8:92:ec:e6:94:91:fa:c0 NAME="md5" SSHCOMMAND_ALLOWED_KEYS="no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding" 2 | SHA256:y/lCsvs2fCWjcYc0whZFYQ3UX/OO2qv+n6wE7D9dCeI NAME="sha256" SSHCOMMAND_ALLOWED_KEYS="no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding" 3 | 9f:7d:fd:8e:48:86:c5:ed:41:82:f7:df:3c:2c:18:9e NAME="without-quotes" SSHCOMMAND_ALLOWED_KEYS="no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding" 4 | -------------------------------------------------------------------------------- /tests/unit/fixtures/authorized_keys/sshcommand_list_expected_json_output_md5_filtered: -------------------------------------------------------------------------------- 1 | [{"fingerprint":"2a:f7:39:1c:63:80:c4:9e:a8:92:ec:e6:94:91:fa:c0","name":"md5","SSHCOMMAND_ALLOWED_KEYS":"no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding","public-key":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC9y0nhYCAWn7PAf/jlOOSKNnnlKWQ1qkCLkrkHIzMy6hBjRyicFPZp3h+edkPIsUt0tZxXfxt/duJtFGXGS47n8aaWC4Dwu9V2l2U9kCbXtbvuV270+ayk4ax7imBBuMkUWUYgoxrzd13Z9VcSZYePMfZXqEwLN3+XXAZvK32nMfKZ9b0AX4jraDA3JMAXnBxWxoZk4ic+vlNzOZBjH7BS8XwackuqhjLddNPGjmo/YeBJL5Av32sV+tOvpPx4Zu4mTZXy8WQtx7r+Q+209Lt6eVKjO+FYR1gPcyh77KHeNGRvK0WcpnJAC3yU1wgUnpmNOcZ8F2FUpSdaxjX6JSwV md5"}] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request: 7 | branches: 8 | - "*" 9 | push: 10 | branches: 11 | - "main" 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | name: build 20 | runs-on: ubuntu-24.04 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - name: setup 25 | run: | 26 | make version ci-setup 27 | env: 28 | PACKAGECLOUD_TOKEN: ${{ secrets.PACKAGECLOUD_TOKEN }} 29 | GITHUB_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} 30 | 31 | - name: build 32 | run: make build-docker-image build-in-docker 33 | 34 | - name: test 35 | run: make validate-in-docker 36 | 37 | - name: upload packages 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: build 41 | path: build 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 Jeff Lindsay 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /tests/unit/functions.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load test_helper 4 | 5 | @test "(fn) print-os-id (custom path)" { 6 | load "/usr/local/bin/sshcommand" 7 | 8 | SSHCOMMAND_OSRELEASE=$BATS_TEST_DIRNAME/fixtures/os/ubuntu-1404-os-release run "fn-print-os-id" 9 | echo "output: $output" 10 | echo "status: $status" 11 | assert_output "ubuntu" 12 | assert_success 13 | 14 | SSHCOMMAND_OSRELEASE=$BATS_TEST_DIRNAME/fixtures/os/debian-jessie-os-release run "fn-print-os-id" 15 | echo "output: $output" 16 | echo "status: $status" 17 | assert_output "debian" 18 | assert_success 19 | 20 | SSHCOMMAND_OSRELEASE=$BATS_TEST_DIRNAME/fixtures/os/alpine-32-os-release run "fn-print-os-id" 21 | echo "output: $output" 22 | echo "status: $status" 23 | assert_output "alpine" 24 | assert_success 25 | 26 | SSHCOMMAND_OSRELEASE=$BATS_TEST_DIRNAME/fixtures/os/amzn-2-os-release run "fn-print-os-id" 27 | echo "output: $output" 28 | echo "status: $status" 29 | assert_output "amzn" 30 | assert_success 31 | 32 | SSHCOMMAND_OSRELEASE=$BATS_TEST_DIRNAME/fixtures/os/arch-os-release run "fn-print-os-id" 33 | echo "output: $output" 34 | echo "status: $status" 35 | assert_output "arch" 36 | assert_success 37 | } 38 | 39 | @test "(fn) print-os-id (invalid path)" { 40 | load "/usr/local/bin/sshcommand" 41 | SSHCOMMAND_OSRELEASE=/tmp/nonexistent-os-release run "fn-print-os-id" 42 | echo "output: $output" 43 | echo "status: $status" 44 | assert_output "unknown" 45 | assert_success 46 | } 47 | -------------------------------------------------------------------------------- /.github/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | arr-pm (0.0.12) 5 | backports (3.25.0) 6 | cabin (0.9.0) 7 | clamp (1.3.2) 8 | domain_name (0.6.20240107) 9 | dotenv (3.1.4) 10 | fpm (1.16.0) 11 | arr-pm (~> 0.0.11) 12 | backports (>= 2.6.2) 13 | cabin (>= 0.6.0) 14 | clamp (>= 1.0.0) 15 | pleaserun (~> 0.0.29) 16 | rexml 17 | stud 18 | highline (2.0.3) 19 | http-accept (1.7.0) 20 | http-cookie (1.0.5) 21 | domain_name (~> 0.5) 22 | insist (1.0.0) 23 | json_pure (2.3.1) 24 | mime-types (3.5.2) 25 | mime-types-data (~> 3.2015) 26 | mime-types-data (3.2024.0507) 27 | mustache (0.99.8) 28 | netrc (0.11.0) 29 | package_cloud (0.3.14) 30 | highline (~> 2.0.0) 31 | json_pure (~> 2.3.0) 32 | rainbow (= 2.2.2) 33 | rest-client (~> 2.0) 34 | thor (~> 1.2) 35 | pleaserun (0.0.32) 36 | cabin (> 0) 37 | clamp 38 | dotenv 39 | insist 40 | mustache (= 0.99.8) 41 | stud 42 | rainbow (2.2.2) 43 | rake 44 | rake (13.2.1) 45 | rest-client (2.1.0) 46 | http-accept (>= 1.7.0, < 2.0) 47 | http-cookie (>= 1.0.2, < 2.0) 48 | mime-types (>= 1.16, < 4.0) 49 | netrc (~> 0.8) 50 | rexml (3.3.9) 51 | stud (0.0.23) 52 | thor (1.4.0) 53 | 54 | PLATFORMS 55 | arm64-darwin-23 56 | ruby 57 | 58 | DEPENDENCIES 59 | fpm 60 | package_cloud 61 | 62 | RUBY VERSION 63 | ruby 3.3.1p55 64 | 65 | BUNDLED WITH 66 | 2.5.9 67 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "bump-version" 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | bump_type: 9 | description: "Bump type" 10 | default: "patch" 11 | required: true 12 | type: choice 13 | options: 14 | - patch 15 | - minor 16 | - major 17 | 18 | env: 19 | GITHUB_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} 20 | 21 | jobs: 22 | bump-version: 23 | name: bump-version 24 | runs-on: ubuntu-24.04 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v5 29 | with: 30 | fetch-depth: 0 31 | token: ${{ env.GITHUB_ACCESS_TOKEN }} 32 | 33 | - name: Get Latest Tag 34 | id: latest-tag 35 | run: | 36 | echo GIT_LATEST_TAG="$(git describe --tags "$(git rev-list --tags --max-count=1)")" >>"$GITHUB_OUTPUT" 37 | 38 | - name: Compute Next Tag 39 | id: next-tag 40 | uses: docker://ghcr.io/dokku/semver-generator:latest 41 | with: 42 | bump: ${{ github.event.inputs.bump_type }} 43 | input: ${{ steps.latest-tag.outputs.GIT_LATEST_TAG }} 44 | 45 | - name: Create and Push Tag 46 | run: | 47 | git config --global user.name 'Dokku Bot' 48 | git config --global user.email no-reply@dokku.com 49 | git tag "$GIT_NEXT_TAG" 50 | git push origin "$GIT_NEXT_TAG" 51 | env: 52 | GIT_NEXT_TAG: ${{ steps.next-tag.outputs.version }} 53 | -------------------------------------------------------------------------------- /tests/unit/fixtures/authorized_keys/input_variants: -------------------------------------------------------------------------------- 1 | command="FINGERPRINT=2a:f7:39:1c:63:80:c4:9e:a8:92:ec:e6:94:91:fa:c0 NAME=\"md5\" `cat /home/sshcommand_user/.sshcommand` $SSH_ORIGINAL_COMMAND",no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC9y0nhYCAWn7PAf/jlOOSKNnnlKWQ1qkCLkrkHIzMy6hBjRyicFPZp3h+edkPIsUt0tZxXfxt/duJtFGXGS47n8aaWC4Dwu9V2l2U9kCbXtbvuV270+ayk4ax7imBBuMkUWUYgoxrzd13Z9VcSZYePMfZXqEwLN3+XXAZvK32nMfKZ9b0AX4jraDA3JMAXnBxWxoZk4ic+vlNzOZBjH7BS8XwackuqhjLddNPGjmo/YeBJL5Av32sV+tOvpPx4Zu4mTZXy8WQtx7r+Q+209Lt6eVKjO+FYR1gPcyh77KHeNGRvK0WcpnJAC3yU1wgUnpmNOcZ8F2FUpSdaxjX6JSwV md5 2 | command="FINGERPRINT=SHA256:y/lCsvs2fCWjcYc0whZFYQ3UX/OO2qv+n6wE7D9dCeI NAME=\"sha256\" `cat /home/dokku/.sshcommand` $SSH_ORIGINAL_COMMAND",no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA1F98keUFBulWE04RO2+wv3bXASs3TeBXZwG7l4/DKAGc8Qx+U0wb8hZNRtPo2cZP2NRBnVRe6hYEHhr++ocDkPjWY1vggJu/nhubqVELVg+gblpC3rYJur6c4uoTUx/Ea1JzHZ9CKhQJRUj7t6KDfDYv8CM7mgS71QdL/YifTjDeIqXiaSRnb0Q2HkuKQQ5yxT0TLwN3chp4WfVqYcbWvnjUk23IfKAos/1e7F3DOMvO+gU8OYyV17iXVB2Bmb5YZodEYeomprL+yfeh5Qa8Kswo+fYRKJ+9SLYVAVNGxbpktUyv+DlLxuRD4AsGDWKuDqk5U3O6cK3CNQWYUGSVew== sha256 3 | command="FINGERPRINT=9f:7d:fd:8e:48:86:c5:ed:41:82:f7:df:3c:2c:18:9e NAME=without-quotes `cat /home/dokku/.sshcommand` $SSH_ORIGINAL_COMMAND",no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAx6lOy3WkAieirh3UgZK0x2wwUkmaPeR+tJca4ZXGRYAh/7rqX3MHjIvgne8KDW2KGvPdy5BJpY3wkix9vRF7XU7nrl/dGnHWUT+8IcCKuAKiJQvVU1ZqYrvUXnRMeoRr1diHWoixo9BRxzu4GS4Gg+cFX3wH+qsJvMm5LollUUWy2RKWK2l3RFR6XQ3sUtvPe7FSSI+QYgst1HYQk50tigp1RYIFiLYXnvZrnAFQ05N4PjP15rTJRkOTatuDUzDB9lzEQjUq/Ew0zV8JEIn6Svp68UmeK+IWSMNsHj30k6aiZBHWYvKkrCgqAMZJdh/7nDNePqbUftdS2On9lwENpQ== 4 | -------------------------------------------------------------------------------- /tests/unit/fixtures/authorized_keys/sshcommand_list_expected_json_output: -------------------------------------------------------------------------------- 1 | [{ "fingerprint": "2a:f7:39:1c:63:80:c4:9e:a8:92:ec:e6:94:91:fa:c0", "name": "md5", "SSHCOMMAND_ALLOWED_KEYS": "no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding", "public-key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC9y0nhYCAWn7PAf/jlOOSKNnnlKWQ1qkCLkrkHIzMy6hBjRyicFPZp3h+edkPIsUt0tZxXfxt/duJtFGXGS47n8aaWC4Dwu9V2l2U9kCbXtbvuV270+ayk4ax7imBBuMkUWUYgoxrzd13Z9VcSZYePMfZXqEwLN3+XXAZvK32nMfKZ9b0AX4jraDA3JMAXnBxWxoZk4ic+vlNzOZBjH7BS8XwackuqhjLddNPGjmo/YeBJL5Av32sV+tOvpPx4Zu4mTZXy8WQtx7r+Q+209Lt6eVKjO+FYR1gPcyh77KHeNGRvK0WcpnJAC3yU1wgUnpmNOcZ8F2FUpSdaxjX6JSwV md5" },{ "fingerprint": "SHA256:y/lCsvs2fCWjcYc0whZFYQ3UX/OO2qv+n6wE7D9dCeI", "name": "sha256", "SSHCOMMAND_ALLOWED_KEYS": "no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding", "public-key": "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA1F98keUFBulWE04RO2+wv3bXASs3TeBXZwG7l4/DKAGc8Qx+U0wb8hZNRtPo2cZP2NRBnVRe6hYEHhr++ocDkPjWY1vggJu/nhubqVELVg+gblpC3rYJur6c4uoTUx/Ea1JzHZ9CKhQJRUj7t6KDfDYv8CM7mgS71QdL/YifTjDeIqXiaSRnb0Q2HkuKQQ5yxT0TLwN3chp4WfVqYcbWvnjUk23IfKAos/1e7F3DOMvO+gU8OYyV17iXVB2Bmb5YZodEYeomprL+yfeh5Qa8Kswo+fYRKJ+9SLYVAVNGxbpktUyv+DlLxuRD4AsGDWKuDqk5U3O6cK3CNQWYUGSVew== sha256" },{ "fingerprint": "9f:7d:fd:8e:48:86:c5:ed:41:82:f7:df:3c:2c:18:9e", "name": "without-quotes", "SSHCOMMAND_ALLOWED_KEYS": "no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding", "public-key": "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAx6lOy3WkAieirh3UgZK0x2wwUkmaPeR+tJca4ZXGRYAh/7rqX3MHjIvgne8KDW2KGvPdy5BJpY3wkix9vRF7XU7nrl/dGnHWUT+8IcCKuAKiJQvVU1ZqYrvUXnRMeoRr1diHWoixo9BRxzu4GS4Gg+cFX3wH+qsJvMm5LollUUWy2RKWK2l3RFR6XQ3sUtvPe7FSSI+QYgst1HYQk50tigp1RYIFiLYXnvZrnAFQ05N4PjP15rTJRkOTatuDUzDB9lzEQjUq/Ew0zV8JEIn6Svp68UmeK+IWSMNsHj30k6aiZBHWYvKkrCgqAMZJdh/7nDNePqbUftdS2On9lwENpQ==" }] 2 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "lint" 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request: 7 | branches: 8 | - "*" 9 | push: 10 | branches: 11 | - "main" 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | hadolint: 19 | name: hadolint 20 | runs-on: ubuntu-24.04 21 | steps: 22 | - name: Clone 23 | uses: actions/checkout@v5 24 | - name: Run hadolint 25 | uses: hadolint/hadolint-action@3fc49fb50d59c6ab7917a2e4195dba633e515b29 26 | 27 | markdown-lint: 28 | name: markdown-lint 29 | runs-on: ubuntu-24.04 30 | steps: 31 | - name: Clone 32 | uses: actions/checkout@v5 33 | - name: Run markdown-lint 34 | uses: avto-dev/markdown-lint@04d43ee9191307b50935a753da3b775ab695eceb 35 | with: 36 | config: ".github/linters/.markdown-lint.yml" 37 | args: "./README.md" 38 | 39 | shellcheck: 40 | name: shellcheck 41 | runs-on: ubuntu-24.04 42 | steps: 43 | - name: Clone 44 | uses: actions/checkout@v5 45 | - name: Run shellcheck 46 | uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 47 | env: 48 | SHELLCHECK_OPTS: -e SC2034 49 | 50 | shfmt: 51 | name: shfmt 52 | runs-on: ubuntu-24.04 53 | steps: 54 | - name: Clone 55 | uses: actions/checkout@v5 56 | - name: Run shfmt 57 | uses: luizm/action-sh-checker@17bd25a6ee188d2b91f677060038f4ba37ba14b2 58 | env: 59 | SHFMT_OPTS: -l -bn -ci -i 2 -d 60 | with: 61 | sh_checker_shellcheck_disable: true 62 | 63 | yamllint: 64 | name: yamllint 65 | runs-on: ubuntu-24.04 66 | steps: 67 | - name: Clone 68 | uses: actions/checkout@v5 69 | - name: Run yamllint 70 | uses: ibiqlik/action-yamllint@2576378a8e339169678f9939646ee3ee325e845c 71 | with: 72 | config_file: ".github/linters/.yamllint.yml" 73 | -------------------------------------------------------------------------------- /.github/workflows/tagged-release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "tagged-release" 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | tags: 8 | - "*" 9 | 10 | permissions: 11 | attestations: write 12 | id-token: write 13 | contents: write 14 | 15 | jobs: 16 | tagged-release: 17 | name: tagged-release 18 | runs-on: ubuntu-24.04 19 | env: 20 | CI_BRANCH: release 21 | PACKAGECLOUD_REPOSITORY: dokku/dokku 22 | VERSION: ${{ github.ref_name }} 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v5 27 | 28 | - name: Get Repository Name 29 | id: repo-name 30 | run: | 31 | echo "REPOSITORY_NAME=$(echo "${{ github.repository }}" | cut -d '/' -f 2)" >> $GITHUB_OUTPUT 32 | 33 | - name: Build binaries 34 | run: | 35 | mkdir -p dist 36 | make version build/linux/${{ steps.repo-name.outputs.REPOSITORY_NAME }} 37 | cp build/linux/${{ steps.repo-name.outputs.REPOSITORY_NAME }} dist/${{ steps.repo-name.outputs.REPOSITORY_NAME }} 38 | 39 | - name: Setup Ruby 40 | uses: ruby/setup-ruby@v1.257.0 41 | with: 42 | bundler-cache: true 43 | working-directory: .github 44 | 45 | - name: Build Debian Packages 46 | run: | 47 | bundle exec make build/deb/${{ steps.repo-name.outputs.REPOSITORY_NAME }}_${{ github.ref_name }}_all.deb 48 | cp build/deb/*.deb dist/ 49 | env: 50 | BUNDLE_GEMFILE: .github/Gemfile 51 | 52 | - name: Upload Artifacts 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: dist 56 | path: dist/* 57 | 58 | - name: Release to PackageCloud 59 | run: bundle exec make release-packagecloud 60 | env: 61 | BUNDLE_GEMFILE: .github/Gemfile 62 | PACKAGECLOUD_TOKEN: ${{ secrets.PACKAGECLOUD_TOKEN }} 63 | 64 | - name: Release 65 | uses: softprops/action-gh-release@v2 66 | with: 67 | files: dist/* 68 | generate_release_notes: true 69 | make_latest: "true" 70 | -------------------------------------------------------------------------------- /tests/unit/test_helper.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # constants 4 | TEST_USER=sshcommand_user 5 | TEST_KEY_NAME=test_key 6 | TEST_KEY_DIR=/tmp/test_keys 7 | 8 | # test functions 9 | flunk() { 10 | { 11 | if [[ "$#" -eq 0 ]]; then 12 | cat - 13 | else 14 | echo "$*" 15 | fi 16 | } 17 | return 1 18 | } 19 | 20 | # ShellCheck doesn't know about $status from Bats 21 | # shellcheck disable=SC2154 22 | assert_success() { 23 | if [[ "$status" -ne 0 ]]; then 24 | flunk "command failed with exit status $status" 25 | elif [[ "$#" -gt 0 ]]; then 26 | assert_output "$1" 27 | fi 28 | } 29 | 30 | assert_failure() { 31 | if [[ "$status" -eq 0 ]]; then 32 | flunk "expected failed exit status" 33 | elif [[ "$#" -gt 0 ]]; then 34 | assert_output "$1" 35 | fi 36 | } 37 | 38 | assert_equal() { 39 | if [[ "$1" != "$2" ]]; then 40 | { 41 | echo "expected: $1" 42 | echo "actual: $2" 43 | } | flunk 44 | fi 45 | } 46 | 47 | # ShellCheck doesn't know about $output from Bats 48 | # shellcheck disable=SC2154 49 | assert_output() { 50 | local expected 51 | if [[ $# -eq 0 ]]; then 52 | expected="$(cat -)" 53 | else 54 | expected="$1" 55 | fi 56 | assert_equal "$expected" "$output" 57 | } 58 | 59 | # ShellCheck doesn't know about $lines from Bats 60 | # shellcheck disable=SC2154 61 | assert_line() { 62 | if [[ "$1" -ge 0 ]] 2>/dev/null; then 63 | assert_equal "$2" "${lines[$1]}" 64 | else 65 | local line 66 | for line in "${lines[@]}"; do 67 | [[ "$line" = "$1" ]] && return 0 68 | done 69 | flunk "expected line \`$1'" 70 | fi 71 | } 72 | 73 | refute_line() { 74 | if [[ "$1" -ge 0 ]] 2>/dev/null; then 75 | local num_lines="${#lines[@]}" 76 | if [[ "$1" -lt "$num_lines" ]]; then 77 | flunk "output has $num_lines lines" 78 | fi 79 | else 80 | local line 81 | for line in "${lines[@]}"; do 82 | if [[ "$line" = "$1" ]]; then 83 | flunk "expected to not find line \`$line'" 84 | fi 85 | done 86 | fi 87 | } 88 | 89 | assert() { 90 | if ! "$*"; then 91 | flunk "failed: $*" 92 | fi 93 | } 94 | 95 | assert_exit_status() { 96 | assert_equal "$status" "$1" 97 | } 98 | 99 | # sshcommand helpers 100 | create_user() { 101 | sshcommand create $TEST_USER ls >/dev/null 102 | } 103 | 104 | delete_user() { 105 | userdel --remove $TEST_USER 106 | } 107 | 108 | create_test_key() { 109 | KEY_NAME="$1" 110 | KEY_NAME=${KEY_NAME:=$TEST_KEY_NAME} 111 | mkdir -p $TEST_KEY_DIR 112 | ssh-keygen -N '' -q -f "$TEST_KEY_DIR/$KEY_NAME" 113 | } 114 | 115 | delete_test_keys() { 116 | rm -rf $TEST_KEY_DIR 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshcommand 2 | 3 | Simplifies running a single command over SSH, and manages authorized keys (ACL) and users in order to do so. 4 | 5 | It basically simplifies running: 6 | 7 | ```shell 8 | ssh user@server 'ls -l ' 9 | ``` 10 | 11 | into: 12 | 13 | ```shell 14 | ssh ls@server 15 | ``` 16 | 17 | ## Commands 18 | 19 | ```shell 20 | sshcommand create # Creates a local system user and installs sshcommand skeleton 21 | sshcommand acl-add # Adds named SSH key to user from STDIN or argument 22 | sshcommand acl-remove # Removes SSH key by name 23 | sshcommand acl-remove-by-fingerprint # Removes SSH key by fingerprint 24 | sshcommand list [] [] # Lists SSH keys by user, an optional name and a optional output format (JSON) 25 | sshcommand help # Shows help information 26 | sshcommand version # Shows version 27 | ``` 28 | 29 | ## Example 30 | 31 | On a server, create a new command user: 32 | 33 | ```shell 34 | sshcommand create cmd /path/to/command 35 | ``` 36 | 37 | On your computer, add authorized keys with your key: 38 | 39 | ```shell 40 | cat ~/.ssh/id_rsa.pub | ssh root@server sshcommand acl-add cmd progrium 41 | ``` 42 | 43 | If the public key is already on the server, you may also specify it as an argument: 44 | 45 | ```shell 46 | ssh root@server sshcommand acl-add cmd progrium ~/.ssh/id_rsa.pub 47 | ``` 48 | 49 | By default, key names and fingerprints must be unique. Both of these checks can be disabled by setting the following environment variables to `false`: 50 | 51 | ```shell 52 | export SSHCOMMAND_CHECK_DUPLICATE_FINGERPRINT="false" 53 | export SSHCOMMAND_CHECK_DUPLICATE_NAME="false" 54 | ``` 55 | 56 | Now anywhere with the private key you can easily run: 57 | 58 | ```shell 59 | ssh cmd@server 60 | ``` 61 | 62 | Anything you pass as the command string will be appended to the command. You can use this 63 | to pass arguments or if your command takes subcommands, expose those subcommands easily. 64 | 65 | ```shell 66 | /path/to/command subcommand 67 | ``` 68 | 69 | Can be run remotely with: 70 | 71 | ```shell 72 | ssh cmd@server subcommand 73 | ``` 74 | 75 | When adding an authorized key, you can also specify custom options for `AUTHORIZED_KEYS` 76 | by specifying the `SSHCOMMAND_ALLOWED_KEYS` environment variable. This should be a list 77 | of comma-separated options. The default keys are as follows: 78 | 79 | ```shell 80 | no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding 81 | ``` 82 | 83 | This can be useful for cases where the ssh server does not allow certain options or you 84 | wish to further constrain a user's environment. Please see `man sshd` for more information. 85 | 86 | Existing keys can be listed via the `list` subcommand: 87 | 88 | ```shell 89 | # in text format 90 | sshcommand list cmd 91 | 92 | # filter by a particular name 93 | sshcommand list cmd progrium 94 | 95 | # in json format 96 | sshcommand list cmd "" json 97 | 98 | # with name filtering 99 | sshcommand list cmd progrium json 100 | 101 | # ignore validation errors (though they will be printed to stderr) 102 | export SSHCOMMAND_IGNORE_LIST_WARNINGS=true 103 | sshcommand list cmd 104 | ``` 105 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: ci-dependencies shellcheck bats install lint unit-tests test 2 | NAME = sshcommand 3 | EMAIL = sshcommand@josediazgonzalez.com 4 | MAINTAINER = dokku 5 | MAINTAINER_NAME = Jose Diaz-Gonzalez 6 | REPOSITORY = sshcommand 7 | HARDWARE = $(shell uname -m) 8 | SYSTEM_NAME = $(shell uname -s | tr '[:upper:]' '[:lower:]') 9 | BASE_VERSION ?= 0.17.1 10 | IMAGE_NAME ?= $(MAINTAINER)/$(REPOSITORY) 11 | PACKAGECLOUD_REPOSITORY ?= dokku/dokku-betafish 12 | 13 | ifeq ($(CI_BRANCH),release) 14 | VERSION ?= $(BASE_VERSION) 15 | DOCKER_IMAGE_VERSION = $(VERSION) 16 | else 17 | VERSION = $(shell echo "${BASE_VERSION}")build+$(shell git rev-parse --short HEAD) 18 | DOCKER_IMAGE_VERSION = $(shell echo "${BASE_VERSION}")build-$(shell git rev-parse --short HEAD) 19 | endif 20 | 21 | version: 22 | @sed -i.bak 's/SSHCOMMAND_VERSION=""/SSHCOMMAND_VERSION="$(VERSION)"/' sshcommand && rm sshcommand.bak 23 | @echo "$(CI_BRANCH)" 24 | @echo "$(VERSION)" 25 | @./sshcommand version 26 | 27 | 28 | define PACKAGE_DESCRIPTION 29 | Turn SSH into a thin client specifically for your app 30 | Simplifies running a single command over SSH, and 31 | manages authorized keys (ACL) and users in order to do so. 32 | endef 33 | 34 | export PACKAGE_DESCRIPTION 35 | 36 | LIST = build release release-packagecloud validate 37 | targets = $(addsuffix -in-docker, $(LIST)) 38 | 39 | .env.docker: 40 | @rm -f .env.docker 41 | @touch .env.docker 42 | @echo "CI_BRANCH=$(CI_BRANCH)" >> .env.docker 43 | @echo "GITHUB_ACCESS_TOKEN=$(GITHUB_ACCESS_TOKEN)" >> .env.docker 44 | @echo "IMAGE_NAME=$(IMAGE_NAME)" >> .env.docker 45 | @echo "PACKAGECLOUD_REPOSITORY=$(PACKAGECLOUD_REPOSITORY)" >> .env.docker 46 | @echo "PACKAGECLOUD_TOKEN=$(PACKAGECLOUD_TOKEN)" >> .env.docker 47 | @echo "VERSION=$(VERSION)" >> .env.docker 48 | 49 | build: pre-build 50 | @$(MAKE) build/darwin/$(NAME) 51 | @$(MAKE) build/linux/$(NAME) 52 | @$(MAKE) build/deb/$(NAME)_$(VERSION)_all.deb 53 | 54 | build-docker-image: 55 | docker build --rm -q -t $(IMAGE_NAME):build . 56 | 57 | $(targets): %-in-docker: .env.docker 58 | docker run \ 59 | --env-file .env.docker \ 60 | --rm \ 61 | --volume /var/lib/docker:/var/lib/docker \ 62 | --volume /var/run/docker.sock:/var/run/docker.sock:ro \ 63 | --volume ${PWD}:/src/github.com/$(MAINTAINER)/$(REPOSITORY) \ 64 | --workdir /src/github.com/$(MAINTAINER)/$(REPOSITORY) \ 65 | $(IMAGE_NAME):build make -e $(@:-in-docker=) 66 | 67 | build/darwin/$(NAME): 68 | chmod +x sshcommand 69 | mkdir -p build/darwin 70 | cp -f sshcommand build/darwin/sshcommand 71 | 72 | build/linux/$(NAME): 73 | chmod +x sshcommand 74 | mkdir -p build/linux 75 | cp -f sshcommand build/linux/sshcommand 76 | 77 | build/deb/$(NAME)_$(VERSION)_all.deb: build/linux/$(NAME) 78 | chmod 644 LICENSE 79 | export SOURCE_DATE_EPOCH=$(shell git log -1 --format=%ct) \ 80 | && mkdir -p build/deb \ 81 | && fpm \ 82 | --architecture all \ 83 | --category admin \ 84 | --depends adduser \ 85 | --depends coreutils \ 86 | --depends jq \ 87 | --depends libc-bin \ 88 | --depends openssh-client \ 89 | --description "$$PACKAGE_DESCRIPTION" \ 90 | --input-type dir \ 91 | --license 'MIT License' \ 92 | --maintainer "$(MAINTAINER_NAME) <$(EMAIL)>" \ 93 | --name $(NAME) \ 94 | --output-type deb \ 95 | --package build/deb/$(NAME)_$(VERSION)_all.deb \ 96 | --url "https://github.com/$(MAINTAINER)/$(REPOSITORY)" \ 97 | --vendor "" \ 98 | --version $(VERSION) \ 99 | --verbose \ 100 | build/linux/$(NAME)=/usr/bin/$(NAME) \ 101 | LICENSE=/usr/share/doc/$(NAME)/copyright 102 | 103 | clean: 104 | rm -rf build release validation 105 | 106 | ci-setup: 107 | docker version 108 | rm -f ~/.gitconfig 109 | 110 | bin/gh-release: 111 | mkdir -p bin 112 | curl -o bin/gh-release.tgz -sL https://github.com/progrium/gh-release/releases/download/v2.3.0/gh-release_2.3.0_$(SYSTEM_NAME)_$(HARDWARE).tgz 113 | tar xf bin/gh-release.tgz -C bin 114 | chmod +x bin/gh-release 115 | 116 | bin/gh-release-body: 117 | mkdir -p bin 118 | curl -o bin/gh-release-body "https://raw.githubusercontent.com/dokku/gh-release-body/master/gh-release-body" 119 | chmod +x bin/gh-release-body 120 | 121 | release: bin/gh-release bin/gh-release-body 122 | rm -rf release && mkdir release 123 | tar -zcf release/$(NAME)_$(VERSION)_linux_$(HARDWARE).tgz -C build/linux $(NAME) 124 | tar -zcf release/$(NAME)_$(VERSION)_darwin_$(HARDWARE).tgz -C build/darwin $(NAME) 125 | cp build/deb/$(NAME)_$(VERSION)_all.deb release/$(NAME)_$(VERSION)_all.deb 126 | bin/gh-release create $(MAINTAINER)/$(REPOSITORY) $(VERSION) $(shell git rev-parse --abbrev-ref HEAD) 127 | bin/gh-release-body $(MAINTAINER)/$(REPOSITORY) v$(VERSION) 128 | 129 | release-packagecloud: 130 | @$(MAKE) release-packagecloud-deb 131 | 132 | release-packagecloud-deb: build/deb/$(NAME)_$(VERSION)_all.deb 133 | package_cloud push $(PACKAGECLOUD_REPOSITORY)/ubuntu/jammy build/deb/$(NAME)_$(VERSION)_all.deb 134 | package_cloud push $(PACKAGECLOUD_REPOSITORY)/ubuntu/noble build/deb/$(NAME)_$(VERSION)_all.deb 135 | package_cloud push $(PACKAGECLOUD_REPOSITORY)/debian/bullseye build/deb/$(NAME)_$(VERSION)_all.deb 136 | package_cloud push $(PACKAGECLOUD_REPOSITORY)/debian/bookworm build/deb/$(NAME)_$(VERSION)_all.deb 137 | package_cloud push $(PACKAGECLOUD_REPOSITORY)/debian/trixie build/deb/$(NAME)_$(VERSION)_all.deb 138 | package_cloud push $(PACKAGECLOUD_REPOSITORY)/raspbian/bullseye build/deb/$(NAME)_$(VERSION)_all.deb 139 | 140 | validate: test 141 | mkdir -p validation 142 | lintian build/deb/$(NAME)_$(VERSION)_all.deb || true 143 | dpkg-deb --info build/deb/$(NAME)_$(VERSION)_all.deb 144 | dpkg -c build/deb/$(NAME)_$(VERSION)_all.deb 145 | cd validation && ar -x ../build/deb/$(NAME)_$(VERSION)_all.deb 146 | ls -lah build/deb validation 147 | sha1sum build/deb/$(NAME)_$(VERSION)_all.deb 148 | 149 | test: lint unit-tests 150 | 151 | lint: shellcheck bats 152 | @echo linting... 153 | # SC2034: VAR appears unused - https://github.com/koalaman/shellcheck/wiki/SC2034 154 | # desc is used to declare the description of the function 155 | @$(QUIET) find . -not -path '*/\.*' | xargs file | egrep "shell|bash" | awk '{ print $$1 }' | sed 's/://g' | xargs shellcheck -e SC2034 156 | 157 | unit-tests: /usr/local/bin/sshcommand 158 | @echo running unit tests... 159 | @mkdir -p test-results/bats 160 | @$(QUIET) TERM=linux bats --report-formatter junit --timing -o test-results/bats tests/unit 161 | 162 | pre-build: 163 | git config --global --add safe.directory $(shell pwd) 164 | git status 165 | 166 | /usr/local/bin/sshcommand: 167 | @echo installing sshcommand 168 | cp ./sshcommand /usr/local/bin/sshcommand 169 | chmod +x /usr/local/bin/sshcommand 170 | 171 | shellcheck: 172 | ifneq ($(shell shellcheck --version >/dev/null 2>&1 ; echo $$?),0) 173 | ifeq ($(SYSTEM_NAME),darwin) 174 | brew install shellcheck 175 | else 176 | sudo apt-get update -qq && sudo apt-get install -qq -y shellcheck 177 | endif 178 | endif 179 | 180 | bats: 181 | ifeq ($(SYSTEM_NAME),darwin) 182 | ifneq ($(shell bats --version >/dev/null 2>&1 ; echo $$?),0) 183 | brew install bats-core 184 | endif 185 | else 186 | git clone https://github.com/bats-core/bats-core.git /tmp/bats 187 | cd /tmp/bats && sudo ./install.sh /usr/local 188 | rm -rf /tmp/bats 189 | endif 190 | -------------------------------------------------------------------------------- /tests/unit/core.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | load test_helper 4 | 5 | setup() { 6 | create_user 7 | create_test_key "$TEST_KEY_NAME" 8 | } 9 | 10 | teardown() { 11 | delete_user 12 | delete_test_keys 13 | } 14 | 15 | check_authorized_keys_entry() { 16 | # shellcheck disable=SC2034 17 | local KEYFILE_NAME="$1" 18 | local ENTRY_ID="$2" 19 | 20 | run bash -c "sed -n 's/.*\(NAME=\\\\\"${ENTRY_ID}\\\\\"\).*/\1/p' /home/${TEST_USER}/.ssh/authorized_keys" 21 | echo "entry: $(grep "$ENTRY_ID" "/home/${TEST_USER}/.ssh/authorized_keys")" 22 | echo "output: $output" 23 | echo "status: $status" 24 | assert_output "NAME=\\\"$ENTRY_ID\\\"" 25 | } 26 | 27 | check_custom_allowed_keys() { 28 | local ALLOWED_KEYS="$1" 29 | 30 | run bash -c "grep ${ALLOWED_KEYS} /home/${TEST_USER}/.ssh/authorized_keys" 31 | echo "entry: $(cat "/home/${TEST_USER}/.ssh/authorized_keys")" 32 | echo "output: $output" 33 | echo "status: $status" 34 | assert_success 35 | } 36 | 37 | @test "(core) sshcommand create" { 38 | delete_user 39 | 40 | run bash -c "sshcommand create $TEST_USER ls > /dev/null" 41 | echo "output: $output" 42 | echo "status: $status" 43 | assert_success 44 | 45 | run bash -c "test -f ~${TEST_USER}/.ssh/authorized_keys" 46 | echo "output: $output" 47 | echo "status: $status" 48 | assert_success 49 | 50 | run bash -c "grep -F ls ~${TEST_USER}/.sshcommand" 51 | echo "output: $output" 52 | echo "status: $status" 53 | assert_success 54 | } 55 | 56 | @test "(core) sshcommand acl-add" { 57 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | sshcommand acl-add $TEST_USER user1" 58 | echo "output: $output" 59 | echo "status: $status" 60 | assert_success 61 | 62 | create_test_key new_key 63 | run bash -c "cat ${TEST_KEY_DIR}/new_key.pub | sshcommand acl-add $TEST_USER user2" 64 | echo "output: $output" 65 | echo "status: $status" 66 | assert_success 67 | 68 | check_authorized_keys_entry "$TEST_KEY_NAME" user1 69 | check_authorized_keys_entry new_key user2 70 | } 71 | 72 | @test "(core) sshcommand acl-add (as argument)" { 73 | run bash -c "sshcommand acl-add $TEST_USER user1 ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub" 74 | echo "output: $output" 75 | echo "status: $status" 76 | assert_success 77 | 78 | create_test_key new_key 79 | run bash -c "sshcommand acl-add $TEST_USER user2 ${TEST_KEY_DIR}/new_key.pub" 80 | echo "output: $output" 81 | echo "status: $status" 82 | assert_success 83 | 84 | check_authorized_keys_entry "$TEST_KEY_NAME" user1 85 | check_authorized_keys_entry new_key user2 86 | } 87 | 88 | @test "(core) sshcommand acl-add (custom allowed keys)" { 89 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | SSHCOMMAND_ALLOWED_KEYS=keys-user1 sshcommand acl-add $TEST_USER user1" 90 | echo "output: $output" 91 | echo "status: $status" 92 | assert_success 93 | 94 | create_test_key new_key 95 | run bash -c "cat ${TEST_KEY_DIR}/new_key.pub | sshcommand acl-add $TEST_USER user2" 96 | echo "output: $output" 97 | echo "status: $status" 98 | assert_success 99 | 100 | check_authorized_keys_entry "$TEST_KEY_NAME" user1 101 | check_authorized_keys_entry new_key user2 102 | check_custom_allowed_keys keys-user1 103 | check_custom_allowed_keys no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding 104 | } 105 | 106 | @test "(core) sshcommand acl-add (bad key failure)" { 107 | run bash -c "echo test_key | sshcommand acl-add $TEST_USER user1" 108 | echo "output: $output" 109 | echo "status: $status" 110 | assert_failure 111 | } 112 | 113 | @test "(core) sshcommand acl-add (with identifier space)" { 114 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | sshcommand acl-add $TEST_USER 'broken user'" 115 | echo "output: $output" 116 | echo "status: $status" 117 | assert_success 118 | 119 | check_authorized_keys_entry "$TEST_KEY_NAME" 'broken user' 120 | } 121 | 122 | @test "(core) sshcommand acl-add (with authorized_keys with options)" { 123 | run bash -c "sshcommand acl-add $TEST_USER user1 ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub" 124 | echo "output: "$output 125 | echo "status: "$status 126 | assert_success 127 | 128 | run bash -c "sshcommand acl-add $TEST_USER user2 /home/${TEST_USER}/.ssh/authorized_keys" 129 | echo "output: "$output 130 | echo "status: "$status 131 | assert_failure 132 | } 133 | 134 | @test "(core) sshcommand acl-add (multiple keys)" { 135 | create_test_key second_key 136 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub ${TEST_KEY_DIR}/second_key.pub | sshcommand acl-add $TEST_USER user1" 137 | echo "output: $output" 138 | echo "status: $status" 139 | assert_failure 140 | } 141 | 142 | @test "(core) sshcommand acl-add (duplicate key)" { 143 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | sshcommand acl-add $TEST_USER user1" 144 | echo "output: $output" 145 | echo "status: $status" 146 | assert_success 147 | 148 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | sshcommand acl-add $TEST_USER user1" 149 | echo "output: $output" 150 | echo "status: $status" 151 | assert_failure 152 | check_authorized_keys_entry "$TEST_KEY_NAME" user1 153 | 154 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | sshcommand acl-add $TEST_USER user2" 155 | echo "output: $output" 156 | echo "status: $status" 157 | assert_failure 158 | 159 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | SSHCOMMAND_CHECK_DUPLICATE_NAME=false sshcommand acl-add $TEST_USER user2" 160 | echo "output: $output" 161 | echo "status: $status" 162 | assert_failure 163 | 164 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | SSHCOMMAND_CHECK_DUPLICATE_NAME=false SSHCOMMAND_CHECK_DUPLICATE_FINGERPRINT=false sshcommand acl-add $TEST_USER user2" 165 | echo "output: $output" 166 | echo "status: $status" 167 | assert_success 168 | } 169 | 170 | @test "(core) sshcommand acl-remove" { 171 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | sshcommand acl-add $TEST_USER user1" 172 | echo "output: $output" 173 | echo "status: $status" 174 | assert_success 175 | 176 | run bash -c "grep -F \"$(<"${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub")\" ~${TEST_USER}/.ssh/authorized_keys | grep user1" 177 | echo "output: $output" 178 | echo "status: $status" 179 | assert_success 180 | 181 | run bash -c "sshcommand acl-remove $TEST_USER user1" 182 | echo "output: $output" 183 | echo "status: $status" 184 | assert_success 185 | 186 | run bash -c "grep -F \"$(<"${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub")\" ~${TEST_USER}/.ssh/authorized_keys | grep user1" 187 | echo "output: $output" 188 | echo "status: $status" 189 | assert_failure 190 | } 191 | 192 | @test "(core) sshcommand list" { 193 | run bash -c "cat ${TEST_KEY_DIR}/${TEST_KEY_NAME}.pub | SSHCOMMAND_ALLOWED_KEYS=keys-user1 sshcommand acl-add $TEST_USER user1" 194 | echo "output: $output" 195 | echo "status: $status" 196 | assert_success 197 | 198 | assert_equal \ 199 | "$(ssh-keygen -l -f "/home/${TEST_USER}/.ssh/authorized_keys" | awk '{print $2}') NAME=\"user1\" SSHCOMMAND_ALLOWED_KEYS=\"keys-user1\"" \ 200 | "$(sshcommand list "$TEST_USER")" 201 | 202 | run bash -c "sshcommand acl-remove $TEST_USER user1 && sshcommand list" 203 | echo "output: $output" 204 | echo "status: $status" 205 | assert_failure 206 | 207 | cp tests/unit/fixtures/authorized_keys/input_variants "/home/${TEST_USER}/.ssh/authorized_keys" 208 | run bash -c "sshcommand list $TEST_USER '' json" 209 | echo "output: $output" 210 | echo "status: $status" 211 | assert_equal \ 212 | "$(head -n1 tests/unit/fixtures/authorized_keys/sshcommand_list_expected_json_output)" \ 213 | "$(sshcommand list "$TEST_USER" "" json)" 214 | } 215 | 216 | @test "(core) sshcommand list (authorized_keys format variants)" { 217 | cp tests/unit/fixtures/authorized_keys/input_variants "/home/${TEST_USER}/.ssh/authorized_keys" 218 | run bash -c "sshcommand list $TEST_USER" 219 | echo "output: $output" 220 | echo "status: $status" 221 | assert_equal \ 222 | "$(cat tests/unit/fixtures/authorized_keys/sshcommand_list_expected_output)" \ 223 | "$(sshcommand list "$TEST_USER")" 224 | rm "/home/${TEST_USER}/.ssh/authorized_keys" 225 | } 226 | 227 | @test "(core) sshcommand list (json output)" { 228 | cp tests/unit/fixtures/authorized_keys/input_variants "/home/${TEST_USER}/.ssh/authorized_keys" 229 | run bash -c "sshcommand list $TEST_USER md5 json" 230 | echo "output: $output" 231 | echo "status: $status" 232 | 233 | assert_equal \ 234 | "$(cat tests/unit/fixtures/authorized_keys/sshcommand_list_expected_json_output_md5_filtered)" \ 235 | "$(sshcommand list "$TEST_USER" "md5" json)" 236 | rm "/home/${TEST_USER}/.ssh/authorized_keys" 237 | } 238 | 239 | @test "(core) sshcommand help" { 240 | run bash -c "sshcommand help | wc -l" 241 | echo "output: $output" 242 | echo "status: $status" 243 | [[ "$output" -ge 7 ]] 244 | } 245 | -------------------------------------------------------------------------------- /sshcommand: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | [[ $SSHCOMMAND_TRACE ]] && set -x 4 | shopt -s nocasematch # For case insensitive string matching, for the first parameter 5 | 6 | if [[ -f /etc/defaults/sshcommand ]]; then 7 | # shellcheck disable=SC1091 8 | source /etc/defaults/sshcommand 9 | fi 10 | 11 | declare SSHCOMMAND_VERSION="" 12 | declare SSHCOMMAND_CHECK_DUPLICATE_FINGERPRINT=${SSHCOMMAND_CHECK_DUPLICATE_FINGERPRINT:="true"} 13 | declare SSHCOMMAND_CHECK_DUPLICATE_NAME=${SSHCOMMAND_CHECK_DUPLICATE_NAME:="true"} 14 | 15 | cmd-help() { 16 | declare desc="Shows help information for a command" 17 | declare args="$*" 18 | if [[ "$args" ]]; then 19 | for cmd; do true; done # last arg 20 | local fn="sshcommand-$cmd" 21 | fn-info "$fn" 1 22 | fi 23 | } 24 | 25 | fn-args() { 26 | declare desc="Inspect a function's arguments" 27 | local argline 28 | argline=$(type "$1" | grep declare | grep -v "declare desc" | head -1) 29 | echo -e "${argline// /"\\n"}" | awk -F= '/=/{print "<"$1">"}' | tr "\\n" " " 30 | } 31 | 32 | fn-desc() { 33 | declare desc="Inspect a function's description" 34 | desc="" 35 | eval "$(type "$1" | grep desc | head -1)" 36 | echo "$desc" 37 | } 38 | 39 | fn-info() { 40 | declare desc="Inspects a function" 41 | declare fn="$1" showsource="$2" 42 | local fn_name="${1//sshcommand-/}" 43 | echo "$fn_name $(fn-args "$fn")" 44 | echo " $(fn-desc "$fn")" 45 | echo 46 | if [[ "$showsource" ]]; then 47 | type "$fn" | tail -n +2 48 | echo 49 | fi 50 | } 51 | 52 | fn-print-os-id() { 53 | declare desc="Returns the release id of the operating system" 54 | local OSRELEASE="${SSHCOMMAND_OSRELEASE:="/etc/os-release"}" 55 | if [[ -f $OSRELEASE ]]; then 56 | sed -n 's#^ID=\(.*\)#\1#p' "$OSRELEASE" | tr -d '"' 57 | else 58 | echo unknown 59 | fi 60 | return 0 61 | } 62 | 63 | fn-adduser() { 64 | declare desc="Add a user to the system" 65 | local l_user l_platform 66 | 67 | l_user=$1 68 | l_platform="$(fn-print-os-id)" 69 | case $l_platform in 70 | alpine) 71 | adduser -D -g "" -s /bin/bash "$l_user" 72 | passwd -u "$l_user" 73 | ;; 74 | debian* | ubuntu | raspbian*) 75 | adduser --disabled-password --gecos "" "$l_user" 76 | ;; 77 | arch | amzn) 78 | useradd -m -s /bin/bash "$l_user" 79 | usermod -L -aG "$l_user" "$l_user" 80 | ;; 81 | *) 82 | useradd -m -s /bin/bash "$l_user" 83 | groupadd "$l_user" 84 | usermod -L -aG "$l_user" "$l_user" 85 | ;; 86 | esac 87 | } 88 | 89 | fn-verify-file() { 90 | declare desc="Test that public key is valid" 91 | declare file="$1" 92 | local has_errors=false 93 | 94 | local key line=0 95 | local TMP_KEY_FILE 96 | TMP_KEY_FILE=$(mktemp "/tmp/dokku-${DOKKU_PID}-${FUNCNAME[0]}.XXXXXX") 97 | # shellcheck disable=SC2064 98 | trap "rm -rf '$TMP_KEY_FILE' >/dev/null" RETURN INT TERM EXIT 99 | 100 | SSHCOMMAND_IGNORE_LIST_WARNINGS="${SSHCOMMAND_IGNORE_LIST_WARNINGS:-false}" 101 | while read -r key; do 102 | line=$((line + 1)) 103 | [[ -z "$key" ]] && continue 104 | [[ "$key" =~ ^#.*$ ]] && continue 105 | 106 | echo "$key" >"$TMP_KEY_FILE" 107 | if ! ssh-keygen -lf "$TMP_KEY_FILE" &>/dev/null; then 108 | has_errors=true 109 | if [[ "$SSHCOMMAND_IGNORE_LIST_WARNINGS" == "false" ]]; then 110 | log-warn "${file} line $line failed ssh-keygen check." 111 | else 112 | log-warn "${file} line $line failed ssh-keygen check, ignoring." 113 | fi 114 | fi 115 | done <"${file}" 116 | 117 | if [[ "$has_errors" == "true" ]]; then 118 | return 1 119 | fi 120 | } 121 | 122 | log-fail() { 123 | declare desc="Log fail formatter" 124 | echo "$@" 1>&2 125 | exit 1 126 | } 127 | 128 | log-warn() { 129 | declare desc="Log warn formatter" 130 | echo "$@" 1>&2 131 | } 132 | 133 | log-verbose() { 134 | declare desc="Log verbose formatter" 135 | if [[ -n "$SSHCOMMAND_VERBOSE_OUTPUT" ]]; then 136 | echo "$@" 137 | fi 138 | } 139 | 140 | sshcommand-create() { 141 | declare desc="Creates a local system user and installs sshcommand skeleton" 142 | declare USER="$1" COMMAND="$2" 143 | local USERHOME 144 | 145 | if [[ -z "$USER" ]] || [[ -z "$COMMAND" ]]; then 146 | log-fail "Usage: sshcommand create" "$(fn-args "sshcommand-create")" 147 | fi 148 | 149 | if id -u "$USER" >/dev/null 2>&1; then 150 | log-verbose "User '$USER' already exists" 151 | else 152 | fn-adduser "$USER" 153 | fi 154 | 155 | USERHOME=$(sh -c "echo ~$USER") 156 | mkdir -p "$USERHOME/.ssh" 157 | touch "$USERHOME/.ssh/authorized_keys" 158 | chmod 0700 "$USERHOME/.ssh/authorized_keys" 159 | echo "$COMMAND" >"$USERHOME/.sshcommand" 160 | chown -R "$USER" "$USERHOME" 161 | } 162 | 163 | sshcommand-acl-add() { 164 | declare desc="Adds named SSH key to user from STDIN or argument" 165 | declare USER="$1" NAME="$2" KEY_FILE="$3" 166 | local ALLOWED_KEYS FINGERPRINT KEY KEY_FILE KEY_PREFIX NEW_KEY USERHOME 167 | 168 | if [[ -z "$USER" ]] || [[ -z "$NAME" ]]; then 169 | log-fail "Usage: sshcommand acl-add" "$(fn-args "sshcommand-acl-add")" 170 | fi 171 | 172 | getent passwd "$USER" >/dev/null || false 173 | USERHOME=$(sh -c "echo ~$USER") 174 | 175 | NEW_KEY=$(grep "NAME=\\\\\"$NAME"\\\\\" "$USERHOME/.ssh/authorized_keys" || true) 176 | if [[ "$SSHCOMMAND_CHECK_DUPLICATE_NAME" == "true" ]] && [[ -n "$NEW_KEY" ]]; then 177 | log-fail "Duplicate ssh key name" 178 | fi 179 | 180 | if [[ -z "$KEY_FILE" ]]; then 181 | KEY=$(cat) 182 | else 183 | KEY=$(cat "$KEY_FILE") 184 | fi 185 | 186 | local count 187 | count="$(wc -l <<<"$KEY")" 188 | [[ "$count" -eq 1 ]] || log-fail "Too many keys provided, set one per invocation of sshcommand acl-add " 189 | 190 | FINGERPRINT=$(ssh-keygen -lf <(echo "command=\"dummy to fail when options already exist\" $KEY") | awk '{print $2}') 191 | 192 | if [[ ! "$FINGERPRINT" =~ :.* ]]; then 193 | log-fail "Invalid ssh public key" 194 | fi 195 | 196 | if [[ "$SSHCOMMAND_CHECK_DUPLICATE_FINGERPRINT" == "true" ]] && grep -qF "$FINGERPRINT" "$USERHOME/.ssh/authorized_keys"; then 197 | log-fail "Duplicate ssh public key specified" 198 | fi 199 | 200 | ALLOWED_KEYS="${SSHCOMMAND_ALLOWED_KEYS:="no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding"}" 201 | KEY_PREFIX="command=\"FINGERPRINT=$FINGERPRINT NAME=\\\"$NAME\\\" \`cat $USERHOME/.sshcommand\` \$SSH_ORIGINAL_COMMAND\",$ALLOWED_KEYS" 202 | echo "$KEY_PREFIX $KEY" >>"$USERHOME/.ssh/authorized_keys" 203 | chmod 0700 "$USERHOME/.ssh/authorized_keys" 204 | echo "$FINGERPRINT" 205 | } 206 | 207 | sshcommand-acl-remove() { 208 | declare desc="Removes SSH key by name" 209 | declare USER="$1" NAME="$2" 210 | local USERHOME 211 | 212 | if [[ -z "$USER" ]] || [[ -z "$NAME" ]]; then 213 | log-fail "Usage: sshcommand acl-remove" "$(fn-args "sshcommand-acl-remove")" 214 | fi 215 | 216 | getent passwd "$USER" >/dev/null || false 217 | USERHOME=$(sh -c "echo ~$USER") 218 | 219 | sed --in-place "/ NAME=\\\\\"$NAME\\\\\" /d" "$USERHOME/.ssh/authorized_keys" 220 | chmod 0700 "$USERHOME/.ssh/authorized_keys" 221 | } 222 | 223 | sshcommand-acl-remove-by-fingerprint() { 224 | declare desc="Removes SSH key by fingerprint" 225 | declare USER="$1" FINGERPRINT="$2" 226 | local USERHOME 227 | 228 | if [[ -z "$USER" ]] || [[ -z "$FINGERPRINT" ]]; then 229 | log-fail "Usage: sshcommand acl-remove-by-fingerprint" "$(fn-args "sshcommand-acl-remove-by-fingerprint")" 230 | fi 231 | 232 | getent passwd "$USER" >/dev/null || false 233 | USERHOME=$(sh -c "echo ~$USER") 234 | 235 | # shellcheck disable=SC1117 236 | sed --in-place "\#\"FINGERPRINT=$FINGERPRINT #d" "$USERHOME/.ssh/authorized_keys" 237 | chmod 0700 "$USERHOME/.ssh/authorized_keys" 238 | } 239 | 240 | sshcommand-list() { 241 | declare desc="Lists SSH keys by user, an optional name and a optional output format (JSON)" 242 | declare userhome USER="$1" NAME="$2" OUTPUT_TYPE="${3:-$2}" 243 | [[ -z "$USER" ]] && log-fail "Usage: sshcommand list" "$(fn-args "sshcommand-list")" 244 | SSHCOMMAND_IGNORE_LIST_WARNINGS="${SSHCOMMAND_IGNORE_LIST_WARNINGS:-false}" 245 | 246 | getent passwd "$USER" >/dev/null || log-fail "\"$USER\" is not a user on this system" 247 | userhome=$(sh -c "echo ~$USER") 248 | if [[ ! -e "$userhome/.ssh/authorized_keys" ]]; then 249 | log-warn "authorized_keys not found for $USER" 250 | if [[ "$SSHCOMMAND_IGNORE_LIST_WARNINGS" == "false" ]]; then 251 | return 1 252 | fi 253 | return 254 | fi 255 | 256 | if [[ ! -s "$userhome/.ssh/authorized_keys" ]]; then 257 | log-warn "authorized_keys is empty for $USER" 258 | if [[ "$SSHCOMMAND_IGNORE_LIST_WARNINGS" == "false" ]]; then 259 | return 1 260 | fi 261 | 262 | if [[ "$OUTPUT_TYPE" == "json" ]]; then 263 | echo "[]" 264 | fi 265 | return 266 | fi 267 | 268 | if ! fn-verify-file "$userhome/.ssh/authorized_keys" && [[ "$SSHCOMMAND_IGNORE_LIST_WARNINGS" == "false" ]]; then 269 | return 1 270 | fi 271 | 272 | if [[ -n "$OUTPUT_TYPE" ]] && [[ "$OUTPUT_TYPE" == "json" ]]; then 273 | data=$(sed --silent --regexp-extended \ 274 | 's/^command="FINGERPRINT=(\S+) NAME=(\\"|)(.*)\2 `.*",(\S+) (.*)/{ "fingerprint": "\1", "name": "\3", "SSHCOMMAND_ALLOWED_KEYS": "\4", "public-key": "\5" }/p' \ 275 | "$userhome/.ssh/authorized_keys" | tr '\n' ',' | sed '$s/,$/\n/') 276 | 277 | if [[ -n "$NAME" ]]; then 278 | echo "[${data}]" | jq -cM --arg NAME "$NAME" 'map( select (.name == $NAME) )' 279 | else 280 | echo "[${data}]" 281 | fi 282 | else 283 | OUTPUT="$(sed --silent --regexp-extended \ 284 | 's/^command="FINGERPRINT=(\S+) NAME=(\\"|)(.*)\2 `.*",(\S+).*/\1 NAME="\3" SSHCOMMAND_ALLOWED_KEYS="\4"/p' \ 285 | "$userhome/.ssh/authorized_keys")" 286 | if [[ -n "$NAME" ]]; then 287 | echo "$OUTPUT" | grep "NAME=\"$NAME\"" 288 | else 289 | echo "$OUTPUT" 290 | fi 291 | fi 292 | } 293 | 294 | sshcommand-help() { 295 | declare desc="Shows help information" 296 | declare COMMAND="$1" 297 | 298 | if [[ -n "$COMMAND" ]]; then 299 | cmd-help "$COMMAND" 300 | return 0 301 | fi 302 | 303 | echo "sshcommand ${SSHCOMMAND_VERSION}" 304 | echo "" 305 | printf " %-25s %-30s %s\\n" "create" "$(fn-args "sshcommand-create")" "$(fn-desc "sshcommand-create")" 306 | printf " %-25s %-30s %s\\n" "acl-add" "$(fn-args "sshcommand-acl-add")" "$(fn-desc "sshcommand-acl-add")" 307 | printf " %-25s %-30s %s\\n" "acl-remove" "$(fn-args "sshcommand-acl-remove")" "$(fn-desc "sshcommand-acl-remove")" 308 | printf " %-25s %-30s %s\\n" "acl-remove-by-fingerprint" "$(fn-args "sshcommand-acl-remove-by-fingerprint")" "$(fn-desc "sshcommand-acl-remove-by-fingerprint")" 309 | printf " %-25s %-30s %s\\n" "list" "$(fn-args "sshcommand-list")" "$(fn-desc "sshcommand-list")" 310 | printf " %-25s %-30s %s\\n" "help" "$(fn-args "sshcommand-help")" "$(fn-desc "sshcommand-help")" 311 | printf " %-25s %-30s %s\\n" "version" "$(fn-args "sshcommand-version")" "$(fn-desc "sshcommand-version")" 312 | } 313 | 314 | sshcommand-version() { 315 | declare desc="Shows version" 316 | echo "sshcommand ${SSHCOMMAND_VERSION}" 317 | } 318 | 319 | main() { 320 | declare COMMAND_SUFFIX="$1" 321 | if [[ -z "$COMMAND_SUFFIX" ]]; then 322 | sshcommand-help "$@" 323 | exit 1 324 | fi 325 | 326 | if [[ "$COMMAND_SUFFIX" == "-h" ]] || [[ "$COMMAND_SUFFIX" == "--help" ]]; then 327 | COMMAND_SUFFIX="help" 328 | fi 329 | 330 | if [[ "$COMMAND_SUFFIX" == "-v" ]] || [[ "$COMMAND_SUFFIX" == "--version" ]]; then 331 | COMMAND_SUFFIX="version" 332 | fi 333 | 334 | local cmd="sshcommand-$COMMAND_SUFFIX" 335 | shift 1 336 | 337 | if declare -f "$cmd" >/dev/null; then 338 | $cmd "$@" 339 | else 340 | log-fail "Invalid command" 341 | fi 342 | } 343 | 344 | # shellcheck disable=SC2128 345 | if [[ "$0" == "$BASH_SOURCE" ]]; then 346 | main "$@" 347 | fi 348 | --------------------------------------------------------------------------------