├── .config └── goreleaser.yaml ├── .github ├── FUNDING.yml └── workflows │ ├── install_tests.yml │ └── shellcheck.yml ├── .gitignore ├── .ignore ├── LICENSE ├── authors ├── changelog.md ├── data ├── config_example ├── demo.png ├── help ├── tag.mk └── thc.png ├── go.mod ├── go.sum ├── main.go ├── makefile ├── pkg ├── sshconf │ ├── keywords.go │ ├── parser.go │ ├── parser_test.go │ └── util.go └── tui │ ├── list.go │ ├── log.go │ ├── model.go │ ├── msg.go │ ├── run.go │ ├── state.go │ ├── styles.go │ ├── syscmd.go │ └── themes.go ├── readme.md └── scripts ├── create_config.sh ├── dev.sh └── get.sh /.config/goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 3 | 4 | version: 2 5 | dist: build 6 | report_sizes: true 7 | 8 | before: 9 | hooks: 10 | - go mod tidy 11 | - go fmt ./... 12 | # - go vet ./... 13 | 14 | builds: 15 | - env: 16 | - CGO_ENABLED=0 17 | goos: 18 | - linux 19 | - darwin 20 | - freebsd 21 | - netbsd 22 | - openbsd 23 | - solaris 24 | goarch: 25 | - amd64 26 | - "386" 27 | - arm64 28 | - arm 29 | tags: 30 | - netgo 31 | - osusergo 32 | - static_build 33 | flags: 34 | - -trimpath 35 | - -buildvcs=false 36 | ldflags: 37 | - -s -w 38 | - -X main.BuildVersion={{.Version}} 39 | - -X main.BuildDate={{.Date}} 40 | - -X main.BuildSHA={{.Commit}} 41 | - -extldflags '-static' 42 | 43 | archives: 44 | - formats: [tar.gz] 45 | # this name template makes the OS and Arch compatible with the results of `uname`. 46 | name_template: >- 47 | {{ .ProjectName }}_ 48 | {{- .Version}}_ 49 | {{- .Os }}_ 50 | {{- if eq .Arch "amd64" }}x86_64 51 | {{- else if eq .Arch "386" }}i386 52 | {{- else }}{{ .Arch }}{{ end }} 53 | {{- if .Arm }}v{{ .Arm }}{{ end }} 54 | # use zip for windows archives 55 | format_overrides: 56 | - goos: windows 57 | formats: [zip] 58 | 59 | changelog: 60 | sort: asc 61 | filters: 62 | exclude: 63 | - "^docs:" 64 | - "^test:" 65 | 66 | universal_binaries: 67 | - 68 | replace: true 69 | name_template: "ssm" 70 | 71 | nfpms: 72 | - 73 | vendor: "Leonardo Faoro" 74 | homepage: "https://github.com/lfaoro/ssm" 75 | maintainer: "Leonardo Faoro " 76 | file_name_template: "ssm_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 77 | formats: 78 | - deb 79 | - rpm 80 | license: BSD 3-clause 81 | dependencies: 82 | - ssh 83 | suggests: 84 | - sshpass 85 | - mosh 86 | 87 | brews: 88 | - name: ssm 89 | homepage: "https://github.com/lfaoro/ssm" 90 | description: "SSM | Secure Shell Manager" 91 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 92 | repository: 93 | owner: lfaoro 94 | name: tap 95 | commit_author: 96 | name: bot 97 | email: bot@leonardofaoro.com 98 | skip_upload: false 99 | 100 | nix: 101 | - name: ssm 102 | homepage: "https://github.com/lfaoro/ssm" 103 | description: "SSM | Secure Shell Manager" 104 | repository: 105 | owner: lfaoro 106 | name: tap 107 | commit_author: 108 | name: bot 109 | email: bot@leonardofaoro.com 110 | dependencies: 111 | - ssh 112 | skip_upload: false 113 | 114 | snapcrafts: 115 | - description: "SSM | Secure Shell Manager" 116 | summary: "SSM | Secure Shell Manager" 117 | hooks: 118 | install: 119 | - network 120 | confinement: classic 121 | plugs: 122 | personal-files: 123 | read: 124 | - $HOME/.ssh/ 125 | 126 | 127 | binary_signs: 128 | - signature: "${artifact}_sig" 129 | 130 | release: 131 | disable: false 132 | mode: replace 133 | draft: false 134 | prerelease: auto 135 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [lfaoro] 2 | -------------------------------------------------------------------------------- /.github/workflows/install_tests.yml: -------------------------------------------------------------------------------- 1 | name: Install tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | install_on_linux_x86: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout repository 9 | uses: actions/checkout@v4 10 | - name: Run install script 11 | run: ./scripts/get.sh 12 | shell: bash 13 | install_on_linux_arm: 14 | runs-on: ubuntu-24.04-arm 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | - name: Run install script 19 | run: ./scripts/get.sh 20 | shell: bash 21 | install_on_macos_arm: 22 | runs-on: macos-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | - name: Run install script 27 | run: brew install lfaoro/tap/ssm 28 | -------------------------------------------------------------------------------- /.github/workflows/shellcheck.yml: -------------------------------------------------------------------------------- 1 | name: ShellCheck 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | shellcheck: 6 | name: ShellCheck 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Run ShellCheck 11 | run: shellcheck scripts/*.sh -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | build/ 3 | tmp/ 4 | _* 5 | todo 6 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | # helix editor ignore 2 | build/ 3 | bin/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD-3-Clause 2 | 3 | Copyright (c) 2025 Leonardo Faoro & authors 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /authors: -------------------------------------------------------------------------------- 1 | # This is the official list of Secure Shell Manager 2 | # authors for copyright purposes. 3 | # 4 | # Names should be added to this file as one of 5 | # Organization name (when applicable) 6 | # Individual name 7 | # 8 | # Please keep the list sorted. 9 | # 10 | # You do not need to add entries to this list, and we don't actively 11 | # populate this list. If you do want to be acknowledged explicitly as 12 | # a copyright holder, though, then please send a PR referencing your 13 | # earlier contributions and clarifying whether it's you or your 14 | # company that owns the rights to your contribution. 15 | 16 | Leonardo Faoro 17 | George Cindea 18 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # [0.3.6] next 2 | - add show comments for keys in view mode 3 | - add run command view 4 | - add themes `--theme matrix` editable from themes.go 5 | 6 | # [0.3.5] May 14, 2025 7 | - add use ENV variables to configure FLAGS 8 | - fix bug causing high cpu usage 9 | 10 | # [0.3.4] May 9, 2025 11 | - resize list dynamically when error 12 | - add ctrl+c to quit the app 13 | - remove segfault auto add 14 | 15 | # [0.3.3] May 5, 2025 16 | - add #tagorder key to show `#tag` hosts first 17 | - add `--order` flag to show `#tag` hosts first 18 | - add clear filter using `backspace` 19 | - fix could not resolve hostname bug 20 | - fix show Host when HostName is missing 21 | - refactor ssh config parser 22 | - add when EDITOR not set search for vim,vi,nano,ed 23 | 24 | # [0.3.2] April 30, 2025 25 | - fix error/log message alignment 26 | - add emacs keys: ctrl+p/n/b/f(up/down/left/right) 27 | - update libraries 28 | 29 | # [0.3.1] April 30, 2025 30 | - fix exithost invalid character ssh error 31 | - exit filtering on enter key if prompt is empty 32 | - remove watcher from parser 33 | - skip parsing wildcard(*) hosts 34 | 35 | # [0.3.0] April 30, 2025 36 | - add cursor while filtering 37 | - restructure codebase 38 | 39 | # [0.2.2] April 29, 2025 40 | - return error when EDITOR env is not set 41 | - add version check 42 | - inform via msg when new version is released 43 | - fix custom config path 44 | - improve codebase 45 | 46 | # [0.2.1] April 25, 2025 47 | - fix crash on segfault 48 | - remove windows release 49 | - upgrade deps 50 | - general improvements 51 | 52 | # [0.2.0] April 24, 2025 53 | - add exit flag `--exit / -e`: ssm will exit after connecting to a host 54 | - add `ctrl+v`: view full config for selected host 55 | - add ordered map for config options 56 | - fix filtering hosts 57 | - improve cli helpfile 58 | - improve readme 59 | 60 | # [0.1.2] - April 21, 2025 61 | - fix parsing of tag keys 62 | 63 | # [0.1.1] - April 21, 2025 64 | - fix parsing comments on same line as config keys 65 | - move segfault free server at the bottom 66 | - resolve absolute path from custom --config 67 | - add help section to readme 68 | - add ssh config example in data/config_example 69 | 70 | # [0.1.0] - April 20, 2025 71 | - extend pkg/sshconf to support #tag: keys e.g. #tag: admin,vpn 72 | - add arg for tags e.g. `ssm admin` will show only admin tagged hosts 73 | - add `--config, -c` flag to provide custom config location other than default search paths 74 | 75 | # [0.0.1] - April 18, 2025 76 | - initial release 77 | - pkg/sshconf: parse, watch logic 78 | - pkg/tui: bubbletea UI implementation 79 | - main.go: initilization logic, args & flags handling 80 | 81 | [0.0.1]: https://github.com/lfaoro/ssm/releases/tag/0.0.1 82 | [0.1.0]: https://github.com/lfaoro/ssm/compare/0.0.1...0.1.0 83 | [0.1.1]: https://github.com/lfaoro/ssm/compare/0.1.0...0.1.1 84 | [0.1.2]: https://github.com/lfaoro/ssm/compare/0.1.1...0.1.2 85 | [0.2.0]: https://github.com/lfaoro/ssm/compare/0.1.2...0.2.0 86 | [0.2.1]: https://github.com/lfaoro/ssm/compare/0.2.0...0.2.1 87 | [0.2.2]: https://github.com/lfaoro/ssm/compare/0.2.1...0.2.2 88 | [0.3.0]: https://github.com/lfaoro/ssm/compare/0.2.2...0.3.0 89 | [0.3.1]: https://github.com/lfaoro/ssm/compare/0.3.0...0.3.1 90 | [0.3.2]: https://github.com/lfaoro/ssm/compare/0.3.1...0.3.2 91 | [0.3.3]: https://github.com/lfaoro/ssm/compare/0.3.2...0.3.3 92 | [0.3.4]: https://github.com/lfaoro/ssm/compare/0.3.3...0.3.4 93 | [0.3.4]: https://github.com/lfaoro/ssm/compare/0.3.4...0.3.5 94 | -------------------------------------------------------------------------------- /data/config_example: -------------------------------------------------------------------------------- 1 | # Example SSH config 2 | # $ man ssh_config 3 | 4 | Host hostname1 5 | #tag: tagValue1,tagValue2,tagValueN 6 | User user 7 | HostName hello.world 8 | Port 2222 9 | IdentityFile ~/.ssh/id_rsa 10 | 11 | Host terminalcoffee 12 | #tag: shops 13 | User adam 14 | HostName terminal.shop 15 | 16 | Host segfault.net 17 | #tag: research 18 | #pass: segfault 19 | User root 20 | HostName segfault.net 21 | -------------------------------------------------------------------------------- /data/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfaoro/ssm/cfe5e2c3fba3750df3e83e9db00639c4c9269c05/data/demo.png -------------------------------------------------------------------------------- /data/help: -------------------------------------------------------------------------------- 1 | NAME: 2 | ssm - Secure Shell Manager 3 | 4 | USAGE: 5 | ssm [--options] [tag] 6 | example: ssm --show --exit vpn 7 | example: ssm -se vpn 8 | 9 | VERSION: 10 | 0.3.5 11 | 12 | DESCRIPTION: 13 | ssm is a connection manager designed to help organize servers, connect, filter, tag, and much more from a simple terminal interface. It works on top of installed command-line programs and does not require any setup on remote systems. 14 | 15 | AUTHOR: 16 | "Leonardo Faoro" 17 | 18 | GLOBAL OPTIONS: 19 | --show, -s always show config params (default: false) [$SSM_SHOW] 20 | --exit, -e exit after connection (default: false) [$SSM_EXIT] 21 | --order, -o show hosts with a tag first (default: false) [$SSM_ORDER] 22 | --config string, -c string custom ssh config file path [$SSM_SSH_CONFIG_PATH] 23 | --debug, -d enable debug mode with verbose logging (default: false) [$SSM_DEBUG] 24 | --help, -h show help 25 | --version, -v print the version 26 | 27 | COPYRIGHT: 28 | (c) Leonardo Faoro & authors 29 | -------------------------------------------------------------------------------- /data/tag.mk: -------------------------------------------------------------------------------- 1 | # Makefile for automatic semantic versioning and git tagging 2 | # Usage: 3 | # make tag: increments patch version and tags (default) 4 | # make tag TYPE=minor: increments minor version and tags 5 | # make tag TYPE=major: increments major version and tags 6 | 7 | # default version type 8 | TYPE ?= patch 9 | 10 | # get version from latest git tag, default to 0.0.0 if no tags exist 11 | CURRENT_VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0") 12 | 13 | # extract major, minor, patch numbers 14 | MAJOR := $(word 1, $(subst ., ,$(CURRENT_VERSION))) 15 | MINOR := $(word 2, $(subst ., ,$(CURRENT_VERSION))) 16 | PATCH := $(word 3, $(subst ., ,$(CURRENT_VERSION))) 17 | 18 | # calculate new version based on type 19 | NEW_VERSION = $(shell \ 20 | if [ "$(TYPE)" = "major" ]; then \ 21 | echo $$(( $(MAJOR) + 1 )).0.0; \ 22 | elif [ "$(TYPE)" = "minor" ]; then \ 23 | echo $(MAJOR).$$(( $(MINOR) + 1 )).0; \ 24 | else \ 25 | echo $(MAJOR).$(MINOR).$$(( $(PATCH) + 1 )); \ 26 | fi) 27 | 28 | tag: 29 | @echo "current version: $(CURRENT_VERSION)" 30 | @echo "new version: $(NEW_VERSION)" 31 | @git tag -a $(NEW_VERSION) -m "version $(NEW_VERSION)" 32 | @git push --tags 33 | @echo "tagged and pushed $(NEW_VERSION)" 34 | 35 | 36 | untag: 37 | @if [ "$(CURRENT_VERSION)" = "0.0.0" ]; then \ 38 | echo "No tags to delete"; \ 39 | else \ 40 | echo "deleting latest tag: $(CURRENT_VERSION)"; \ 41 | git tag -d $(CURRENT_VERSION); \ 42 | git push origin :refs/tags/$(CURRENT_VERSION); \ 43 | echo "latest tag deleted"; \ 44 | fi 45 | 46 | .PHONY: tag untag 47 | -------------------------------------------------------------------------------- /data/thc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lfaoro/ssm/cfe5e2c3fba3750df3e83e9db00639c4c9269c05/data/thc.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lfaoro/ssm 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 7 | github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 8 | github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 9 | github.com/google/go-github v17.0.0+incompatible 10 | github.com/thalesfsp/go-common-types v0.2.4 11 | github.com/urfave/cli/v3 v3.3.2 12 | golang.org/x/term v0.31.0 13 | ) 14 | 15 | require ( 16 | github.com/atotto/clipboard v0.1.4 // indirect 17 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 18 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 19 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 20 | github.com/charmbracelet/x/input v0.3.4 // indirect 21 | github.com/charmbracelet/x/term v0.2.1 // indirect 22 | github.com/charmbracelet/x/windows v0.2.1 // indirect 23 | github.com/google/go-cmp v0.6.0 // indirect 24 | github.com/google/go-querystring v1.1.0 // indirect 25 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 26 | github.com/mattn/go-runewidth v0.0.16 // indirect 27 | github.com/muesli/cancelreader v0.2.2 // indirect 28 | github.com/rivo/uniseg v0.4.7 // indirect 29 | github.com/sahilm/fuzzy v0.1.1 // indirect 30 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 31 | golang.org/x/sync v0.14.0 // indirect 32 | golang.org/x/sys v0.33.0 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 7 | github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= 8 | github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= 9 | github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1 h1:yaxFt97mvofGY7bYZn8U/aSVoamXGE3O4AEvWhshUDI= 10 | github.com/charmbracelet/bubbletea/v2 v2.0.0-beta1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc= 11 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 12 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 13 | github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= 14 | github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 15 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= 16 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 17 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 18 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= 20 | github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 21 | github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= 22 | github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= 23 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 24 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 25 | github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= 26 | github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= 27 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 30 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 31 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 32 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 33 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 34 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 35 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 36 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 37 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 38 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 39 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 40 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 41 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 42 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 43 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 44 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 46 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 47 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 48 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 49 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 50 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 51 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 52 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 53 | github.com/thalesfsp/go-common-types v0.2.4 h1:OJ+5NjKBebzjx6AUGxvs4+eUZqV+ciBBjbxXudOZQF4= 54 | github.com/thalesfsp/go-common-types v0.2.4/go.mod h1:VbwMiYw41/ET/pNXl3e9XUftE+2T58Mrz2jJ4MIrKV4= 55 | github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= 56 | github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 57 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 58 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 59 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 60 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 61 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 62 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 63 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 64 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 65 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 66 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 67 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 68 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 69 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Leonardo Faoro & authors 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/mail" 10 | "os" 11 | "os/exec" 12 | "sync" 13 | "syscall" 14 | 15 | tea "github.com/charmbracelet/bubbletea/v2" 16 | "github.com/google/go-github/github" 17 | "github.com/lfaoro/ssm/pkg/sshconf" 18 | "github.com/lfaoro/ssm/pkg/tui" 19 | "github.com/urfave/cli/v3" 20 | "golang.org/x/term" 21 | ) 22 | 23 | var BuildVersion = "0.0.0-dev" 24 | var BuildDate = "unset" 25 | var BuildSHA = "unset" 26 | 27 | // cli arguments 28 | var ( 29 | filterTag string 30 | ) 31 | 32 | func main() { 33 | appcmd := &cli.Command{ 34 | Name: "ssm", 35 | Authors: []any{ 36 | &mail.Address{ 37 | Name: "Leonardo Faoro", 38 | Address: "me@leonardofaoro.com", 39 | }, 40 | }, 41 | EnableShellCompletion: true, 42 | UseShortOptionHandling: true, 43 | Suggest: true, 44 | Copyright: "(c) Leonardo Faoro & authors", 45 | Usage: "Secure Shell Manager", 46 | UsageText: "ssm [--options] [tag]\nexample: ssm --show --exit vpn\nexample: ssm -se vpn", 47 | ArgsUsage: "[tag]", 48 | Description: "ssm is a connection manager designed to help organize servers, connect, filter, tag, and much more from a simple terminal interface. It works on top of installed command-line programs and does not require any setup on remote systems.", 49 | 50 | Version: BuildVersion, 51 | ExtraInfo: func() map[string]string { 52 | return map[string]string{ 53 | "Build version": BuildVersion, 54 | "Build date": BuildDate, 55 | "Build sha": BuildSHA, 56 | } 57 | }, 58 | 59 | Before: func(c context.Context, cmd *cli.Command) (context.Context, error) { 60 | _ = cmd 61 | return c, nil 62 | }, 63 | 64 | Action: mainCmd, 65 | Arguments: []cli.Argument{ 66 | &cli.StringArg{ 67 | Name: "tag", 68 | UsageText: "comma separated arguments for filtering #tag: hosts", 69 | Destination: &filterTag, 70 | }, 71 | }, 72 | 73 | Flags: []cli.Flag{ 74 | &cli.BoolFlag{ 75 | Name: "show", 76 | Aliases: []string{"s"}, 77 | Usage: "always show config params", 78 | Value: false, 79 | Sources: cli.EnvVars("SSM_SHOW"), 80 | }, 81 | &cli.BoolFlag{ 82 | Name: "exit", 83 | Aliases: []string{"e"}, 84 | Usage: "exit after connection", 85 | Value: false, 86 | Sources: cli.EnvVars("SSM_EXIT"), 87 | }, 88 | &cli.BoolFlag{ 89 | Name: "order", 90 | Aliases: []string{"o"}, 91 | Usage: "show hosts with a tag first", 92 | Value: false, 93 | Sources: cli.EnvVars("SSM_ORDER"), 94 | }, 95 | &cli.StringFlag{ 96 | Name: "config", 97 | TakesFile: true, 98 | Aliases: []string{"c"}, 99 | Usage: "custom ssh config file path", 100 | Sources: cli.EnvVars("SSM_SSH_CONFIG_PATH"), 101 | }, 102 | &cli.StringFlag{ 103 | Name: "theme", 104 | TakesFile: false, 105 | Aliases: []string{"t"}, 106 | Usage: "define a color theme", 107 | DefaultText: "sky|matrix", 108 | Value: "matrix", 109 | Sources: cli.EnvVars("SSM_THEME"), 110 | }, 111 | &cli.BoolFlag{ 112 | // TODO: not implemented 113 | Name: "ping", 114 | Aliases: []string{"p"}, 115 | Usage: "ping all hosts and show liveness", 116 | Value: false, 117 | Hidden: true, 118 | }, 119 | &cli.BoolFlag{ 120 | Name: "debug", 121 | Aliases: []string{"d"}, 122 | Usage: "enable debug mode with verbose logging", 123 | Value: false, 124 | Sources: cli.EnvVars("SSM_DEBUG"), 125 | }, 126 | }, 127 | 128 | Commands: []*cli.Command{ 129 | generateCmd, 130 | testCmd, 131 | }, 132 | } 133 | 134 | err := appcmd.Run(context.Background(), os.Args) 135 | if err != nil { 136 | fmt.Println(err) 137 | os.Exit(1) 138 | } 139 | } 140 | 141 | func mainCmd(_ context.Context, cmd *cli.Command) error { 142 | debug := cmd.Bool("debug") 143 | if debug { 144 | for k, v := range cmd.ExtraInfo() { 145 | fmt.Println(k, v) 146 | } 147 | } 148 | 149 | if !term.IsTerminal(int(os.Stdin.Fd())) { 150 | return fmt.Errorf("not an interactive terminal :(") 151 | } 152 | 153 | var err error 154 | var config = sshconf.New() 155 | if cmd.Bool("order") { 156 | config.SetOrder(sshconf.TagOrder) 157 | } 158 | configFlag := cmd.String("config") 159 | if configFlag != "" { 160 | err = config.ParsePath(configFlag) 161 | if err != nil { 162 | return err 163 | } 164 | } else { 165 | err = config.Parse() 166 | if err != nil { 167 | return err 168 | } 169 | } 170 | 171 | m := tui.NewModel(config, debug) 172 | p := tea.NewProgram( 173 | m, 174 | tea.WithOutput(os.Stderr)) 175 | wg := sync.WaitGroup{} 176 | wg.Add(1) 177 | go func() { 178 | defer wg.Done() 179 | final, err := p.Run() 180 | if err != nil { 181 | e := fmt.Errorf("failed to run %v: %w", cmd.Name, err) 182 | fmt.Println(e) 183 | os.Exit(1) 184 | } 185 | m, ok := final.(*tui.Model) 186 | if !ok { 187 | fmt.Println("you found bug#1: open an issue") 188 | os.Exit(1) 189 | } 190 | if m.ExitOnCmd && m.ExitHost != "" { 191 | sshPath, err := exec.LookPath(m.Cmd.String()) 192 | if err != nil { 193 | fmt.Printf("can't find `%s` cmd in your path: %v\n", m.Cmd, err) 194 | os.Exit(1) 195 | } 196 | err = syscall.Exec(sshPath, []string{"ssh", "-F", config.GetPath(), m.ExitHost}, os.Environ()) 197 | if err != nil { 198 | fmt.Println(err) 199 | os.Exit(1) 200 | } 201 | } 202 | }() 203 | 204 | if filterTag != "" { 205 | p.Send(tui.FilterTagMsg{ 206 | Arg: fmt.Sprintf("#%s", filterTag), 207 | }) 208 | } 209 | if cmd.Bool("exit") { 210 | p.Send(tui.ExitOnConnMsg{}) 211 | } 212 | if cmd.Bool("show") { 213 | p.Send(tui.ShowConfigMsg{}) 214 | } 215 | theme := cmd.String("theme") 216 | if theme != "" { 217 | p.Send(tui.SetThemeMsg{ 218 | Theme: theme, 219 | }) 220 | } 221 | if cmd.Bool("ping") { 222 | p.Send(tui.LivenessCheckMsg{}) 223 | } 224 | 225 | // inform user when new version is available 226 | go func() { 227 | tag, err := latestTag() 228 | if err != nil { 229 | if cmd.Bool("debug") { 230 | p.Send(tui.AppMsg{Text: fmt.Sprintf("%s", err)}) 231 | } 232 | return 233 | } 234 | if tag != cmd.Version && cmd.Version != "0.0.0-dev" { 235 | msg := fmt.Sprintf("%s: new version %s is available", cmd.Version, tag) 236 | p.Send(tui.AppMsg{Text: msg}) 237 | } 238 | }() 239 | 240 | wg.Wait() 241 | return nil 242 | } 243 | 244 | var testCmd = &cli.Command{ 245 | Name: "test", 246 | Action: testAction, 247 | Hidden: true, 248 | } 249 | var testAction = func(_ context.Context, cmd *cli.Command) error { 250 | return nil 251 | } 252 | 253 | var generateCmd = &cli.Command{ 254 | Name: "generate", 255 | Aliases: []string{"gen"}, 256 | Action: generateAction, 257 | Hidden: true, 258 | } 259 | var generateAction = func(_ context.Context, cmd *cli.Command) error { 260 | return nil 261 | } 262 | 263 | func latestTag() (string, error) { 264 | client := github.NewClient(nil) 265 | owner := "lfaoro" 266 | repo := "ssm" 267 | 268 | tags, _, err := client.Repositories.ListTags(context.Background(), owner, repo, &github.ListOptions{PerPage: 1}) 269 | if err != nil { 270 | return "", fmt.Errorf("failed to list tags: %v", err) 271 | } 272 | 273 | if len(tags) == 0 { 274 | return "", fmt.Errorf("no tags found in the repository") 275 | } 276 | 277 | return *tags[0].Name, nil 278 | } 279 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | FLAGS=-trimpath -buildvcs=false -tags='netgo,osusergo,static_build' 2 | LDFLAGS=-ldflags='-w -s -extldflags -static -buildid=' 3 | 4 | default: build-static 5 | 6 | build-static: 7 | @go mod tidy 8 | CGO_ENABLED=0 go build ${FLAGS} ${LDFLAGS} -o ./bin/ . 9 | build-linked: 10 | @go mod tidy 11 | rm bin/ssm 12 | go build -ldflags='-buildid= -w -s' -trimpath -buildvcs=false -o ./bin . 13 | 14 | clean: 15 | rm -rf build/* 16 | rm -rf bin/* 17 | go clean -i -r 18 | distclean: clean 19 | go clean -cache 20 | go clean -modcache 21 | go clean -testcache 22 | go clean -fuzzcache 23 | 24 | release: pre release-prod help 25 | release-check: 26 | goreleaser check 27 | goreleaser healthcheck 28 | release-prod: release-check 29 | goreleaser release --verbose --clean --skip=validate 30 | release-dev: 31 | goreleaser release --verbose --snapshot --clean 32 | 33 | pre: 34 | @go mod tidy 35 | # @go fmt ./... && go vet ./... 36 | 37 | update: 38 | go get -u . 39 | 40 | stop: 41 | @pkill -9 dev.sh ||: 42 | @pkill -9 inotify ||: 43 | @pkill -9 ssm ||: 44 | 45 | .PHONY: help 46 | help: 47 | build/ssm_linux_amd64_v1/ssm --help >data/help 48 | 49 | 50 | backup: 51 | rm -rf build/* 52 | tar -czvf ../ssm-$(shell date +%Y%m%d).tgz --exclude='.git' . 53 | 54 | include data/tag.mk 55 | -------------------------------------------------------------------------------- /pkg/sshconf/keywords.go: -------------------------------------------------------------------------------- 1 | package sshconf 2 | 3 | // Keyword is a known SSH config keyword. 4 | type Keyword string 5 | 6 | const ( 7 | Include Keyword = "Include" 8 | HostKeyword Keyword = "Host" 9 | MatchKeyword Keyword = "Match" 10 | AddressFamilyKeyword Keyword = "AddressFamily" 11 | BatchModeKeyword Keyword = "BatchMode" 12 | BindAddressKeyword Keyword = "BindAddress" 13 | CanonicalDomainsKeyword Keyword = "CanonicalDomains" 14 | CanonicalizeFallbackLocalKeyword Keyword = "CanonicalizeFallbackLocal" 15 | CanonicalizeHostnameKeyword Keyword = "CanonicalizeHostname" 16 | CanonicalizeMaxDotsKeyword Keyword = "CanonicalizeMaxDots" 17 | CanonicalizePermittedCNAMEsKeyword Keyword = "CanonicalizePermittedCNAMEs" 18 | ChallengeResponseAuthenticationKeyword Keyword = "ChallengeResponseAuthentication" 19 | CheckHostIPKeyword Keyword = "CheckHostIP" 20 | CipherKeyword Keyword = "Cipher" 21 | CiphersKeyword Keyword = "Ciphers" 22 | ClearAllForwardingsKeyword Keyword = "ClearAllForwardings" 23 | CompressionKeyword Keyword = "Compression" 24 | CompressionLevelKeyword Keyword = "CompressionLevel" 25 | ConnectionAttemptsKeyword Keyword = "ConnectionAttempts" 26 | ConnectTimeoutKeyword Keyword = "ConnectTimeout" 27 | ControlMasterKeyword Keyword = "ControlMaster" 28 | ControlPathKeyword Keyword = "ControlPath" 29 | ControlPersistKeyword Keyword = "ControlPersist" 30 | DynamicForwardKeyword Keyword = "DynamicForward" 31 | EnableSSHKeysignKeyword Keyword = "EnableSSHKeysign" 32 | EscapeCharKeyword Keyword = "EscapeChar" 33 | ExitOnForwardFailureKeyword Keyword = "ExitOnForwardFailure" 34 | FingerprintHashKeyword Keyword = "FingerprintHash" 35 | ForwardAgentKeyword Keyword = "ForwardAgent" 36 | ForwardX11Keyword Keyword = "ForwardX11" 37 | ForwardX11TimeoutKeyword Keyword = "ForwardX11Timeout" 38 | ForwardX11TrustedKeyword Keyword = "ForwardX11Trusted" 39 | GatewayPortsKeyword Keyword = "GatewayPorts" 40 | GlobalKnownHostsFileKeyword Keyword = "GlobalKnownHostsFile" 41 | GSSAPIAuthenticationKeyword Keyword = "GSSAPIAuthentication" 42 | GSSAPIDelegateCredentialsKeyword Keyword = "GSSAPIDelegateCredentials" 43 | HashKnownHostsKeyword Keyword = "HashKnownHosts" 44 | HostbasedAuthenticationKeyword Keyword = "HostbasedAuthentication" 45 | HostbasedKeyTypesKeyword Keyword = "HostbasedKeyTypes" 46 | HostKeyAlgorithmsKeyword Keyword = "HostKeyAlgorithms" 47 | HostKeyAliasKeyword Keyword = "HostKeyAlias" 48 | HostNameKeyword Keyword = "HostName" 49 | IdentitiesOnlyKeyword Keyword = "IdentitiesOnly" 50 | IdentityFileKeyword Keyword = "IdentityFile" 51 | IgnoreUnknownKeyword Keyword = "IgnoreUnknown" 52 | IPQoSKeyword Keyword = "IPQoS" 53 | KbdInteractiveAuthenticationKeyword Keyword = "KbdInteractiveAuthentication" 54 | KbdInteractiveDevicesKeyword Keyword = "KbdInteractiveDevices" 55 | KexAlgorithmsKeyword Keyword = "KexAlgorithms" 56 | LocalCommandKeyword Keyword = "LocalCommand" 57 | LocalForwardKeyword Keyword = "LocalForward" 58 | LogLevelKeyword Keyword = "LogLevel" 59 | MACsKeyword Keyword = "MACs" 60 | NoHostAuthenticationForLocalhostKeyword Keyword = "NoHostAuthenticationForLocalhost" 61 | NumberOfPasswordPromptsKeyword Keyword = "NumberOfPasswordPrompts" 62 | PasswordAuthenticationKeyword Keyword = "PasswordAuthentication" 63 | PermitLocalCommandKeyword Keyword = "PermitLocalCommand" 64 | PKCS11ProviderKeyword Keyword = "PKCS11Provider" 65 | PortKeyword Keyword = "Port" 66 | PreferredAuthenticationsKeyword Keyword = "PreferredAuthentications" 67 | ProtocolKeyword Keyword = "Protocol" 68 | ProxyCommandKeyword Keyword = "ProxyCommand" 69 | ProxyUseFdpassKeyword Keyword = "ProxyUseFdpass" 70 | PubkeyAuthenticationKeyword Keyword = "PubkeyAuthentication" 71 | RekeyLimitKeyword Keyword = "RekeyLimit" 72 | RemoteForwardKeyword Keyword = "RemoteForward" 73 | RequestTTYKeyword Keyword = "RequestTTY" 74 | RevokedHostKeysKeyword Keyword = "RevokedHostKeys" 75 | RhostsRSAAuthenticationKeyword Keyword = "RhostsRSAAuthentication" 76 | RSAAuthenticationKeyword Keyword = "RSAAuthentication" 77 | SendEnvKeyword Keyword = "SendEnv" 78 | ServerAliveCountMaxKeyword Keyword = "ServerAliveCountMax" 79 | ServerAliveIntervalKeyword Keyword = "ServerAliveInterval" 80 | StreamLocalBindMaskKeyword Keyword = "StreamLocalBindMask" 81 | StreamLocalBindUnlinkKeyword Keyword = "StreamLocalBindUnlink" 82 | StrictHostKeyCheckingKeyword Keyword = "StrictHostKeyChecking" 83 | TCPKeepAliveKeyword Keyword = "TCPKeepAlive" 84 | TunnelKeyword Keyword = "Tunnel" 85 | TunnelDeviceKeyword Keyword = "TunnelDevice" 86 | UpdateHostKeysKeyword Keyword = "UpdateHostKeys" 87 | UsePrivilegedPortKeyword Keyword = "UsePrivilegedPort" 88 | UserKeyword Keyword = "User" 89 | UserKnownHostsFileKeyword Keyword = "UserKnownHostsFile" 90 | VerifyHostKeyDNSKeyword Keyword = "VerifyHostKeyDNS" 91 | VisualHostKeyKeyword Keyword = "VisualHostKey" 92 | XAuthLocationKeyword Keyword = "XAuthLocation" 93 | ) 94 | -------------------------------------------------------------------------------- /pkg/sshconf/parser.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Leonardo Faoro & authors 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package sshconf loads, parses SSH config files, 5 | // tries to be thread-safe. 6 | // ref: https://man.openbsd.org/ssh_config.5 7 | package sshconf 8 | 9 | import ( 10 | "bufio" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "sync" 15 | 16 | som "github.com/thalesfsp/go-common-types/safeorderedmap" 17 | ) 18 | 19 | type Config struct { 20 | // protects Hosts 21 | mu sync.Mutex 22 | Hosts []Host // higher priority 23 | secondaryHosts []Host // lower priority 24 | 25 | order Order 26 | path string 27 | } 28 | 29 | type Host struct { 30 | Name string 31 | Options *som.SafeOrderedMap[string] 32 | } 33 | 34 | // Order defines how hosts are organized when parsed. 35 | type Order int 36 | 37 | const ( 38 | TagOrder Order = iota + 1 39 | ) 40 | 41 | func New() *Config { 42 | return &Config{} 43 | } 44 | 45 | func (c *Config) SetOrder(o Order) { 46 | c.order = o 47 | } 48 | 49 | // Parse parses SSH config files from default known locations. 50 | // User: ~/.ssh/config 51 | // System: /etc/ssh/ssh_config 52 | // Parse also follows `Include` statements via recursion. 53 | func (c *Config) Parse() error { 54 | path, err := defaultConfigPath() 55 | if err != nil { 56 | return err 57 | } 58 | return c.parse(path) 59 | } 60 | 61 | // Parse parses SSH config file from custom location. 62 | func (c *Config) ParsePath(s string) error { 63 | if !strings.HasPrefix(s, "/") { 64 | wd, err := os.Getwd() 65 | if err != nil { 66 | return err 67 | } 68 | s = filepath.Join(wd, s) 69 | } 70 | return c.parse(s) 71 | } 72 | 73 | func (c *Config) GetHost(name string) Host { 74 | for _, h := range c.Hosts { 75 | if h.Name == name { 76 | return h 77 | } 78 | } 79 | return Host{} 80 | } 81 | 82 | func (c *Config) GetParamFor(host Host, key string) string { 83 | for _, h := range c.Hosts { 84 | if h.Name == host.Name { 85 | val, ok := h.Options.Get(key) 86 | if !ok { 87 | return "" 88 | } 89 | return val 90 | } 91 | } 92 | return "" 93 | } 94 | 95 | func (c *Config) GetPath() string { 96 | c.mu.Lock() 97 | defer c.mu.Unlock() 98 | return c.path 99 | } 100 | 101 | const ( 102 | commentPrefix = "#" 103 | tagPrefix = "#tag:" 104 | tagOrderPrefix = "#tagorder" 105 | ) 106 | 107 | func (c *Config) parse(path string) error { 108 | c.mu.Lock() 109 | defer c.mu.Unlock() 110 | 111 | // clear hosts in case parse is 112 | // called multiple times. 113 | c.Hosts = []Host{} 114 | c.secondaryHosts = []Host{} 115 | 116 | f, err := os.Open(path) 117 | if err != nil { 118 | return err 119 | } 120 | defer f.Close() 121 | c.path = path 122 | scanner := bufio.NewScanner(f) 123 | var tagOrder bool 124 | var currentHost *Host 125 | for scanner.Scan() { 126 | line := strings.TrimSpace(scanner.Text()) 127 | 128 | // set orderbyTag 129 | if line == tagOrderPrefix { 130 | tagOrder = true 131 | } 132 | if c.order == TagOrder { 133 | tagOrder = true 134 | } 135 | 136 | // ignore empty or comment line 137 | if line == "" || 138 | strings.HasPrefix(line, commentPrefix) && 139 | !strings.HasPrefix(line, tagPrefix) { 140 | continue 141 | } 142 | parts := strings.Fields(line) 143 | // malformed line, skip 144 | if len(parts) < 2 { 145 | continue 146 | } 147 | k, v := strings.ToLower(parts[0]), strings.Join(parts[1:], " ") 148 | // remove comment suffixes 149 | // when not a tag 150 | if !strings.HasPrefix(line, tagPrefix) { 151 | k = removeComments(k) 152 | v = removeComments(v) 153 | } 154 | // recurse include files 155 | if k == "include" { 156 | if !strings.HasPrefix(v, "/") { 157 | path = filepath.Dir(path) 158 | v = filepath.Join(path, v) 159 | } 160 | paths, err := filepath.Glob(v) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | for _, path := range paths { 166 | cfg := New() 167 | err := cfg.parse(path) // recursion 168 | if err != nil { 169 | return err 170 | } 171 | c.Hosts = append(c.Hosts, cfg.Hosts...) 172 | } 173 | } 174 | // all blocks must start with Host key 175 | if k == "host" { 176 | if strings.Contains(v, "*") { 177 | continue 178 | } 179 | if currentHost != nil { 180 | newHost(tagOrder, currentHost, c) 181 | } 182 | currentHost = &Host{ 183 | Name: v, 184 | Options: som.New[string](), 185 | } 186 | continue 187 | } 188 | // if not a host key must be an option 189 | if currentHost != nil { 190 | currentHost.Options.Add(k, v) 191 | } 192 | } 193 | if currentHost != nil { 194 | newHost(tagOrder, currentHost, c) 195 | } 196 | if err := scanner.Err(); err != nil { 197 | return err 198 | } 199 | c.Hosts = append(c.Hosts, c.secondaryHosts...) 200 | return nil 201 | } 202 | 203 | func newHost(tagOrder bool, currentHost *Host, config *Config) { 204 | if tagOrder { 205 | if currentHost.Options.Contains("#tag:") { 206 | config.Hosts = append(config.Hosts, *currentHost) 207 | } else { 208 | config.secondaryHosts = append(config.secondaryHosts, *currentHost) 209 | } 210 | return 211 | } 212 | config.Hosts = append(config.Hosts, *currentHost) 213 | } 214 | 215 | func removeComments(input string) string { 216 | // find index of '#' and take substring up to that point 217 | if index := strings.Index(input, "#"); index != -1 { 218 | return strings.TrimSpace(input[:index]) 219 | } 220 | // if no '#' found, return trimmed input 221 | return strings.TrimSpace(input) 222 | } 223 | -------------------------------------------------------------------------------- /pkg/sshconf/parser_test.go: -------------------------------------------------------------------------------- 1 | package sshconf_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "testing" 7 | 8 | "github.com/lfaoro/ssm/pkg/sshconf" 9 | ) 10 | 11 | func TestParse(t *testing.T) { 12 | cfg := sshconf.New() 13 | err := cfg.ParsePath("../../data/config_example") 14 | if err != nil { 15 | log.Println(err) 16 | t.FailNow() 17 | } 18 | 19 | for _, h := range cfg.Hosts { 20 | fmt.Println(h) 21 | fmt.Println(h.Name) 22 | for i, k := range h.Options.Keys() { 23 | fmt.Println(k, h.Options.Values()[i]) 24 | } 25 | } 26 | err = cfg.ParsePath("./nonexistent") 27 | if err == nil { 28 | t.FailNow() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/sshconf/util.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Leonardo Faoro & authors 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package sshconf 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | func defaultConfigPath() (string, error) { 13 | home, err := os.UserHomeDir() 14 | if err != nil { 15 | return filepath.Join("etc", "ssh", "config"), nil 16 | } 17 | // home config 18 | path := filepath.Join(home, ".ssh", "config") 19 | if fileExists(path) { 20 | return path, nil 21 | } 22 | // server config 23 | path = filepath.Join("/", "etc", "ssh", "ssh_config") 24 | if fileExists(path) { 25 | return path, nil 26 | } 27 | return "", fmt.Errorf("unable to parse config %v: are you sure ssh is installed?", path) 28 | } 29 | func fileExists(path string) bool { 30 | _, err := os.Stat(path) 31 | if err != nil { 32 | if os.IsNotExist(err) { 33 | return false 34 | } 35 | // any other error we still return false 36 | return false 37 | } 38 | // file exists 39 | return true 40 | } 41 | -------------------------------------------------------------------------------- /pkg/tui/list.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/v2/key" 7 | "github.com/charmbracelet/bubbles/v2/list" 8 | "github.com/charmbracelet/bubbles/v2/textinput" 9 | tea "github.com/charmbracelet/bubbletea/v2" 10 | lg "github.com/charmbracelet/lipgloss/v2" 11 | "github.com/lfaoro/ssm/pkg/sshconf" 12 | ) 13 | 14 | type item struct { 15 | title, desc string 16 | } 17 | 18 | func (i item) Title() string { return i.title } 19 | func (i item) Description() string { return i.desc } 20 | func (i item) FilterValue() string { return i.title + i.desc } 21 | 22 | func listFrom(config *sshconf.Config, theme theme) list.Model { 23 | var li list.Model 24 | var c = theme 25 | lightDark := lg.LightDark(true) 26 | d := list.NewDefaultDelegate() 27 | d.ShowDescription = true 28 | d.SetSpacing(0) 29 | d.Styles.SelectedTitle = lg.NewStyle(). 30 | Border(lg.NormalBorder(), false, false, false, true). 31 | BorderForeground(lightDark(lg.Color("#F79F3F"), lg.Color(c.selectedBorderColor))). 32 | Foreground(lightDark(lg.Color("#F79F3F"), lg.Color(c.selectedTitleColor))). 33 | Padding(0, 0, 0, 1) 34 | d.Styles.SelectedDesc = d.Styles.SelectedTitle. 35 | Foreground(lightDark(lg.Color("#F79F3F"), lg.Color(c.selectedDescriptionColor))) 36 | // d.Styles.SelectedTitle = lg.NewStyle(). 37 | // Border(lg.NormalBorder(), false, false, false, true). 38 | // BorderForeground(lightDark(lg.Color("#F79F3F"), lg.Color("#00bfff"))). 39 | // Foreground(lightDark(lg.Color("#F79F3F"), lg.Color("#00bfff"))). 40 | // Padding(0, 0, 0, 1) 41 | // d.Styles.SelectedDesc = d.Styles.SelectedTitle. 42 | // Foreground(lightDark(lg.Color("#F79F3F"), lg.Color("#4682b4"))) 43 | 44 | li = list.New( 45 | []list.Item{}, 46 | d, 47 | 0, 48 | 0, 49 | ) 50 | li.AdditionalFullHelpKeys = func() []key.Binding { 51 | return initKeys() 52 | } 53 | li.FilterInput.Prompt = "Search: " 54 | li.FilterInput.CharLimit = 12 55 | li.FilterInput.VirtualCursor = true 56 | li.FilterInput.Placeholder = "hostName or tagName" 57 | li.FilterInput.Styles.Cursor = textinput.CursorStyle{ 58 | Color: lg.BrightBlue, 59 | Shape: tea.CursorBlock, 60 | } 61 | li.Styles.StatusBar = lg.NewStyle(). 62 | Foreground(lightDark(lg.Color("#A49FA5"), lg.Color("#777777"))). 63 | Padding(0, 0, 1, 2) //nolint:mnd 64 | li.Styles.Title = lg.NewStyle(). 65 | Background(lg.Color(c.mainTitleColor)). 66 | Foreground(lg.Color("230")). 67 | Padding(0, 1) 68 | li.SetStatusBarItemName("host", "hosts") 69 | li.Title = fmt.Sprintf("SSH servers (%v)", config.GetPath()) 70 | 71 | // add segfault.net (free root server provider) 72 | // segfaultHost := sshconf.Host{ 73 | // Name: "create free research root server", 74 | // Options: safeorderedmap.New[string](), 75 | // } 76 | // segfaultHost.Options.Add("hostname", "segfault.net") 77 | // segfaultHost.Options.Add("user", "root") 78 | // config.Hosts = append(config.Hosts, segfaultHost) 79 | 80 | for _, host := range config.Hosts { 81 | newitem := formatHost(host) 82 | li.InsertItem(len(config.Hosts), newitem) 83 | } 84 | return li 85 | } 86 | 87 | func formatHost(host sshconf.Host) item { 88 | fmtDescription := func() string { 89 | port := func() string { 90 | _port, _ := host.Options.Get("port") 91 | if _port != "" && _port != "22" { 92 | return fmt.Sprintf(":%s", _port) 93 | } 94 | return "" 95 | } 96 | user := func() string { 97 | _user, _ := host.Options.Get("user") 98 | if _user != "" { 99 | return _user + "@" 100 | } 101 | return "" 102 | } 103 | hostname := func() string { 104 | _host, _ := host.Options.Get("hostname") 105 | if _host != "" { 106 | return _host 107 | } 108 | return host.Name 109 | } 110 | tags := func() string { 111 | _tags, _ := host.Options.Get("#tag:") 112 | if _tags != "" { 113 | s := lg.NewStyle().Foreground(lg.Color("8")) 114 | return s.Render("#" + _tags) 115 | } 116 | return "" 117 | } 118 | out := fmt.Sprintf("%s%s%s %s", user(), hostname(), port(), tags()) 119 | return out 120 | }() 121 | newitem := item{ 122 | title: host.Name, 123 | desc: fmtDescription, 124 | } 125 | return newitem 126 | } 127 | 128 | func initKeys() []key.Binding { 129 | editKey := key.NewBinding( 130 | key.WithKeys("ctrl+e"), 131 | key.WithHelp("ctrl+e", "edit config"), 132 | ) 133 | showKey := key.NewBinding( 134 | key.WithKeys("ctrl+v"), 135 | key.WithHelp("ctrl+v", "show config"), 136 | ) 137 | switchKey := key.NewBinding( 138 | key.WithKeys("tab"), 139 | key.WithHelp("tab", "switch ssh/mosh"), 140 | ) 141 | connectKey := key.NewBinding( 142 | key.WithKeys("enter"), 143 | key.WithHelp("enter", "connect"), 144 | ) 145 | return []key.Binding{ 146 | connectKey, 147 | switchKey, 148 | editKey, 149 | showKey, 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /pkg/tui/log.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea/v2" 8 | "github.com/charmbracelet/lipgloss/v2" 9 | ) 10 | 11 | type Log struct { 12 | err error 13 | debugLogs []string 14 | debugActive bool 15 | debugHistory int 16 | debugCount int 17 | 18 | ErrStyle lipgloss.Style 19 | DebugStyle lipgloss.Style 20 | } 21 | 22 | type DebugMsg struct { 23 | Log string 24 | } 25 | 26 | type ErrorMsg struct { 27 | Err error 28 | } 29 | 30 | type LogOption func(*Log) 31 | 32 | func WithDebug(debug bool) LogOption { 33 | return func(l *Log) { 34 | l.debugActive = debug 35 | } 36 | } 37 | 38 | func WithDebugHistory(length int) LogOption { 39 | return func(l *Log) { 40 | l.debugHistory = length 41 | } 42 | } 43 | 44 | func NewLog(opts ...LogOption) Log { 45 | l := Log{ 46 | debugLogs: make([]string, 0), 47 | err: nil, 48 | debugActive: false, // default 49 | debugHistory: 5, // default 50 | } 51 | l.ErrStyle = lipgloss.NewStyle(). 52 | Foreground(lipgloss.Color("1")) 53 | l.DebugStyle = lipgloss.NewStyle(). 54 | Foreground(lipgloss.Color("8")) 55 | 56 | for _, opt := range opts { 57 | opt(&l) 58 | } 59 | return l 60 | } 61 | 62 | func AddLog(format string, args ...any) tea.Cmd { 63 | return func() tea.Msg { 64 | return DebugMsg{ 65 | Log: fmt.Sprintf(format, args...), 66 | } 67 | } 68 | } 69 | 70 | func AddError(err error) tea.Cmd { 71 | return func() tea.Msg { 72 | return ErrorMsg{Err: err} 73 | } 74 | } 75 | 76 | func ClearDebug() tea.Cmd { 77 | return tea.Batch( 78 | func() tea.Msg { 79 | return ErrorMsg{Err: nil} 80 | }, 81 | func() tea.Msg { 82 | return DebugMsg{Log: ""} 83 | }, 84 | ) 85 | } 86 | 87 | func ClearError() tea.Cmd { 88 | return tea.Batch( 89 | func() tea.Msg { 90 | return ErrorMsg{Err: nil} 91 | }, 92 | ) 93 | } 94 | 95 | func (l Log) Init() tea.Cmd { 96 | return AddLog("log: debug activated") 97 | } 98 | 99 | func (l Log) Update(msg tea.Msg) (Log, tea.Cmd) { 100 | switch msg := msg.(type) { 101 | case DebugMsg: 102 | l.debugCount++ 103 | msgLog := fmt.Sprintf("%d: %s", l.debugCount, msg.Log) 104 | if l.debugActive { 105 | l.debugLogs = append(l.debugLogs, msgLog) 106 | } 107 | if len(l.debugLogs) > l.debugHistory { 108 | l.debugLogs = l.debugLogs[len(l.debugLogs)-l.debugHistory:] 109 | } 110 | case ErrorMsg: 111 | l.err = msg.Err 112 | } 113 | return l, nil 114 | } 115 | 116 | func (l Log) View() string { 117 | errMsg := func() string { 118 | if l.err != nil { 119 | var msg = l.err.Error() 120 | return l.ErrStyle.Render(msg) 121 | } 122 | return "" 123 | } 124 | debugMsg := func() string { 125 | if !l.debugActive { 126 | return "" 127 | } 128 | out := "" 129 | for i, log := range l.debugLogs { 130 | if len(l.debugLogs)-1 == i { 131 | // if last log, don't add a newline 132 | out += log 133 | } else { 134 | out += fmt.Sprintf("%s\n", log) 135 | } 136 | } 137 | out = l.DebugStyle.Render(out) 138 | return out 139 | } 140 | var out string 141 | if !l.debugActive { 142 | out = errMsg() + "\n" + debugMsg() 143 | } else { 144 | out = errMsg() 145 | } 146 | out = strings.TrimSpace(out) 147 | return lipgloss.NewStyle(). 148 | Padding(0, 0, 0, 1). 149 | Border(lipgloss.HiddenBorder(), true). 150 | BorderForeground(lipgloss.Color("240")). 151 | Render(out) 152 | } 153 | -------------------------------------------------------------------------------- /pkg/tui/model.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Leonardo Faoro & authors 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | // Package tui defines the terminal user interface of this application. 5 | package tui 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "image/color" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "strings" 15 | "time" 16 | 17 | "github.com/charmbracelet/bubbles/v2/list" 18 | "github.com/charmbracelet/bubbles/v2/viewport" 19 | tea "github.com/charmbracelet/bubbletea/v2" 20 | lg "github.com/charmbracelet/lipgloss/v2" 21 | "github.com/lfaoro/ssm/pkg/sshconf" 22 | ) 23 | 24 | type Model struct { 25 | config *sshconf.Config 26 | showConfig bool 27 | theme theme 28 | 29 | li list.Model 30 | vp viewport.Model 31 | 32 | Cmd SysCmd 33 | ExitOnCmd bool 34 | ExitHost string 35 | 36 | debug bool 37 | log Log 38 | 39 | errbuf bytes.Buffer 40 | isDark bool 41 | } 42 | 43 | func NewModel(config *sshconf.Config, debug bool) *Model { 44 | m := &Model{} 45 | m.debug = debug 46 | m.config = config 47 | m.li = listFrom(m.config, m.theme) 48 | m.log = NewLog(WithDebug(debug)) 49 | m.Cmd = sshCmd // defaults to ssh 50 | m.vp = viewport.New() 51 | m.vp.SetWidth(40) 52 | m.vp.SetHeight(20) 53 | return m 54 | } 55 | 56 | func (m *Model) Init() tea.Cmd { 57 | cmds := []tea.Cmd{ 58 | tea.SetWindowTitle("SSM | Secure Shell Manager"), 59 | tea.RequestKeyboardEnhancements(), 60 | tea.EnterAltScreen, 61 | tea.EnableBracketedPaste, 62 | tea.EnableReportFocus, 63 | tea.SetBackgroundColor(color.Black), 64 | // tea.EnableMouseAllMotion, 65 | // tea.EnableMouseCellMotion, 66 | } 67 | if m.debug { 68 | cmds = append(cmds, AddLog("debug: isdarkbg %v", m.isDark)) 69 | } 70 | m.li.NewStatusMessage(fmt.Sprintf("[%s]", m.Cmd)) 71 | cmds = append(cmds, tick()) 72 | return tea.Batch(cmds...) 73 | } 74 | 75 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 76 | var cmd tea.Cmd 77 | cmds := []tea.Cmd{} 78 | 79 | switch msg := msg.(type) { 80 | case tea.BackgroundColorMsg: 81 | m.isDark = msg.IsDark() 82 | case tea.WindowSizeMsg: 83 | var errSize = 1 84 | if m.log.err != nil { 85 | errSize = 3 86 | } 87 | m.li.SetSize(msg.Width, msg.Height-errSize) 88 | if m.debug { 89 | m.li.SetSize(msg.Width, msg.Height-9) 90 | } 91 | 92 | m.vp.SetHeight(m.li.Height()) 93 | m.vp.SetWidth(msg.Width / 2) 94 | 95 | // m.ta.SetWidth(msg.Width) 96 | // m.ta.SetHeight(msg.Height / 2) 97 | if m.log.err != nil { 98 | cmds = append(cmds, tea.RequestWindowSize) 99 | } 100 | case tickMsg: 101 | return m, tea.Batch(tick(), 102 | AddLog("ticking...")) 103 | case AppMsg: 104 | return m, AddError(fmt.Errorf("%s", msg.Text)) 105 | case LivenessCheckMsg: 106 | // TODO: not implemented 107 | return m, AddLog("liveness check") 108 | for _, h := range m.config.Hosts { 109 | host, _ := h.Options.Get("hostname") 110 | // resolve host 111 | // ping server 112 | _ = host 113 | h.Options.Add("alive", "yes") 114 | } 115 | m.li = listFrom(m.config, m.theme) 116 | return m, nil 117 | case ExitOnConnMsg: 118 | m.ExitOnCmd = true 119 | return m, AddLog("exit true") 120 | case FilterTagMsg: 121 | m.li.SetFilterText(msg.Arg) 122 | m.li.SetFilteringEnabled(true) 123 | return m, AddLog("filter true") 124 | case ReloadConfigMsg: 125 | err := m.config.ParsePath(m.config.GetPath()) 126 | if err != nil { 127 | return m, AddError(err) 128 | } 129 | m.li = listFrom(m.config, m.theme) 130 | m.li.NewStatusMessage(fmt.Sprintf("[%s]", m.Cmd)) 131 | return m, AddLog("reloading config") 132 | case ShowConfigMsg: 133 | m.showConfig = true 134 | return m, nil 135 | case SetThemeMsg: 136 | m.theme = themes[msg.Theme] 137 | m.li = listFrom(m.config, m.theme) 138 | return m, nil 139 | 140 | case tea.KeyPressMsg: 141 | switch msg.Code { 142 | case tea.KeyTab: 143 | if m.Cmd == sshCmd { 144 | m.Cmd = moshCmd 145 | m.li.NewStatusMessage(fmt.Sprintf("[%s]", m.Cmd)) 146 | } else { 147 | m.Cmd = sshCmd 148 | m.li.NewStatusMessage(fmt.Sprintf("[%s]", m.Cmd)) 149 | } 150 | case tea.KeyEnter: 151 | if m.li.FilterState() == list.Filtering { 152 | if m.li.FilterValue() == "" { 153 | m.li.ResetFilter() 154 | } 155 | break 156 | } 157 | conncmd := m.connect() 158 | return m, tea.Batch( 159 | conncmd, 160 | AddError(fmt.Errorf("%s", m.errbuf.String())), 161 | ) 162 | case tea.KeyBackspace: 163 | if m.li.FilteringEnabled() { 164 | m.li.ResetFilter() 165 | return m, nil 166 | } 167 | case 'q': 168 | if m.li.FilterState() != 1 { 169 | return m, tea.Quit 170 | } 171 | } 172 | switch msg.Mod { 173 | // we're only interested in ctrl+ 174 | case tea.ModCtrl: 175 | switch msg.Code { 176 | case 'c': 177 | if m.li.FilterState() == list.Filtering || 178 | m.li.IsFiltered() { 179 | m.li.ResetFilter() 180 | return m, nil 181 | } 182 | return m, tea.Quit 183 | 184 | // emacs keybinds 185 | case 'p': 186 | m.li.CursorUp() 187 | case 'n': 188 | m.li.CursorDown() 189 | case 'b': 190 | m.li.PrevPage() 191 | case 'f': 192 | m.li.NextPage() 193 | 194 | case 'e': 195 | confFile := m.config.GetPath() 196 | editorPath := os.Getenv("EDITOR") 197 | knownEditors := [...]string{ 198 | editorPath, 199 | "vim", 200 | "vi", 201 | "nano", 202 | "ed", 203 | } 204 | for _, cmd := range knownEditors { 205 | path, err := exec.LookPath(cmd) 206 | if err != nil { 207 | continue 208 | } 209 | editorPath = path 210 | break 211 | } 212 | if editorPath == "" { 213 | return m, AddError(fmt.Errorf("env EDITOR not set, nor any %v found in PATH", knownEditors[1:])) 214 | } 215 | cmd := exec.Command(editorPath, confFile) 216 | cmd.Dir = filepath.Dir(confFile) 217 | cmd.Stderr = &m.errbuf 218 | execCmd := tea.ExecProcess(cmd, func(err error) tea.Msg { 219 | logCmd := AddLog("%v", err) 220 | var errCmd tea.Cmd 221 | if err != nil { 222 | errCmd = AddError(err) 223 | } 224 | return tea.Batch(logCmd, errCmd) 225 | }) 226 | return m, tea.Sequence( 227 | execCmd, 228 | func() tea.Msg { 229 | return ReloadConfigMsg{} 230 | }, 231 | ) 232 | case 'r': 233 | return m, AddError(fmt.Errorf("run command on host: not yet implemented")) 234 | case 's': 235 | return m, AddError(fmt.Errorf("sftp: not yet implemented")) 236 | case 'v': 237 | m.showConfig = !m.showConfig 238 | m.setConfig() 239 | default: 240 | return m, AddError(fmt.Errorf("that's an interesting key combo! %s", msg)) 241 | } 242 | default: 243 | cmds = append(cmds, ClearError()) 244 | } 245 | } 246 | if len(m.errbuf.Bytes()) > 0 { 247 | cmds = append(cmds, 248 | AddError(fmt.Errorf("%v", m.errbuf.String())), 249 | ) 250 | m.errbuf.Reset() 251 | } 252 | 253 | m.li, cmd = m.li.Update(msg) 254 | cmds = append(cmds, cmd) 255 | 256 | if m.showConfig { 257 | m.setConfig() 258 | } 259 | m.vp, cmd = m.vp.Update(msg) 260 | cmds = append(cmds, cmd) 261 | 262 | m.log, cmd = m.log.Update(msg) 263 | cmds = append(cmds, cmd) 264 | 265 | return m, tea.Batch(cmds...) 266 | } 267 | 268 | func (m *Model) connect() tea.Cmd { 269 | host, ok := m.li.SelectedItem().(item) 270 | if !ok { 271 | return AddError(fmt.Errorf("unable to find selected item: open bug report")) 272 | } 273 | if m.ExitOnCmd { 274 | m.ExitHost = strings.TrimSpace(host.title) 275 | return tea.Quit 276 | } 277 | 278 | cmdPath, err := exec.LookPath(m.Cmd.String()) 279 | if err != nil { 280 | return AddError(fmt.Errorf("can't find `%s` cmd in your path: %v", m.Cmd, err)) 281 | } 282 | 283 | var errbuf bytes.Buffer 284 | var cmd *exec.Cmd 285 | cmd = exec.Command(cmdPath, host.title, "-F", m.config.GetPath()) 286 | if m.Cmd == moshCmd { 287 | sshFlag := fmt.Sprintf("--ssh='ssh -F %s'", m.config.GetPath()) 288 | cmd = exec.Command( 289 | cmdPath, 290 | host.title, 291 | sshFlag, 292 | ) 293 | } 294 | if host.title == "create free research root server" { 295 | host.desc = strings.TrimSpace(host.desc) 296 | _cmdPath, err := exec.LookPath("sshpass") 297 | if err != nil { 298 | _cmdPath, err = exec.LookPath("ssh") 299 | if err != nil { 300 | return AddError(fmt.Errorf("can't find `%s` cmd in your path: %v", m.Cmd, err)) 301 | } 302 | cmd = exec.Command(_cmdPath, host.title, "-F", m.config.GetPath()) 303 | } else { 304 | cmd = exec.Command(_cmdPath, "-p", "segfault", "ssh", host.desc) 305 | } 306 | } 307 | cmd.Stderr = &m.errbuf 308 | execmd := tea.ExecProcess(cmd, func(err error) tea.Msg { 309 | return tea.Batch( 310 | AddError( 311 | fmt.Errorf("connection closed: %v, err: %v", host.title, err), 312 | ), 313 | AddError(fmt.Errorf("%s", m.errbuf.String())), 314 | ) 315 | }) 316 | return execmd 317 | } 318 | 319 | func (m *Model) setConfig() { 320 | i := m.li.GlobalIndex() 321 | host := m.config.Hosts[i] 322 | var out string 323 | keyStyle := lg.NewStyle(). 324 | Foreground(lg.Color("#4682b4")) 325 | for i, k := range host.Options.Keys() { 326 | k = keyStyle.Render(k) 327 | out += fmt.Sprintf("%s %s\n", k, host.Options.Values()[i]) 328 | } 329 | m.vp.SetContent(out) 330 | } 331 | 332 | func (m *Model) View() string { 333 | var out string 334 | // style := lg.NewStyle(). 335 | // Margin(1, 0, 0, 1). 336 | // Render 337 | // out += style(m.li.View()) 338 | // out += style(m.log.View()) 339 | vertView := lg.JoinVertical(0, m.li.View(), m.log.View()) 340 | if m.debug { 341 | border := lg.NewStyle().Border(lg.RoundedBorder(), true) 342 | m.vp.Style = border 343 | } else { 344 | border := lg.NewStyle(). 345 | Padding(2). 346 | Border(lg.HiddenBorder(), true) 347 | m.vp.Style = border 348 | } 349 | if m.showConfig { 350 | out += lg.JoinHorizontal(0.2, vertView, m.vp.View()) 351 | } else { 352 | out += vertView 353 | } 354 | return out 355 | } 356 | 357 | func tick() tea.Cmd { 358 | return tea.Tick(time.Second, func(time.Time) tea.Msg { 359 | return tickMsg{} 360 | }) 361 | } 362 | -------------------------------------------------------------------------------- /pkg/tui/msg.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | type ( 4 | ShowConfigMsg struct{} 5 | ReloadConfigMsg struct{} 6 | LivenessCheckMsg struct{} 7 | ExitOnConnMsg struct{} 8 | SetThemeMsg struct { 9 | Theme string 10 | } 11 | tickMsg struct{} 12 | AppMsg struct { 13 | Text string 14 | } 15 | FilterTagMsg struct { 16 | Arg string 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /pkg/tui/run.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/v2/key" 5 | "github.com/charmbracelet/bubbles/v2/textarea" 6 | tea "github.com/charmbracelet/bubbletea/v2" 7 | lg "github.com/charmbracelet/lipgloss/v2" 8 | ) 9 | 10 | func newTextarea() textarea.Model { 11 | t := textarea.New() 12 | t.Prompt = "" 13 | t.Placeholder = `Every line is a command and runs in its own ssh session` 14 | t.ShowLineNumbers = true 15 | t.Styles.Cursor = textarea.CursorStyle{ 16 | Color: lg.Color("212"), 17 | Shape: tea.CursorBlock, 18 | Blink: false, 19 | BlinkSpeed: 0, 20 | } 21 | t.Styles.Focused.Placeholder = focusedPlaceholderStyle 22 | t.Styles.Focused.CursorLine = cursorLineStyle 23 | t.Styles.Focused.Base = focusedBorderStyle 24 | t.Styles.Focused.EndOfBuffer = endOfBufferStyle 25 | t.Styles.Blurred.Placeholder = placeholderStyle 26 | t.Styles.Blurred.Base = blurredBorderStyle 27 | t.Styles.Blurred.EndOfBuffer = endOfBufferStyle 28 | t.KeyMap.DeleteWordBackward.SetEnabled(false) 29 | t.KeyMap.LineNext = key.NewBinding(key.WithKeys("down")) 30 | t.KeyMap.LinePrevious = key.NewBinding(key.WithKeys("up")) 31 | t.Blur() 32 | return t 33 | } 34 | -------------------------------------------------------------------------------- /pkg/tui/state.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | type State int 4 | 5 | const ( 6 | ListHosts State = iota 7 | RunCommand 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/tui/styles.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "github.com/charmbracelet/lipgloss/v2" 4 | 5 | var ( 6 | cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) 7 | 8 | cursorLineStyle = lipgloss.NewStyle(). 9 | Background(lipgloss.Color("57")). 10 | Foreground(lipgloss.Color("230")) 11 | 12 | placeholderStyle = lipgloss.NewStyle(). 13 | Foreground(lipgloss.Color("238")) 14 | 15 | endOfBufferStyle = lipgloss.NewStyle(). 16 | Foreground(lipgloss.Color("235")) 17 | 18 | focusedPlaceholderStyle = lipgloss.NewStyle(). 19 | Foreground(lipgloss.Color("99")) 20 | 21 | focusedBorderStyle = lipgloss.NewStyle(). 22 | Border(lipgloss.RoundedBorder()). 23 | BorderForeground(lipgloss.Color("238")) 24 | 25 | blurredBorderStyle = lipgloss.NewStyle(). 26 | Border(lipgloss.HiddenBorder()) 27 | ) 28 | -------------------------------------------------------------------------------- /pkg/tui/syscmd.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | type SysCmd string 4 | 5 | func (s SysCmd) String() string { 6 | return string(s) 7 | } 8 | 9 | const ( 10 | sshCmd SysCmd = "ssh" 11 | moshCmd SysCmd = "mosh" 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/tui/themes.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // theme provides the colors for the properties. 4 | // you can use ANSI, ANSI256 or Hex colors. 5 | // https://html-color.code 6 | type theme struct { 7 | mainTitleColor string 8 | selectedBorderColor string 9 | selectedTitleColor string 10 | selectedDescriptionColor string 11 | } 12 | 13 | var themes = map[string]theme{ 14 | "matrix": matrixTheme(), 15 | "sky": skyTheme(), 16 | } 17 | 18 | func matrixTheme() theme { 19 | return theme{ 20 | mainTitleColor: "#648c11", 21 | selectedTitleColor: "#9efd38", 22 | selectedBorderColor: "#9efd38", 23 | selectedDescriptionColor: "#648c11", 24 | } 25 | } 26 | 27 | func skyTheme() theme { 28 | return theme{ 29 | mainTitleColor: "#4682b4", 30 | selectedTitleColor: "#00bfff", 31 | selectedBorderColor: "#00bfff", 32 | selectedDescriptionColor: "#4682b4", 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Secure Shell Manager 2 | 3 | > Streamline SSH connections with a simple Terminal UI 4 | 5 | [![version][version-badge]](changelog.md) 6 | [![license][license-badge]](license) 7 | [![go report card](https://goreportcard.com/badge/github.com/lfaoro/ssm)](https://goreportcard.com/report/github.com/lfaoro/ssm) 8 | [![follow on x][x-badge]](https://x.com/intent/follow?screen_name=leonardofaoro) 9 | 10 | [version-badge]: https://img.shields.io/badge/version-0.3.5-blue.svg 11 | [license-badge]: https://img.shields.io/badge/license-BSD3-blue 12 | [x-badge]: https://img.shields.io/twitter/follow/leonardofaoro?label=follow&style=social 13 | 14 | # Purpose 15 | Scratching my own itch: `ssm` is an SSH connection manager designed to connect, filter, tag, and much more from a simple terminal interface. Works on top of installed command-line programs and does not require any setup on remote systems. 16 | 17 | **tl;dr** - [try now](#Install) 18 | 19 | See [HELP](data/help) for CLI flags. \ 20 | See [CHANGELOG](changelog.md) for dev info. 21 | 22 | ![demo](data/thc.png) 23 | 24 | ## Features 25 | - vim keys: jkhl, ctrl+d/u, g/G 26 | - emacs keys: ctrl+p/n/b/f 27 | - filter through all your servers 28 | - switch between SSH and MOSH with a tab 29 | - `ctrl+e` edit the loaded config 30 | - `ctrl+v` shows all config params 31 | - config will automatically reload on change 32 | - CLI short-flags support e.g. `ssm -seo` enables `--show`, `--exit`, and `--order` 33 | - group servers using tags e.g. `#tag: admin` 34 | - show only admin tagged servers `ssm admin` 35 | - use `#tagorder` key to prioritize tagged hosts in list-view 36 | - use `--theme` to change color scheme 37 | - edit [themes.go](pkg/tui/themes.go) to add more 38 | 39 | ## Keys 40 | ``` 41 | connect to selected host 42 | edit ssh config 43 | show all config params 44 | switch between SSH/MOSH 45 | < / > filter hosts 46 | quit 47 | 48 | # under development (coming soon) 49 | ctrl+r run commands on the server without starting a pty 50 | ctrl+s sftp upload/download files to/from server 51 | ctrl+g port-forwarding UI 52 | space␣ select multiple hosts to interact with 53 | ``` 54 | 55 | ## Quickstart 56 | > If you're not accustomed to ssh config start here otherwise skip to [Install](#install) 57 | - [SSH config manual](https://man.openbsd.org/ssh_config.5) 58 | ```bash 59 | # backup any existing config 60 | [ -f ~/.ssh/config ] && cp ~/.ssh/config ~/.ssh/config.bak 61 | # create ssh config 62 | cat <>~/.ssh/config 63 | # This is an example config for SSH 64 | 65 | #tagorder is a key used to prioritize #tag: hosts in list-view 66 | 67 | Host hostname1 68 | #tag: tagValue1,tagValue2,tagValueN 69 | User user 70 | HostName hello.world 71 | Port 2222 72 | IdentityFile ~/.ssh/id_rsa 73 | 74 | Host segfault.net 75 | #tag: research 76 | User root 77 | HostName segfault.net 78 | 79 | Host terminalcoffee 80 | #tag: shops 81 | User adam 82 | HostName terminal.shop 83 | EOF 84 | # file must have 600 perms for security 85 | chmod 600 ~/.ssh/config 86 | ``` 87 | 88 | ## Install 89 | Download `ssm` binary from [releases](https://github.com/lfaoro/ssm/releases) 90 | > available for [Linux, MacOS, FreeBSD, NetBSD, OpenBSD, Solaris] \ 91 | > on [x86_64, i386, arm64, arm] architectures, 92 | _need more? just ask_ 93 | 94 | ```bash 95 | # verify the binary is signed with my key 96 | gpg --verify ssm_sig ssm 97 | ``` 98 | 99 | ```bash 100 | # bash script install for linux|macos|freebsd|netbsd|openbsd|solaris 101 | curl -sSL https://github.com/lfaoro/ssm/raw/main/scripts/get.sh | bash 102 | wget -qO- https://github.com/lfaoro/ssm/raw/main/scripts/get.sh | bash 103 | 104 | # we don't pay Apple for a signing key, therefore you might need to run 105 | xattr -d com.apple.quarantine ssm # on MacOS 106 | 107 | # brew tap for macos/linux 108 | brew install lfaoro/tap/ssm 109 | ``` 110 | 111 | 112 | 113 | ## Build 114 | > requires [Go](https://go.dev/doc/install) 115 | 116 | ```bash 117 | # bootstrap 118 | go install github.com/lfaoro/ssm@latest 119 | 120 | # build 121 | git clone https://github.com/lfaoro/ssm.git \ 122 | && cd ssm \ 123 | && make \ 124 | && bin/ssm 125 | 126 | # build from sr.ht mirror 127 | git clone https://git.sr.ht/~faoro/ssm \ 128 | && cd ssm \ 129 | && make \ 130 | && bin/ssm 131 | 132 | make clean 133 | # clean everything even caches 134 | make distclean 135 | ``` 136 | 137 | ## Help 138 | - [SSH config example](data/config_example) 139 | - [message me on Telegram](https://t.me/leonarth) 140 | - [tag me on X](https://x.com/leonardofaoro) 141 | 142 | ## Contributors 143 | [See all](https://github.com/lfaoro/ssm/graphs/contributors) 144 | 145 | Pull requests are very welcome and will be merged. \ 146 | Report a bug or request a new feature, feel free to open a [new issue](https://github.com/lfaoro/ssm/issues). 147 | 148 | ## Show support 149 | 150 | > If `ssm` is useful to you, please consider giving it a ⭐. 151 | 152 | - **star the repo** 153 | - **tell your friends** 154 | 155 | - [GitHub sponsor](https://github.com/sponsors/lfaoro) 156 | - [BTC sponsor](https://mempool.space/address/bc1qzaqeqwklaq86uz8h2lww87qwfpnyh9fveyh3hs) 157 | - [XMR sponsor](https://xmrchain.net/search?value=89XCyahmZiQgcVwjrSZTcJepPqCxZgMqwbABvzPKVpzC7gi8URDme8H6UThpCqX69y5i1aA81AKq57Wynjovy7g4K9MeY5c) 158 | - [FIAT sponsor](https://checkout.revolut.com/pay/1122870b-1836-42e7-942b-90a99ef5e457) 159 | -------------------------------------------------------------------------------- /scripts/create_config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | mkdir -p ~/.ssh && chmod 700 ~/.ssh 3 | touch ~/.ssh/config && chmod 600 ~/.ssh/config 4 | -------------------------------------------------------------------------------- /scripts/dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright (c) 2025 Leonardo Faoro & authors 4 | # SPDX-License-Identifier: BSD-3-Clause 5 | 6 | APP=ssm 7 | APP_PATH=./ 8 | PID_PATH=/tmp/${APP}_dev.pid 9 | echo $$ > ${PID_PATH} 10 | 11 | cleanup() { 12 | reset 13 | echo "cleaning up..." 14 | 15 | # prevent recursive cleanup calls 16 | trap - SIGINT SIGTERM SIGQUIT 17 | 18 | pkill -TERM -x $NOTIFY_PID || pkill -9 -x $NOTIFY_PID || true 19 | pkill -TERM -x inotifywait || pkill -9 -x inotifywait || true 20 | pkill -TERM -x "$APP" || pkill -9 -x "$APP" || true 21 | 22 | sleep 1s 23 | make stop 24 | exit 0 25 | } 26 | trap cleanup SIGINT SIGTERM SIGQUIT 27 | 28 | start_app() { 29 | reset 30 | export TERM=xterm-256color 31 | echo "starting ${APP}" 32 | go run -ldflags="" ${APP_PATH} --debug 33 | echo "${APP} process exited" 34 | } 35 | 36 | # background file monitoring 37 | ( 38 | while true; do 39 | inotifywait -q -r -e modify ${APP_PATH} --include '\.go$' 40 | echo "file change detected!" 41 | pkill -TERM -x ${APP} 2>/dev/null || true 42 | done 43 | ) & 44 | NOTIFY_PID=$! 45 | 46 | # start the app initially 47 | start_app 48 | 49 | # main loop - after app exits, restart it 50 | while true; do 51 | echo "restarting ${APP}" 52 | sleep 0.5 53 | start_app 54 | done 55 | -------------------------------------------------------------------------------- /scripts/get.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright (c) 2025 Leonardo Faoro & authors 4 | # SPDX-License-Identifier: BSD-3-Clause 5 | 6 | set -euo pipefail 7 | 8 | cleanup() { 9 | if [[ "${TEMP_FILE:-}" ]]; then rm -f "$TEMP_FILE"; fi 10 | if [[ "${TEMP_DIR:-}" ]]; then rm -rf "$TEMP_DIR"; fi 11 | } 12 | trap cleanup EXIT 13 | 14 | error() { 15 | echo "error: $1" >&2 16 | exit 1 17 | } 18 | 19 | is_writable() { 20 | local path temp_check 21 | path="$1" 22 | if [[ ! -d "$path" ]]; then return 1; fi 23 | temp_check=$(mktemp -t install_check_XXXXXX) || error "failed to create temp file" 24 | if ! mv "$temp_check" "$path/" 2>/dev/null; then 25 | rm -f "$temp_check" 26 | return 1 27 | fi 28 | rm -f "$path/$(basename "$temp_check")" 29 | return 0 30 | } 31 | 32 | check_permissions() { 33 | local path 34 | path="$1" 35 | TEMP_FILE=$(mktemp -t install_XXXXXX) || error "failed to create temp file" 36 | if ! mv "$TEMP_FILE" "$path/" 2>/dev/null; then 37 | echo "warning: no write permission in $path" 38 | INSTALL_DIR="$HOME/.local/bin" 39 | mkdir -p "$INSTALL_DIR" || error "failed to create $INSTALL_DIR" 40 | fi 41 | rm -f "$path/$(basename "$TEMP_FILE")" 2>/dev/null 42 | } 43 | 44 | check_path() { 45 | local path 46 | path="$1" 47 | if [[ ":$PATH:" != *":$path:"* ]]; then 48 | echo "Warning: $path is not in your PATH" 49 | case "$SHELL" in 50 | *bash) echo "Run: echo 'export PATH=\$PATH:$path' >> ~/.bashrc" ;; 51 | *zsh) echo "Run: echo 'export PATH=\$PATH:$path' >> ~/.zshrc" ;; 52 | *) echo "Add $path to your PATH" ;; 53 | esac 54 | fi 55 | } 56 | 57 | # Configuration 58 | APP_NAME=ssm 59 | REPO="lfaoro/ssm" 60 | DOWNLOAD_URL="https://github.com/${REPO}/releases/download" 61 | 62 | # get latest version 63 | echo "Fetching latest version..." 64 | echo "Making API request to: https://api.github.com/repos/${REPO}/releases/latest" 65 | 66 | if ! API_RESPONSE=$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest" 2>&1); then 67 | echo "Error: Failed to fetch from GitHub API" 68 | echo "Debug: Curl response:" 69 | echo "$API_RESPONSE" 70 | error "GitHub API request failed" 71 | fi 72 | 73 | VERSION=$(sed 's/"tag_name": "//;s/"//' <<< "$(grep -o '"tag_name": "[^"]*"' <<< "$API_RESPONSE")") 74 | if [[ -z "$VERSION" ]]; then 75 | echo "Debug: Raw API response:" 76 | echo "$API_RESPONSE" 77 | error "failed to determine latest version" 78 | fi 79 | echo "Found version: ${VERSION}" 80 | 81 | OS=$(tr '[:upper:]' '[:lower:]' <<< "$(uname -s)") 82 | ARCH=$(uname -m) 83 | case "${ARCH}" in 84 | x86_64|amd64) ARCH="x86_64" ;; 85 | aarch64|arm64) ARCH="arm64" ;; 86 | *) error "Unsupported architecture: ${ARCH}" ;; 87 | esac 88 | case "${OS}" in 89 | linux|freebsd|netbsd|openbsd|solaris) 90 | ARCHIVE_NAME="${APP_NAME}_${VERSION}_${OS}_${ARCH}.tar.gz" 91 | if is_writable "/usr/local/bin"; then 92 | INSTALL_DIR="/usr/local/bin" 93 | else 94 | INSTALL_DIR="$HOME/.local/bin" 95 | fi 96 | ;; 97 | darwin) 98 | ARCHIVE_NAME="${APP_NAME}_darwin_all.tar.gz" 99 | if is_writable "/usr/local/bin"; then 100 | INSTALL_DIR="/usr/local/bin" 101 | else 102 | INSTALL_DIR="$HOME/.local/bin" 103 | fi 104 | ;; 105 | *) error "Unsupported operating system: ${OS}" ;; 106 | esac 107 | 108 | # create installation directory 109 | mkdir -p "${INSTALL_DIR}" || error "failed to create installation directory" 110 | 111 | # only check permissions if we're not already in a fallback directory 112 | if [[ "$INSTALL_DIR" != "/tmp" && "$INSTALL_DIR" != "$HOME/.local/bin" && "$INSTALL_DIR" != "$HOME/bin" ]]; then 113 | check_permissions "$INSTALL_DIR" 114 | fi 115 | 116 | # download and install binary 117 | DOWNLOAD_ARCHIVE_URL="${DOWNLOAD_URL}/${VERSION}/${ARCHIVE_NAME}" 118 | echo "Downloading ${APP_NAME} ${VERSION} for ${OS}/${ARCH}..." 119 | echo "Attempting to download from: ${DOWNLOAD_ARCHIVE_URL}" 120 | 121 | # verify the download url exists before attempting to download 122 | echo "Verifying download URL..." 123 | HTTP_STATUS=$(curl -L -s -o /dev/null -w "%{http_code}" "${DOWNLOAD_ARCHIVE_URL}") 124 | if [[ "$HTTP_STATUS" != "200" ]]; then 125 | echo "Error: HTTP status code: ${HTTP_STATUS}" 126 | echo "Debug: Attempting to list available assets..." 127 | sed 's/"browser_download_url": "//;s/"//' <<< "$(grep -o '"browser_download_url": "[^"]*"' <<< "$API_RESPONSE")" 128 | error "download URL not accessible: ${DOWNLOAD_ARCHIVE_URL}" 129 | fi 130 | 131 | # Create temporary directory for extraction 132 | TEMP_DIR=$(mktemp -d) || error "failed to create temporary directory" 133 | 134 | # Download and extract the archive 135 | echo "Downloading binary..." 136 | if ! /usr/bin/curl -fsSL "${DOWNLOAD_ARCHIVE_URL}" -o "${TEMP_DIR}/${ARCHIVE_NAME}" --progress-bar; then 137 | echo "Error: Failed to download binary" 138 | error "Download failed" 139 | fi 140 | echo "Extracting binary..." 141 | if ! tar -xzf "${TEMP_DIR}/${ARCHIVE_NAME}" -C "${TEMP_DIR}"; then 142 | echo "Error: Failed to extract binary" 143 | error "Failed to extract archive" 144 | fi 145 | echo "Installing binary..." 146 | if ! mv "${TEMP_DIR}/ssm" "${INSTALL_DIR}/${APP_NAME}"; then 147 | echo "Error: Failed to move binary" 148 | error "Failed to move binary" 149 | fi 150 | if ! chmod +x "${INSTALL_DIR}/${APP_NAME}"; then 151 | echo "Error: Failed to set executable permissions" 152 | error "failed to set executable permissions" 153 | fi 154 | BINARY_PATH="${INSTALL_DIR}/${APP_NAME}" 155 | 156 | echo "Successfully installed ${APP_NAME} to: ${BINARY_PATH}" 157 | check_path "${INSTALL_DIR}" 158 | 159 | # verify 160 | "${BINARY_PATH}" --version || error "failed to run ${APP_NAME}" 161 | --------------------------------------------------------------------------------