├── .dockerignore ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── renovate.json └── workflows │ ├── ci.yml │ ├── release.yml │ └── semgrep.yml ├── .gitignore ├── .golangci.yml ├── .releaserc.js ├── AUTHORS ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── contrib ├── completion │ ├── bash_autocomplete │ ├── gen │ │ └── main.go │ └── zsh_autocomplete ├── dummy-config.yml ├── generate-loggers.sh ├── homebrew │ └── assh.rb └── webapp │ ├── logger.gen.go │ └── main.go ├── doc.go ├── examples ├── gateways-wildcard │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ └── ssh_config ├── readme-example-gateways │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ └── ssh_config ├── readme-full-example │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ └── ssh_config ├── readme-troubleshooting-gateways │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ └── ssh_config ├── resolvecommand-with-gateway │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ ├── ssh_config │ ├── test.log │ └── test.sh ├── same-option-multiple-times │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ └── ssh_config ├── test-263 │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ └── ssh_config └── test-289 │ ├── assh.yml │ ├── graphviz.dot │ ├── graphviz.png │ └── ssh_config ├── go.mod ├── go.sum ├── logger.gen.go ├── main.go ├── pkg ├── commands │ ├── build.go │ ├── commands.go │ ├── config.go │ ├── control-sockets.go │ ├── doc.go │ ├── graphviz.go │ ├── info.go │ ├── list.go │ ├── logger.gen.go │ ├── ping.go │ ├── proxy.go │ ├── proxy_test.go │ ├── search.go │ └── wrapper.go ├── config │ ├── config.go │ ├── config_test.go │ ├── doc.go │ ├── graphviz │ │ ├── doc.go │ │ ├── graphviz.go │ │ ├── graphviz_test.go │ │ └── logger.gen.go │ ├── helpers.go │ ├── helpers_test.go │ ├── hook.go │ ├── host.go │ ├── host_test.go │ ├── hostlist.go │ ├── hostlist_test.go │ ├── logger.gen.go │ ├── option.go │ ├── option_test.go │ └── ssh_flags.go ├── controlsockets │ ├── control-sockets.go │ ├── doc.go │ └── logger.gen.go ├── hooks │ ├── doc.go │ ├── driver_daemon.go │ ├── driver_exec.go │ ├── driver_notification.go │ ├── driver_notification_unsupported.go │ ├── driver_write.go │ ├── hooks.go │ └── logger.gen.go ├── logger │ ├── doc.go │ ├── logger.gen.go │ ├── logger.go │ ├── logger_test.go │ ├── process.go │ └── process_unsupported.go ├── ratelimit │ ├── doc.go │ ├── logger.gen.go │ └── ratelimit.go ├── templates │ ├── doc.go │ ├── logger.gen.go │ └── templates.go ├── utils │ ├── doc.go │ ├── env.go │ ├── env_test.go │ ├── imported.go │ └── logger.gen.go └── version │ ├── doc.go │ ├── logger.gen.go │ ├── version.go │ └── version_test.go ├── resources ├── assh.png ├── closed_connection_notification.png ├── graphviz.png └── new_connection_notification.png └── rules.mk /.dockerignore: -------------------------------------------------------------------------------- 1 | *# 2 | *~ 3 | .#* 4 | #.git/ 5 | Dockerfile 6 | ./assh 7 | dist/ 8 | #/vendor/ 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Collapse vendored and generated files on GitHub 5 | AUTHORS linguist-generated 6 | vendor/* linguist-vendored 7 | rules.mk linguist-vendored 8 | */vendor/* linguist-vendored 9 | *.gen.* linguist-generated 10 | *.pb.go linguist-generated 11 | go.sum linguist-generated 12 | go.mod linguist-generated 13 | gen.sum linguist-generated 14 | contrib/completion/*_autocomplete linguist-generated 15 | 16 | # Reduce conflicts on markdown files 17 | *.md merge=union 18 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @moul 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at m+coc-report@42.am. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, you can first discuss the change you wish to make via issue, 4 | email, or any other method with the maintainers of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ["moul"] 2 | patreon: moul 3 | open_collective: moul 4 | custom: 5 | - "https://www.buymeacoffee.com/moul" 6 | - "https://manfred.life/donate" 7 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: docker 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: github-actions 10 | directory: "/" 11 | schedule: 12 | interval: daily 13 | time: "04:00" 14 | open-pull-requests-limit: 10 15 | - package-ecosystem: gomod 16 | directory: "/" 17 | schedule: 18 | interval: daily 19 | time: "04:00" 20 | open-pull-requests-limit: 10 21 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "groupName": "all", 6 | "gomodTidy": true 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | docker-build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Build the Docker image 16 | run: docker build . --file Dockerfile 17 | golangci-lint: 18 | name: golangci-lint 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-go@v3 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v3 25 | with: 26 | version: v1.50.1 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | tests-on-windows: 29 | needs: golangci-lint # run after golangci-lint action to not produce duplicated errors 30 | runs-on: windows-latest 31 | strategy: 32 | matrix: 33 | golang: 34 | - 1.18.x 35 | - 1.19.x 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Install Go 39 | uses: actions/setup-go@v3 40 | with: 41 | go-version: ${{ matrix.golang }} 42 | - name: Run tests on Windows 43 | run: make.exe unittest 44 | continue-on-error: true 45 | tests-on-mac: 46 | needs: golangci-lint # run after golangci-lint action to not produce duplicated errors 47 | runs-on: macos-latest 48 | strategy: 49 | matrix: 50 | golang: 51 | - 1.18.x 52 | - 1.19.x 53 | steps: 54 | - uses: actions/checkout@v3 55 | - name: Install Go 56 | uses: actions/setup-go@v3 57 | with: 58 | go-version: ${{ matrix.golang }} 59 | - uses: actions/cache@v3.2.6 60 | with: 61 | path: ~/go/pkg/mod 62 | key: ${{ runner.os }}-go-${{ matrix.golang }}-${{ hashFiles('**/go.sum') }} 63 | restore-keys: | 64 | ${{ runner.os }}-go-${{ matrix.golang }}- 65 | - name: Run tests on Unix-like operating systems 66 | run: make unittest 67 | tests-on-linux: 68 | needs: golangci-lint # run after golangci-lint action to not produce duplicated errors 69 | runs-on: ubuntu-latest 70 | strategy: 71 | matrix: 72 | golang: 73 | - 1.13.x 74 | - 1.14.x 75 | - 1.15.x 76 | - 1.16.x 77 | - 1.17.x 78 | - 1.18.x 79 | - 1.19.x 80 | steps: 81 | - uses: actions/checkout@v3 82 | - name: Install Go 83 | uses: actions/setup-go@v3 84 | with: 85 | go-version: ${{ matrix.golang }} 86 | - uses: actions/cache@v3.2.6 87 | with: 88 | path: ~/go/pkg/mod 89 | key: ${{ runner.os }}-go-${{ matrix.golang }}-${{ hashFiles('**/go.sum') }} 90 | restore-keys: | 91 | ${{ runner.os }}-go-${{ matrix.golang }}- 92 | - name: Run tests on Unix-like operating systems 93 | run: make unittest 94 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: releaser 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | golang: [1.17.x] 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v3 19 | - 20 | name: Unshallow 21 | run: git fetch --prune --unshallow 22 | - 23 | uses: moul/repoman-action@v1 24 | id: repoman 25 | - 26 | name: Run Semantic Release 27 | id: semantic 28 | uses: docker://ghcr.io/codfish/semantic-release-action:v1 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | - 32 | name: Set up Go 33 | if: steps.semantic.outputs.new-release-published == 'true' && steps.repoman.outputs.has-go-binary == 'true' 34 | uses: actions/setup-go@v3 35 | with: 36 | go-version: ${{ matrix.golang }} 37 | - 38 | name: Cache Go modules 39 | if: steps.semantic.outputs.new-release-published == 'true' && steps.repoman.outputs.has-go-binary == 'true' 40 | uses: actions/cache@v3.2.6 41 | with: 42 | path: ~/go/pkg/mod 43 | key: ${{ runner.os }}-go-${{ matrix.golang }}-v1-${{ hashFiles('**/go.sum') }} 44 | restore-keys: ${{ runner.os }}-go-${{ matrix.golang }}-v1- 45 | - 46 | name: Run GoReleaser 47 | if: steps.semantic.outputs.new-release-published == 'true' && steps.repoman.outputs.has-go-binary == 'true' 48 | uses: goreleaser/goreleaser-action@v3.2.0 49 | with: 50 | version: latest 51 | args: release --rm-dist 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | - 55 | name: Register version on pkg.go.dev 56 | if: steps.semantic.outputs.new-release-published == 'true' 57 | run: | 58 | package=$(cat go.mod | grep ^module | awk '{print $2}') 59 | version=v${{ steps.semantic.outputs.release-version }} 60 | url=https://proxy.golang.org/${package}/@v/${version}.info 61 | set -x +e 62 | curl -i $url 63 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: {} 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - .github/workflows/semgrep.yml 8 | schedule: 9 | - cron: '0 0 * * 0' 10 | name: Semgrep 11 | jobs: 12 | semgrep: 13 | name: Scan 14 | runs-on: ubuntu-20.04 15 | env: 16 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 17 | container: 18 | image: returntocorp/semgrep 19 | steps: 20 | - uses: actions/checkout@v3 21 | - run: semgrep ci 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage.txt 3 | contrib/docker/entrypoint 4 | /advanced-ssh-config 5 | /webapp 6 | /assh 7 | /assh_* 8 | /cmd/assh/assh 9 | profile.out 10 | dist/ 11 | docker/assh 12 | docker/[0-9]* 13 | /.release -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 1m 3 | tests: false 4 | skip-files: 5 | - "testing.go" 6 | - ".*\\.pb\\.go" 7 | - ".*\\.gen\\.go" 8 | 9 | linters-settings: 10 | golint: 11 | min-confidence: 0 12 | maligned: 13 | suggest-new: true 14 | goconst: 15 | min-len: 5 16 | min-occurrences: 4 17 | misspell: 18 | locale: US 19 | 20 | linters: 21 | disable-all: true 22 | enable: 23 | - bodyclose 24 | #- deadcode 25 | - depguard 26 | - dogsled 27 | - dupl 28 | - errcheck 29 | #- funlen 30 | - gochecknoinits 31 | #- gocognit 32 | - goconst 33 | - gocritic 34 | - gocyclo 35 | - gofmt 36 | - goimports 37 | #- golint 38 | - gosimple 39 | - govet 40 | - ineffassign 41 | #- interfacer 42 | #- maligned 43 | - misspell 44 | - nakedret 45 | - prealloc 46 | #- scopelint 47 | - staticcheck 48 | #- structcheck 49 | #- stylecheck 50 | - typecheck 51 | - unconvert 52 | - unparam 53 | - unused 54 | #- varcheck 55 | - whitespace 56 | - revive 57 | - exportloopref 58 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branch: 'master', 3 | plugins: [ 4 | '@semantic-release/commit-analyzer', 5 | '@semantic-release/release-notes-generator', 6 | '@semantic-release/github', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This file lists all individuals having contributed content to the repository. 2 | # For how it is generated, see 'https://github.com/moul/rules.mk' 3 | 4 | Aaron Bach 5 | adasauce <60991921+adasauce@users.noreply.github.com> 6 | ahh 7 | Alen Masic 8 | Anthony Cruz 9 | Ash Matadeen 10 | Ashish SHUKLA 11 | Cameron Moon <198225+cmrn@users.noreply.github.com> 12 | Corentin Kerisit 13 | daca11 14 | Daniel Malon 15 | Dave Eddy 16 | dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 17 | Dominik Schilling 18 | Dong Wang 19 | fossabot 20 | Frank Rosquin 21 | Jess 22 | Jimmy Tang 23 | Kevin Borgolte 24 | Linus Gasser 25 | Lucy Crockett <58056722+lcrockett@users.noreply.github.com> 26 | Manfred Touron <94029+moul@users.noreply.github.com> 27 | Manfred Touron 28 | Maxime Loliée 29 | milkpirate 30 | moul-bot 31 | Noel Georgi <18496730+frezbo@users.noreply.github.com> 32 | Paul Schroeder 33 | Philipp Schmitt 34 | phineas0fog 35 | Quentin Perez 36 | Quentin Perez 37 | ReadmeCritic 38 | Renovate Bot 39 | Robert Loomans 40 | Robin Schneider 41 | semgrep.dev on behalf of @moul 42 | Sergei Dyshel 43 | Tin Lai 44 | Will Fantom 45 | Will O <0100wrxb@gmail.com> 46 | Zakhar Bessarab 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # dynamic config 2 | ARG BUILD_DATE 3 | ARG VCS_REF 4 | ARG VERSION 5 | 6 | # build 7 | FROM golang:1.19.2-alpine as builder 8 | RUN apk add --no-cache git gcc musl-dev make 9 | ENV GO111MODULE=on 10 | WORKDIR /go/src/moul.io/assh 11 | COPY go.* ./ 12 | RUN go mod download 13 | COPY . ./ 14 | RUN make install 15 | 16 | # minimalist runtime 17 | FROM alpine:3.16.0 18 | LABEL org.label-schema.build-date=$BUILD_DATE \ 19 | org.label-schema.name="assh" \ 20 | org.label-schema.description="" \ 21 | org.label-schema.url="https://moul.io/assh/" \ 22 | org.label-schema.vcs-ref=$VCS_REF \ 23 | org.label-schema.vcs-url="https://github.com/moul/assh" \ 24 | org.label-schema.vendor="Manfred Touron" \ 25 | org.label-schema.version=$VERSION \ 26 | org.label-schema.schema-version="1.0" \ 27 | org.label-schema.cmd="docker run -i -t --rm moul/assh" \ 28 | org.label-schema.help="docker exec -it $CONTAINER assh --help" 29 | COPY --from=builder /go/bin/assh /bin/ 30 | ENTRYPOINT ["/bin/assh"] 31 | #CMD [] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2009-2021 Manfred Touron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOPKG ?= moul.io/assh 2 | DOCKER_IMAGE ?= moul/assh 3 | GOBINS ?= . 4 | 5 | PRE_INSTALL_STEPS += generate 6 | PRE_UNITTEST_STEPS += generate 7 | PRE_TEST_STEPS += generate 8 | PRE_BUILD_STEPS += generate 9 | PRE_LINT_STEPS += generate 10 | PRE_TIDY_STEPS += generate 11 | PRE_BUMPDEPS_STEPS += generate 12 | 13 | VERSION ?= `git describe --tags --always` 14 | VCS_REF ?= `git rev-parse --short HEAD` 15 | 16 | GO_INSTALL_OPTS = -ldflags="-X 'moul.io/assh/v2/pkg/version.Version=$(VERSION)' -X 'moul.io/assh/v2/pkg/version.VcsRef=$(VCS_REF)' " 17 | 18 | include rules.mk 19 | 20 | .PHONY: generate 21 | generate: 22 | go generate 23 | 24 | .PHONY: examples 25 | examples: $(TARGET) 26 | @for example in $(dir $(wildcard examples/*/assh.yml)); do \ 27 | set -xe; \ 28 | $(TARGET) -c $$example/assh.yml config build > $$example/ssh_config; \ 29 | $(TARGET) -c $$example/assh.yml config graphviz > $$example/graphviz.dot; \ 30 | dot -Tpng $$example/graphviz.dot > $$example/graphviz.png; \ 31 | if [ -x $$example/test.sh ]; then (cd $$example; ./test.sh || exit 1); fi; \ 32 | done 33 | 34 | .PHONY: gen-release 35 | gen-release: generate 36 | mkdir -p .release 37 | GOOS=linux GOARCH=amd64 go build $(GO_INSTALL_OPTS) -v -o .release/assh_linux_amd64 . 38 | GOOS=linux GOARCH=386 go build $(GO_INSTALL_OPTS) -v -o .release/assh_linux_386 . 39 | GOOS=linux GOARCH=arm go build $(GO_INSTALL_OPTS) -v -o .release/assh_linux_arm . 40 | GOOS=linux GOARCH=arm64 go build $(GO_INSTALL_OPTS) -v -o .release/assh_linux_arm64 . 41 | GOOS=openbsd GOARCH=amd64 go build $(GO_INSTALL_OPTS) -v -o .release/assh_openbsd_amd64 . 42 | GOOS=openbsd GOARCH=386 go build $(GO_INSTALL_OPTS) -v -o .release/assh_openbsd_386 . 43 | GOOS=openbsd GOARCH=arm go build $(GO_INSTALL_OPTS) -v -o .release/assh_openbsd_arm . 44 | GOOS=darwin GOARCH=amd64 go build $(GO_INSTALL_OPTS) -v -o .release/assh_darwin_amd64 . 45 | # GOOS=darwin GOARCH=386 go build $(GO_INSTALL_OPTS) -v -o .release/assh_darwin_386 . 46 | # GOOS=darwin GOARCH=arm go build $(GO_INSTALL_OPTS) -v -o .release/assh_darwin_arm . 47 | GOOS=netbsd GOARCH=amd64 go build $(GO_INSTALL_OPTS) -v -o .release/assh_netbsd_amd64 . 48 | GOOS=netbsd GOARCH=386 go build $(GO_INSTALL_OPTS) -v -o .release/assh_netbsd_386 . 49 | GOOS=netbsd GOARCH=arm go build $(GO_INSTALL_OPTS) -v -o .release/assh_netbsd_arm . 50 | GOOS=freebsd GOARCH=amd64 go build $(GO_INSTALL_OPTS) -v -o .release/assh_freebsd_amd64 . 51 | GOOS=freebsd GOARCH=386 go build $(GO_INSTALL_OPTS) -v -o .release/assh_freebsd_386 . 52 | GOOS=freebsd GOARCH=arm go build $(GO_INSTALL_OPTS) -v -o .release/assh_freebsd_arm . 53 | GOOS=windows GOARCH=amd64 go build $(GO_INSTALL_OPTS) -v -o .release/assh_windows_amd64.exe . 54 | GOOS=windows GOARCH=386 go build $(GO_INSTALL_OPTS) -v -o .release/assh_windows_386.exe . 55 | # GOOS=windows GOARCH=arm go build $(GO_INSTALL_OPTS) -v -o .release/assh_windows_arm.exe . 56 | -------------------------------------------------------------------------------- /contrib/completion/gen/main.go: -------------------------------------------------------------------------------- 1 | package main // import "moul.io/assh/v2/contrib/completion/gen" 2 | 3 | import ( 4 | "log" 5 | 6 | "moul.io/assh/v2/pkg/commands" 7 | ) 8 | 9 | func main() { 10 | if err := commands.RootCmd.GenBashCompletionFile("../bash_autocomplete"); err != nil { 11 | log.Println("failed to generate bash completion file: ", err) 12 | } 13 | if err := commands.RootCmd.GenZshCompletionFile("../zsh_autocomplete"); err != nil { 14 | log.Println("failed to generate zsh completion file: ", err) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /contrib/completion/zsh_autocomplete: -------------------------------------------------------------------------------- 1 | #compdef _assh assh 2 | 3 | # zsh completion for assh -*- shell-script -*- 4 | 5 | __assh_debug() 6 | { 7 | local file="$BASH_COMP_DEBUG_FILE" 8 | if [[ -n ${file} ]]; then 9 | echo "$*" >> "${file}" 10 | fi 11 | } 12 | 13 | _assh() 14 | { 15 | local shellCompDirectiveError=1 16 | local shellCompDirectiveNoSpace=2 17 | local shellCompDirectiveNoFileComp=4 18 | local shellCompDirectiveFilterFileExt=8 19 | local shellCompDirectiveFilterDirs=16 20 | 21 | local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace 22 | local -a completions 23 | 24 | __assh_debug "\n========= starting completion logic ==========" 25 | __assh_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" 26 | 27 | # The user could have moved the cursor backwards on the command-line. 28 | # We need to trigger completion from the $CURRENT location, so we need 29 | # to truncate the command-line ($words) up to the $CURRENT location. 30 | # (We cannot use $CURSOR as its value does not work when a command is an alias.) 31 | words=("${=words[1,CURRENT]}") 32 | __assh_debug "Truncated words[*]: ${words[*]}," 33 | 34 | lastParam=${words[-1]} 35 | lastChar=${lastParam[-1]} 36 | __assh_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" 37 | 38 | # For zsh, when completing a flag with an = (e.g., assh -n=) 39 | # completions must be prefixed with the flag 40 | setopt local_options BASH_REMATCH 41 | if [[ "${lastParam}" =~ '-.*=' ]]; then 42 | # We are dealing with a flag with an = 43 | flagPrefix="-P ${BASH_REMATCH}" 44 | fi 45 | 46 | # Prepare the command to obtain completions 47 | requestComp="${words[1]} __complete ${words[2,-1]}" 48 | if [ "${lastChar}" = "" ]; then 49 | # If the last parameter is complete (there is a space following it) 50 | # We add an extra empty parameter so we can indicate this to the go completion code. 51 | __assh_debug "Adding extra empty parameter" 52 | requestComp="${requestComp} \"\"" 53 | fi 54 | 55 | __assh_debug "About to call: eval ${requestComp}" 56 | 57 | # Use eval to handle any environment variables and such 58 | out=$(eval ${requestComp} 2>/dev/null) 59 | __assh_debug "completion output: ${out}" 60 | 61 | # Extract the directive integer following a : from the last line 62 | local lastLine 63 | while IFS='\n' read -r line; do 64 | lastLine=${line} 65 | done < <(printf "%s\n" "${out[@]}") 66 | __assh_debug "last line: ${lastLine}" 67 | 68 | if [ "${lastLine[1]}" = : ]; then 69 | directive=${lastLine[2,-1]} 70 | # Remove the directive including the : and the newline 71 | local suffix 72 | (( suffix=${#lastLine}+2)) 73 | out=${out[1,-$suffix]} 74 | else 75 | # There is no directive specified. Leave $out as is. 76 | __assh_debug "No directive found. Setting do default" 77 | directive=0 78 | fi 79 | 80 | __assh_debug "directive: ${directive}" 81 | __assh_debug "completions: ${out}" 82 | __assh_debug "flagPrefix: ${flagPrefix}" 83 | 84 | if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then 85 | __assh_debug "Completion received error. Ignoring completions." 86 | return 87 | fi 88 | 89 | while IFS='\n' read -r comp; do 90 | if [ -n "$comp" ]; then 91 | # If requested, completions are returned with a description. 92 | # The description is preceded by a TAB character. 93 | # For zsh's _describe, we need to use a : instead of a TAB. 94 | # We first need to escape any : as part of the completion itself. 95 | comp=${comp//:/\\:} 96 | 97 | local tab=$(printf '\t') 98 | comp=${comp//$tab/:} 99 | 100 | __assh_debug "Adding completion: ${comp}" 101 | completions+=${comp} 102 | lastComp=$comp 103 | fi 104 | done < <(printf "%s\n" "${out[@]}") 105 | 106 | if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then 107 | __assh_debug "Activating nospace." 108 | noSpace="-S ''" 109 | fi 110 | 111 | if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then 112 | # File extension filtering 113 | local filteringCmd 114 | filteringCmd='_files' 115 | for filter in ${completions[@]}; do 116 | if [ ${filter[1]} != '*' ]; then 117 | # zsh requires a glob pattern to do file filtering 118 | filter="\*.$filter" 119 | fi 120 | filteringCmd+=" -g $filter" 121 | done 122 | filteringCmd+=" ${flagPrefix}" 123 | 124 | __assh_debug "File filtering command: $filteringCmd" 125 | _arguments '*:filename:'"$filteringCmd" 126 | elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then 127 | # File completion for directories only 128 | local subDir 129 | subdir="${completions[1]}" 130 | if [ -n "$subdir" ]; then 131 | __assh_debug "Listing directories in $subdir" 132 | pushd "${subdir}" >/dev/null 2>&1 133 | else 134 | __assh_debug "Listing directories in ." 135 | fi 136 | 137 | local result 138 | _arguments '*:dirname:_files -/'" ${flagPrefix}" 139 | result=$? 140 | if [ -n "$subdir" ]; then 141 | popd >/dev/null 2>&1 142 | fi 143 | return $result 144 | else 145 | __assh_debug "Calling _describe" 146 | if eval _describe "completions" completions $flagPrefix $noSpace; then 147 | __assh_debug "_describe found some completions" 148 | 149 | # Return the success of having called _describe 150 | return 0 151 | else 152 | __assh_debug "_describe did not find completions." 153 | __assh_debug "Checking if we should do file completion." 154 | if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then 155 | __assh_debug "deactivating file completion" 156 | 157 | # We must return an error code here to let zsh know that there were no 158 | # completions found by _describe; this is what will trigger other 159 | # matching algorithms to attempt to find completions. 160 | # For example zsh can match letters in the middle of words. 161 | return 1 162 | else 163 | # Perform file completion 164 | __assh_debug "Activating file completion" 165 | 166 | # We must return the result of this command, so it must be the 167 | # last command, or else we must store its result to return it. 168 | _arguments '*:filename:_files'" ${flagPrefix}" 169 | fi 170 | fi 171 | fi 172 | } 173 | 174 | # don't run the completion function when being source-ed or eval-ed 175 | if [ "$funcstack[1]" = "_assh" ]; then 176 | _assh 177 | fi 178 | -------------------------------------------------------------------------------- /contrib/dummy-config.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | 3 | homer: 4 | # ssh homer -> ssh 1.2.3.4 -p 2222 -u robert 5 | Hostname: 1.2.3.4 6 | User: robert 7 | Port: 2222 8 | 9 | bart: 10 | # ssh bart -> ssh 5.6.7.8 -u bart <- direct access 11 | # or ssh 5.6.7.8/homer -u bart <- using homer as a gateway 12 | Hostname: 5.6.7.8 13 | User: bart 14 | Gateways: 15 | - direct # tries a direct access first 16 | - homer # fallback on homer gateway 17 | 18 | maggie: 19 | # ssh maggie -> ssh 5.6.7.8 -u maggie <- direct access 20 | # or ssh 5.6.7.8/homer -u maggie <- using homer as a gateway 21 | User: maggie 22 | Inherits: 23 | - bart # inherits rules from "bart" 24 | 25 | bart-access: 26 | # ssh bart-access -> ssh home.simpson.springfield.us -u bart 27 | Inherits: 28 | - bart-template 29 | - simpson-template 30 | 31 | lisa-access: 32 | # ssh lisa-access -> ssh home.simpson.springfield.us -u lisa 33 | Inherits: 34 | - lisa-template 35 | - simpson-template 36 | 37 | marvin: 38 | # ssh marvin -> ssh marvin -p 23 39 | # ssh sad-robot -> ssh sad-robot -p 23 40 | # ssh bighead -> ssh bighead -p 23 41 | # aliases inherit everything from marvin, except hostname 42 | Port: 23 43 | Aliases: 44 | - sad-robot 45 | - bighead 46 | 47 | dolphin: 48 | # ssh dolphin -> ssh dolphin -p 24 49 | # ssh ecco -> ssh dolphin -p 24 50 | # same as above, but with fixed hostname 51 | Port: 24 52 | Hostname: dolphin 53 | Aliases: 54 | - sad-robot 55 | - bighead 56 | 57 | schooltemplate: 58 | User: student 59 | IdentityFile: ~/.ssh/school-rsa 60 | ForwardX11: yes 61 | 62 | schoolgw: 63 | # ssh school -> ssh gw.school.com -l student -o ForwardX11=no -i ~/.ssh/school-rsa 64 | Hostname: gw.school.com 65 | ForwardX11: no 66 | Inherits: 67 | - schooltemplate 68 | 69 | "expanded-host[0-7]*": 70 | # ssh somehost2042 -> ssh somehost2042.some.zone 71 | Hostname: "%h.some.zone" 72 | 73 | vm-*.school.com: 74 | # ssh vm-42.school.com -> ssh vm-42.school.com/gw.school.com -l student -o ForwardX11=yes -i ~/.ssh/school-rsa 75 | Gateways: 76 | - schoolgw 77 | Inherits: 78 | - schooltemplate 79 | 80 | "*.shortcut1": 81 | ResolveCommand: /bin/sh -c "echo %h | sed s/.shortcut1/.my-long-domain-name.com/" 82 | 83 | "*.shortcut2": 84 | ResolveCommand: /bin/sh -c "echo $(echo %h | sed s/.shortcut2//).my-other-long-domain-name.com" 85 | 86 | "*.scw": 87 | # ssh toto.scw -> 1. dynamically resolves the IP address 88 | # 2. ssh {resolved ip address} -u root -p 22 -o UserKnownHostsFile=null -o StrictHostKeyChecking=no 89 | # requires github.com/scaleway/scaleway-cli 90 | ResolveCommand: /bin/sh -c "scw inspect -f {{.PublicAddress.IP}} server:$(echo %h | sed s/.scw//)" 91 | User: root 92 | Port: 22 93 | UserKnownHostsFile: /dev/null 94 | StrictHostKeyChecking: no 95 | 96 | my-env-host: 97 | User: user-$USER 98 | Hostname: ${HOSTNAME}${HOSTNAME_SUFFIX} 99 | 100 | templates: 101 | # Templates are similar to Hosts, you can inherits from them 102 | # but you cannot ssh to a template 103 | bart-template: 104 | User: bart 105 | lisa-template: 106 | User: lisa 107 | simpson-template: 108 | Host: home.simpson.springfield.us 109 | 110 | defaults: 111 | # Defaults are applied to each hosts 112 | ControlMaster: auto 113 | ControlPath: ~/tmp/.ssh/cm/%h-%p-%r.sock 114 | ControlPersist: yes 115 | Port: 22 116 | User: bob 117 | 118 | includes: 119 | #- ~/.ssh/assh.d/*.yml 120 | - /etc/assh.yml 121 | - $ENV_VAR/blah-blah-*/*.yml 122 | 123 | ASSHBinaryPath: ~/bin/assh # optionally set the path of assh 124 | -------------------------------------------------------------------------------- /contrib/generate-loggers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | for pkg in $(git grep func\ | grep .go: | cut -d: -f1 | uniq | xargs -n1 dirname | uniq); do 4 | echo "+ generate $pkg/logger.gen.go" 5 | logname="assh."$(echo $pkg | tr / .) 6 | pkgname=$(basename $pkg) 7 | 8 | if [ "$pkg" = "api/node/graphql/models" ]; then 9 | logname="vendor.graphql.models" 10 | fi 11 | 12 | if grep "package main" $pkg/*.go >/dev/null 2>/dev/null; then 13 | pkgname="main" 14 | fi 15 | 16 | logname="$(echo $logname | sed 's/\.\././g;s/\.$//')" 17 | 18 | cat > $pkg/logger.gen.go < :build 12 | 13 | def install 14 | ENV["GOPATH"] = buildpath 15 | ENV["GOBIN"] = buildpath 16 | ENV["GO15VENDOREXPERIMENT"] = "1" 17 | (buildpath/"src/github.com/moul/advanced-ssh-config").install Dir["*"] 18 | 19 | system "go", "build", "-o", "#{bin}/assh", "-v", "github.com/moul/advanced-ssh-config/cmd/assh/" 20 | 21 | bash_completion.install "src/github.com/moul/advanced-ssh-config/contrib/completion/bash_autocomplete" 22 | zsh_completion.install "src/github.com/moul/advanced-ssh-config/contrib/completion/zsh_autocomplete" 23 | end 24 | 25 | test do 26 | output = shell_output(bin/"assh --version") 27 | assert output.include? "assh version 2" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /contrib/webapp/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package main 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.contrib.webapp") 9 | } 10 | -------------------------------------------------------------------------------- /contrib/webapp/main.go: -------------------------------------------------------------------------------- 1 | package main // import "moul.io/assh/v2/contrib/webapp" 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/urfave/cli" 12 | "moul.io/assh/v2/pkg/config" 13 | ) 14 | 15 | func main() { 16 | app := cli.NewApp() 17 | app.Flags = []cli.Flag{ 18 | cli.StringFlag{ 19 | Name: "bind-address", 20 | Value: ":8080", 21 | }, 22 | } 23 | app.Action = server 24 | if err := app.Run(os.Args); err != nil { 25 | log.Fatalf("cannot run app: %v", err) 26 | } 27 | } 28 | 29 | func server(c *cli.Context) error { 30 | router := gin.Default() 31 | router.GET("/ping", func(c *gin.Context) { c.String(200, "pong") }) 32 | router.POST("/assh-to-ssh", func(c *gin.Context) { 33 | var ( 34 | err error 35 | cfg = config.New() 36 | buffer bytes.Buffer 37 | json struct { 38 | AsshConfig string `form:"assh_config" json:"assh_config"` 39 | } 40 | ) 41 | 42 | if err = c.BindJSON(&json); err != nil { 43 | goto serverEnd 44 | } 45 | 46 | if json.AsshConfig == "" { 47 | err = fmt.Errorf("invalid input") 48 | goto serverEnd 49 | } 50 | 51 | if err = cfg.LoadConfig(strings.NewReader(json.AsshConfig)); err != nil { 52 | goto serverEnd 53 | } 54 | 55 | if err = cfg.WriteSSHConfigTo(&buffer); err != nil { 56 | goto serverEnd 57 | } 58 | 59 | serverEnd: 60 | if err != nil { 61 | c.JSON(500, gin.H{ 62 | "error": err.Error(), 63 | }) 64 | } else { 65 | c.JSON(200, gin.H{ 66 | // "assh_config": json.AsshConfig, 67 | "ssh_config": buffer.String(), 68 | }) 69 | } 70 | }) 71 | return router.Run(c.String("bind-address")) 72 | } 73 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | package main // import "moul.io/assh/v2" 2 | -------------------------------------------------------------------------------- /examples/gateways-wildcard/assh.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | customer*: 3 | hostname: 1.1.1.1 4 | tricky: 5 | gateways: customerterm 6 | -------------------------------------------------------------------------------- /examples/gateways-wildcard/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | "tricky"->"customer*"[ color=red, label="customerterm" ]; 3 | "customer*" [ color=blue ]; 4 | "tricky" [ color=blue ]; 5 | 6 | } 7 | 8 | -------------------------------------------------------------------------------- /examples/gateways-wildcard/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/gateways-wildcard/graphviz.png -------------------------------------------------------------------------------- /examples/gateways-wildcard/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | Host customer* 8 | # HostName: 1.1.1.1 9 | 10 | Host tricky 11 | # Gateways: [customerterm] 12 | 13 | # global configuration 14 | Host * 15 | ProxyCommand /home/moul/go/bin/assh connect --port=%p %h 16 | -------------------------------------------------------------------------------- /examples/readme-example-gateways/assh.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | hosta: 3 | Hostname: 1.2.3.4 4 | 5 | hostb: 6 | Hostname: 5.6.7.8 7 | Gateways: hosta 8 | 9 | hostc: 10 | Hostname: 9.10.11.12 11 | Gateways: hostb 12 | 13 | hostd: 14 | Hostname: 13.14.15.16 15 | Gateways: 16 | - direct 17 | - hosta -------------------------------------------------------------------------------- /examples/readme-example-gateways/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | "hostc"->"hostb"[ color=red, label=1 ]; 3 | "hostd"->"hosta"[ color=red, label=1 ]; 4 | "hostb"->"hosta"[ color=red, label=1 ]; 5 | "hosta" [ color=blue ]; 6 | "hostb" [ color=blue ]; 7 | "hostc" [ color=blue ]; 8 | "hostd" [ color=blue ]; 9 | 10 | } 11 | 12 | -------------------------------------------------------------------------------- /examples/readme-example-gateways/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/readme-example-gateways/graphviz.png -------------------------------------------------------------------------------- /examples/readme-example-gateways/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | Host hosta 8 | # HostName: 1.2.3.4 9 | 10 | Host hostb 11 | # HostName: 5.6.7.8 12 | # Gateways: [hosta] 13 | 14 | Host hostc 15 | # HostName: 9.10.11.12 16 | # Gateways: [hostb] 17 | 18 | Host hostd 19 | # HostName: 13.14.15.16 20 | # Gateways: [direct, hosta] 21 | 22 | # global configuration 23 | Host * 24 | ProxyCommand /home/moul/go/bin/assh connect --port=%p %h 25 | -------------------------------------------------------------------------------- /examples/readme-full-example/assh.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | 3 | homer: 4 | # ssh homer -> ssh 1.2.3.4 -p 2222 -u robert 5 | Hostname: 1.2.3.4 6 | User: robert 7 | Port: 2222 8 | 9 | bart: 10 | # ssh bart -> ssh 5.6.7.8 -u bart <- direct access 11 | # or ssh 5.6.7.8/homer -u bart <- using homer as a gateway 12 | Hostname: 5.6.7.8 13 | User: bart 14 | Gateways: 15 | - direct # tries a direct access first 16 | - homer # fallback on homer gateway 17 | 18 | maggie: 19 | # ssh maggie -> ssh 5.6.7.8 -u maggie <- direct access 20 | # or ssh 5.6.7.8/homer -u maggie <- using homer as a gateway 21 | User: maggie 22 | Inherits: bart # inherits rules from "bart" 23 | 24 | bart-access: 25 | # ssh bart-access -> ssh home.simpson.springfield.us -u bart 26 | Inherits: 27 | - bart-template 28 | - simpson-template 29 | 30 | lisa-access: 31 | # ssh lisa-access -> ssh home.simpson.springfield.us -u lisa 32 | Inherits: 33 | - lisa-template 34 | - simpson-template 35 | 36 | marvin: 37 | # ssh marvin -> ssh marvin -p 23 38 | # ssh sad-robot -> ssh sad-robot -p 23 39 | # ssh bighead -> ssh bighead -p 23 40 | # aliases inherit everything from marvin, except hostname 41 | Port: 23 42 | Aliases: 43 | - sad-robot 44 | - bighead 45 | 46 | dolphin: 47 | # ssh dolphin -> ssh dolphin -p 24 48 | # ssh ecco -> ssh dolphin -p 24 49 | # same as above, but with fixed hostname 50 | Port: 24 51 | Hostname: dolphin 52 | Aliases: ecco 53 | RateLimit: 10M # 10Mbytes/second rate limiting 54 | 55 | schooltemplate: 56 | User: student 57 | IdentityFile: ~/.ssh/school-rsa 58 | ForwardX11: yes 59 | 60 | schoolgw: 61 | # ssh school -> ssh gw.school.com -l student -o ForwardX11=no -i ~/.ssh/school-rsa 62 | Hostname: gw.school.com 63 | ForwardX11: no 64 | Inherits: schooltemplate 65 | 66 | "expanded-host[0-7]*": 67 | # ssh somehost2042 -> ssh somehost2042.some.zone 68 | Hostname: "%h.some.zone" 69 | 70 | vm-*.school.com: 71 | # ssh vm-42.school.com -> ssh vm-42.school.com/gw.school.com -l student -o ForwardX11=yes -i ~/.ssh/school-rsa 72 | Gateways: schoolgw 73 | Inherits: schooltemplate 74 | # do not automatically create `ControlPath` -> may result in error 75 | ControlMasterMkdir: true 76 | 77 | "*.shortcut1": 78 | ResolveCommand: /bin/sh -c "echo %h | sed s/.shortcut1/.my-long-domain-name.com/" 79 | 80 | "*.shortcut2": 81 | ResolveCommand: /bin/sh -c "echo $(echo %h | sed s/.shortcut2//).my-other-long-domain-name.com" 82 | 83 | "*.scw": 84 | # ssh toto.scw -> 1. dynamically resolves the IP address 85 | # 2. ssh {resolved ip address} -u root -p 22 -o UserKnownHostsFile=null -o StrictHostKeyChecking=no 86 | # requires github.com/scaleway/scaleway-cli 87 | ResolveCommand: /bin/sh -c "scw inspect -f {{.PublicAddress.IP}} server:$(echo %h | sed s/.scw//)" 88 | User: root 89 | Port: 22 90 | UserKnownHostsFile: /dev/null 91 | StrictHostKeyChecking: no 92 | 93 | my-env-host: 94 | User: user-$USER 95 | Hostname: ${HOSTNAME}${HOSTNAME_SUFFIX} 96 | 97 | templates: 98 | # Templates are similar to Hosts; you can inherit from them 99 | # but you cannot ssh to a template 100 | bart-template: 101 | User: bart 102 | lisa-template: 103 | User: lisa 104 | simpson-template: 105 | Host: home.simpson.springfield.us 106 | 107 | defaults: 108 | # Defaults are applied to each hosts 109 | ControlMaster: auto 110 | ControlPath: ~/tmp/.ssh/cm/%h-%p-%r.sock 111 | ControlPersist: yes 112 | Port: 22 113 | User: bob 114 | Hooks: 115 | # Automatically backup ~/.ssh/config 116 | BeforeConfigWrite: 117 | - 'exec set -x; cp {{.SSHConfigPath}} {{.SSHConfigPath}}.bkp' 118 | 119 | AfterConfigWrite: 120 | # Concat another `ssh_config` file with the one just generated by `assh` 121 | - 'exec cat ~/.ssh/my-heroku-generated-config >> {{.SSHConfigPath}}' 122 | 123 | # Alert me with a Desktop notification 124 | - notify "{{.SSHConfigPath}} has been rewritten" 125 | 126 | OnConnect: 127 | # Log internal information to a file 128 | - 'exec echo {{.}} | jq . >> ~/.ssh/last_connected_host.txt' 129 | 130 | # Alert me with a Desktop notification 131 | - notify New SSH connection to {{.Host.Prototype}} at {{.Stats.ConnectedAt}} 132 | 133 | # Write the host prototype to the terminal stderr 134 | - write New SSH connection to {{.Host.Prototype}} 135 | 136 | OnDisconnect: 137 | # write on terminal and in a Desktop notification some statistics about the finished connection 138 | - "write SSH connection to {{.Host.HostName}} closed, {{.Stats.WrittenBytes }} bytes written in {{.Stats.ConnectionDuration}} ({{.Stats.AverageSpeed}}bps)" 139 | - "notify SSH connection to {{.Host.HostName}} closed, {{.Stats.WrittenBytes }} bytes written in {{.Stats.ConnectionDuration}} ({{.Stats.AverageSpeed}}bps)" 140 | 141 | includes: 142 | #- ~/.ssh/assh.d/*.yml 143 | #- /etc/assh.yml 144 | - $ENV_VAR/blah-blah-*/*.yml 145 | 146 | ASSHBinaryPath: ~/bin/assh # optionally set the path of assh 147 | -------------------------------------------------------------------------------- /examples/readme-full-example/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | "vm-*.school.com"->"schoolgw"[ color=red, label=1 ]; 3 | "vm-*.school.com"->"schooltemplate"[ color=black, style=dashed ]; 4 | "bart"->"homer"[ color=red, label=1 ]; 5 | "bart" [ color=blue ]; 6 | "homer" [ color=blue ]; 7 | "schoolgw" [ color=blue ]; 8 | "schooltemplate" [ color=blue ]; 9 | "vm-*.school.com" [ color=blue ]; 10 | 11 | } 12 | 13 | -------------------------------------------------------------------------------- /examples/readme-full-example/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/readme-full-example/graphviz.png -------------------------------------------------------------------------------- /examples/readme-full-example/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | Host *.scw 8 | Port 22 9 | StrictHostKeyChecking no 10 | User root 11 | UserKnownHostsFile /dev/null 12 | # ResolveCommand: /bin/sh -c "scw inspect -f {{.PublicAddress.IP}} server:$(echo %h | sed s/.scw//)" 13 | 14 | Host *.shortcut1 15 | # ResolveCommand: /bin/sh -c "echo %h | sed s/.shortcut1/.my-long-domain-name.com/" 16 | 17 | Host *.shortcut2 18 | # ResolveCommand: /bin/sh -c "echo $(echo %h | sed s/.shortcut2//).my-other-long-domain-name.com" 19 | 20 | Host bart 21 | User bart 22 | # HostName: 5.6.7.8 23 | # Gateways: [direct, homer] 24 | 25 | Host bart-access 26 | Port 22 27 | User bart 28 | # Inherits: [bart-template, simpson-template] 29 | 30 | Host dolphin 31 | Port 24 32 | # HostName: dolphin 33 | # Aliases: [ecco] 34 | # RateLimit: 10M 35 | 36 | Host ecco 37 | Port 24 38 | # HostName: dolphin 39 | # AliasOf: dolphin 40 | # RateLimit: 10M 41 | 42 | Host expanded-host[0-7]* 43 | # HostName: %h.some.zone 44 | 45 | Host homer 46 | Port 2222 47 | User robert 48 | # HostName: 1.2.3.4 49 | 50 | Host lisa-access 51 | Port 22 52 | User lisa 53 | # Inherits: [lisa-template, simpson-template] 54 | 55 | Host maggie 56 | Port 22 57 | User maggie 58 | # HostName: 5.6.7.8 59 | # Inherits: [bart] 60 | # Gateways: [direct, homer] 61 | 62 | Host marvin 63 | Port 23 64 | # Aliases: [sad-robot, bighead] 65 | 66 | Host sad-robot 67 | Port 23 68 | # AliasOf: marvin 69 | 70 | Host bighead 71 | Port 23 72 | # AliasOf: marvin 73 | 74 | Host my-env-host 75 | User user-$USER 76 | # HostName: ${HOSTNAME}${HOSTNAME_SUFFIX} 77 | 78 | Host schoolgw 79 | ForwardX11 no 80 | IdentityFile ~/.ssh/school-rsa 81 | Port 22 82 | User student 83 | # HostName: gw.school.com 84 | # Inherits: [schooltemplate] 85 | 86 | Host schooltemplate 87 | ForwardX11 yes 88 | IdentityFile ~/.ssh/school-rsa 89 | User student 90 | 91 | Host vm-*.school.com 92 | ForwardX11 yes 93 | IdentityFile ~/.ssh/school-rsa 94 | Port 22 95 | User student 96 | # ControlMasterMkdir: true 97 | # Inherits: [schooltemplate] 98 | # Gateways: [schoolgw] 99 | 100 | # global configuration 101 | Host * 102 | ControlMaster auto 103 | ControlPath ~/tmp/.ssh/cm/%h-%p-%r.sock 104 | ControlPersist yes 105 | Port 22 106 | User bob 107 | ProxyCommand /home/moul/bin/assh connect --port=%p %h 108 | # Hooks: {"AfterConfigWrite":["exec cat ~/.ssh/my-heroku-generated-config \u003e\u003e {{.SSHConfigPath}}","notify \"{{.SSHConfigPath}} has been rewritten\""],"BeforeConfigWrite":["exec set -x; cp {{.SSHConfigPath}} {{.SSHConfigPath}}.bkp"],"OnConnect":["exec echo {{.}} | jq . \u003e\u003e ~/.ssh/last_connected_host.txt","notify New SSH connection to {{.Host.Prototype}} at {{.Stats.ConnectedAt}}","write New SSH connection to {{.Host.Prototype}}"],"OnDisconnect":["write SSH connection to {{.Host.HostName}} closed, {{.Stats.WrittenBytes }} bytes written in {{.Stats.ConnectionDuration}} ({{.Stats.AverageSpeed}}bps)","notify SSH connection to {{.Host.HostName}} closed, {{.Stats.WrittenBytes }} bytes written in {{.Stats.ConnectionDuration}} ({{.Stats.AverageSpeed}}bps)"]} 109 | -------------------------------------------------------------------------------- /examples/readme-troubleshooting-gateways/assh.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | myserver: 3 | host: 1.2.3.4 4 | gateways: mygateway 5 | # configure a custom proxycommand 6 | proxycommand: /bin/nc %h %p 7 | 8 | mygateway: 9 | host: 5.6.7.8 10 | -------------------------------------------------------------------------------- /examples/readme-troubleshooting-gateways/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | "myserver"->"mygateway"[ color=red, label=1 ]; 3 | "mygateway" [ color=blue ]; 4 | "myserver" [ color=blue ]; 5 | 6 | } 7 | 8 | -------------------------------------------------------------------------------- /examples/readme-troubleshooting-gateways/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/readme-troubleshooting-gateways/graphviz.png -------------------------------------------------------------------------------- /examples/readme-troubleshooting-gateways/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | Host mygateway 8 | 9 | Host myserver 10 | # ProxyCommand /bin/nc %h %p 11 | # Gateways: [mygateway] 12 | 13 | # global configuration 14 | Host * 15 | ProxyCommand /home/moul/go/bin/assh connect --port=%p %h 16 | -------------------------------------------------------------------------------- /examples/resolvecommand-with-gateway/assh.yml: -------------------------------------------------------------------------------- 1 | defaults: 2 | ResolveCommand: echo h=%h p=%p name=%name n=%n g=%g 3 | -------------------------------------------------------------------------------- /examples/resolvecommand-with-gateway/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | 3 | } 4 | 5 | -------------------------------------------------------------------------------- /examples/resolvecommand-with-gateway/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/resolvecommand-with-gateway/graphviz.png -------------------------------------------------------------------------------- /examples/resolvecommand-with-gateway/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | # global configuration 8 | Host * 9 | ProxyCommand /home/moul/go/bin/assh connect --port=%p %h 10 | # ResolveCommand: echo h=%h p=%p name=%name n=%n g=%g 11 | -------------------------------------------------------------------------------- /examples/resolvecommand-with-gateway/test.log: -------------------------------------------------------------------------------- 1 | proxy {"target": "a/b/c"} 2 | config file {"file": "./assh.yml"} 3 | config file {"file": "./assh.yml", "num-host-before": 0, "num-host-after": 0, "num-host-diff": 0} 4 | to load assh known_hosts {"error": "open /home/moul/.ssh/assh_known_hosts: no such file or directory"} 5 | check if ~/.ssh/config is outdated {"error": "open /home/moul/.ssh/assh_known_hosts: no such file or directory"} 6 | moul.io/assh/pkg/commands.runProxyCommand 7 | /home/moul/go/src/moul.io/assh/pkg/commands/proxy.go:83 8 | github.com/spf13/cobra.(*Command).execute 9 | /home/moul/go/pkg/mod/github.com/spf13/cobra@v0.0.4/command.go:762 10 | github.com/spf13/cobra.(*Command).ExecuteC 11 | /home/moul/go/pkg/mod/github.com/spf13/cobra@v0.0.4/command.go:850 12 | github.com/spf13/cobra.(*Command).Execute 13 | /home/moul/go/pkg/mod/github.com/spf13/cobra@v0.0.4/command.go:800 14 | main.main 15 | /home/moul/go/src/moul.io/assh/main.go:13 16 | runtime.main 17 | /usr/lib/go1.12/src/runtime/proc.go:200 18 | ssh config file {"buffer": "Host a\n Port 22\n # HostName: a\n # Gateways: [b/c]\n # ResolveCommand: echo h=%h p=%p name=%name n=%n g=%g\n"} 19 | "{\"Port\":\"22\",\"HostName\":\"a\",\"Gateways\":[\"b/c\"],\"ResolveCommand\":\"echo h=%h p=%p name=%name n=%n g=%g\",\"Hooks\":{}}"} 20 | 2019-05-30T20:35:24.286+0200 DEBUG assh.pkg.commands commands/proxy.go:142 Proxying 21 | gateways {"gateways": "b/c"} 22 | 20:35:24 b/c 23 | host {"hostname": "a", "resolve-command": "echo h=%h p=%p name=%name n=%n g=%g"} 24 | host {"hostname": "h=a p=22 name=a n=a g=b/c"} 25 | gateway {"gateway": "b/c", "command": "ssh -W h=a p=22 name=a n=a g=b/c:22 %name"} 26 | "ssh -W h=a p=22 name=a n=a g=b/c:22 b/c"} 27 | use gateway {"gateway": "b/c", "error": "dry-run: Execute [ssh -W h=a p=22 name=a n=a g=b/c:22 b/c]"} 28 | moul.io/assh/pkg/commands.proxy 29 | /home/moul/go/src/moul.io/assh/pkg/commands/proxy.go:270 30 | moul.io/assh/pkg/commands.runProxyCommand 31 | /home/moul/go/src/moul.io/assh/pkg/commands/proxy.go:143 32 | github.com/spf13/cobra.(*Command).execute 33 | /home/moul/go/pkg/mod/github.com/spf13/cobra@v0.0.4/command.go:762 34 | github.com/spf13/cobra.(*Command).ExecuteC 35 | /home/moul/go/pkg/mod/github.com/spf13/cobra@v0.0.4/command.go:850 36 | github.com/spf13/cobra.(*Command).Execute 37 | /home/moul/go/pkg/mod/github.com/spf13/cobra@v0.0.4/command.go:800 38 | main.main 39 | /home/moul/go/src/moul.io/assh/main.go:13 40 | runtime.main 41 | /usr/lib/go1.12/src/runtime/proc.go:200 42 | no such available gateway 43 | Usage: 44 | assh connect [flags] 45 | 46 | Examples: 47 | is a host. 48 | 49 | Flags: 50 | --dry-run Only show how assh would connect but don't actually do it 51 | -h, --help help for connect 52 | --no-rewrite Do not automatically rewrite outdated configuration 53 | -p, --port int SSH destination port 54 | 55 | such available gateway 56 | -------------------------------------------------------------------------------- /examples/resolvecommand-with-gateway/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | assh -D -c ./assh.yml connect --dry-run a/b/c 2>&1 | cut -d\ -f 2- > test.log || true 4 | -------------------------------------------------------------------------------- /examples/same-option-multiple-times/assh.yml: -------------------------------------------------------------------------------- 1 | # based on https://github.com/moul/assh/issues/248 2 | 3 | defaults: 4 | ControlMaster: auto 5 | ControlPath: ~/tmp/.ssh/%h-%p-%r.sock 6 | ControlPersist: yes 7 | IdentityFile: 8 | - /Users/1/.ssh/1/id_rsa 9 | - /Users/2/.ssh/2/id_rsa 10 | - /Users/3/.ssh/3/id_rsa 11 | UseKeychain: yes 12 | Port: 22 13 | StrictHostkeychecking: no 14 | Forwardx11: no 15 | ForwardAgent: yes 16 | Protocol: 2 17 | ServerAliveInterval: 5 18 | -------------------------------------------------------------------------------- /examples/same-option-multiple-times/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | 3 | } 4 | 5 | -------------------------------------------------------------------------------- /examples/same-option-multiple-times/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/same-option-multiple-times/graphviz.png -------------------------------------------------------------------------------- /examples/same-option-multiple-times/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | # global configuration 8 | Host * 9 | ControlMaster auto 10 | ControlPath ~/tmp/.ssh/%h-%p-%r.sock 11 | ControlPersist yes 12 | ForwardAgent yes 13 | ForwardX11 no 14 | IdentityFile /Users/1/.ssh/1/id_rsa 15 | IdentityFile /Users/2/.ssh/2/id_rsa 16 | IdentityFile /Users/3/.ssh/3/id_rsa 17 | Port 22 18 | Protocol 2 19 | ServerAliveInterval 5 20 | StrictHostKeyChecking no 21 | UseKeychain yes 22 | ProxyCommand /home/moul/go/bin/assh connect --port=%p %h 23 | -------------------------------------------------------------------------------- /examples/test-263/assh.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | a: # should not work 3 | hostname: 1.2.3.4 4 | b: # should work directly 5 | c: # should work directly 6 | gateways: [direct] 7 | d: # should fail via a, then work via b 8 | gateways: [a, b] 9 | e: # should work via b, should not test a 10 | gateways: [b, a] 11 | f: # should work via b, only once 12 | gateways: [b, b] 13 | g: # should work directly, only once 14 | gateways: [direct, b] 15 | h: # should work via a, should not try direct 16 | gateways: [a, direct] 17 | 18 | defaults: 19 | hostname: 127.0.0.1 # should work 20 | connecttimeout: 1 -------------------------------------------------------------------------------- /examples/test-263/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | "f"->"b"[ color=red, label=1 ]; 3 | "f"->"b"[ color=red, label=2 ]; 4 | "g"->"b"[ color=red, label=1 ]; 5 | "h"->"a"[ color=red, label=1 ]; 6 | "d"->"a"[ color=red, label=1 ]; 7 | "d"->"b"[ color=red, label=2 ]; 8 | "e"->"b"[ color=red, label=1 ]; 9 | "e"->"a"[ color=red, label=2 ]; 10 | "a" [ color=blue ]; 11 | "b" [ color=blue ]; 12 | "c" [ color=blue ]; 13 | "d" [ color=blue ]; 14 | "e" [ color=blue ]; 15 | "f" [ color=blue ]; 16 | "g" [ color=blue ]; 17 | "h" [ color=blue ]; 18 | 19 | } 20 | 21 | -------------------------------------------------------------------------------- /examples/test-263/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/test-263/graphviz.png -------------------------------------------------------------------------------- /examples/test-263/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | Host a 8 | # HostName: 1.2.3.4 9 | 10 | Host b 11 | 12 | Host c 13 | # Gateways: [direct] 14 | 15 | Host d 16 | # Gateways: [a, b] 17 | 18 | Host e 19 | # Gateways: [b, a] 20 | 21 | Host f 22 | # Gateways: [b, b] 23 | 24 | Host g 25 | # Gateways: [direct, b] 26 | 27 | Host h 28 | # Gateways: [a, direct] 29 | 30 | # global configuration 31 | Host * 32 | ConnectTimeout 1 33 | ProxyCommand /home/moul/go/bin/assh connect --port=%p %h 34 | # HostName: 127.0.0.1 35 | -------------------------------------------------------------------------------- /examples/test-289/assh.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | 3 | hosta: 4 | HostName: 1.2.3.4 # homerouter 5 | 6 | hostb: 7 | HostName: 192.168.1.3 # my raspi 8 | User: pi 9 | Gateways: [ direct, hosta ] 10 | GatewayConnectTimeout: 1 11 | -------------------------------------------------------------------------------- /examples/test-289/graphviz.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | "hostb"->"hosta"[ color=red, label=1 ]; 3 | "hosta" [ color=blue ]; 4 | "hostb" [ color=blue ]; 5 | 6 | } 7 | 8 | -------------------------------------------------------------------------------- /examples/test-289/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/examples/test-289/graphviz.png -------------------------------------------------------------------------------- /examples/test-289/ssh_config: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by assh v2.8.0+dev 2 | # on 2019-05-30 20:35:24 +0200 CEST, based on ~/.ssh/assh.yml 3 | # 4 | # more info: https://github.com/moul/assh 5 | 6 | # host-based configuration 7 | Host hosta 8 | # HostName: 1.2.3.4 9 | 10 | Host hostb 11 | User pi 12 | # HostName: 192.168.1.3 13 | # Gateways: [direct, hosta] 14 | # GatewayConnectTimeout: 1 15 | 16 | # global configuration 17 | Host * 18 | ProxyCommand /home/moul/go/bin/assh connect --port=%p %h 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module moul.io/assh/v2 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/Masterminds/goutils v1.1.0 // indirect 7 | github.com/Masterminds/semver v1.5.0 // indirect 8 | github.com/Masterminds/sprig v2.22.0+incompatible 9 | github.com/awalterschulze/gographviz v2.0.3+incompatible 10 | github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b 11 | github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb // indirect 12 | github.com/docker/docker v1.13.1 // indirect 13 | github.com/docker/go-units v0.5.0 14 | github.com/docker/libcompose v0.4.0 15 | github.com/dustin/go-humanize v1.0.0 16 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 17 | github.com/gin-gonic/gin v1.7.7 18 | github.com/guelfey/go.dbus v0.0.0-20131113121618-f6a3a2366cc3 // indirect 19 | github.com/haklop/gnotifier v0.0.0-20140909091139-0de36badf601 20 | github.com/huandu/xstrings v1.3.1 // indirect 21 | github.com/imdario/mergo v0.3.12 22 | github.com/mattn/go-colorable v0.1.6 // indirect 23 | github.com/mattn/go-zglob v0.0.3 24 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d 25 | github.com/mitchellh/copystructure v1.0.0 // indirect 26 | github.com/mitchellh/reflectwalk v1.0.1 // indirect 27 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 28 | github.com/moul/flexyaml v0.0.0-20171225152558-f458bfa8afe2 29 | github.com/pkg/errors v0.9.1 30 | github.com/shirou/gopsutil v3.21.11+incompatible 31 | github.com/smartystreets/goconvey v1.7.2 32 | github.com/spf13/cobra v1.4.0 33 | github.com/spf13/pflag v1.0.5 34 | github.com/spf13/viper v1.8.1 35 | github.com/tklauser/go-sysconf v0.3.9 // indirect 36 | github.com/urfave/cli v1.22.9 37 | github.com/yusufpapurcu/wmi v1.2.2 // indirect 38 | go.uber.org/zap v1.21.0 39 | golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 40 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 41 | golang.org/x/text v0.3.5 42 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e 43 | ) 44 | -------------------------------------------------------------------------------- /logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package main 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh") 9 | } 10 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:generate sh -c "cd contrib/completion/gen && go run main.go" 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | 9 | "moul.io/assh/v2/pkg/commands" 10 | ) 11 | 12 | func main() { 13 | if err := commands.RootCmd.Execute(); err != nil { 14 | _, _ = fmt.Fprintf(os.Stderr, "%v\n", err) 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/commands/build.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "moul.io/assh/v2/pkg/config" 12 | ) 13 | 14 | var buildConfigCommand = &cobra.Command{ 15 | Use: "build", 16 | Short: "Build .ssh/config", 17 | RunE: runBuildConfigCommand, 18 | } 19 | 20 | var buildJSONConfigCommand = &cobra.Command{ 21 | Use: "json", 22 | Short: "Returns the JSON output", 23 | RunE: runBuildJSONConfigCommand, 24 | } 25 | 26 | // nolint:gochecknoinits 27 | func init() { 28 | buildConfigCommand.Flags().BoolP("no-automatic-rewrite", "", false, "Disable automatic ~/.ssh/config file regeneration") 29 | buildConfigCommand.Flags().BoolP("expand", "e", false, "Expand all fields") 30 | buildConfigCommand.Flags().BoolP("ignore-known-hosts", "", false, "Ignore known-hosts file") 31 | _ = viper.BindPFlags(buildConfigCommand.Flags()) 32 | 33 | buildJSONConfigCommand.Flags().BoolP("expand", "e", false, "Expand all fields") 34 | _ = viper.BindPFlags(buildJSONConfigCommand.Flags()) 35 | } 36 | 37 | func runBuildConfigCommand(cmd *cobra.Command, args []string) error { 38 | conf, err := config.Open(viper.GetString("config")) 39 | if err != nil { 40 | return errors.Wrap(err, "failed to open config file") 41 | } 42 | 43 | if viper.GetBool("expand") { 44 | for name := range conf.Hosts { 45 | conf.Hosts[name], err = conf.GetHost(name) 46 | if err != nil { 47 | return errors.Wrap(err, "failed to expand hosts") 48 | } 49 | } 50 | } 51 | 52 | if !viper.GetBool("ignore-known-hosts") { 53 | if conf.KnownHostsFileExists() == nil { 54 | if err := conf.LoadKnownHosts(); err != nil { 55 | return errors.Wrap(err, "failed to load known-hosts file") 56 | } 57 | } 58 | } 59 | 60 | if viper.GetBool("no-automatic-rewrite") { 61 | conf.DisableAutomaticRewrite() 62 | } 63 | return conf.WriteSSHConfigTo(os.Stdout) 64 | } 65 | 66 | func runBuildJSONConfigCommand(cmd *cobra.Command, args []string) error { 67 | conf, err := config.Open(viper.GetString("config")) 68 | if err != nil { 69 | return errors.Wrap(err, "failed to open configuration file") 70 | } 71 | 72 | if viper.GetBool("expand") { 73 | for name := range conf.Hosts { 74 | conf.Hosts[name], err = conf.GetHost(name) 75 | if err != nil { 76 | return errors.Wrap(err, "failed to expand hosts") 77 | } 78 | } 79 | } 80 | 81 | s, err := json.MarshalIndent(conf, "", " ") 82 | if err != nil { 83 | return errors.Wrap(err, "failed to marshal config") 84 | } 85 | 86 | fmt.Println(string(s)) 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | 8 | "moul.io/assh/v2/pkg/utils" 9 | 10 | "github.com/pkg/errors" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | "go.uber.org/zap" 14 | "go.uber.org/zap/zapcore" 15 | "moul.io/assh/v2/pkg/config" 16 | loggerpkg "moul.io/assh/v2/pkg/logger" 17 | "moul.io/assh/v2/pkg/version" 18 | ) 19 | 20 | var commands = []*cobra.Command{ 21 | pingCommand, 22 | proxyCommand, 23 | infoCommand, 24 | configCommand, 25 | socketsCommand, 26 | wrapperCommand, 27 | } 28 | 29 | // RootCmd is the root cobra command containing all commands for assh. 30 | var RootCmd = &cobra.Command{ 31 | Use: "assh", 32 | Short: "assh - advanced ssh config", 33 | Version: version.Version + " (" + version.VcsRef + ")", 34 | TraverseChildren: true, 35 | } 36 | 37 | // nolint:gochecknoinits 38 | func init() { 39 | ex, err := os.Executable() 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | abspath, err := filepath.Abs(ex) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | abspath = filepath.ToSlash(abspath) 48 | abspath = utils.EscapeSpaces(abspath) 49 | config.SetASSHBinaryPath(abspath) 50 | 51 | RootCmd.Flags().BoolP("help", "h", false, "print usage") 52 | RootCmd.Flags().StringP("config", "c", "~/.ssh/assh.yml", "Location of config file") 53 | RootCmd.Flags().BoolP("debug", "D", false, "Enable debug mode") 54 | RootCmd.Flags().BoolP("verbose", "V", false, "Enable verbose mode") 55 | 56 | _ = viper.BindEnv("debug", "ASSH_DEBUG") 57 | _ = viper.BindEnv("config", "ASSH_CONFIG") 58 | _ = viper.BindPFlags(RootCmd.Flags()) 59 | 60 | RootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { 61 | if viper.GetBool("debug") { 62 | if err := os.Setenv("ASSH_DEBUG", "1"); err != nil { 63 | return err 64 | } 65 | } 66 | if err := initLogging(viper.GetBool("debug"), viper.GetBool("verbose")); err != nil { 67 | return err 68 | } 69 | return nil 70 | } 71 | 72 | RootCmd.AddCommand(commands...) 73 | } 74 | 75 | func initLogging(debug bool, verbose bool) error { 76 | config := zap.NewDevelopmentConfig() 77 | config.Level.SetLevel(loggerpkg.MustLogLevel(debug, verbose)) 78 | if !debug { 79 | config.DisableStacktrace = true 80 | config.DisableCaller = true 81 | config.EncoderConfig.TimeKey = "" 82 | config.EncoderConfig.NameKey = "" 83 | } 84 | config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 85 | l, err := config.Build() 86 | if err != nil { 87 | return errors.Wrap(err, "failed to initialize logger") 88 | } 89 | zap.ReplaceGlobals(l) 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/commands/config.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | var configCommand = &cobra.Command{ 6 | Use: "config", 7 | Short: "Manage ssh and assh configuration", 8 | } 9 | 10 | // nolint:gochecknoinits 11 | func init() { 12 | configCommand.AddCommand(buildConfigCommand) 13 | configCommand.AddCommand(buildJSONConfigCommand) 14 | configCommand.AddCommand(listConfigCommand) 15 | configCommand.AddCommand(graphvizConfigCommand) 16 | configCommand.AddCommand(searchConfigCommand) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/commands/control-sockets.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "time" 8 | 9 | units "github.com/docker/go-units" 10 | "github.com/pkg/errors" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | "go.uber.org/zap" 14 | "moul.io/assh/v2/pkg/config" 15 | "moul.io/assh/v2/pkg/controlsockets" 16 | ) 17 | 18 | var socketsCommand = &cobra.Command{ 19 | Use: "sockets", 20 | Short: "Manage control sockets", 21 | } 22 | 23 | var listSocketsCommand = &cobra.Command{ 24 | Use: "list", 25 | Short: "List active control sockets", 26 | RunE: runListSocketsCommand, 27 | } 28 | 29 | var flushSocketsCommand = &cobra.Command{ 30 | Use: "flush", 31 | Short: "Close control sockets", 32 | RunE: runFlushSocketsCommand, 33 | } 34 | 35 | var masterSocketCommand = &cobra.Command{ 36 | Use: "master", 37 | Short: "Open a master control socket", 38 | RunE: runMasterSocketCommand, 39 | } 40 | 41 | // nolint:gochecknoinits 42 | func init() { 43 | socketsCommand.AddCommand(listSocketsCommand) 44 | socketsCommand.AddCommand(flushSocketsCommand) 45 | socketsCommand.AddCommand(masterSocketCommand) 46 | } 47 | 48 | func runListSocketsCommand(cmd *cobra.Command, args []string) error { 49 | conf, err := config.Open(viper.GetString("config")) 50 | if err != nil { 51 | return errors.Wrap(err, "failed to open config") 52 | } 53 | 54 | controlPath := conf.Defaults.ControlPath 55 | if controlPath == "" { 56 | return errors.New("missing ControlPath in the configuration; Sockets features are disabled") 57 | } 58 | 59 | activeSockets, err := controlsockets.LookupControlPathDir(controlPath) 60 | if err != nil { 61 | return errors.Wrap(err, "failed to lookup control path") 62 | } 63 | 64 | if len(activeSockets) == 0 { 65 | fmt.Println("No active control sockets.") 66 | return nil 67 | } 68 | 69 | fmt.Printf("%d active control sockets in %q:\n\n", len(activeSockets), controlPath) 70 | now := time.Now().UTC() 71 | for _, socket := range activeSockets { 72 | createdAt, err := socket.CreatedAt() 73 | if err != nil { 74 | logger().Warn("failed to retrieve socket creation date", zap.Error(err)) 75 | } 76 | 77 | fmt.Printf("- %s (%v)\n", socket.RelativePath(), units.HumanDuration(now.Sub(createdAt))) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func runMasterSocketCommand(cmd *cobra.Command, args []string) error { 84 | if len(args) < 1 { 85 | return errors.New("assh: \"sockets master\" requires 1 argument. See 'assh sockets master --help'") 86 | } 87 | 88 | for _, target := range args { 89 | logger().Debug("Opening master control socket", zap.String("host", target)) 90 | 91 | cmd := exec.Command("ssh", target, "-M", "-N", "-f") // #nosec 92 | cmd.Stdout = os.Stdout 93 | cmd.Stderr = os.Stderr 94 | return cmd.Run() 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func runFlushSocketsCommand(cmd *cobra.Command, args []string) error { 101 | conf, err := config.Open(viper.GetString("config")) 102 | if err != nil { 103 | return errors.Wrap(err, "failed to open config") 104 | } 105 | 106 | controlPath := conf.Defaults.ControlPath 107 | if controlPath == "" { 108 | return errors.New("missing ControlPath in the configuration; Sockets features are disabled") 109 | } 110 | 111 | activeSockets, err := controlsockets.LookupControlPathDir(controlPath) 112 | if err != nil { 113 | return errors.Wrap(err, "failed to lookup control path") 114 | } 115 | 116 | if len(activeSockets) == 0 { 117 | fmt.Println("No active control sockets.") 118 | return nil 119 | } 120 | 121 | success := 0 122 | for _, socket := range activeSockets { 123 | if err := os.Remove(socket.Path()); err != nil { 124 | logger().Warn("Failed to close control socket", zap.String("path", socket.Path()), zap.Error(err)) 125 | } else { 126 | success++ 127 | } 128 | } 129 | 130 | if success > 0 { 131 | fmt.Printf("Closed %d control sockets.\n", success) 132 | } 133 | 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /pkg/commands/doc.go: -------------------------------------------------------------------------------- 1 | package commands // import "moul.io/assh/v2/pkg/commands" 2 | -------------------------------------------------------------------------------- /pkg/commands/graphviz.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | "moul.io/assh/v2/pkg/config" 10 | "moul.io/assh/v2/pkg/config/graphviz" 11 | ) 12 | 13 | var graphvizConfigCommand = &cobra.Command{ 14 | Use: "graphviz", 15 | Short: "Generate a Graphviz graph of the hosts", 16 | RunE: runGraphvizConfigCommand, 17 | } 18 | 19 | // nolint:gochecknoinits 20 | func init() { 21 | graphvizConfigCommand.Flags().BoolP("show-isolated-hosts", "", false, "Show isolated hosts") 22 | graphvizConfigCommand.Flags().BoolP("no-resolve-wildcard", "", false, "Do not resolve wildcards in Gateways") 23 | graphvizConfigCommand.Flags().BoolP("no-inheritance-links", "", false, "Do not show inheritance links") 24 | _ = viper.BindPFlags(graphvizConfigCommand.Flags()) 25 | } 26 | 27 | func runGraphvizConfigCommand(cmd *cobra.Command, args []string) error { 28 | conf, err := config.Open(viper.GetString("config")) 29 | if err != nil { 30 | return errors.Wrap(err, "failed to load config") 31 | } 32 | 33 | settings := graphviz.GraphSettings{ 34 | ShowIsolatedHosts: viper.GetBool("show-isolated-hosts"), 35 | NoResolveWildcard: viper.GetBool("no-resolve-wildcard"), 36 | NoInherits: viper.GetBool("no-inheritance-links"), 37 | } 38 | graph, err := graphviz.Graph(conf, &settings) 39 | if err != nil { 40 | return errors.Wrap(err, "failed to build graph") 41 | } 42 | 43 | fmt.Println(graph) 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/commands/info.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | "strings" 8 | 9 | "github.com/bugsnag/osext" 10 | "github.com/pkg/errors" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | "moul.io/assh/v2/pkg/config" 14 | "moul.io/assh/v2/pkg/utils" 15 | "moul.io/assh/v2/pkg/version" 16 | ) 17 | 18 | var infoCommand = &cobra.Command{ 19 | Use: "info", 20 | Short: "Display system-wide information", 21 | RunE: runInfoCommand, 22 | } 23 | 24 | func runInfoCommand(cmd *cobra.Command, args []string) error { 25 | conf, err := config.Open(viper.GetString("config")) 26 | if err != nil { 27 | return errors.Wrap(err, "failed to load config") 28 | } 29 | 30 | fmt.Printf("Debug mode (client): %v\n", os.Getenv("ASSH_DEBUG") == "1") 31 | cliPath, err := osext.Executable() 32 | if err != nil { 33 | return err 34 | } 35 | fmt.Printf("CLI Path: %s\n", cliPath) 36 | fmt.Printf("Go version: %s\n", runtime.Version()) 37 | fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) 38 | fmt.Printf("Version: %s (%s)\n", version.Version, version.VcsRef) 39 | fmt.Println("") 40 | fmt.Printf("RC files:\n") 41 | homeDir := utils.GetHomeDir() 42 | for _, filename := range conf.IncludedFiles() { 43 | relativeFilename := strings.ReplaceAll(filename, homeDir, "~") 44 | fmt.Printf("- %s\n", relativeFilename) 45 | } 46 | fmt.Println("") 47 | fmt.Println("Statistics:") 48 | fmt.Printf("- %d hosts\n", len(conf.Hosts)) 49 | fmt.Printf("- %d templates\n", len(conf.Templates)) 50 | fmt.Printf("- %d included files\n", len(conf.IncludedFiles())) 51 | // FIXME: print info about connections/running processes 52 | // FIXME: print info about current config file version 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/commands/list.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mgutz/ansi" 8 | "github.com/pkg/errors" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "golang.org/x/crypto/ssh/terminal" 12 | "moul.io/assh/v2/pkg/config" 13 | ) 14 | 15 | var listConfigCommand = &cobra.Command{ 16 | Use: "list", 17 | Short: "List all hosts from assh config", 18 | RunE: runListConfigCommand, 19 | } 20 | 21 | // nolint:gochecknoinits 22 | func init() { 23 | listConfigCommand.Flags().BoolP("expand", "e", false, "Expand all fields") 24 | _ = viper.BindPFlags(listConfigCommand.Flags()) 25 | } 26 | 27 | func runListConfigCommand(cmd *cobra.Command, args []string) error { 28 | conf, err := config.Open(viper.GetString("config")) 29 | if err != nil { 30 | return errors.Wrap(err, "failed to load config") 31 | } 32 | 33 | // ansi coloring 34 | greenColorize := func(input string) string { return input } 35 | redColorize := func(input string) string { return input } 36 | yellowColorize := func(input string) string { return input } 37 | cyanColorize := func(input string) string { return input } 38 | if terminal.IsTerminal(int(os.Stdout.Fd())) { 39 | greenColorize = ansi.ColorFunc("green+b+h") 40 | redColorize = ansi.ColorFunc("red") 41 | yellowColorize = ansi.ColorFunc("yellow") 42 | cyanColorize = ansi.ColorFunc("cyan") 43 | } 44 | 45 | fmt.Printf("Listing entries\n\n") 46 | 47 | if viper.GetBool("expand") { 48 | for name := range conf.Hosts { 49 | conf.Hosts[name], err = conf.GetHost(name) 50 | if err != nil { 51 | return errors.Wrap(err, "failed to expand hosts") 52 | } 53 | } 54 | } 55 | 56 | generalOptions := conf.Defaults.Options() 57 | 58 | for _, host := range conf.Hosts.SortedList() { 59 | options := host.Options() 60 | options.Remove("User") 61 | options.Remove("Port") 62 | host.ApplyDefaults(&conf.Defaults) 63 | fmt.Printf(" %s -> %s\n", greenColorize(host.Name()), host.Prototype()) 64 | 65 | for _, opt := range options { 66 | defaultValue := generalOptions.Get(opt.Name) 67 | switch { 68 | case defaultValue == "": 69 | fmt.Printf(" %s %s %s\n", yellowColorize(opt.Name), opt.Value, yellowColorize("[custom option]")) 70 | case defaultValue == opt.Value: 71 | fmt.Printf(" %s: %s\n", redColorize(opt.Name), opt.Value) 72 | default: 73 | fmt.Printf(" %s %s %s\n", cyanColorize(opt.Name), opt.Value, cyanColorize("[override]")) 74 | } 75 | } 76 | fmt.Println() 77 | } 78 | 79 | if len(generalOptions) > 0 { 80 | fmt.Println(greenColorize(" (*) General options:")) 81 | for _, opt := range conf.Defaults.Options() { 82 | fmt.Printf(" %s: %s\n", redColorize(opt.Name), opt.Value) 83 | } 84 | fmt.Println() 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/commands/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package commands 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.commands") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/commands/ping.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "go.uber.org/zap" 12 | "moul.io/assh/v2/pkg/config" 13 | ) 14 | 15 | var pingCommand = &cobra.Command{ 16 | Use: "ping", 17 | Short: "Send packets to the SSH server and display statistics", 18 | RunE: runPingCommand, 19 | } 20 | 21 | // nolint:gochecknoinits 22 | func init() { 23 | pingCommand.Flags().BoolP("no-rewrite", "", false, "Do not automatically rewrite outdated configuration") 24 | pingCommand.Flags().IntP("port", "p", 0, "SSH destination port") 25 | pingCommand.Flags().UintP("count", "c", 0, "Stop after sending 'count' packets") 26 | pingCommand.Flags().Float64P("wait", "i", 1, "Wait 'wait' seconds between sending each packet") 27 | pingCommand.Flags().BoolP("o", "", false, "Exit successfully after receiving one reply packet") 28 | pingCommand.Flags().Float64P("waittime", "W", 1, "Time in seconds to wait for a reply for each packet sent") 29 | _ = viper.BindPFlags(pingCommand.Flags()) 30 | } 31 | 32 | func runPingCommand(cmd *cobra.Command, args []string) error { 33 | if len(args) < 1 { 34 | return errors.New("assh: \"ping\" requires exactly 1 argument. See 'assh ping --help'") 35 | } 36 | 37 | conf, err := config.Open(viper.GetString("config")) 38 | if err != nil { 39 | return errors.Wrap(err, "failed to open configuration file") 40 | } 41 | if err = conf.LoadKnownHosts(); err != nil { 42 | return errors.Wrap(err, "failed to load known-hosts") 43 | } 44 | target := args[0] 45 | host, err := computeHost(target, viper.GetInt("port"), conf) 46 | if err != nil { 47 | return errors.Wrapf(err, "failed to get host %q", target) 48 | } 49 | 50 | if len(host.Gateways) > 0 { 51 | return errors.New("assh \"ping\" is not working with gateways (yet)") 52 | } 53 | if host.ProxyCommand != "" { 54 | return errors.New("assh \"ping\" is not working with custom ProxyCommand (yet)") 55 | } 56 | 57 | portName := "ssh" 58 | if host.Port != "22" { 59 | // fixme: resolve port name 60 | portName = "unknown" 61 | } 62 | proto := "tcp" 63 | fmt.Printf("PING %s (%s) PORT %s (%s) PROTO %s\n", target, host.HostName, host.Port, portName, proto) 64 | dest := fmt.Sprintf("%s:%s", host.HostName, host.Port) 65 | count := uint(viper.GetInt("count")) 66 | transmittedPackets := 0 67 | receivedPackets := 0 68 | minRoundtrip := time.Duration(0) 69 | maxRoundtrip := time.Duration(0) 70 | totalRoundtrip := time.Duration(0) 71 | for seq := uint(0); count == 0 || seq < count; seq++ { 72 | if seq > 0 { 73 | time.Sleep(time.Duration(viper.GetFloat64("wait")) * time.Second) 74 | } 75 | start := time.Now() 76 | conn, err := net.DialTimeout(proto, dest, time.Second*time.Duration(viper.GetFloat64("waittime"))) 77 | transmittedPackets++ 78 | duration := time.Since(start) 79 | totalRoundtrip += duration 80 | if minRoundtrip == 0 || minRoundtrip > duration { 81 | minRoundtrip = duration 82 | } 83 | if maxRoundtrip < duration { 84 | maxRoundtrip = duration 85 | } 86 | if err == nil { 87 | defer func() { 88 | if err2 := conn.Close(); err2 != nil { 89 | logger().Error("Failed to close connection", zap.Error(err2)) 90 | } 91 | }() 92 | } 93 | if err == nil { 94 | receivedPackets++ 95 | fmt.Printf("Connected to %s: seq=%d time=%v protocol=%s port=%s\n", host.HostName, seq, duration, proto, host.Port) 96 | if viper.GetBool("o") { 97 | goto stats 98 | } 99 | } else { 100 | // FIXME: switch on error type 101 | fmt.Printf("Request timeout for seq %d (%v)\n", seq, err) 102 | } 103 | } 104 | 105 | // FIXME: catch Ctrl+C 106 | 107 | stats: 108 | fmt.Printf("\n--- %s assh ping statistics ---\n", target) 109 | packetLossRatio := float64(transmittedPackets-receivedPackets) / float64(transmittedPackets) * 100 110 | fmt.Printf("%d packets transmitted, %d packets received, %.2f%% packet loss\n", transmittedPackets, receivedPackets, packetLossRatio) 111 | avgRoundtrip := totalRoundtrip / time.Duration(transmittedPackets) 112 | fmt.Printf("round-trip min/avg/max = %v/%v/%v\n", minRoundtrip, avgRoundtrip, maxRoundtrip) 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /pkg/commands/proxy.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "math" 9 | "net" 10 | "os" 11 | "os/exec" 12 | "os/signal" 13 | "os/user" 14 | "path" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "syscall" 19 | "time" 20 | 21 | humanize "github.com/dustin/go-humanize" 22 | shlex "github.com/flynn/go-shlex" 23 | "github.com/pkg/errors" 24 | "github.com/spf13/cobra" 25 | "github.com/spf13/viper" 26 | "go.uber.org/zap" 27 | "golang.org/x/net/context" 28 | "golang.org/x/time/rate" 29 | "moul.io/assh/v2/pkg/config" 30 | "moul.io/assh/v2/pkg/ratelimit" 31 | ) 32 | 33 | type contextKey string 34 | 35 | type gatewayErrorMsg struct { 36 | gateway string 37 | err zap.Field 38 | } 39 | 40 | var syncContextKey contextKey = "sync" 41 | 42 | var proxyCommand = &cobra.Command{ 43 | Use: "connect", 44 | Short: "Connect to host SSH socket, used by ProxyCommand", 45 | Example: "Argument is a host.", 46 | Hidden: true, 47 | RunE: runProxyCommand, 48 | } 49 | 50 | // nolint:gochecknoinits 51 | func init() { 52 | proxyCommand.Flags().BoolP("no-rewrite", "", false, "Do not automatically rewrite outdated configuration") 53 | proxyCommand.Flags().IntP("port", "p", 0, "SSH destination port") 54 | proxyCommand.Flags().BoolP("dry-run", "", false, "Only show how assh would connect but don't actually do it") 55 | _ = viper.BindPFlags(proxyCommand.Flags()) 56 | } 57 | 58 | func runProxyCommand(cmd *cobra.Command, args []string) error { 59 | if len(args) < 1 { 60 | return errors.New("assh: \"connect\" requires 1 argument. See 'assh connect --help'") 61 | } 62 | 63 | target := args[0] 64 | logger().Debug("initializing proxy", zap.String("target", target)) 65 | 66 | // dry-run option 67 | // Setting the 'ASSH_DRYRUN=1' environment variable, 68 | // so 'assh' can use gateways using sub-SSH commands. 69 | if viper.GetBool("dry-run") { 70 | if err := os.Setenv("ASSH_DRYRUN", "1"); err != nil { 71 | return errors.Wrap(err, "failed to configure environment") 72 | } 73 | } 74 | dryRun := os.Getenv("ASSH_DRYRUN") == "1" 75 | 76 | conf, err := config.Open(viper.GetString("config")) 77 | if err != nil { 78 | return errors.Wrap(err, "failed to open config file") 79 | } 80 | 81 | if err = conf.LoadKnownHosts(); err != nil { 82 | logger().Debug("Failed to load assh known_hosts", zap.Error(err)) 83 | } 84 | 85 | automaticRewrite := !viper.GetBool("no-rewrite") 86 | isOutdated, err2 := conf.IsConfigOutdated(target) 87 | if err2 != nil { 88 | logger().Warn("Cannot check if ~/.ssh/config is outdated", zap.Error(err)) 89 | } 90 | if isOutdated { 91 | if automaticRewrite { 92 | // BeforeConfigWrite 93 | type configWriteHookArgs struct { 94 | SSHConfigPath string 95 | } 96 | hookArgs := configWriteHookArgs{ 97 | SSHConfigPath: conf.SSHConfigPath(), 98 | } 99 | 100 | logger().Debug("Calling BeforeConfigWrite hooks") 101 | if drivers, err := conf.Defaults.Hooks.BeforeConfigWrite.InvokeAll(hookArgs); err != nil { 102 | logger().Error("BeforeConfigWrite hook failed", zap.Error(err)) 103 | } else { 104 | defer drivers.Close() 105 | } 106 | 107 | // Save 108 | logger().Debug("The configuration file is outdated, rebuilding it before calling ssh") 109 | logger().Warn("'~/.ssh/config' has been rewritten. SSH needs to be restarted. See https://github.com/moul/assh/issues/122 for more information.") 110 | logger().Debug("Saving SSH config") 111 | if err := conf.SaveSSHConfig(); err != nil { 112 | return errors.Wrap(err, "failed to save SSH config file") 113 | } 114 | 115 | // AfterConfigWrite 116 | logger().Debug("Calling AfterConfigWrite hooks") 117 | if drivers, err := conf.Defaults.Hooks.AfterConfigWrite.InvokeAll(hookArgs); err != nil { 118 | logger().Error("AfterConfigWrite hook failed", zap.Error(err)) 119 | } else { 120 | defer drivers.Close() 121 | } 122 | } else { 123 | logger().Warn("The configuration file is outdated; you need to run `assh config build --no-automatic-rewrite > ~/.ssh/config` to stay updated") 124 | } 125 | } 126 | 127 | // FIXME: handle complete host with json 128 | 129 | host, err := computeHost(target, viper.GetInt("port"), conf) 130 | if err != nil { 131 | return errors.Wrapf(err, "Failed to get host %q", target) 132 | } 133 | var w bytes.Buffer 134 | if err := host.WriteSSHConfigTo(&w); err != nil { 135 | return errors.Wrap(err, "failed to write ssh config") 136 | } 137 | logger().Debug("generated ssh config file", zap.String("buffer", w.String())) 138 | 139 | hostJSON, err2 := json.Marshal(host) 140 | if err2 != nil { 141 | logger().Warn("Failed to marshal host", zap.Error(err2)) 142 | } else { 143 | logger().Debug("Host", zap.String("host", string(hostJSON))) 144 | } 145 | 146 | logger().Debug("Proxying") 147 | return proxy(host, conf, dryRun) 148 | } 149 | 150 | // nolint:unparam 151 | func computeHost(dest string, portOverride int, conf *config.Config) (*config.Host, error) { 152 | host := conf.GetHostSafe(dest) 153 | 154 | if portOverride > 0 { 155 | host.Port = strconv.Itoa(portOverride) 156 | } 157 | 158 | return host, nil 159 | } 160 | 161 | func expandSSHTokens(tokenized string, host *config.Host) string { 162 | result := tokenized 163 | 164 | // OpenSSH Token Cheatsheet (stolen directly from the man pages) 165 | // 166 | // %% A literal `%'. 167 | // %C Shorthand for %l%h%p%r. 168 | // %d Local user's home directory. 169 | // %h The remote hostname. 170 | // %i The local user ID. 171 | // %L The local hostname. 172 | // %l The local hostname, including the domain name. 173 | // %n The original remote hostname, as given on the command line. 174 | // %p The remote port. 175 | // %r The remote username. 176 | // %u The local username. 177 | 178 | // TODO: Expansion of strings like "%%C" and "%C" are equivalent due to the 179 | // order that tokens are evaluated. Should look at how OpenSSH implements 180 | // the tokenization behavior. 181 | 182 | // Expand a home directory ~. Assume nobody is using 183 | // the ~otheruser syntax. 184 | homedir := os.ExpandEnv("$HOME") 185 | 186 | if result[0] == '~' { 187 | result = strings.Replace(result, "~", homedir, 1) 188 | } 189 | result = strings.ReplaceAll(result, "%d", homedir) 190 | 191 | result = strings.ReplaceAll(result, "%%", "%") 192 | result = strings.ReplaceAll(result, "%C", "%l%h%p%r") 193 | result = strings.ReplaceAll(result, "%h", host.Name()) 194 | result = strings.ReplaceAll(result, "%i", strconv.Itoa(os.Geteuid())) 195 | result = strings.ReplaceAll(result, "%p", host.Port) 196 | 197 | if hostname, err := os.Hostname(); err == nil { 198 | result = strings.ReplaceAll(result, "%L", hostname) 199 | } else { 200 | result = strings.ReplaceAll(result, "%L", "hostname") 201 | } 202 | 203 | if host.User != "" { 204 | result = strings.ReplaceAll(result, "%r", host.User) 205 | } else { 206 | if userdata, err := user.Current(); err == nil { 207 | result = strings.ReplaceAll(result, "%r", userdata.Username) 208 | } else { 209 | result = strings.ReplaceAll(result, "%r", "username") 210 | } 211 | } 212 | 213 | return result 214 | } 215 | 216 | func prepareHostControlPath(host *config.Host) error { 217 | if !config.BoolVal(host.ControlMasterMkdir) || ("none" == host.ControlPath || "" == host.ControlPath) { 218 | return nil 219 | } 220 | 221 | controlPath := expandSSHTokens(host.ControlPath, host) 222 | controlPathDir := path.Dir(controlPath) 223 | logger().Debug("Creating control path", zap.String("path", controlPathDir)) 224 | return os.MkdirAll(controlPathDir, 0700) 225 | } 226 | 227 | func proxy(host *config.Host, conf *config.Config, dryRun bool) error { 228 | if err := prepareHostControlPath(host.Clone()); err != nil { 229 | return errors.Wrap(err, "failed to prepare host control-path") 230 | } 231 | 232 | if len(host.Gateways) > 0 { 233 | logger().Debug("Trying gateways", zap.String("gateways", strings.Join(host.Gateways, ", "))) 234 | var gatewayErrors []gatewayErrorMsg 235 | for _, gateway := range host.Gateways { 236 | if gateway == "direct" { 237 | if err := proxyDirect(host, dryRun); err != nil { 238 | gatewayErrors = append(gatewayErrors, gatewayErrorMsg{ 239 | gateway: "direct", err: zap.Error(err)}) 240 | } else { 241 | return nil 242 | } 243 | } else { 244 | hostCopy := host.Clone() 245 | gatewayHost := conf.GetGatewaySafe(gateway) 246 | 247 | if err := prepareHostControlPath(hostCopy); err != nil { 248 | return errors.Wrap(err, "failed to prepare host control-path") 249 | } 250 | 251 | // FIXME: dynamically add "-v" flags 252 | 253 | var command string 254 | 255 | // FIXME: detect ssh client version and use netcat if too old 256 | // for now, the workaround is to configure the ProxyCommand of the host to "nc %h %p" 257 | 258 | if err := hostPrepare(hostCopy, gateway); err != nil { 259 | return errors.Wrap(err, "failed to prepare host for gateway") 260 | } 261 | 262 | if hostCopy.ProxyCommand != "" { 263 | command = "ssh %name -- " + hostCopy.ExpandString(hostCopy.ProxyCommand, gateway) 264 | } else { 265 | command = hostCopy.ExpandString("ssh -W %h:%p ", "") + "%name" 266 | } 267 | 268 | logger().Debug( 269 | "Using gateway", 270 | zap.String("gateway", gateway), 271 | zap.String("command", command), 272 | ) 273 | if err := runProxy(gatewayHost, command, dryRun); err != nil { 274 | gatewayErrors = append(gatewayErrors, gatewayErrorMsg{ 275 | gateway: gateway, err: zap.Error(err)}) 276 | } else { 277 | return nil 278 | } 279 | } 280 | } 281 | if len(gatewayErrors) > 0 { 282 | for _, errMsg := range gatewayErrors { 283 | conType := "gateway" 284 | if errMsg.gateway == "direct" { 285 | conType = "connection" 286 | } 287 | logger().Error( 288 | fmt.Sprintf("Failed to use '%s' %s with error:", 289 | errMsg.gateway, conType), errMsg.err) 290 | } 291 | } 292 | return errors.New("no such available gateway") 293 | } 294 | 295 | logger().Debug("Connecting without gateway") 296 | return proxyDirect(host, dryRun) 297 | } 298 | 299 | func proxyDirect(host *config.Host, dryRun bool) error { 300 | if host.ProxyCommand != "" { 301 | return runProxy(host, host.ProxyCommand, dryRun) 302 | } 303 | return proxyGo(host, dryRun) 304 | } 305 | 306 | func runProxy(host *config.Host, command string, dryRun bool) error { 307 | command = host.ExpandString(command, "") 308 | logger().Debug("ProxyCommand", zap.String("command", command)) 309 | args, err := shlex.Split(command) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | if dryRun { 315 | return fmt.Errorf("dry-run: Execute %s", args) 316 | } 317 | 318 | spawn := exec.Command(args[0], args[1:]...) // #nosec 319 | spawn.Stdout = os.Stdout 320 | spawn.Stdin = os.Stdin 321 | spawn.Stderr = os.Stderr 322 | return spawn.Run() 323 | } 324 | 325 | func hostPrepare(host *config.Host, gateway string) error { 326 | if host.HostName == "" { 327 | host.HostName = host.Name() 328 | } 329 | 330 | if len(host.ResolveNameservers) > 0 { 331 | logger().Debug( 332 | "Resolving host", 333 | zap.String("hostname", host.HostName), 334 | zap.String("nameservers", strings.Join(host.ResolveNameservers, ", ")), 335 | ) 336 | // FIXME: resolve using custom dns server 337 | results, err := net.LookupAddr(host.HostName) 338 | if err != nil { 339 | return err 340 | } 341 | if len(results) > 0 { 342 | host.HostName = results[0] 343 | } 344 | logger().Debug("Resolved host", zap.String("hostname", host.HostName)) 345 | } 346 | 347 | if host.ResolveCommand != "" { 348 | command := host.ExpandString(host.ResolveCommand, gateway) 349 | logger().Debug( 350 | "Resolving host", 351 | zap.String("hostname", host.HostName), 352 | zap.String("resolve-command", host.ResolveCommand), 353 | ) 354 | 355 | args, err := shlex.Split(command) 356 | if err != nil { 357 | return err 358 | } 359 | 360 | cmd := exec.Command(args[0], args[1:]...) // #nosec 361 | var stdout bytes.Buffer 362 | var stderr bytes.Buffer 363 | cmd.Stdout = &stdout 364 | cmd.Stderr = &stderr 365 | if err := cmd.Run(); err != nil { 366 | return errors.Wrap(err, "failed to run resolve-command") 367 | } 368 | 369 | host.HostName = strings.TrimSpace(stdout.String()) 370 | logger().Debug("Resolved host", zap.String("hostname", host.HostName)) 371 | } 372 | return nil 373 | } 374 | 375 | type exportReadWrite struct { 376 | written uint64 377 | err error 378 | } 379 | 380 | // ConnectionStats contains network and timing informations about a connection 381 | type ConnectionStats struct { 382 | WrittenBytes uint64 383 | WrittenBytesHuman string 384 | CreatedAt time.Time 385 | ConnectedAt time.Time 386 | DisconnectedAt time.Time 387 | ConnectionDuration time.Duration 388 | ConnectionDurationHuman string 389 | AverageSpeed float64 390 | AverageSpeedHuman string 391 | } 392 | 393 | func (c *ConnectionStats) String() string { 394 | b, err := json.Marshal(c) 395 | if err != nil { 396 | logger().Error("failed to marshal ConnectionStats", zap.Error(err)) 397 | return "" 398 | } 399 | return string(b) 400 | } 401 | 402 | // ConnectHookArgs is the struture sent to the hooks and used in Go templates by the hook drivers 403 | type ConnectHookArgs struct { 404 | Host *config.Host 405 | Stats *ConnectionStats 406 | Error string 407 | } 408 | 409 | func (c ConnectHookArgs) String() string { 410 | b, err := json.Marshal(c) 411 | if err != nil { 412 | logger().Error("failed to marshal ConnectHookArgs", zap.Error(err)) 413 | return "" 414 | } 415 | return string(b) 416 | } 417 | 418 | func proxyGo(host *config.Host, dryRun bool) error { 419 | stats := ConnectionStats{ 420 | CreatedAt: time.Now(), 421 | } 422 | connectHookArgs := ConnectHookArgs{ 423 | Host: host, 424 | Stats: &stats, 425 | } 426 | 427 | logger().Debug("Preparing host object") 428 | if err := hostPrepare(host, ""); err != nil { 429 | return errors.Wrap(err, "failed to prepare host") 430 | } 431 | 432 | if dryRun { 433 | return fmt.Errorf("dry-run: Golang native TCP connection to '%s:%s'", host.HostName, host.Port) 434 | } 435 | 436 | // BeforeConnect hook 437 | logger().Debug("Calling BeforeConnect hooks") 438 | if drivers, err := host.Hooks.BeforeConnect.InvokeAll(connectHookArgs); err != nil { 439 | logger().Error("BeforeConnect hook failed", zap.Error(err)) 440 | } else { 441 | defer drivers.Close() 442 | } 443 | 444 | logger().Debug("Connecting to host", zap.String("hostname", host.HostName), zap.String("port", host.Port)) 445 | 446 | // use GatewayConnectTimeout, fallback on ConnectTimeout 447 | timeout := host.GatewayConnectTimeout 448 | if host.ConnectTimeout != 0 { 449 | timeout = host.ConnectTimeout 450 | } 451 | if timeout < 0 { // set to 0 to disable 452 | timeout = 0 453 | } 454 | conn, err := net.DialTimeout( 455 | "tcp", 456 | fmt.Sprintf("%s:%s", host.HostName, host.Port), 457 | time.Duration(timeout)*time.Second, 458 | ) 459 | if err != nil { 460 | // OnConnectError hook 461 | connectHookArgs.Error = err.Error() 462 | logger().Debug("Calling OnConnectError hooks") 463 | if drivers, err := host.Hooks.OnConnectError.InvokeAll(connectHookArgs); err != nil { 464 | logger().Error("OnConnectError hook failed", zap.Error(err)) 465 | } else { 466 | defer drivers.Close() 467 | } 468 | 469 | return errors.Wrap(err, "failed to dial") 470 | } 471 | logger().Debug( 472 | "Connected", 473 | zap.String("hostname", host.HostName), 474 | zap.String("port", host.Port), 475 | ) 476 | stats.ConnectedAt = time.Now() 477 | 478 | // OnConnect hook 479 | logger().Debug("Calling OnConnect hooks") 480 | if drivers, err := host.Hooks.OnConnect.InvokeAll(connectHookArgs); err != nil { 481 | logger().Error("OnConnect hook failed", zap.Error(err)) 482 | } else { 483 | defer drivers.Close() 484 | } 485 | 486 | // Ignore SIGHUP 487 | signal.Ignore(syscall.SIGHUP) 488 | 489 | waitGroup := sync.WaitGroup{} 490 | result := exportReadWrite{} 491 | 492 | ctx, cancel := context.WithCancel(context.Background()) 493 | ctx = context.WithValue(ctx, syncContextKey, &waitGroup) 494 | 495 | waitGroup.Add(2) 496 | 497 | var reader io.Reader 498 | var writer io.Writer 499 | reader = conn 500 | writer = conn 501 | if host.RateLimit != "" { 502 | bytes, err := humanize.ParseBytes(host.RateLimit) 503 | if err != nil { 504 | return errors.Wrap(err, "failed to parse rate limit configuration") 505 | } 506 | limit := rate.Limit(float64(bytes)) 507 | limiter := rate.NewLimiter(limit, int(bytes)) 508 | reader = ratelimit.NewReader(conn, limiter) 509 | writer = ratelimit.NewWriter(conn, limiter) 510 | } 511 | 512 | c1 := readAndWrite(ctx, reader, os.Stdout) 513 | c2 := readAndWrite(ctx, os.Stdin, writer) 514 | select { 515 | case result = <-c1: 516 | stats.WrittenBytes = result.written 517 | case result = <-c2: 518 | } 519 | if result.err != nil && result.err == io.EOF { 520 | result.err = nil 521 | } 522 | 523 | if err := conn.Close(); err != nil { 524 | return err 525 | } 526 | cancel() 527 | waitGroup.Wait() 528 | select { 529 | case res := <-c1: 530 | stats.WrittenBytes = res.written 531 | default: 532 | } 533 | 534 | stats.DisconnectedAt = time.Now() 535 | stats.ConnectionDuration = stats.DisconnectedAt.Sub(stats.ConnectedAt) 536 | averageSpeed := float64(stats.WrittenBytes) / stats.ConnectionDuration.Seconds() 537 | // round duraction 538 | stats.ConnectionDuration = ((stats.ConnectionDuration + time.Second/2) / time.Second) * time.Second 539 | stats.AverageSpeed = math.Ceil(averageSpeed*1000) / 1000 540 | // human 541 | stats.WrittenBytesHuman = humanize.Bytes(stats.WrittenBytes) 542 | connectionDurationHuman := humanize.RelTime(stats.DisconnectedAt, stats.ConnectedAt, "", "") 543 | stats.ConnectionDurationHuman = strings.ReplaceAll(connectionDurationHuman, "now", "0 sec") 544 | stats.AverageSpeedHuman = humanize.Bytes(uint64(stats.AverageSpeed)) + "/s" 545 | 546 | // OnDisconnect hook 547 | logger().Debug("Calling OnDisconnect hooks") 548 | if drivers, err := host.Hooks.OnDisconnect.InvokeAll(connectHookArgs); err != nil { 549 | logger().Error("OnDisconnect hook failed", zap.Error(err)) 550 | } else { 551 | defer drivers.Close() 552 | } 553 | 554 | logger().Debug( 555 | "Connection finished", 556 | zap.Uint64("bytes written", stats.WrittenBytes), 557 | zap.Error(result.err), 558 | ) 559 | return result.err 560 | } 561 | 562 | func readAndWrite(ctx context.Context, r io.Reader, w io.Writer) <-chan exportReadWrite { 563 | buff := make([]byte, 1024) 564 | c := make(chan exportReadWrite, 1) 565 | 566 | go func() { 567 | defer ctx.Value(syncContextKey).(*sync.WaitGroup).Done() 568 | 569 | export := exportReadWrite{} 570 | for { 571 | select { 572 | case <-ctx.Done(): 573 | c <- export 574 | return 575 | default: 576 | nr, err := r.Read(buff) 577 | if err != nil { 578 | export.err = err 579 | c <- export 580 | return 581 | } 582 | if nr > 0 { 583 | wr, err := w.Write(buff[:nr]) 584 | if err != nil { 585 | export.err = err 586 | c <- export 587 | return 588 | } 589 | if wr > 0 { 590 | export.written += uint64(wr) 591 | } 592 | } 593 | } 594 | } 595 | }() 596 | return c 597 | } 598 | -------------------------------------------------------------------------------- /pkg/commands/proxy_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | "moul.io/assh/v2/pkg/config" 10 | ) 11 | 12 | const configExample string = ` 13 | hosts: 14 | aaa: 15 | HostName: 1.2.3.4 16 | bbb: 17 | Port: 21 18 | ccc: 19 | HostName: 5.6.7.8 20 | Port: 24 21 | User: toor 22 | "*.ddd": 23 | HostName: 1.3.5.7 24 | eee: 25 | ResolveCommand: /bin/sh -c "echo 42.42.42.42" 26 | defaults: 27 | Port: 22 28 | User: root 29 | ` 30 | 31 | func TestComputeHost(t *testing.T) { 32 | Convey("Testing computeHost()", t, func() { 33 | config := config.New() 34 | 35 | err := config.LoadConfig(strings.NewReader(configExample)) 36 | So(err, ShouldBeNil) 37 | host, err := computeHost("aaa", 0, config) 38 | So(err, ShouldBeNil) 39 | So(host.HostName, ShouldEqual, "1.2.3.4") 40 | So(host.Port, ShouldEqual, "22") 41 | 42 | err = config.LoadConfig(strings.NewReader(configExample)) 43 | So(err, ShouldBeNil) 44 | host, err = computeHost("aaa", 42, config) 45 | So(err, ShouldBeNil) 46 | So(host.HostName, ShouldEqual, "1.2.3.4") 47 | So(host.Port, ShouldEqual, "42") 48 | 49 | err = config.LoadConfig(strings.NewReader(configExample)) 50 | So(err, ShouldBeNil) 51 | host, err = computeHost("eee", 0, config) 52 | So(err, ShouldBeNil) 53 | So(host.HostName, ShouldEqual, "eee") 54 | So(host.Port, ShouldEqual, "22") 55 | 56 | err = config.LoadConfig(strings.NewReader(configExample)) 57 | So(err, ShouldBeNil) 58 | host, err = computeHost("eee", 42, config) 59 | So(err, ShouldBeNil) 60 | So(host.HostName, ShouldEqual, "eee") 61 | So(host.Port, ShouldEqual, "42") 62 | }) 63 | } 64 | 65 | func Test_runProxy(t *testing.T) { 66 | Convey("Testing proxyCommand()", t, func() { 67 | // FIXME: test stdout 68 | config := config.New() 69 | err := config.LoadConfig(strings.NewReader(configExample)) 70 | So(err, ShouldBeNil) 71 | host, err := computeHost("aaa", 0, config) 72 | So(err, ShouldBeNil) 73 | 74 | err = runProxy(host, "echo test from proxyCommand", false) 75 | So(err, ShouldBeNil) 76 | 77 | err = runProxy(host, "/bin/sh -c 'echo test from proxyCommand'", false) 78 | So(err, ShouldBeNil) 79 | 80 | err = runProxy(host, "/bin/sh -c 'exit 1'", false) 81 | So(err, ShouldNotBeNil) 82 | 83 | err = runProxy(host, "blah", true) 84 | So(err, ShouldResemble, fmt.Errorf("dry-run: Execute [blah]")) 85 | }) 86 | } 87 | 88 | func Test_hostPrepare(t *testing.T) { 89 | Convey("Testing hostPrepare()", t, func() { 90 | config := config.New() 91 | err := config.LoadConfig(strings.NewReader(configExample)) 92 | So(err, ShouldBeNil) 93 | 94 | host, err := computeHost("aaa", 0, config) 95 | So(err, ShouldBeNil) 96 | So(host.HostName, ShouldEqual, "1.2.3.4") 97 | So(hostPrepare(host, ""), ShouldBeNil) 98 | So(host.HostName, ShouldEqual, "1.2.3.4") 99 | 100 | host, err = computeHost("bbb", 0, config) 101 | So(err, ShouldBeNil) 102 | So(host.HostName, ShouldEqual, "bbb") 103 | So(hostPrepare(host, ""), ShouldBeNil) 104 | So(host.HostName, ShouldEqual, "bbb") 105 | 106 | host, err = computeHost("eee", 0, config) 107 | So(err, ShouldBeNil) 108 | So(host.HostName, ShouldEqual, "eee") 109 | So(hostPrepare(host, ""), ShouldBeNil) 110 | So(host.HostName, ShouldEqual, "42.42.42.42") 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /pkg/commands/search.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | "moul.io/assh/v2/pkg/config" 10 | ) 11 | 12 | var searchConfigCommand = &cobra.Command{ 13 | Use: "search", 14 | Short: "Search entries by given search text", 15 | RunE: searchConfig, 16 | } 17 | 18 | func searchConfig(cmd *cobra.Command, args []string) error { 19 | conf, err := config.Open(viper.GetString("config")) 20 | if err != nil { 21 | return errors.Wrap(err, "failed to load config") 22 | } 23 | 24 | if len(args) != 1 { 25 | return errors.New("assh config search requires 1 argument. See 'assh config search --help'") 26 | } 27 | 28 | needle := args[0] 29 | 30 | found := []*config.Host{} 31 | for _, host := range conf.Hosts.SortedList() { 32 | if host.Matches(needle) { 33 | found = append(found, host) 34 | } 35 | } 36 | 37 | if len(found) == 0 { 38 | fmt.Println("no results found.") 39 | return nil 40 | } 41 | 42 | fmt.Printf("Listing results for %s:\n", needle) 43 | for _, host := range found { 44 | fmt.Printf(" %s -> %s\n", host.Name(), host.Prototype()) 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/commands/wrapper.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "syscall" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | "go.uber.org/zap" 13 | "moul.io/assh/v2/pkg/config" 14 | ) 15 | 16 | var wrapperCommand = &cobra.Command{ 17 | Use: "wrapper", 18 | Short: "Initialize assh, then run ssh/scp/rsync...", 19 | Hidden: true, 20 | } 21 | 22 | var sshWrapperCommand = &cobra.Command{ 23 | Use: "ssh", 24 | Short: "Wrap ssh", 25 | RunE: runSSHWrapperCommand, 26 | } 27 | 28 | // nolint:gochecknoinits 29 | func init() { 30 | sshWrapperCommand.Flags().AddFlagSet(config.SSHFlags()) 31 | wrapperCommand.AddCommand(sshWrapperCommand) 32 | } 33 | 34 | func runSSHWrapperCommand(cmd *cobra.Command, args []string) error { 35 | if len(args) < 1 { 36 | return fmt.Errorf("missing argument. See usage with 'assh wrapper %s -h'", cmd.Name()) 37 | } 38 | 39 | // prepare variables 40 | target := args[0] 41 | command := args[1:] 42 | options := []string{} 43 | for _, flag := range config.SSHBoolFlags { 44 | if viper.GetBool(flag) { 45 | options = append(options, fmt.Sprintf("-%s", flag)) 46 | } 47 | } 48 | for _, flag := range config.SSHStringFlags { 49 | for _, val := range viper.GetStringSlice(flag) { 50 | if (flag == "o" || flag == "O") && val == "false" { 51 | logger().Debug( 52 | "Skip invalid option:", 53 | zap.String("flag", flag), 54 | zap.String("val", val), 55 | ) 56 | continue 57 | } 58 | options = append(options, fmt.Sprintf("-%s", flag)) 59 | options = append(options, val) 60 | } 61 | } 62 | sshArgs := []string{cmd.Name()} 63 | sshArgs = append(sshArgs, options...) 64 | sshArgs = append(sshArgs, target) 65 | sshArgs = append(sshArgs, command...) 66 | bin, err := exec.LookPath(cmd.Name()) 67 | if err != nil { 68 | return errors.Wrapf(err, "failed to lookup %q", cmd.Name()) 69 | } 70 | 71 | logger().Debug( 72 | "Wrapper called", 73 | zap.String("bin", bin), 74 | zap.String("target", target), 75 | zap.Any("command", command), 76 | zap.Any("options", options), 77 | zap.Any("sshArgs", sshArgs), 78 | ) 79 | 80 | // check if config is up-to-date 81 | conf, err := config.Open(viper.GetString("config")) 82 | if err != nil { 83 | return errors.Wrap(err, "failed to open config") 84 | } 85 | 86 | if err = conf.LoadKnownHosts(); err != nil { 87 | logger().Debug("Failed to load assh known_hosts", zap.Error(err)) 88 | } 89 | 90 | // check if .ssh/config is outdated 91 | isOutdated, err := conf.IsConfigOutdated(target) 92 | if err != nil { 93 | logger().Error("failed to check if config is outdated", zap.Error(err)) 94 | } 95 | if isOutdated { 96 | logger().Debug( 97 | "The configuration file is outdated, rebuilding it before calling command", 98 | zap.String("command", cmd.Name()), 99 | ) 100 | if err = conf.SaveSSHConfig(); err != nil { 101 | logger().Error("failed to save ssh config file", zap.Error(err)) 102 | } 103 | } 104 | 105 | // Execute Binary 106 | return syscall.Exec(bin, sshArgs, os.Environ()) // #nosec 107 | } 108 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "sort" 13 | "strings" 14 | "time" 15 | 16 | "github.com/imdario/mergo" 17 | "github.com/moul/flexyaml" 18 | "go.uber.org/zap" 19 | "moul.io/assh/v2/pkg/utils" 20 | "moul.io/assh/v2/pkg/version" 21 | ) 22 | 23 | var asshBinaryPath = "assh" 24 | 25 | const defaultSSHConfigPath = "~/.ssh/config" 26 | 27 | // Config contains a list of Hosts sections and a Defaults section representing a configuration file 28 | type Config struct { 29 | Hosts HostsMap `yaml:"hosts,omitempty,flow" json:"hosts"` 30 | Templates HostsMap `yaml:"templates,omitempty,flow" json:"templates"` 31 | Defaults Host `yaml:"defaults,omitempty,flow" json:"defaults,omitempty"` 32 | Includes []string `yaml:"includes,omitempty,flow" json:"includes,omitempty"` 33 | ASSHKnownHostFile string `yaml:"asshknownhostfile,omitempty,flow" json:"asshknownhostfile,omitempty"` 34 | ASSHBinaryPath string `yaml:"asshbinarypath,omitempty,flow" json:"asshbinarypath,omitempty"` 35 | 36 | includedFiles map[string]bool 37 | sshConfigPath string 38 | } 39 | 40 | // DisableAutomaticRewrite will configure the ~/.ssh/config file to not automatically rewrite the configuration file 41 | func (c *Config) DisableAutomaticRewrite() { 42 | c.Defaults.noAutomaticRewrite = true 43 | } 44 | 45 | // SetASSHBinaryPath sets the default assh binary path 46 | // this value may be overwritten in the assh.yml file using the asshbinarypath variable 47 | func SetASSHBinaryPath(path string) { 48 | asshBinaryPath = path 49 | } 50 | 51 | // String returns the JSON output 52 | func (c *Config) String() string { 53 | s, _ := json.Marshal(c) 54 | return string(s) 55 | } 56 | 57 | // SaveNewKnownHost registers the target as a new known host and save the full known hosts list on disk 58 | func (c *Config) SaveNewKnownHost(target string) { 59 | c.addKnownHost(target) 60 | 61 | path, err := utils.ExpandUser(c.ASSHKnownHostFile) 62 | if err != nil { 63 | logger().Error( 64 | "Cannot append host, unknown assh_known_hosts file", 65 | zap.String("host", target), 66 | zap.Error(err), 67 | ) 68 | } 69 | 70 | file, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0600) 71 | if err != nil { 72 | logger().Error( 73 | "Cannot append host to assh_known_hosts file (perf. degradation)", 74 | zap.String("host", target), 75 | zap.String("file", c.ASSHKnownHostFile), 76 | zap.Error(err), 77 | ) 78 | return 79 | } 80 | defer func() { 81 | if err := file.Close(); err != nil { 82 | panic(err) 83 | } 84 | }() 85 | 86 | _, _ = fmt.Fprintln(file, target) 87 | } 88 | 89 | func (c *Config) addKnownHost(target string) { 90 | host := c.GetHostSafe(target) 91 | if inst, ok := c.Hosts[host.pattern]; ok { 92 | inst.AddKnownHost(target) 93 | } 94 | } 95 | 96 | // KnownHostsFileExists returns nil if it the file exists and an error if it doesn't 97 | func (c *Config) KnownHostsFileExists() error { 98 | path, err := utils.ExpandUser(c.ASSHKnownHostFile) 99 | if err != nil { 100 | return err 101 | } 102 | if _, err := os.Stat(path); os.IsNotExist(err) { 103 | return err 104 | } 105 | return nil 106 | } 107 | 108 | // LoadKnownHosts loads known hosts list from disk 109 | func (c *Config) LoadKnownHosts() error { 110 | path, err := utils.ExpandUser(c.ASSHKnownHostFile) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | file, err := os.Open(path) 116 | if err != nil { 117 | return err 118 | } 119 | defer func() { 120 | if err := file.Close(); err != nil { 121 | panic(err) 122 | } 123 | }() 124 | 125 | scanner := bufio.NewScanner(file) 126 | for scanner.Scan() { 127 | c.addKnownHost(scanner.Text()) 128 | } 129 | 130 | return scanner.Err() 131 | } 132 | 133 | // IncludedFiles returns the list of the included files 134 | func (c *Config) IncludedFiles() []string { 135 | includedFiles := []string{} 136 | for file := range c.includedFiles { 137 | includedFiles = append(includedFiles, file) 138 | } 139 | return includedFiles 140 | } 141 | 142 | // JSONString returns a string representing the JSON of a Config object 143 | func (c *Config) JSONString() ([]byte, error) { 144 | return json.MarshalIndent(c, "", " ") 145 | } 146 | 147 | // computeHost returns a copy of the host with applied defaults, resolved inheritances and configured internal fields 148 | func computeHost(host *Host, config *Config, name string, fullCompute bool) (*Host, error) { 149 | computedHost := NewHost(name) 150 | computedHost.pattern = name 151 | if host != nil { 152 | *computedHost = *host 153 | } 154 | 155 | // name internal field 156 | computedHost.name = name 157 | computedHost.inherited = make(map[string]bool) 158 | // self is already inherited 159 | computedHost.inherited[name] = true 160 | 161 | // Inheritance 162 | // FIXME: allow deeper inheritance: 163 | // currently not resolving inherited hosts 164 | // we should resolve all inherited hosts and pass the 165 | // currently resolved hosts to avoid computing an host twice 166 | for _, name := range host.Inherits { 167 | _, found := computedHost.inherited[name] 168 | if found { 169 | logger().Debug("Detected circular loop inheritance, skiping...") 170 | continue 171 | } 172 | computedHost.inherited[name] = true 173 | 174 | target, err := config.getHostByPath(name, false, false, true) 175 | if err != nil { 176 | logger().Warn( 177 | "Cannot inherits", 178 | zap.String("name", name), 179 | zap.Error(err), 180 | ) 181 | continue 182 | } 183 | computedHost.ApplyDefaults(target) 184 | } 185 | 186 | // fullCompute applies config.Defaults 187 | // config.Defaults should be applied when proxying 188 | // but should not when exporting .ssh/config file 189 | if fullCompute { 190 | // apply defaults based on "Host *" 191 | computedHost.ApplyDefaults(&config.Defaults) 192 | 193 | if computedHost.HostName == "" { 194 | computedHost.HostName = name 195 | } 196 | // expands variables in host 197 | // i.e: %h.some.zone -> {name}.some.zone 198 | hostname := strings.ReplaceAll(computedHost.HostName, "%h", "%n") 199 | 200 | // ssh resolve '%h' in hostnames 201 | // -> we bypass the string expansion if the input matches 202 | // an already resolved hostname 203 | // See https://github.com/moul/assh/issues/103 204 | pattern := strings.ReplaceAll(hostname, "%n", "*") 205 | if match, _ := path.Match(pattern, computedHost.inputName); match { 206 | computedHost.HostName = computedHost.inputName 207 | } else { 208 | computedHost.HostName = computedHost.ExpandString(hostname, "") 209 | } 210 | } 211 | 212 | return computedHost, nil 213 | } 214 | 215 | func (c *Config) getHostByName(name string, safe bool, compute bool, allowTemplate bool) (*Host, error) { 216 | if host, ok := c.Hosts[name]; ok { 217 | logger().Debug("getHostByName direct matching", zap.String("name", name)) 218 | return computeHost(host, c, name, compute) 219 | } 220 | 221 | for origPattern, host := range c.Hosts { 222 | patterns := append([]string{origPattern}, host.Aliases...) 223 | for _, pattern := range patterns { 224 | matched, err := path.Match(pattern, name) 225 | if err != nil { 226 | return nil, err 227 | } 228 | if matched { 229 | logger().Debug("getHostByName pattern matching", zap.String("pattern", pattern), zap.String("name", name)) 230 | return computeHost(host, c, name, compute) 231 | } 232 | } 233 | } 234 | 235 | if allowTemplate { 236 | for pattern, template := range c.Templates { 237 | matched, err := path.Match(pattern, name) 238 | if err != nil { 239 | return nil, err 240 | } 241 | if matched { 242 | return computeHost(template, c, name, compute) 243 | } 244 | } 245 | } 246 | 247 | if safe { 248 | host := NewHost(name) 249 | host.HostName = name 250 | return computeHost(host, c, name, compute) 251 | } 252 | 253 | return nil, fmt.Errorf("no such host: %s", name) 254 | } 255 | 256 | func (c *Config) getHostByPath(path string, safe bool, compute bool, allowTemplate bool) (*Host, error) { 257 | parts := strings.SplitN(path, "/", 2) 258 | 259 | host, err := c.getHostByName(parts[0], safe, compute, allowTemplate) 260 | if err != nil { 261 | return nil, err 262 | } 263 | 264 | if len(parts) > 1 { 265 | host.Gateways = []string{parts[1]} 266 | } 267 | 268 | return host, nil 269 | } 270 | 271 | // GetGatewaySafe returns gateway Host configuration, a gateway is like a Host, except, the host path is not resolved 272 | func (c *Config) GetGatewaySafe(name string) *Host { 273 | host, err := c.getHostByName(name, true, true, false) // FIXME: fullCompute for gateway ? 274 | if err != nil { 275 | panic(err) 276 | } 277 | return host 278 | } 279 | 280 | // GetHost returns a matching host form Config hosts list 281 | func (c *Config) GetHost(name string) (*Host, error) { 282 | return c.getHostByPath(name, false, true, false) 283 | } 284 | 285 | // GetHostSafe won't fail, in case the host is not found, it will returns a virtual host matching the pattern 286 | func (c *Config) GetHostSafe(name string) *Host { 287 | host, err := c.getHostByPath(name, true, true, false) 288 | if err != nil { 289 | panic(err) 290 | } 291 | return host 292 | } 293 | 294 | // isSSHConfigOutdated returns true if assh.yml or an included file has a 295 | // modification date more recent than .ssh/config 296 | func (c *Config) isSSHConfigOutdated() (bool, error) { 297 | filepath, err := utils.ExpandUser(c.sshConfigPath) 298 | if err != nil { 299 | return false, err 300 | } 301 | sshConfigStat, err := os.Stat(filepath) 302 | if err != nil { 303 | return false, err 304 | } 305 | sshConfigModTime := sshConfigStat.ModTime() 306 | 307 | for filepath := range c.includedFiles { 308 | asshConfigStat, err := os.Stat(filepath) 309 | if err != nil { 310 | return false, err 311 | } 312 | if asshConfigStat.ModTime().After(sshConfigModTime) { 313 | return true, nil 314 | } 315 | } 316 | return false, nil 317 | } 318 | 319 | // IsConfigOutdated returns true if .ssh/config needs to be rebuild. 320 | // The reason may be: 321 | // - assh.yml (or an included file) was updated recently 322 | // - matches a regex and was never seen before (not present in known-hosts file) 323 | func (c *Config) IsConfigOutdated(target string) (bool, error) { 324 | // check if the target is a regex and if the pattern 325 | // was never matched before (not in known hosts) 326 | if c.needsARebuildForTarget(target) { 327 | c.SaveNewKnownHost(target) 328 | return true, nil 329 | } 330 | 331 | // check if the ~/.ssh/config file is older than assh.yml or any included file 332 | return c.isSSHConfigOutdated() 333 | } 334 | 335 | // needsARebuildForTarget returns true if the .ssh/config file needs to be rebuild for a specific target 336 | func (c *Config) needsARebuildForTarget(target string) bool { 337 | parts := strings.Split(target, "/") 338 | 339 | // compute lists 340 | aliases := map[string]bool{} 341 | for _, host := range c.Hosts { 342 | for _, alias := range host.Aliases { 343 | aliases[alias] = true 344 | } 345 | for _, knownHost := range host.knownHosts { 346 | aliases[knownHost] = true 347 | } 348 | } 349 | 350 | patterns := []string{} 351 | for origPattern, host := range c.Hosts { 352 | patterns = append(patterns, origPattern) 353 | patterns = append(patterns, host.Aliases...) 354 | } 355 | 356 | for _, part := range parts { 357 | // check for direct hostname matching 358 | if _, ok := c.Hosts[part]; ok { 359 | continue 360 | } 361 | 362 | // check for direct alias matching 363 | if _, ok := aliases[part]; ok { 364 | continue 365 | } 366 | 367 | // check for pattern matching 368 | for _, pattern := range patterns { 369 | matched, err := path.Match(pattern, part) 370 | if err != nil { 371 | continue 372 | } 373 | if matched { 374 | return true 375 | } 376 | } 377 | } 378 | 379 | return false 380 | } 381 | 382 | // LoadConfig loads the content of an io.Reader source 383 | func (c *Config) LoadConfig(source io.Reader) error { 384 | buf, err := ioutil.ReadAll(source) 385 | if err != nil { 386 | return err 387 | } 388 | err = flexyaml.Unmarshal(buf, &c) 389 | if err != nil { 390 | return err 391 | } 392 | c.applyMissingNames() 393 | c.mergeWildCardEntries() 394 | return nil 395 | } 396 | 397 | func (c *Config) mergeWildCardEntries() { 398 | for k, host := range c.Hosts { 399 | if strings.Contains(k, "*") { 400 | continue 401 | } 402 | 403 | for key, subHost := range c.Hosts { 404 | if strings.Contains(key, "*") { 405 | keyParts := strings.Split(key, "*") 406 | // if * is in the middle 407 | if keyParts[0] != "" && keyParts[1] != "" { 408 | // if the wildcard matches 409 | if strings.Contains(k, keyParts[0]) && strings.Contains(k, keyParts[1]) { 410 | if err := mergo.Merge(host, subHost); err != nil { 411 | fmt.Println(err.Error()) 412 | } 413 | } 414 | } else { 415 | tempKey := strings.ReplaceAll(key, "*", "") 416 | // if the wildcard matches 417 | if strings.Contains(k, tempKey) { 418 | if err := mergo.Merge(host, subHost); err != nil { 419 | fmt.Println(err.Error()) 420 | } 421 | } 422 | } 423 | } 424 | } 425 | } 426 | } 427 | 428 | func (c *Config) applyMissingNames() { 429 | for key, host := range c.Hosts { 430 | if host == nil { 431 | c.Hosts[key] = &Host{} 432 | host = c.Hosts[key] 433 | } 434 | host.pattern = key 435 | host.name = key // should be removed 436 | host.prepare() 437 | } 438 | for key, template := range c.Templates { 439 | if template == nil { 440 | c.Templates[key] = &Host{} 441 | template = c.Templates[key] 442 | } 443 | template.pattern = key 444 | template.name = key // should be removed 445 | template.isTemplate = true 446 | template.prepare() 447 | } 448 | c.Defaults.isDefault = true 449 | if c.Defaults.Hooks == nil { 450 | c.Defaults.Hooks = &HostHooks{} 451 | } 452 | } 453 | 454 | // SaveSSHConfig saves the configuration to ~/.ssh/config 455 | func (c *Config) SaveSSHConfig() error { 456 | if c.sshConfigPath == "" { 457 | return fmt.Errorf("no Config.sshConfigPath configured") 458 | } 459 | configPath, err := utils.ExpandUser(c.sshConfigPath) 460 | if err != nil { 461 | return err 462 | } 463 | 464 | // validate hosts 465 | if err = c.ValidateSummary(); err != nil { 466 | return err 467 | } 468 | 469 | logger().Debug("Writing SSH config file", zap.String("file", configPath)) 470 | 471 | tmpDir := filepath.Dir(configPath) 472 | tmpFile, err := ioutil.TempFile(tmpDir, "config") 473 | if err != nil { 474 | return err 475 | } 476 | shouldRemoveTempfile := true 477 | defer func() { 478 | if !shouldRemoveTempfile { 479 | return 480 | } 481 | if err = os.Remove(tmpFile.Name()); err != nil { 482 | logger().Debug("Unable to remove tempfile", zap.String("file", tmpFile.Name())) 483 | } 484 | }() 485 | 486 | if err = c.WriteSSHConfigTo(tmpFile); err != nil { 487 | return err 488 | } 489 | if err = tmpFile.Close(); err != nil { 490 | return err 491 | } 492 | 493 | err = os.Rename(tmpFile.Name(), configPath) 494 | if err != nil { 495 | shouldRemoveTempfile = false 496 | } 497 | return err 498 | } 499 | 500 | // LoadFile loads the content of a configuration file in the Config object 501 | func (c *Config) LoadFile(filename string) error { 502 | beforeHostsCount := len(c.Hosts) 503 | 504 | // Resolve '~' and '$HOME' 505 | filepath, err := utils.ExpandUser(filename) 506 | if err != nil { 507 | return err 508 | } 509 | 510 | // Anti-loop protection 511 | if _, ok := c.includedFiles[filepath]; ok { 512 | return nil 513 | } 514 | c.includedFiles[filepath] = false 515 | 516 | logger().Debug("Loading config file", zap.String("file", filepath)) 517 | 518 | // Read file 519 | source, err := os.Open(filepath) 520 | if err != nil { 521 | return err 522 | } 523 | 524 | // Load config stream 525 | err = c.LoadConfig(source) 526 | if err != nil { 527 | return err 528 | } 529 | 530 | // Successful loading 531 | c.includedFiles[filepath] = true 532 | afterHostsCount := len(c.Hosts) 533 | diffHostsCount := afterHostsCount - beforeHostsCount 534 | logger().Debug( 535 | "Loaded config file", 536 | zap.String("file", filepath), 537 | zap.Int("num-host-before", beforeHostsCount), 538 | zap.Int("num-host-after", afterHostsCount), 539 | zap.Int("num-host-diff", diffHostsCount), 540 | ) 541 | 542 | // Handling includes 543 | for _, include := range c.Includes { 544 | if err = c.LoadFiles(include); err != nil { 545 | return err 546 | } 547 | } 548 | 549 | return nil 550 | } 551 | 552 | // LoadFiles will try to glob the pattern and load each matching entries 553 | func (c *Config) LoadFiles(pattern string) error { 554 | // Resolve '~' and '$HOME' 555 | expandedPattern, err := utils.ExpandUser(pattern) 556 | if err != nil { 557 | return err 558 | } 559 | 560 | // Globbing 561 | filepaths, err := filepath.Glob(expandedPattern) 562 | if err != nil { 563 | return err 564 | } 565 | 566 | // Load files iteratively 567 | for _, filepath := range filepaths { 568 | if err := c.LoadFile(filepath); err != nil { 569 | logger().Warn("Cannot include file", zap.String("file", filepath), zap.Error(err)) 570 | } 571 | } 572 | 573 | if c.ASSHBinaryPath != "" { 574 | path, err := utils.ExpandUser(c.ASSHBinaryPath) 575 | if err != nil { 576 | return err 577 | } 578 | asshBinaryPath = path 579 | } 580 | return nil 581 | } 582 | 583 | // sortedNames returns the host names sorted alphabetically 584 | func (c *Config) sortedNames() []string { 585 | names := sort.StringSlice{} 586 | for key := range c.Hosts { 587 | names = append(names, key) 588 | } 589 | sort.Sort(names) 590 | return names 591 | } 592 | 593 | // Validate checks for values errors 594 | func (c *Config) Validate() []error { 595 | errs := []error{} 596 | for _, host := range c.Hosts { 597 | errs = append(errs, host.Validate()...) 598 | } 599 | return errs 600 | } 601 | 602 | // ValidateSummary summaries Validate() errors slice 603 | func (c *Config) ValidateSummary() error { 604 | switch errs := c.Validate(); len(errs) { 605 | case 0: 606 | return nil 607 | case 1: 608 | return errs[0] 609 | default: 610 | errsStrings := []string{} 611 | for _, err := range errs { 612 | errsStrings = append(errsStrings, fmt.Sprintf("- %s", err.Error())) 613 | } 614 | return fmt.Errorf("multiple errors:\n%s", strings.Join(errsStrings, "\n")) 615 | } 616 | } 617 | 618 | // WriteSSHConfigTo returns a .ssh/config valid file containing assh configuration 619 | func (c *Config) WriteSSHConfigTo(w io.Writer) error { 620 | header := strings.TrimSpace(` 621 | # This file was automatically generated by assh v%VERSION (%VCS_REF) 622 | # on %BUILD_DATE, based on ~/.ssh/assh.yml 623 | # 624 | # more info: https://github.com/moul/assh 625 | `) 626 | header = strings.ReplaceAll(header, "%VERSION", version.Version) 627 | header = strings.ReplaceAll(header, "%VCS_REF", version.VcsRef) 628 | header = strings.ReplaceAll(header, "%BUILD_DATE", time.Now().Format("2006-01-02 15:04:05 -0700 MST")) 629 | _, _ = fmt.Fprintln(w, header) 630 | // FIXME: add version 631 | _, _ = fmt.Fprintln(w) 632 | 633 | _, _ = fmt.Fprintln(w, "# host-based configuration") 634 | for _, name := range c.sortedNames() { 635 | host := c.Hosts[name] 636 | computedHost, err := computeHost(host, c, name, false) 637 | if err != nil { 638 | return err 639 | } 640 | if err = computedHost.WriteSSHConfigTo(w); err != nil { 641 | return err 642 | } 643 | _, _ = fmt.Fprintln(w) 644 | } 645 | 646 | _, _ = fmt.Fprintln(w, "# global configuration") 647 | c.Defaults.name = "*" 648 | return c.Defaults.WriteSSHConfigTo(w) 649 | } 650 | 651 | // SSHConfigPath returns the ~/.ssh/config file path 652 | func (c *Config) SSHConfigPath() string { return c.sshConfigPath } 653 | 654 | // New returns an instantiated Config object 655 | func New() *Config { 656 | var config Config 657 | config.Hosts = make(map[string]*Host) 658 | config.Templates = make(map[string]*Host) 659 | config.includedFiles = make(map[string]bool) 660 | config.sshConfigPath = defaultSSHConfigPath 661 | config.ASSHKnownHostFile = "~/.ssh/assh_known_hosts" 662 | config.ASSHBinaryPath = "" 663 | return &config 664 | } 665 | 666 | // Open parses a configuration file and returns a *Config object 667 | func Open(path string) (*Config, error) { 668 | config := New() 669 | err := config.LoadFile(path) 670 | if err != nil { 671 | return nil, err 672 | } 673 | return config, nil 674 | } 675 | -------------------------------------------------------------------------------- /pkg/config/doc.go: -------------------------------------------------------------------------------- 1 | package config // import "moul.io/assh/v2/pkg/config" 2 | -------------------------------------------------------------------------------- /pkg/config/graphviz/doc.go: -------------------------------------------------------------------------------- 1 | package graphviz // import "moul.io/assh/v2/pkg/config/graphviz" 2 | -------------------------------------------------------------------------------- /pkg/config/graphviz/graphviz.go: -------------------------------------------------------------------------------- 1 | package graphviz 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/awalterschulze/gographviz" 7 | "moul.io/assh/v2/pkg/config" 8 | ) 9 | 10 | func nodename(input string) string { 11 | return fmt.Sprintf(`"%s"`, input) 12 | } 13 | 14 | // GraphSettings are used to change the Graph() function behavior. 15 | type GraphSettings struct { 16 | ShowIsolatedHosts bool 17 | NoResolveWildcard bool 18 | NoInherits bool 19 | } 20 | 21 | // Graph computes and returns a dot-compatible graph representation of the config. 22 | func Graph(cfg *config.Config, settings *GraphSettings) (string, error) { 23 | graph := gographviz.NewGraph() 24 | if err := graph.SetName("G"); err != nil { 25 | return "", err 26 | } 27 | if err := graph.SetDir(true); err != nil { 28 | return "", err 29 | } 30 | 31 | hostsToShow := map[string]bool{} 32 | 33 | for _, host := range cfg.Hosts { 34 | if len(host.Gateways) == 0 && !settings.ShowIsolatedHosts { 35 | continue 36 | } 37 | 38 | hostsToShow[nodename(host.Name())] = true 39 | idx := 0 40 | for _, gateway := range host.Gateways { 41 | if gateway == "direct" { 42 | continue 43 | } 44 | if _, found := cfg.Hosts[gateway]; !found { 45 | if settings.NoResolveWildcard { 46 | continue 47 | } 48 | gw := cfg.GetGatewaySafe(gateway) 49 | if gw == nil { 50 | continue 51 | } 52 | if err := graph.AddEdge(nodename(host.Name()), nodename(gw.RawName()), true, map[string]string{"color": "red", "label": nodename(gateway)}); err != nil { 53 | return "", err 54 | } 55 | hostsToShow[nodename(gw.RawName())] = true 56 | continue 57 | } 58 | idx++ 59 | hostsToShow[nodename(gateway)] = true 60 | if err := graph.AddEdge(nodename(host.Name()), nodename(gateway), true, map[string]string{"color": "red", "label": fmt.Sprintf("%d", idx)}); err != nil { 61 | return "", err 62 | } 63 | } 64 | 65 | if !settings.NoInherits { 66 | for _, inherit := range host.Inherits { 67 | hostsToShow[nodename(inherit)] = true 68 | if err := graph.AddEdge(nodename(host.Name()), nodename(inherit), true, map[string]string{"color": "black", "style": "dashed"}); err != nil { 69 | return "", err 70 | } 71 | } 72 | } 73 | } 74 | 75 | for hostname := range hostsToShow { 76 | if err := graph.AddNode("G", hostname, map[string]string{"color": "blue"}); err != nil { 77 | return "", err 78 | } 79 | } 80 | 81 | return graph.String(), nil 82 | } 83 | -------------------------------------------------------------------------------- /pkg/config/graphviz/graphviz_test.go: -------------------------------------------------------------------------------- 1 | package graphviz 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "testing" 8 | 9 | . "github.com/smartystreets/goconvey/convey" 10 | "moul.io/assh/v2/pkg/config" 11 | ) 12 | 13 | func TestGraph(t *testing.T) { 14 | yamlConfig := `hosts: 15 | aaa: 16 | gateways: [bbb, direct] 17 | bbb: 18 | gateways: [ccc, aaa] 19 | ccc: 20 | gateways: [eee, direct] 21 | ddd: 22 | eee: 23 | fff: 24 | gateways: [eee, direct] 25 | ggg: 26 | ` 27 | 28 | Convey("Testing Graph()", t, func() { 29 | conf := config.New() 30 | err := conf.LoadConfig(strings.NewReader(yamlConfig)) 31 | So(err, ShouldBeNil) 32 | 33 | graph, err := Graph(conf, &GraphSettings{}) 34 | So(err, ShouldBeNil) 35 | fmt.Println(graph) 36 | 37 | expected := `digraph G { 38 | "fff"->"eee"[ color=red, label=1 ]; 39 | "aaa"->"bbb"[ color=red, label=1 ]; 40 | "bbb"->"ccc"[ color=red, label=1 ]; 41 | "bbb"->"aaa"[ color=red, label=2 ]; 42 | "ccc"->"eee"[ color=red, label=1 ]; 43 | "aaa" [ color=blue ]; 44 | "bbb" [ color=blue ]; 45 | "ccc" [ color=blue ]; 46 | "eee" [ color=blue ]; 47 | "fff" [ color=blue ]; 48 | 49 | } 50 | ` 51 | So(sortedOutput(graph), ShouldEqual, sortedOutput(expected)) 52 | }) 53 | 54 | Convey("Testing Graph() with isolated hosts", t, func() { 55 | conf := config.New() 56 | err := conf.LoadConfig(strings.NewReader(yamlConfig)) 57 | So(err, ShouldBeNil) 58 | 59 | graph, err := Graph(conf, &GraphSettings{ShowIsolatedHosts: true}) 60 | So(err, ShouldBeNil) 61 | fmt.Println(graph) 62 | 63 | expected := `digraph G { 64 | "fff"->"eee"[ color=red, label=1 ]; 65 | "aaa"->"bbb"[ color=red, label=1 ]; 66 | "bbb"->"ccc"[ color=red, label=1 ]; 67 | "bbb"->"aaa"[ color=red, label=2 ]; 68 | "ccc"->"eee"[ color=red, label=1 ]; 69 | "aaa" [ color=blue ]; 70 | "bbb" [ color=blue ]; 71 | "ccc" [ color=blue ]; 72 | "ddd" [ color=blue ]; 73 | "eee" [ color=blue ]; 74 | "fff" [ color=blue ]; 75 | "ggg" [ color=blue ]; 76 | 77 | } 78 | ` 79 | 80 | So(sortedOutput(graph), ShouldEqual, sortedOutput(expected)) 81 | }) 82 | } 83 | 84 | func sortedOutput(input string) string { 85 | lines := strings.Split(input, "\n") 86 | sort.Strings(lines) 87 | return strings.Join(lines, "\n") 88 | } 89 | -------------------------------------------------------------------------------- /pkg/config/graphviz/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package graphviz 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.config.graphviz") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/config/helpers.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func isDynamicHostname(hostname string) bool { 10 | return strings.Contains(hostname, `*`) || 11 | strings.Contains(hostname, `[`) || 12 | strings.Contains(hostname, `]`) 13 | } 14 | 15 | // BoolVal returns a boolean matching a configuration string 16 | func BoolVal(input string) bool { 17 | input = cleanupValue(input) 18 | trueValues := []string{"yes", "ok", "true", "1", "enabled"} 19 | for _, val := range trueValues { 20 | if val == input { 21 | return true 22 | } 23 | } 24 | return false 25 | } 26 | 27 | func cleanupValue(input string) string { 28 | return strings.TrimSpace(strings.ToLower(input)) 29 | } 30 | 31 | // stringComment splits comment strings into <1024 char lines 32 | func stringComment(name, value string) string { 33 | maxLength := 1024 - len(name) - 9 34 | ret := []string{} 35 | for _, line := range splitSubN(value, maxLength) { 36 | ret = append(ret, fmt.Sprintf(" # %s: %s", name, line)) 37 | } 38 | return strings.Join(ret, "\n") + "\n" 39 | } 40 | 41 | // sliceComment splits comment strings into <1024 char lines 42 | func sliceComment(name string, slice []string) string { 43 | var ( 44 | bundles [][]string 45 | bundleIdx = 0 46 | curLen = 0 47 | maxLength = 1024 - len(name) - 12 48 | ) 49 | bundles = append(bundles, []string{}) 50 | 51 | for _, item := range slice { 52 | for _, line := range strings.Split(item, "\n") { 53 | line = strings.TrimSpace(line) 54 | if line == "" { 55 | continue 56 | } 57 | 58 | if curLen+len(line) >= maxLength { 59 | bundleIdx++ 60 | bundles = append(bundles, []string{}) 61 | curLen = 0 62 | } 63 | bundles[bundleIdx] = append(bundles[bundleIdx], line) 64 | curLen += len(line) + 2 65 | } 66 | } 67 | 68 | ret := []string{} 69 | for _, bundle := range bundles { 70 | ret = append(ret, fmt.Sprintf(" # %s: [%s]", name, strings.Join(bundle, ", "))) 71 | } 72 | return strings.Join(ret, "\n") + "\n" 73 | } 74 | 75 | // splitSubN splits a string by length 76 | // from: http://stackoverflow.com/questions/25686109/split-string-by-length-in-golang 77 | func splitSubN(s string, n int) []string { 78 | sub := "" 79 | subs := []string{} 80 | runes := bytes.Runes([]byte(s)) 81 | l := len(runes) 82 | for i, r := range runes { 83 | sub += string(r) 84 | if (i+1)%n == 0 { 85 | subs = append(subs, sub) 86 | sub = "" 87 | } else if (i + 1) == l { 88 | subs = append(subs, sub) 89 | } 90 | } 91 | return subs 92 | } 93 | -------------------------------------------------------------------------------- /pkg/config/helpers_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | // BoolVal returns a boolean matching a configuration string 10 | func TestBoolVal(t *testing.T) { 11 | Convey("Testing BoolVal", t, func() { 12 | trueValues := []string{"yes", "ok", "true", "1", "enabled", "True", "TRUE", "YES", "Yes"} 13 | falseValues := []string{"no", "0", "false", "False", "FALSE", "disabled"} 14 | for _, val := range trueValues { 15 | So(BoolVal(val), ShouldBeTrue) 16 | } 17 | for _, val := range falseValues { 18 | So(BoolVal(val), ShouldBeFalse) 19 | } 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/config/hook.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "moul.io/assh/v2/pkg/hooks" 7 | ) 8 | 9 | // HostHooks represents a static list of Hooks 10 | type HostHooks struct { 11 | AfterConfigWrite hooks.Hooks `yaml:"afterconfigwrite,omitempty,flow" json:"AfterConfigWrite,omitempty"` 12 | BeforeConfigWrite hooks.Hooks `yaml:"beforeconfigwrite,omitempty,flow" json:"BeforeConfigWrite,omitempty"` 13 | BeforeConnect hooks.Hooks `yaml:"beforeconnect,omitempty,flow" json:"BeforeConnect,omitempty"` 14 | OnConnect hooks.Hooks `yaml:"onconnect,omitempty,flow" json:"OnConnect,omitempty"` 15 | OnConnectError hooks.Hooks `yaml:"onconnecterror,omitempty,flow" json:"OnConnectError,omitempty"` 16 | OnDisconnect hooks.Hooks `yaml:"ondisconnect,omitempty,flow" json:"OnDisconnect,omitempty"` 17 | } 18 | 19 | // Length returns the quantity of hooks of any type 20 | func (hh *HostHooks) Length() int { 21 | if hh == nil { 22 | return 0 23 | } 24 | return len(hh.AfterConfigWrite) + 25 | len(hh.BeforeConnect) + 26 | len(hh.OnConnectError) + 27 | len(hh.OnDisconnect) + 28 | len(hh.OnConnect) 29 | } 30 | 31 | // String returns the JSON output 32 | func (hh *HostHooks) String() string { 33 | s, err := json.Marshal(hh) 34 | if err != nil { 35 | return err.Error() 36 | } 37 | return string(s) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/config/host_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os/user" 6 | "testing" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestHost_ApplyDefaults(t *testing.T) { 12 | Convey("Testing Host.ApplyDefaults", t, func() { 13 | Convey("Standard configuration", func() { 14 | host := &Host{ 15 | name: "example", 16 | HostName: "example.com", 17 | User: "root", 18 | } 19 | defaults := &Host{ 20 | User: "bobby", 21 | Port: "42", 22 | } 23 | host.ApplyDefaults(defaults) 24 | So(host.Port, ShouldEqual, "42") 25 | So(host.Name(), ShouldEqual, "example") 26 | So(host.HostName, ShouldEqual, "example.com") 27 | So(host.User, ShouldEqual, "root") 28 | So(len(host.Gateways), ShouldEqual, 0) 29 | So(host.ProxyCommand, ShouldEqual, "") 30 | So(len(host.ResolveNameservers), ShouldEqual, 0) 31 | So(host.ResolveCommand, ShouldEqual, "") 32 | So(host.ControlPath, ShouldEqual, "") 33 | }) 34 | Convey("Empty configuration", func() { 35 | host := &Host{} 36 | defaults := &Host{} 37 | host.ApplyDefaults(defaults) 38 | So(host.Port, ShouldEqual, "22") 39 | So(host.Name(), ShouldEqual, "") 40 | So(host.HostName, ShouldEqual, "") 41 | So(host.User, ShouldEqual, "") 42 | So(len(host.Gateways), ShouldEqual, 0) 43 | So(host.ProxyCommand, ShouldEqual, "") 44 | So(len(host.ResolveNameservers), ShouldEqual, 0) 45 | So(host.ResolveCommand, ShouldEqual, "") 46 | So(host.ControlPath, ShouldEqual, "") 47 | }) 48 | }) 49 | } 50 | 51 | func TestHost_ExpandString(t *testing.T) { 52 | Convey("Testing Host.ExpandString()", t, func() { 53 | host := NewHost("abc") 54 | host.HostName = "1.2.3.4" 55 | host.Port = "42" 56 | 57 | var input, output, expected string 58 | 59 | input = "ls -la" 60 | output = host.ExpandString(input, "") 61 | expected = "ls -la" 62 | So(output, ShouldEqual, expected) 63 | 64 | input = "nc %h %p" 65 | output = host.ExpandString(input, "") 66 | expected = "nc 1.2.3.4 42" 67 | So(output, ShouldEqual, expected) 68 | 69 | input = "ssh %name" 70 | output = host.ExpandString(input, "") 71 | expected = "ssh abc" 72 | So(output, ShouldEqual, expected) 73 | 74 | input = "echo %h %p %name %h %p %name" 75 | output = host.ExpandString(input, "") 76 | expected = "echo 1.2.3.4 42 abc 1.2.3.4 42 abc" 77 | So(output, ShouldEqual, expected) 78 | 79 | input = "echo %g" 80 | output = host.ExpandString(input, "") 81 | expected = "echo " 82 | So(output, ShouldEqual, expected) 83 | 84 | input = "echo %g" 85 | output = host.ExpandString(input, "def") 86 | expected = "echo def" 87 | So(output, ShouldEqual, expected) 88 | }) 89 | } 90 | 91 | func TestHost_Clone(t *testing.T) { 92 | Convey("Testing Host.Clone()", t, func() { 93 | a := NewHost("abc") 94 | a.HostName = "1.2.3.4" 95 | a.Port = "42" 96 | 97 | b := a.Clone() 98 | 99 | So(a, ShouldNotEqual, b) 100 | So(a.HostName, ShouldEqual, "1.2.3.4") 101 | So(b.HostName, ShouldEqual, "1.2.3.4") 102 | So(a.Port, ShouldEqual, "42") 103 | So(b.Port, ShouldEqual, "42") 104 | }) 105 | } 106 | 107 | func TestHost_Prototype(t *testing.T) { 108 | Convey("Testing Host.Prototype()", t, func() { 109 | currentUser, err := user.Current() 110 | if err != nil { 111 | panic(err) 112 | } 113 | 114 | host := NewHost("abc") 115 | So(host.Prototype(), ShouldEqual, fmt.Sprintf("%s@abc:22", currentUser.Username)) 116 | 117 | host = NewHost("abc-*") 118 | So(host.Prototype(), ShouldEqual, fmt.Sprintf("%s@[dynamic]:22", currentUser.Username)) 119 | 120 | host = NewHost("abc") 121 | host.User = "toto" 122 | host.HostName = "1.2.3.4" 123 | host.Port = "42" 124 | So(host.Prototype(), ShouldEqual, "toto@1.2.3.4:42") 125 | }) 126 | } 127 | 128 | func TestHost_Matches(t *testing.T) { 129 | Convey("Testing Host.Matches()", t, func() { 130 | host := NewHost("abc") 131 | So(host.Matches("a"), ShouldBeTrue) 132 | So(host.Matches("ab"), ShouldBeTrue) 133 | So(host.Matches("abc"), ShouldBeTrue) 134 | So(host.Matches("bcd"), ShouldBeFalse) 135 | So(host.Matches("b"), ShouldBeTrue) 136 | So(host.Matches("bc"), ShouldBeTrue) 137 | So(host.Matches("c"), ShouldBeTrue) 138 | 139 | host.User = "bcd" 140 | So(host.Matches("a"), ShouldBeTrue) 141 | So(host.Matches("ab"), ShouldBeTrue) 142 | So(host.Matches("abc"), ShouldBeTrue) 143 | So(host.Matches("bcd"), ShouldBeTrue) 144 | So(host.Matches("b"), ShouldBeTrue) 145 | So(host.Matches("bc"), ShouldBeTrue) 146 | So(host.Matches("c"), ShouldBeTrue) 147 | }) 148 | } 149 | 150 | func TestHost_Validate(t *testing.T) { 151 | Convey("Testing Host.Validate()", t, FailureContinues, func() { 152 | host := NewHost("abc") 153 | 154 | errs := host.Validate() 155 | So(len(errs), ShouldEqual, 0) 156 | 157 | for _, value := range []string{"yes", "no", "ask", "auto", "autoask", "", "Yes", "YES", "yEs", " yes "} { 158 | host.ControlMaster = value 159 | errs = host.Validate() 160 | So(len(errs), ShouldEqual, 0) 161 | } 162 | 163 | for _, value := range []string{"blah blah", "invalid"} { 164 | host.ControlMaster = value 165 | errs = host.Validate() 166 | So(len(errs), ShouldEqual, 1) 167 | } 168 | }) 169 | } 170 | 171 | func TestHost_Options(t *testing.T) { 172 | Convey("Testing Host.Options()", t, func() { 173 | host := NewHost("abc") 174 | options := host.Options() 175 | So(len(options), ShouldEqual, 0) 176 | So(options, ShouldResemble, OptionsList{}) 177 | 178 | host = dummyHost() 179 | options = host.Options() 180 | So(len(options), ShouldEqual, 98) 181 | So(options, ShouldResemble, OptionsList{{Name: "AddKeysToAgent", Value: "yes"}, {Name: "AddressFamily", Value: "any"}, {Name: "AskPassGUI", Value: "yes"}, {Name: "BatchMode", Value: "no"}, {Name: "CanonicalDomains", Value: "42.am"}, {Name: "CanonicalizeFallbackLocal", Value: "no"}, {Name: "CanonicalizeHostname", Value: "yes"}, {Name: "CanonicalizeMaxDots", Value: "1"}, {Name: "CanonicalizePermittedCNAMEs", Value: "*.a.example.com:*.b.example.com:*.c.example.com"}, {Name: "ChallengeResponseAuthentication", Value: "yes"}, {Name: "CheckHostIP", Value: "yes"}, {Name: "Cipher", Value: "blowfish"}, {Name: "Ciphers", Value: "aes128-ctr,aes192-ctr,aes256-ctr,test"}, {Name: "ClearAllForwardings", Value: "yes"}, {Name: "Compression", Value: "yes"}, {Name: "CompressionLevel", Value: "6"}, {Name: "ConnectionAttempts", Value: "1"}, {Name: "ConnectTimeout", Value: "10"}, {Name: "ControlMaster", Value: "yes"}, {Name: "ControlPath", Value: "/tmp/%L-%l-%n-%p-%u-%r-%C-%h"}, {Name: "ControlPersist", Value: "yes"}, {Name: "DynamicForward", Value: "0.0.0.0:4242"}, {Name: "DynamicForward", Value: "0.0.0.0:4343"}, {Name: "EnableSSHKeysign", Value: "yes"}, {Name: "EscapeChar", Value: "~"}, {Name: "ExitOnForwardFailure", Value: "yes"}, {Name: "FingerprintHash", Value: "sha256"}, {Name: "ForwardAgent", Value: "yes"}, {Name: "ForwardX11", Value: "yes"}, {Name: "ForwardX11Timeout", Value: "42"}, {Name: "ForwardX11Trusted", Value: "yes"}, {Name: "GatewayPorts", Value: "yes"}, {Name: "GlobalKnownHostsFile", Value: "/etc/ssh/ssh_known_hosts /tmp/ssh_known_hosts"}, {Name: "GSSAPIAuthentication", Value: "no"}, {Name: "GSSAPIClientIdentity", Value: "moul"}, {Name: "GSSAPIDelegateCredentials", Value: "no"}, {Name: "GSSAPIKeyExchange", Value: "no"}, {Name: "GSSAPIRenewalForcesRekey", Value: "no"}, {Name: "GSSAPIServerIdentity", Value: "gssapi.example.com"}, {Name: "GSSAPITrustDNS", Value: "no"}, {Name: "HashKnownHosts", Value: "no"}, {Name: "HostbasedAuthentication", Value: "no"}, {Name: "HostbasedKeyTypes", Value: "*"}, {Name: "HostKeyAlgorithms", Value: "ecdsa-sha2-nistp256-cert-v01@openssh.com,test"}, {Name: "HostKeyAlias", Value: "z"}, {Name: "IdentitiesOnly", Value: "yes"}, {Name: "IdentityFile", Value: "~/.ssh/identity"}, {Name: "IdentityFile", Value: "~/.ssh/identity2"}, {Name: "IgnoreUnknown", Value: "testtest"}, {Name: "IPQoS", Value: "lowdelay highdelay"}, {Name: "KbdInteractiveAuthentication", Value: "yes"}, {Name: "KbdInteractiveDevices", Value: "bsdauth,test"}, {Name: "KexAlgorithms", Value: "curve25519-sha256@libssh.org,test"}, {Name: "KeychainIntegration", Value: "yes"}, {Name: "LocalCommand", Value: "echo %h > /tmp/logs"}, {Name: "RemoteCommand", Value: "echo %h > /tmp/logs"}, {Name: "LocalForward", Value: "0.0.0.0:1234"}, {Name: "LocalForward", Value: "0.0.0.0:1235"}, {Name: "LogLevel", Value: "DEBUG3"}, {Name: "MACs", Value: "umac-64-etm@openssh.com,umac-128-etm@openssh.com,test"}, {Name: "Match", Value: "all"}, {Name: "NoHostAuthenticationForLocalhost", Value: "yes"}, {Name: "NumberOfPasswordPrompts", Value: "3"}, {Name: "PasswordAuthentication", Value: "yes"}, {Name: "PermitLocalCommand", Value: "yes"}, {Name: "PKCS11Provider", Value: "/a/b/c/pkcs11.so"}, {Name: "Port", Value: "22"}, {Name: "PreferredAuthentications", Value: "gssapi-with-mic,hostbased,publickey"}, {Name: "Protocol", Value: "2,3"}, {Name: "ProxyJump", Value: "proxy.host"}, {Name: "ProxyUseFdpass", Value: "no"}, {Name: "PubkeyAcceptedAlgorithms", Value: "+ssh-rsa"}, {Name: "PubkeyAcceptedKeyTypes", Value: "+ssh-dss"}, {Name: "PubkeyAuthentication", Value: "yes"}, {Name: "RekeyLimit", Value: "default none"}, {Name: "RemoteForward", Value: "0.0.0.0:1234"}, {Name: "RemoteForward", Value: "0.0.0.0:1235"}, {Name: "RequestTTY", Value: "yes"}, {Name: "RevokedHostKeys", Value: "/a/revoked-keys"}, {Name: "RhostsRSAAuthentication", Value: "no"}, {Name: "RSAAuthentication", Value: "yes"}, {Name: "SendEnv", Value: "CUSTOM_*,TEST"}, {Name: "SendEnv", Value: "TEST2"}, {Name: "ServerAliveCountMax", Value: "3"}, {Name: "StreamLocalBindMask", Value: "0177"}, {Name: "StreamLocalBindUnlink", Value: "no"}, {Name: "StrictHostKeyChecking", Value: "ask"}, {Name: "TCPKeepAlive", Value: "yes"}, {Name: "Tunnel", Value: "yes"}, {Name: "TunnelDevice", Value: "any:any"}, {Name: "UpdateHostKeys", Value: "ask"}, {Name: "UseKeychain", Value: "no"}, {Name: "UsePrivilegedPort", Value: "no"}, {Name: "User", Value: "moul"}, {Name: "UserKnownHostsFile", Value: "~/.ssh/known_hosts ~/.ssh/known_hosts2 /tmp/known_hosts"}, {Name: "VerifyHostKeyDNS", Value: "no"}, {Name: "VisualHostKey", Value: "yes"}, {Name: "XAuthLocation", Value: "xauth"}}) 182 | }) 183 | } 184 | 185 | func dummyHost() *Host { 186 | return &Host{ 187 | // ssh-config fields 188 | AddKeysToAgent: "yes", 189 | AddressFamily: "any", 190 | AskPassGUI: "yes", 191 | BatchMode: "no", 192 | BindAddress: "", 193 | CanonicalDomains: "42.am", 194 | CanonicalizeFallbackLocal: "no", 195 | CanonicalizeHostname: "yes", 196 | CanonicalizeMaxDots: "1", 197 | CanonicalizePermittedCNAMEs: "*.a.example.com:*.b.example.com:*.c.example.com", 198 | ChallengeResponseAuthentication: "yes", 199 | CheckHostIP: "yes", 200 | Cipher: "blowfish", 201 | Ciphers: []string{"aes128-ctr,aes192-ctr,aes256-ctr", "test"}, 202 | ClearAllForwardings: "yes", 203 | Compression: "yes", 204 | CompressionLevel: 6, 205 | ConnectionAttempts: "1", 206 | ConnectTimeout: 10, 207 | ControlMaster: "yes", 208 | ControlPath: "/tmp/%L-%l-%n-%p-%u-%r-%C-%h", 209 | ControlPersist: "yes", 210 | DynamicForward: []string{"0.0.0.0:4242", "0.0.0.0:4343"}, 211 | EnableSSHKeysign: "yes", 212 | EscapeChar: "~", 213 | ExitOnForwardFailure: "yes", 214 | FingerprintHash: "sha256", 215 | ForwardAgent: "yes", 216 | ForwardX11: "yes", 217 | ForwardX11Timeout: 42, 218 | ForwardX11Trusted: "yes", 219 | GatewayPorts: "yes", 220 | GlobalKnownHostsFile: []string{"/etc/ssh/ssh_known_hosts", "/tmp/ssh_known_hosts"}, 221 | GSSAPIAuthentication: "no", 222 | GSSAPIKeyExchange: "no", 223 | GSSAPIClientIdentity: "moul", 224 | GSSAPIServerIdentity: "gssapi.example.com", 225 | GSSAPIDelegateCredentials: "no", 226 | GSSAPIRenewalForcesRekey: "no", 227 | GSSAPITrustDNS: "no", 228 | HashKnownHosts: "no", 229 | HostbasedAuthentication: "no", 230 | HostbasedKeyTypes: "*", 231 | HostKeyAlgorithms: []string{"ecdsa-sha2-nistp256-cert-v01@openssh.com", "test"}, 232 | HostKeyAlias: "z", 233 | IdentitiesOnly: "yes", 234 | IdentityFile: []string{"~/.ssh/identity", "~/.ssh/identity2"}, 235 | IgnoreUnknown: "testtest", // FIXME: looks very interesting to generate .ssh/config without comments ! 236 | IPQoS: []string{"lowdelay", "highdelay"}, 237 | KbdInteractiveAuthentication: "yes", 238 | KbdInteractiveDevices: []string{"bsdauth", "test"}, 239 | KeychainIntegration: "yes", 240 | KexAlgorithms: []string{"curve25519-sha256@libssh.org", "test"}, // for all algorithms/ciphers, we could have an "assh diagnose" that warns about unsafe connections 241 | LocalCommand: "echo %h > /tmp/logs", 242 | LocalForward: []string{"0.0.0.0:1234", "0.0.0.0:1235"}, // FIXME: may be a list 243 | LogLevel: "DEBUG3", 244 | MACs: []string{"umac-64-etm@openssh.com,umac-128-etm@openssh.com", "test"}, 245 | Match: "all", 246 | NoHostAuthenticationForLocalhost: "yes", 247 | NumberOfPasswordPrompts: "3", 248 | PasswordAuthentication: "yes", 249 | PermitLocalCommand: "yes", 250 | PKCS11Provider: "/a/b/c/pkcs11.so", 251 | Port: "22", 252 | PreferredAuthentications: "gssapi-with-mic,hostbased,publickey", 253 | Protocol: []string{"2", "3"}, 254 | ProxyJump: "proxy.host", 255 | ProxyUseFdpass: "no", 256 | PubkeyAcceptedAlgorithms: "+ssh-rsa", 257 | PubkeyAcceptedKeyTypes: "+ssh-dss", 258 | PubkeyAuthentication: "yes", 259 | RekeyLimit: "default none", 260 | RemoteCommand: "echo %h > /tmp/logs", 261 | RemoteForward: []string{"0.0.0.0:1234", "0.0.0.0:1235"}, 262 | RequestTTY: "yes", 263 | RevokedHostKeys: "/a/revoked-keys", 264 | RhostsRSAAuthentication: "no", 265 | RSAAuthentication: "yes", 266 | SendEnv: []string{"CUSTOM_*,TEST", "TEST2"}, 267 | ServerAliveCountMax: 3, 268 | ServerAliveInterval: 0, 269 | StreamLocalBindMask: "0177", 270 | StreamLocalBindUnlink: "no", 271 | StrictHostKeyChecking: "ask", 272 | TCPKeepAlive: "yes", 273 | Tunnel: "yes", 274 | TunnelDevice: "any:any", 275 | UpdateHostKeys: "ask", 276 | UseKeychain: "no", 277 | UsePrivilegedPort: "no", 278 | User: "moul", 279 | UserKnownHostsFile: []string{"~/.ssh/known_hosts ~/.ssh/known_hosts2", "/tmp/known_hosts"}, 280 | VerifyHostKeyDNS: "no", 281 | VisualHostKey: "yes", 282 | XAuthLocation: "xauth", 283 | 284 | // ssh-config fields with a different behavior 285 | ProxyCommand: "nc %h %p", 286 | HostName: "zzz.com", 287 | 288 | // assh fields 289 | isDefault: false, 290 | Inherits: []string{}, 291 | Gateways: []string{}, 292 | Aliases: []string{}, 293 | ResolveNameservers: []string{}, 294 | ResolveCommand: "", 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /pkg/config/hostlist.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | // HostsMap is a map of **Host).Name -> *Host 9 | type HostsMap map[string]*Host 10 | 11 | // HostsList is a list of *Host 12 | type HostsList []*Host 13 | 14 | // ToList returns a slice of *Hosts 15 | func (hm *HostsMap) ToList() HostsList { 16 | list := HostsList{} 17 | for _, host := range *hm { 18 | list = append(list, host) 19 | } 20 | return list 21 | } 22 | 23 | func (hl HostsList) Len() int { return len(hl) } 24 | func (hl HostsList) Swap(i, j int) { hl[i], hl[j] = hl[j], hl[i] } 25 | func (hl HostsList) Less(i, j int) bool { return strings.Compare(hl[i].name, hl[j].name) < 0 } 26 | 27 | // SortedList returns a list of hosts sorted by their name 28 | func (hm *HostsMap) SortedList() HostsList { 29 | sortedList := hm.ToList() 30 | sort.Sort(sortedList) 31 | return sortedList 32 | } 33 | -------------------------------------------------------------------------------- /pkg/config/hostlist_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func TestHostsListToList(t *testing.T) { 10 | Convey("Testing HostsList.ToList()", t, func() { 11 | m := HostsMap{ 12 | "aaa": &Host{name: "aaa"}, 13 | "bbb": &Host{name: "bbb"}, 14 | "ccc": &Host{name: "ccc"}, 15 | } 16 | 17 | list := m.ToList() 18 | So(len(list), ShouldEqual, 3) 19 | }) 20 | } 21 | 22 | func TestHostsListSortedList(t *testing.T) { 23 | Convey("Testing HostsList.SortedList()", t, func() { 24 | m := HostsMap{ 25 | "ccc": &Host{name: "ccc"}, 26 | "ddd": &Host{name: "ddd"}, 27 | "aaa": &Host{name: "aaa"}, 28 | "bbb": &Host{name: "bbb"}, 29 | } 30 | 31 | sorted := m.SortedList() 32 | 33 | So(sorted[0].name, ShouldEqual, "aaa") 34 | So(sorted[1].name, ShouldEqual, "bbb") 35 | So(sorted[2].name, ShouldEqual, "ccc") 36 | So(sorted[3].name, ShouldEqual, "ddd") 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/config/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package config 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.config") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/config/option.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "fmt" 4 | 5 | // Option is an host option 6 | type Option struct { 7 | Name string 8 | Value string 9 | } 10 | 11 | // OptionsList is a list of options 12 | type OptionsList []Option 13 | 14 | func (o *Option) String() string { 15 | return fmt.Sprintf("%s=%s", o.Name, o.Value) 16 | } 17 | 18 | // Get returns the option value matching the name or "" if the key is not found 19 | func (ol *OptionsList) Get(name string) string { 20 | for _, opt := range *ol { 21 | if opt.Name == name { 22 | return opt.Value 23 | } 24 | } 25 | return "" 26 | } 27 | 28 | // ToStringList returns a list of string with the following format: `key=value` 29 | func (ol *OptionsList) ToStringList() []string { 30 | list := []string{} 31 | for _, opt := range *ol { 32 | list = append(list, opt.String()) 33 | } 34 | return list 35 | } 36 | 37 | // Remove removes an option from the list based on its key 38 | func (ol *OptionsList) Remove(key string) { 39 | for i, opt := range *ol { 40 | if opt.Name == key { 41 | *ol = append((*ol)[:i], (*ol)[i+1:]...) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/config/option_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func TestOptionString(t *testing.T) { 10 | Convey("Testing Option.String()", t, func() { 11 | option := Option{ 12 | Name: "name", 13 | Value: "value", 14 | } 15 | So(option.String(), ShouldEqual, "name=value") 16 | }) 17 | } 18 | 19 | func TestOptionsListToStringList(t *testing.T) { 20 | Convey("Testing OptionsList.ToStringList()", t, func() { 21 | ol := OptionsList{ 22 | {Name: "name1", Value: "value1"}, 23 | {Name: "name2", Value: "value2"}, 24 | {Name: "name3", Value: "value3"}, 25 | } 26 | So(ol.ToStringList(), ShouldResemble, []string{"name1=value1", "name2=value2", "name3=value3"}) 27 | }) 28 | } 29 | 30 | func TestOptionsListRemove(t *testing.T) { 31 | Convey("Testing OptionsList.Remove()", t, func() { 32 | ol := OptionsList{ 33 | {Name: "name1", Value: "value1"}, 34 | {Name: "name2", Value: "value2"}, 35 | {Name: "name3", Value: "value3"}, 36 | } 37 | So(ol.ToStringList(), ShouldResemble, []string{"name1=value1", "name2=value2", "name3=value3"}) 38 | 39 | ol.Remove("name4") 40 | So(ol.ToStringList(), ShouldResemble, []string{"name1=value1", "name2=value2", "name3=value3"}) 41 | 42 | ol.Remove("name2") 43 | So(ol.ToStringList(), ShouldResemble, []string{"name1=value1", "name3=value3"}) 44 | 45 | ol.Remove("name2") 46 | So(ol.ToStringList(), ShouldResemble, []string{"name1=value1", "name3=value3"}) 47 | 48 | ol.Remove("name3") 49 | So(ol.ToStringList(), ShouldResemble, []string{"name1=value1"}) 50 | 51 | ol.Remove("name1") 52 | So(ol.ToStringList(), ShouldResemble, []string{}) 53 | 54 | ol.Remove("name1") 55 | So(ol.ToStringList(), ShouldResemble, []string{}) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/config/ssh_flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/spf13/pflag" 4 | 5 | var ( 6 | // SSHBoolFlags contains list of available SSH boolean options 7 | SSHBoolFlags = []string{"1", "2", "4", "6", "A", "a", "C", "f", "G", "g", "K", "k", "M", "N", "n", "q", "s", "T", "t", "V", "v", "X", "x", "Y", "y"} 8 | // SSHStringFlags contains list of available SSH string options 9 | SSHStringFlags = []string{"b", "c", "D", "E", "e", "F", "I", "i", "L", "l", "m", "O", "o", "p", "Q", "R", "S", "W", "w"} 10 | ) 11 | 12 | // SSHFlags contains cobra string and bool flags for SSH 13 | func SSHFlags() *pflag.FlagSet { 14 | flags := pflag.NewFlagSet("SSHFlags", pflag.PanicOnError) 15 | // Populate SSHFlags 16 | // FIXME: support count flags (-vvv == -v -v -v) 17 | // FIXME: support joined bool flags (-it == -i -t) 18 | for _, flag := range SSHBoolFlags { 19 | flags.Bool(flag, false, "") 20 | } 21 | for _, flag := range SSHStringFlags { 22 | flags.StringSlice(flag, nil, "") 23 | } 24 | 25 | return flags 26 | } 27 | -------------------------------------------------------------------------------- /pkg/controlsockets/control-sockets.go: -------------------------------------------------------------------------------- 1 | package controlsockets 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mattn/go-zglob" 10 | "moul.io/assh/v2/pkg/utils" 11 | ) 12 | 13 | // ControlSocket defines a unix-domain socket controlled by a master SSH process 14 | type ControlSocket struct { 15 | path string 16 | controlPath string 17 | } 18 | 19 | // ControlSockets is a list of ControlSocket 20 | type ControlSockets []ControlSocket 21 | 22 | func translateControlPath(input string) string { 23 | controlPath, err := utils.ExpandUser(input) 24 | if err != nil { 25 | return input 26 | } 27 | 28 | controlPath = strings.ReplaceAll(controlPath, "%h", "**/*") 29 | 30 | for _, component := range []string{"%L", "%p", "%n", "%C", "%l", "%r"} { 31 | controlPath = strings.ReplaceAll(controlPath, component, "*") 32 | } 33 | return controlPath 34 | } 35 | 36 | // LookupControlPathDir returns the ControlSockets in the ControlPath directory 37 | func LookupControlPathDir(controlPath string) (ControlSockets, error) { 38 | controlPath = translateControlPath(controlPath) 39 | 40 | matches, err := zglob.Glob(controlPath) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | list := ControlSockets{} 46 | for _, socketPath := range matches { 47 | list = append(list, ControlSocket{ 48 | path: socketPath, 49 | controlPath: controlPath, 50 | }) 51 | } 52 | return list, nil 53 | } 54 | 55 | // Path returns the absolute path of the socket 56 | func (s *ControlSocket) Path() string { 57 | return s.path 58 | } 59 | 60 | // RelativePath returns a path relative to the configured ControlPath 61 | func (s *ControlSocket) RelativePath() string { 62 | idx := strings.Index(s.controlPath, "*") 63 | return s.path[idx:] 64 | } 65 | 66 | // CreatedAt returns the modification time of the sock file 67 | func (s *ControlSocket) CreatedAt() (time.Time, error) { 68 | stat, err := os.Stat(s.path) 69 | if err != nil { 70 | return time.Now(), err 71 | } 72 | 73 | return stat.ModTime(), nil 74 | } 75 | 76 | // ActiveConnections returns the amount of active connections using a control socket 77 | func (s *ControlSocket) ActiveConnections() (int, error) { 78 | return -1, fmt.Errorf("not implemented") 79 | } 80 | -------------------------------------------------------------------------------- /pkg/controlsockets/doc.go: -------------------------------------------------------------------------------- 1 | package controlsockets // import "moul.io/assh/v2/pkg/controlsockets" 2 | -------------------------------------------------------------------------------- /pkg/controlsockets/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package controlsockets 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.controlsockets") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/hooks/doc.go: -------------------------------------------------------------------------------- 1 | package hooks // import "moul.io/assh/v2/pkg/hooks" 2 | -------------------------------------------------------------------------------- /pkg/hooks/driver_daemon.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | 8 | "go.uber.org/zap" 9 | "moul.io/assh/v2/pkg/templates" 10 | ) 11 | 12 | // DaemonDriver is a driver that daemons some texts to the terminal 13 | type DaemonDriver struct { 14 | line string 15 | cmd *exec.Cmd 16 | } 17 | 18 | // NewDaemonDriver returns a DaemonDriver instance 19 | func NewDaemonDriver(line string) (DaemonDriver, error) { 20 | return DaemonDriver{ 21 | line: line, 22 | }, nil 23 | } 24 | 25 | // Run daemons a line to the terminal 26 | func (d DaemonDriver) Run(args RunArgs) error { 27 | var buff bytes.Buffer 28 | tmpl, err := templates.New(d.line + "\n") 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if err := tmpl.Execute(&buff, args); err != nil { 34 | return err 35 | } 36 | 37 | d.cmd = exec.Command("/bin/sh", "-c", buff.String()) // #nosec 38 | d.cmd.Stdout = os.Stderr 39 | d.cmd.Stderr = os.Stderr 40 | d.cmd.Stdin = os.Stdin 41 | if err := d.cmd.Start(); err != nil { 42 | return err 43 | } 44 | 45 | go func() { 46 | if err := d.cmd.Wait(); err != nil { 47 | logger().Error("daemon driver error", zap.Error(err)) 48 | } 49 | logger().Info("daemon exited", zap.String("line", d.line)) 50 | }() 51 | 52 | return nil 53 | } 54 | 55 | // Close closes a running command 56 | func (d DaemonDriver) Close() error { 57 | if d.cmd == nil || d.cmd.Process == nil { 58 | return nil 59 | } 60 | 61 | err := d.cmd.Process.Kill() 62 | if err != nil { 63 | logger().Warn("daemon failed to stop", zap.String("line", d.line), zap.Error(err)) 64 | } 65 | return err 66 | } 67 | -------------------------------------------------------------------------------- /pkg/hooks/driver_exec.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "moul.io/assh/v2/pkg/templates" 11 | ) 12 | 13 | // ExecDriver is a driver that execs some texts to the terminal 14 | type ExecDriver struct { 15 | line string 16 | } 17 | 18 | // NewExecDriver returns a ExecDriver instance 19 | func NewExecDriver(line string) (ExecDriver, error) { 20 | return ExecDriver{ 21 | line: line, 22 | }, nil 23 | } 24 | 25 | // Run execs a line to the terminal 26 | func (d ExecDriver) Run(args RunArgs) error { 27 | var buff bytes.Buffer 28 | tmpl, err := templates.New(d.line + "\n") 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if err := tmpl.Execute(&buff, args); err != nil { 34 | return err 35 | } 36 | 37 | var ( 38 | availableShells = []string{ 39 | "/bin/sh", "/bin/bash", "/bin/zsh", 40 | "/usr/bin/sh", "/usr/bin/bash", "/usr/bin/zsh", 41 | "/usr/local/bin/sh", "/usr/local/bin/bash", "/usr/local/bin/zsh", 42 | } 43 | selectedShell = "" 44 | ) 45 | for _, shell := range availableShells { 46 | info, err := os.Stat(shell) 47 | if err != nil { 48 | continue 49 | } 50 | if info.Mode()&0111 != 0 { 51 | selectedShell = shell 52 | break 53 | } 54 | } 55 | if selectedShell == "" { 56 | return fmt.Errorf("no available shell found. (tried %s)", strings.Join(availableShells, ", ")) 57 | } 58 | 59 | cmd := exec.Command(selectedShell, "-c", buff.String()) // #nosec 60 | cmd.Stdout = os.Stderr 61 | cmd.Stderr = os.Stderr 62 | cmd.Stdin = os.Stdin 63 | if err := cmd.Start(); err != nil { 64 | return err 65 | } 66 | return cmd.Wait() 67 | } 68 | 69 | // Close is mandatory for the interface, here it does nothing 70 | func (d ExecDriver) Close() error { return nil } 71 | -------------------------------------------------------------------------------- /pkg/hooks/driver_notification.go: -------------------------------------------------------------------------------- 1 | //go:build !openbsd && !freebsd && !netbsd && !windows 2 | // +build !openbsd,!freebsd,!netbsd,!windows 3 | 4 | package hooks 5 | 6 | import ( 7 | "bytes" 8 | 9 | "github.com/haklop/gnotifier" 10 | "moul.io/assh/v2/pkg/templates" 11 | ) 12 | 13 | // NotificationDriver is a driver that notifications some texts to the terminal 14 | type NotificationDriver struct { 15 | line string 16 | } 17 | 18 | // NewNotificationDriver returns a NotificationDriver instance 19 | func NewNotificationDriver(line string) (NotificationDriver, error) { 20 | return NotificationDriver{ 21 | line: line, 22 | }, nil 23 | } 24 | 25 | // Run notifications a line to the terminal 26 | func (d NotificationDriver) Run(args RunArgs) error { 27 | var buff bytes.Buffer 28 | tmpl, err := templates.New(d.line + "\n") 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if err := tmpl.Execute(&buff, args); err != nil { 34 | return err 35 | } 36 | 37 | notification := gnotifier.Notification("ASSH", buff.String()) 38 | notification.GetConfig().Expiration = 3000 39 | notification.GetConfig().ApplicationName = "assh" 40 | 41 | return notification.Push() 42 | } 43 | 44 | // Close is mandatory for the interface, here it does nothing 45 | func (d NotificationDriver) Close() error { return nil } 46 | -------------------------------------------------------------------------------- /pkg/hooks/driver_notification_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build openbsd || freebsd || netbsd || windows 2 | // +build openbsd freebsd netbsd windows 3 | 4 | package hooks 5 | 6 | type NotificationDriver struct{} 7 | 8 | func NewNotificationDriver(_ string) (NotificationDriver, error) { return NotificationDriver{}, nil } 9 | func (NotificationDriver) Run(_ RunArgs) error { return nil } 10 | func (d NotificationDriver) Close() error { return nil } 11 | -------------------------------------------------------------------------------- /pkg/hooks/driver_write.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "os" 5 | 6 | "moul.io/assh/v2/pkg/templates" 7 | ) 8 | 9 | // WriteDriver is a driver that writes some texts to the terminal 10 | type WriteDriver struct { 11 | line string 12 | } 13 | 14 | // NewWriteDriver returns a WriteDriver instance 15 | func NewWriteDriver(line string) (WriteDriver, error) { 16 | return WriteDriver{ 17 | line: line, 18 | }, nil 19 | } 20 | 21 | // Run writes a line to the terminal 22 | func (d WriteDriver) Run(args RunArgs) error { 23 | tmpl, err := templates.New(d.line + "\n") 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return tmpl.Execute(os.Stderr, args) 29 | } 30 | 31 | // Close is mandatory for the interface, here it does nothing 32 | func (d WriteDriver) Close() error { return nil } 33 | -------------------------------------------------------------------------------- /pkg/hooks/hooks.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | composeyaml "github.com/docker/libcompose/yaml" 8 | ) 9 | 10 | // Hooks represents a slice of Hook 11 | type Hooks composeyaml.Stringorslice 12 | 13 | // HookDriver represents a hook driver 14 | type HookDriver interface { 15 | Run(RunArgs) error 16 | Close() error 17 | } 18 | 19 | // HookDrivers represents a slice of HookDriver 20 | type HookDrivers []HookDriver 21 | 22 | // RunArgs is a map of interface{} 23 | type RunArgs interface{} 24 | 25 | // InvokeAll calls all hooks 26 | func (h *Hooks) InvokeAll(args RunArgs) (HookDrivers, error) { 27 | drivers := HookDrivers{} 28 | 29 | for _, expr := range *h { 30 | driver, err := New(expr) 31 | if err != nil { 32 | return nil, err 33 | } 34 | drivers = append(drivers, driver) 35 | } 36 | 37 | for _, driver := range drivers { 38 | if err := driver.Run(args); err != nil { 39 | return nil, err 40 | } 41 | } 42 | return drivers, nil 43 | } 44 | 45 | // Close closes all hook drivers and returns a slice of errs 46 | func (hd *HookDrivers) Close() []error { 47 | var errs []error 48 | for _, driver := range *hd { 49 | if err := driver.Close(); err != nil { 50 | errs = append(errs, err) 51 | } 52 | } 53 | return errs 54 | } 55 | 56 | // New returns an HookDriver instance 57 | func New(expr string) (HookDriver, error) { 58 | driverName := strings.Split(expr, " ")[0] 59 | param := strings.Join(strings.Split(expr, " ")[1:], " ") 60 | switch driverName { 61 | case "write": 62 | driver, err := NewWriteDriver(param) 63 | return driver, err 64 | case "notify": 65 | driver, err := NewNotificationDriver(param) 66 | return driver, err 67 | case "exec": 68 | driver, err := NewExecDriver(param) 69 | return driver, err 70 | case "daemon": 71 | driver, err := NewDaemonDriver(param) 72 | return driver, err 73 | default: 74 | return nil, fmt.Errorf("no such driver %q", driverName) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/hooks/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package hooks 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.hooks") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/logger/doc.go: -------------------------------------------------------------------------------- 1 | package logger // import "moul.io/assh/v2/pkg/logger" 2 | -------------------------------------------------------------------------------- /pkg/logger/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package logger 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.logger") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "go.uber.org/zap/zapcore" 4 | 5 | // MustLogLevel returns a log level based on both user input and parent SSH process 6 | func MustLogLevel(debug, verbose bool) zapcore.Level { 7 | parentLevel, err := LogLevelFromParentSSHProcess() 8 | if err != nil { 9 | parentLevel = zapcore.WarnLevel 10 | } 11 | asshLevel := zapcore.WarnLevel 12 | switch { 13 | case debug: 14 | asshLevel = zapcore.DebugLevel 15 | case verbose: 16 | asshLevel = zapcore.InfoLevel 17 | } 18 | if parentLevel < asshLevel { 19 | return parentLevel 20 | } 21 | return asshLevel 22 | } 23 | -------------------------------------------------------------------------------- /pkg/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | // FIXME: test MustLogLevel 10 | 11 | func TestLogLevelFromParentSSHProcess(t *testing.T) { 12 | Convey("Testing LogLevelFromParentSSHProcess()", t, func() { 13 | _, err := LogLevelFromParentSSHProcess() 14 | So(err, ShouldBeNil) 15 | // FIXME: mock process 16 | // So(level, ShouldEqual, zapcore.InfoLevel) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/logger/process.go: -------------------------------------------------------------------------------- 1 | //go:build !openbsd && !freebsd && !netbsd 2 | // +build !openbsd,!freebsd,!netbsd 3 | 4 | package logger 5 | 6 | import ( 7 | "os" 8 | "strings" 9 | 10 | "github.com/shirou/gopsutil/process" 11 | "go.uber.org/zap/zapcore" 12 | ) 13 | 14 | // LogLevelFromParentSSHProcess inspects parent `ssh` process for eventual passed `-v` flags. 15 | func LogLevelFromParentSSHProcess() (zapcore.Level, error) { 16 | // FIXME: check if parent process is `ssh` 17 | ppid := os.Getppid() 18 | process, err := process.NewProcess(int32(ppid)) 19 | if err != nil { 20 | return zapcore.WarnLevel, err 21 | } 22 | 23 | cmdline, err := process.Cmdline() 24 | if err != nil { 25 | return zapcore.WarnLevel, err 26 | } 27 | 28 | switch { 29 | case strings.Contains(cmdline, "-vv"): 30 | return zapcore.DebugLevel, nil 31 | case strings.Contains(cmdline, "-v"): 32 | return zapcore.InfoLevel, nil 33 | case strings.Contains(cmdline, "-q"): 34 | return zapcore.ErrorLevel, nil 35 | default: 36 | return zapcore.WarnLevel, nil 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/logger/process_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build openbsd || netbsd || freebsd 2 | // +build openbsd netbsd freebsd 3 | 4 | package logger 5 | 6 | import "go.uber.org/zap/zapcore" 7 | 8 | // LogLevelFromParentSSHProcess inspects parent `ssh` process for eventual passed `-v` flags. 9 | func LogLevelFromParentSSHProcess() (zapcore.Level, error) { 10 | return zapcore.WarnLevel, nil 11 | } 12 | -------------------------------------------------------------------------------- /pkg/ratelimit/doc.go: -------------------------------------------------------------------------------- 1 | package ratelimit // import "moul.io/assh/v2/pkg/ratelimit" 2 | -------------------------------------------------------------------------------- /pkg/ratelimit/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package ratelimit 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.ratelimit") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/ratelimit/ratelimit.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | // Package ratelimit based on http://hustcat.github.io/rate-limit-example-in-go/ 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "time" 9 | 10 | "golang.org/x/time/rate" 11 | ) 12 | 13 | type reader struct { 14 | r io.Reader 15 | limiter *rate.Limiter 16 | } 17 | 18 | // NewReader returns a reader that is rate limited by 19 | // the given token bucket. Each token in the bucket 20 | // represents one byte. 21 | func NewReader(r io.Reader, l *rate.Limiter) io.Reader { 22 | return &reader{ 23 | r: r, 24 | limiter: l, 25 | } 26 | } 27 | 28 | func (r *reader) Read(buf []byte) (int, error) { 29 | n, err := r.r.Read(buf) 30 | if n <= 0 { 31 | return n, err 32 | } 33 | 34 | now := time.Now() 35 | rv := r.limiter.ReserveN(now, n) 36 | if !rv.OK() { 37 | return 0, fmt.Errorf("exceeds limiter's burst") 38 | } 39 | delay := rv.DelayFrom(now) 40 | // fmt.Printf("Read %d bytes, delay %d\n", n, delay) 41 | time.Sleep(delay) 42 | return n, err 43 | } 44 | 45 | type writer struct { 46 | w io.Writer 47 | limiter *rate.Limiter 48 | } 49 | 50 | // NewWriter returns a writer that is rate limited by 51 | // the given token bucket. Each token in the bucket 52 | // represents one byte. 53 | func NewWriter(w io.Writer, l *rate.Limiter) io.Writer { 54 | return &writer{ 55 | w: w, 56 | limiter: l, 57 | } 58 | } 59 | 60 | func (w *writer) Write(buf []byte) (int, error) { 61 | n, err := w.w.Write(buf) 62 | if n <= 0 { 63 | return n, err 64 | } 65 | 66 | now := time.Now() 67 | rv := w.limiter.ReserveN(now, n) 68 | if !rv.OK() { 69 | return 0, fmt.Errorf("exceeds limiter's burst") 70 | } 71 | delay := rv.DelayFrom(now) 72 | // fmt.Printf("Write %d bytes, delay %d\n", n, delay) 73 | time.Sleep(delay) 74 | return n, err 75 | } 76 | -------------------------------------------------------------------------------- /pkg/templates/doc.go: -------------------------------------------------------------------------------- 1 | package templates // import "moul.io/assh/v2/pkg/templates" 2 | -------------------------------------------------------------------------------- /pkg/templates/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package templates 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.templates") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "text/template" 7 | 8 | "github.com/Masterminds/sprig" 9 | "golang.org/x/text/cases" 10 | "golang.org/x/text/language" 11 | ) 12 | 13 | func funcMap() template.FuncMap { 14 | var m = template.FuncMap{ 15 | "json": func(v interface{}) string { 16 | a, err := json.Marshal(v) 17 | if err != nil { 18 | return err.Error() 19 | } 20 | return string(a) 21 | }, 22 | "prettyjson": func(v interface{}) string { 23 | a, err := json.MarshalIndent(v, "", " ") 24 | if err != nil { 25 | return err.Error() 26 | } 27 | return string(a) 28 | }, 29 | "join": strings.Join, 30 | "title": cases.Title(language.Und, cases.NoLower).String, 31 | "lower": strings.ToLower, 32 | "upper": strings.ToUpper, 33 | } 34 | for k, v := range sprig.TxtFuncMap() { 35 | m[k] = v 36 | } 37 | return m 38 | } 39 | 40 | // New creates a new template with funcMap and parses the given format. 41 | func New(format string) (*template.Template, error) { 42 | return template.New("").Funcs(funcMap()).Parse(format) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/utils/doc.go: -------------------------------------------------------------------------------- 1 | package utils // import "moul.io/assh/v2/pkg/utils" 2 | -------------------------------------------------------------------------------- /pkg/utils/env.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // EscapeSpaces escapes all space characters with a backslash (\) 11 | func EscapeSpaces(s string) string { 12 | return strings.ReplaceAll(s, " ", "\\ ") 13 | } 14 | 15 | // GetHomeDir returns '~' as a path 16 | func GetHomeDir() string { 17 | if homeDir := os.Getenv("HOME"); homeDir != "" { 18 | return homeDir 19 | } 20 | if homeDir := os.Getenv("USERPROFILE"); homeDir != "" { 21 | return homeDir 22 | } 23 | return "" 24 | } 25 | 26 | // ExpandEnvSafe replaces ${var} or $var in the string according to the values 27 | // of the current environment variables. 28 | // As opposed to os.ExpandEnv, ExpandEnvSafe won't remove the dollar in '$(...)' 29 | // See https://golang.org/src/os/env.go?s=963:994#L22 for the original function 30 | func ExpandEnvSafe(s string) string { 31 | buf := make([]byte, 0, 2*len(s)) 32 | i := 0 33 | for j := 0; j < len(s); j++ { 34 | // the following line is the only one changing 35 | if s[j] == '$' && j+1 < len(s) && s[j+1] != '(' { 36 | buf = append(buf, s[i:j]...) 37 | name, w := getShellName(s[j+1:]) 38 | buf = append(buf, os.Getenv(name)...) 39 | j += w 40 | i = j + 1 41 | } 42 | } 43 | return string(buf) + s[i:] 44 | } 45 | 46 | // ExpandUser expands tild and env vars in unix paths 47 | func ExpandUser(path string) (string, error) { 48 | // Expand variables 49 | path = ExpandEnvSafe(path) 50 | 51 | if strings.HasPrefix(path, "~/") { 52 | homeDir := GetHomeDir() 53 | if homeDir == "" { 54 | return "", errors.New("user home directory not found") 55 | } 56 | 57 | path = strings.Replace(path, "~", homeDir, 1) 58 | } 59 | 60 | path = filepath.FromSlash(path) // OS-agnostic slashes 61 | path = EscapeSpaces(path) 62 | 63 | return path, nil 64 | } 65 | 66 | // ExpandField expands environment variables in field 67 | func ExpandField(input string) string { 68 | if input == "" { 69 | return "" 70 | } 71 | return ExpandEnvSafe(input) 72 | } 73 | 74 | // ExpandSliceField expands environment variables in every entries of a slice field 75 | func ExpandSliceField(input []string) []string { 76 | ret := []string{} 77 | for _, entry := range input { 78 | ret = append(ret, ExpandField(entry)) 79 | } 80 | return ret 81 | } 82 | -------------------------------------------------------------------------------- /pkg/utils/env_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "testing" 7 | 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestEscapeSpaces(t *testing.T) { 12 | Convey("Testing EscapeSpaces", t, func() { 13 | So(EscapeSpaces("foo bar"), ShouldEqual, "foo\\ bar") 14 | So(EscapeSpaces("/a/b c/d"), ShouldEqual, "/a/b\\ c/d") 15 | }) 16 | } 17 | 18 | func TestExpandEnvSafe(t *testing.T) { 19 | Convey("Testing ExpandEnvSafe", t, func() { 20 | So(os.Setenv("FOO", "bar"), ShouldBeNil) 21 | So(ExpandEnvSafe("/a/$FOO/c"), ShouldEqual, "/a/bar/c") 22 | So(ExpandEnvSafe("/a/${FOO}/c"), ShouldEqual, "/a/bar/c") 23 | So(ExpandEnvSafe("/a/${FOO}/c/$FOO"), ShouldEqual, "/a/bar/c/bar") 24 | So(ExpandEnvSafe("/a/$(FOO)/c"), ShouldEqual, "/a/$(FOO)/c") 25 | So(ExpandEnvSafe(""), ShouldEqual, "") 26 | }) 27 | } 28 | 29 | func TestGetHomeDir(t *testing.T) { 30 | Convey("Testing GetHomeDir", t, func() { 31 | oldHome := os.Getenv("HOME") 32 | oldUserProfile := os.Getenv("USERPROFILE") 33 | 34 | So(os.Setenv("HOME", "/a/b/c"), ShouldBeNil) 35 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 36 | So(GetHomeDir(), ShouldEqual, "/a/b/c") 37 | 38 | So(os.Setenv("HOME", "/a/b/d"), ShouldBeNil) 39 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 40 | So(GetHomeDir(), ShouldEqual, "/a/b/d") 41 | 42 | So(os.Setenv("HOME", "/a/b/d"), ShouldBeNil) 43 | So(os.Setenv("USERPROFILE", "/a/b/e"), ShouldBeNil) 44 | So(GetHomeDir(), ShouldEqual, "/a/b/d") 45 | 46 | So(os.Setenv("HOME", ""), ShouldBeNil) 47 | So(os.Setenv("USERPROFILE", "/a/b/f"), ShouldBeNil) 48 | So(GetHomeDir(), ShouldEqual, "/a/b/f") 49 | 50 | So(os.Setenv("HOME", ""), ShouldBeNil) 51 | So(os.Setenv("USERPROFILE", "/a/b/g"), ShouldBeNil) 52 | So(GetHomeDir(), ShouldEqual, "/a/b/g") 53 | 54 | So(os.Setenv("HOME", ""), ShouldBeNil) 55 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 56 | So(GetHomeDir(), ShouldEqual, "") 57 | 58 | So(os.Setenv("HOME", oldHome), ShouldBeNil) 59 | So(os.Setenv("USERPROFILE", oldUserProfile), ShouldBeNil) 60 | }) 61 | } 62 | 63 | func TestExpandUser(t *testing.T) { 64 | expected := "/a/b/c/test" 65 | expectedEscaped := "/a/b/c/test\\ dir" 66 | 67 | if runtime.GOOS == "windows" { 68 | expected = "\\a\\b\\c\\test" 69 | expectedEscaped = "\\a\\b\\c\\test\\ dir" 70 | } 71 | 72 | Convey("Testing ExpandUser", t, func() { 73 | oldHome := os.Getenv("HOME") 74 | oldUserProfile := os.Getenv("USERPROFILE") 75 | 76 | So(os.Setenv("HOME", "/a/b/c"), ShouldBeNil) 77 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 78 | dir, err := ExpandUser("~/test dir") 79 | So(dir, ShouldEqual, expectedEscaped) 80 | So(err, ShouldBeNil) 81 | 82 | So(os.Setenv("HOME", "/a/b/c"), ShouldBeNil) 83 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 84 | dir, err = ExpandUser("~/test") 85 | So(dir, ShouldEqual, expected) 86 | So(err, ShouldBeNil) 87 | 88 | So(os.Setenv("HOME", "/a/b/c"), ShouldBeNil) 89 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 90 | dir, err = ExpandUser("~/test") 91 | So(dir, ShouldEqual, expected) 92 | So(err, ShouldBeNil) 93 | 94 | So(os.Setenv("HOME", "/a/b/c"), ShouldBeNil) 95 | So(os.Setenv("USERPROFILE", "/a/b/e"), ShouldBeNil) 96 | dir, err = ExpandUser("~/test") 97 | So(dir, ShouldEqual, expected) 98 | So(err, ShouldBeNil) 99 | 100 | So(os.Setenv("HOME", ""), ShouldBeNil) 101 | So(os.Setenv("USERPROFILE", "/a/b/c"), ShouldBeNil) 102 | dir, err = ExpandUser("~/test") 103 | So(dir, ShouldEqual, expected) 104 | So(err, ShouldBeNil) 105 | 106 | So(os.Setenv("HOME", ""), ShouldBeNil) 107 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 108 | dir, err = ExpandUser("~/test") 109 | So(dir, ShouldEqual, "") 110 | So(err, ShouldNotBeNil) 111 | 112 | So(os.Setenv("HOME", ""), ShouldBeNil) 113 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 114 | dir, err = ExpandUser("/a/b/c/test") 115 | So(dir, ShouldEqual, expected) 116 | So(err, ShouldBeNil) 117 | 118 | So(os.Setenv("HOME", "/e/f"), ShouldBeNil) 119 | So(os.Setenv("USERPROFILE", ""), ShouldBeNil) 120 | dir, err = ExpandUser("/a/b/c/test") 121 | So(dir, ShouldEqual, expected) 122 | So(err, ShouldBeNil) 123 | 124 | So(os.Setenv("HOME", ""), ShouldBeNil) 125 | So(os.Setenv("USERPROFILE", "/e/g"), ShouldBeNil) 126 | dir, err = ExpandUser("/a/b/c/test") 127 | So(dir, ShouldEqual, expected) 128 | So(err, ShouldBeNil) 129 | 130 | So(os.Setenv("HOME", "/e/h"), ShouldBeNil) 131 | So(os.Setenv("USERPROFILE", "/e/i"), ShouldBeNil) 132 | dir, err = ExpandUser("/a/b/c/test") 133 | So(dir, ShouldEqual, expected) 134 | So(err, ShouldBeNil) 135 | 136 | So(os.Setenv("HOME", oldHome), ShouldBeNil) 137 | So(os.Setenv("USERPROFILE", oldUserProfile), ShouldBeNil) 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /pkg/utils/imported.go: -------------------------------------------------------------------------------- 1 | // This file contains imported functions 2 | // The license and copyright is reported for each functions in the comments. 3 | 4 | package utils 5 | 6 | // Imported and unmodified from https://golang.org/src/os/env.go 7 | // Function under the BSD-License - Copyrighted by the Go Authors 8 | // isShellSpecialVar reports whether the character identifies a special 9 | // shell variable such as $*. 10 | func isShellSpecialVar(c uint8) bool { 11 | switch c { 12 | case '*', '#', '$', '@', '!', '?', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': 13 | return true 14 | } 15 | return false 16 | } 17 | 18 | // Imported and unmodified from https://golang.org/src/os/env.go 19 | // Function under the BSD-License - Copyrighted by the Go Authors 20 | // isAlphaNum reports whether the byte is an ASCII letter, number, or underscore 21 | func isAlphaNum(c uint8) bool { 22 | return c == '_' || '0' <= c && c <= '9' || 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' 23 | } 24 | 25 | // Imported and unmodified from https://golang.org/src/os/env.go 26 | // Function under the BSD-License - Copyrighted by the Go Authors 27 | // getShellName returns the name that begins the string and the number of bytes 28 | // consumed to extract it. If the name is enclosed in {}, it's part of a ${} 29 | // expansion and two more bytes are needed than the length of the name. 30 | func getShellName(s string) (string, int) { 31 | switch { 32 | case s[0] == '{': 33 | if len(s) > 2 && isShellSpecialVar(s[1]) && s[2] == '}' { 34 | return s[1:2], 3 35 | } 36 | // Scan to closing brace 37 | for i := 1; i < len(s); i++ { 38 | if s[i] == '}' { 39 | return s[1:i], i + 1 40 | } 41 | } 42 | return "", 1 // Bad syntax; just eat the brace. 43 | case isShellSpecialVar(s[0]): 44 | return s[0:1], 1 45 | } 46 | // Scan alphanumerics. 47 | var i int 48 | for i = 0; i < len(s) && isAlphaNum(s[i]); i++ { 49 | } 50 | return s[:i], i 51 | } 52 | -------------------------------------------------------------------------------- /pkg/utils/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package utils 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.utils") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/version/doc.go: -------------------------------------------------------------------------------- 1 | package version // import "moul.io/assh/v2/pkg/version" 2 | -------------------------------------------------------------------------------- /pkg/version/logger.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by moul.io/assh/contrib/generate-loggers.sh 2 | 3 | package version 4 | 5 | import "go.uber.org/zap" 6 | 7 | func logger() *zap.Logger { 8 | return zap.L().Named("assh.pkg.version") 9 | } 10 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | // Version should be updated by hand at each release 5 | Version = "n/a" 6 | // VcsRef will be overwritten automatically by the build system 7 | VcsRef = "n/a" 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func Test(t *testing.T) { 10 | Convey("Testing version", t, func() { 11 | So(Version, ShouldNotEqual, "") 12 | So(VcsRef, ShouldNotEqual, "") 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /resources/assh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/resources/assh.png -------------------------------------------------------------------------------- /resources/closed_connection_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/resources/closed_connection_notification.png -------------------------------------------------------------------------------- /resources/graphviz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/resources/graphviz.png -------------------------------------------------------------------------------- /resources/new_connection_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/assh/869f9789172e5c778ced5121ca4ac5abdf29bd57/resources/new_connection_notification.png -------------------------------------------------------------------------------- /rules.mk: -------------------------------------------------------------------------------- 1 | # +--------------------------------------------------------------+ 2 | # | * * * moul.io/rules.mk | 3 | # +--------------------------------------------------------------+ 4 | # | | 5 | # | ++ ______________________________________ | 6 | # | ++++ / \ | 7 | # | ++++ | | | 8 | # | ++++++++++ | https://moul.io/rules.mk is a set | | 9 | # | +++ | | of common Makefile rules that can | | 10 | # | ++ | | be configured from the Makefile | | 11 | # | + -== ==| | or with environment variables. | | 12 | # | ( <*> <*> | | | 13 | # | | | /| Manfred Touron | | 14 | # | | _) / | manfred.life | | 15 | # | | +++ / \______________________________________/ | 16 | # | \ =+ / | 17 | # | \ + | 18 | # | |\++++++ | 19 | # | | ++++ ||// | 20 | # | ___| |___ _||/__ __| 21 | # | / --- \ \| ||| __ _ ___ __ __/ /| 22 | # |/ | | \ \ / / ' \/ _ \/ // / / | 23 | # || | | | | | /_/_/_/\___/\_,_/_/ | 24 | # +--------------------------------------------------------------+ 25 | 26 | .PHONY: _default_entrypoint 27 | _default_entrypoint: help 28 | 29 | ## 30 | ## Common helpers 31 | ## 32 | 33 | rwildcard = $(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d)) 34 | check-program = $(foreach exec,$(1),$(if $(shell PATH="$(PATH)" which $(exec)),,$(error "No $(exec) in PATH"))) 35 | my-filter-out = $(foreach v,$(2),$(if $(findstring $(1),$(v)),,$(v))) 36 | novendor = $(call my-filter-out,vendor/,$(1)) 37 | 38 | ## 39 | ## rules.mk 40 | ## 41 | ifneq ($(wildcard rules.mk),) 42 | .PHONY: rulesmk.bumpdeps 43 | rulesmk.bumpdeps: 44 | wget -O rules.mk https://raw.githubusercontent.com/moul/rules.mk/master/rules.mk 45 | BUMPDEPS_STEPS += rulesmk.bumpdeps 46 | endif 47 | 48 | ## 49 | ## Maintainer 50 | ## 51 | 52 | ifneq ($(wildcard .git/HEAD),) 53 | .PHONY: generate.authors 54 | generate.authors: AUTHORS 55 | AUTHORS: .git/ 56 | echo "# This file lists all individuals having contributed content to the repository." > AUTHORS 57 | echo "# For how it is generated, see 'https://github.com/moul/rules.mk'" >> AUTHORS 58 | echo >> AUTHORS 59 | git log --format='%aN <%aE>' | LC_ALL=C.UTF-8 sort -uf >> AUTHORS 60 | GENERATE_STEPS += generate.authors 61 | endif 62 | 63 | ## 64 | ## Golang 65 | ## 66 | 67 | ifndef GOPKG 68 | ifneq ($(wildcard go.mod),) 69 | GOPKG = $(shell sed '/module/!d;s/^omdule\ //' go.mod) 70 | endif 71 | endif 72 | ifdef GOPKG 73 | GO ?= go 74 | GOPATH ?= $(HOME)/go 75 | GO_INSTALL_OPTS ?= 76 | GO_TEST_OPTS ?= -test.timeout=30s 77 | GOMOD_DIRS ?= $(sort $(call novendor,$(dir $(call rwildcard,*,*/go.mod go.mod)))) 78 | GOCOVERAGE_FILE ?= ./coverage.txt 79 | GOTESTJSON_FILE ?= ./go-test.json 80 | GOBUILDLOG_FILE ?= ./go-build.log 81 | GOINSTALLLOG_FILE ?= ./go-install.log 82 | 83 | ifdef GOBINS 84 | .PHONY: go.install 85 | go.install: 86 | ifeq ($(CI),true) 87 | @rm -f /tmp/goinstall.log 88 | @set -e; for dir in $(GOBINS); do ( set -xe; \ 89 | cd $$dir; \ 90 | $(GO) install -v $(GO_INSTALL_OPTS) .; \ 91 | ); done 2>&1 | tee $(GOINSTALLLOG_FILE) 92 | 93 | else 94 | @set -e; for dir in $(GOBINS); do ( set -xe; \ 95 | cd $$dir; \ 96 | $(GO) install $(GO_INSTALL_OPTS) .; \ 97 | ); done 98 | endif 99 | INSTALL_STEPS += go.install 100 | 101 | .PHONY: go.release 102 | go.release: 103 | $(call check-program, goreleaser) 104 | goreleaser --snapshot --skip-publish --rm-dist 105 | @echo -n "Do you want to release? [y/N] " && read ans && \ 106 | if [ $${ans:-N} = y ]; then set -xe; goreleaser --rm-dist; fi 107 | RELEASE_STEPS += go.release 108 | endif 109 | 110 | .PHONY: go.unittest 111 | go.unittest: 112 | ifeq ($(CI),true) 113 | @echo "mode: atomic" > /tmp/gocoverage 114 | @rm -f $(GOTESTJSON_FILE) 115 | @set -e; for dir in $(GOMOD_DIRS); do (set -e; (set -euf pipefail; \ 116 | cd $$dir; \ 117 | (($(GO) test ./... $(GO_TEST_OPTS) -cover -coverprofile=/tmp/profile.out -covermode=atomic -race -json && touch $@.ok) | tee -a $(GOTESTJSON_FILE) 3>&1 1>&2 2>&3 | tee -a $(GOBUILDLOG_FILE); \ 118 | ); \ 119 | rm $@.ok 2>/dev/null || exit 1; \ 120 | if [ -f /tmp/profile.out ]; then \ 121 | cat /tmp/profile.out | sed "/mode: atomic/d" >> /tmp/gocoverage; \ 122 | rm -f /tmp/profile.out; \ 123 | fi)); done 124 | @mv /tmp/gocoverage $(GOCOVERAGE_FILE) 125 | else 126 | @echo "mode: atomic" > /tmp/gocoverage 127 | @set -e; for dir in $(GOMOD_DIRS); do (set -e; (set -xe; \ 128 | cd $$dir; \ 129 | $(GO) test ./... $(GO_TEST_OPTS) -cover -coverprofile=/tmp/profile.out -covermode=atomic -race); \ 130 | if [ -f /tmp/profile.out ]; then \ 131 | cat /tmp/profile.out | sed "/mode: atomic/d" >> /tmp/gocoverage; \ 132 | rm -f /tmp/profile.out; \ 133 | fi); done 134 | @mv /tmp/gocoverage $(GOCOVERAGE_FILE) 135 | endif 136 | 137 | .PHONY: go.checkdoc 138 | go.checkdoc: 139 | go doc $(first $(GOMOD_DIRS)) 140 | 141 | .PHONY: go.coverfunc 142 | go.coverfunc: go.unittest 143 | go tool cover -func=$(GOCOVERAGE_FILE) | grep -v .pb.go: | grep -v .pb.gw.go: 144 | 145 | .PHONY: go.lint 146 | go.lint: 147 | @set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \ 148 | cd $$dir; \ 149 | golangci-lint run --verbose ./...; \ 150 | ); done 151 | 152 | .PHONY: go.tidy 153 | go.tidy: 154 | @# tidy dirs with go.mod files 155 | @set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \ 156 | cd $$dir; \ 157 | $(GO) mod tidy; \ 158 | ); done 159 | 160 | .PHONY: go.depaware-update 161 | go.depaware-update: go.tidy 162 | @# gen depaware for bins 163 | @set -e; for dir in $(GOBINS); do ( set -xe; \ 164 | cd $$dir; \ 165 | $(GO) run github.com/tailscale/depaware --update .; \ 166 | ); done 167 | @# tidy unused depaware deps if not in a tools_test.go file 168 | @set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \ 169 | cd $$dir; \ 170 | $(GO) mod tidy; \ 171 | ); done 172 | 173 | .PHONY: go.depaware-check 174 | go.depaware-check: go.tidy 175 | @# gen depaware for bins 176 | @set -e; for dir in $(GOBINS); do ( set -xe; \ 177 | cd $$dir; \ 178 | $(GO) run github.com/tailscale/depaware --check .; \ 179 | ); done 180 | 181 | 182 | .PHONY: go.build 183 | go.build: 184 | @set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \ 185 | cd $$dir; \ 186 | $(GO) build ./...; \ 187 | ); done 188 | 189 | .PHONY: go.bump-deps 190 | go.bumpdeps: 191 | @set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \ 192 | cd $$dir; \ 193 | $(GO) get -u ./...; \ 194 | ); done 195 | 196 | .PHONY: go.bump-deps 197 | go.fmt: 198 | @set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \ 199 | cd $$dir; \ 200 | $(GO) run golang.org/x/tools/cmd/goimports -w `go list -f '{{.Dir}}' ./...` \ 201 | ); done 202 | 203 | VERIFY_STEPS += go.depaware-check 204 | BUILD_STEPS += go.build 205 | BUMPDEPS_STEPS += go.bumpdeps go.depaware-update 206 | TIDY_STEPS += go.tidy 207 | LINT_STEPS += go.lint 208 | UNITTEST_STEPS += go.unittest 209 | FMT_STEPS += go.fmt 210 | 211 | # FIXME: disabled, because currently slow 212 | # new rule that is manually run sometimes, i.e. `make pre-release` or `make maintenance`. 213 | # alternative: run it each time the go.mod is changed 214 | #GENERATE_STEPS += go.depaware-update 215 | endif 216 | 217 | ## 218 | ## Gitattributes 219 | ## 220 | 221 | ifneq ($(wildcard .gitattributes),) 222 | .PHONY: _linguist-ignored 223 | _linguist-kept: 224 | @git check-attr linguist-vendored $(shell git check-attr linguist-generated $(shell find . -type f | grep -v .git/) | grep unspecified | cut -d: -f1) | grep unspecified | cut -d: -f1 | sort 225 | 226 | .PHONY: _linguist-kept 227 | _linguist-ignored: 228 | @git check-attr linguist-vendored linguist-ignored `find . -not -path './.git/*' -type f` | grep '\ set$$' | cut -d: -f1 | sort -u 229 | endif 230 | 231 | ## 232 | ## Node 233 | ## 234 | 235 | ifndef NPM_PACKAGES 236 | ifneq ($(wildcard package.json),) 237 | NPM_PACKAGES = . 238 | endif 239 | endif 240 | ifdef NPM_PACKAGES 241 | .PHONY: npm.publish 242 | npm.publish: 243 | @echo -n "Do you want to npm publish? [y/N] " && read ans && \ 244 | @if [ $${ans:-N} = y ]; then \ 245 | set -e; for dir in $(NPM_PACKAGES); do ( set -xe; \ 246 | cd $$dir; \ 247 | npm publish --access=public; \ 248 | ); done; \ 249 | fi 250 | RELEASE_STEPS += npm.publish 251 | endif 252 | 253 | ## 254 | ## Docker 255 | ## 256 | 257 | docker_build = docker build \ 258 | --build-arg VCS_REF=`git rev-parse --short HEAD` \ 259 | --build-arg BUILD_DATE=`date -u +"%Y-%m-%dT%H:%M:%SZ"` \ 260 | --build-arg VERSION=`git describe --tags --always` \ 261 | -t "$2" -f "$1" "$(dir $1)" 262 | 263 | ifndef DOCKERFILE_PATH 264 | DOCKERFILE_PATH = ./Dockerfile 265 | endif 266 | ifndef DOCKER_IMAGE 267 | ifneq ($(wildcard Dockerfile),) 268 | DOCKER_IMAGE = $(notdir $(PWD)) 269 | endif 270 | endif 271 | ifdef DOCKER_IMAGE 272 | ifneq ($(DOCKER_IMAGE),none) 273 | .PHONY: docker.build 274 | docker.build: 275 | $(call check-program, docker) 276 | $(call docker_build,$(DOCKERFILE_PATH),$(DOCKER_IMAGE)) 277 | 278 | BUILD_STEPS += docker.build 279 | endif 280 | endif 281 | 282 | ## 283 | ## Common 284 | ## 285 | 286 | TEST_STEPS += $(UNITTEST_STEPS) 287 | TEST_STEPS += $(LINT_STEPS) 288 | TEST_STEPS += $(TIDY_STEPS) 289 | 290 | ifneq ($(strip $(TEST_STEPS)),) 291 | .PHONY: test 292 | test: $(PRE_TEST_STEPS) $(TEST_STEPS) 293 | endif 294 | 295 | ifdef INSTALL_STEPS 296 | .PHONY: install 297 | install: $(PRE_INSTALL_STEPS) $(INSTALL_STEPS) 298 | endif 299 | 300 | ifdef UNITTEST_STEPS 301 | .PHONY: unittest 302 | unittest: $(PRE_UNITTEST_STEPS) $(UNITTEST_STEPS) 303 | endif 304 | 305 | ifdef LINT_STEPS 306 | .PHONY: lint 307 | lint: $(PRE_LINT_STEPS) $(FMT_STEPS) $(LINT_STEPS) 308 | endif 309 | 310 | ifdef TIDY_STEPS 311 | .PHONY: tidy 312 | tidy: $(PRE_TIDY_STEPS) $(TIDY_STEPS) 313 | endif 314 | 315 | ifdef BUILD_STEPS 316 | .PHONY: build 317 | build: $(PRE_BUILD_STEPS) $(BUILD_STEPS) 318 | endif 319 | 320 | ifdef VERIFY_STEPS 321 | .PHONY: verify 322 | verify: $(PRE_VERIFY_STEPS) $(VERIFY_STEPS) 323 | endif 324 | 325 | ifdef RELEASE_STEPS 326 | .PHONY: release 327 | release: $(PRE_RELEASE_STEPS) $(RELEASE_STEPS) 328 | endif 329 | 330 | ifdef BUMPDEPS_STEPS 331 | .PHONY: bumpdeps 332 | bumpdeps: $(PRE_BUMDEPS_STEPS) $(BUMPDEPS_STEPS) 333 | endif 334 | 335 | ifdef FMT_STEPS 336 | .PHONY: fmt 337 | fmt: $(PRE_FMT_STEPS) $(FMT_STEPS) 338 | endif 339 | 340 | ifdef GENERATE_STEPS 341 | .PHONY: generate 342 | generate: $(PRE_GENERATE_STEPS) $(GENERATE_STEPS) 343 | endif 344 | 345 | .PHONY: help 346 | help:: 347 | @echo "General commands:" 348 | @[ "$(BUILD_STEPS)" != "" ] && echo " build" || true 349 | @[ "$(BUMPDEPS_STEPS)" != "" ] && echo " bumpdeps" || true 350 | @[ "$(FMT_STEPS)" != "" ] && echo " fmt" || true 351 | @[ "$(GENERATE_STEPS)" != "" ] && echo " generate" || true 352 | @[ "$(INSTALL_STEPS)" != "" ] && echo " install" || true 353 | @[ "$(LINT_STEPS)" != "" ] && echo " lint" || true 354 | @[ "$(RELEASE_STEPS)" != "" ] && echo " release" || true 355 | @[ "$(TEST_STEPS)" != "" ] && echo " test" || true 356 | @[ "$(TIDY_STEPS)" != "" ] && echo " tidy" || true 357 | @[ "$(UNITTEST_STEPS)" != "" ] && echo " unittest" || true 358 | @[ "$(VERIFY_STEPS)" != "" ] && echo " verify" || true 359 | @# FIXME: list other commands 360 | 361 | print-% : ; $(info $* is a $(flavor $*) variable set to [$($*)]) @true 362 | --------------------------------------------------------------------------------