├── .assets ├── bastion.jpg ├── client.gif ├── cluster-mysql.dot ├── cluster-mysql.png ├── cluster-mysql.svg ├── demo.gif ├── flow-diagram.dot ├── flow-diagram.png ├── flow-diagram.svg ├── overview.dot ├── overview.png ├── overview.svg ├── server.gif └── sql-schema.svg ├── .circleci └── config.yml ├── .dockerignore ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── renovate.json └── workflows │ ├── ci.yml │ ├── release.yml │ └── semgrep.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .releaserc.js ├── AUTHORS ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── _config.yml ├── depaware.txt ├── examples ├── homebrew │ └── sshportal.rb ├── integration │ ├── Dockerfile │ ├── Makefile │ ├── _client.sh │ ├── client_test_rsa │ └── docker-compose.yml └── mysql │ └── docker-compose.yml ├── go.mod ├── go.sum ├── healthcheck.go ├── helm └── sshportal │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── horizontal-pod-autoscaling.yaml │ ├── service.yaml │ └── tests │ │ └── test-connection.yaml │ └── values.yaml ├── internal └── tools │ └── tools.go ├── main.go ├── pkg ├── bastion │ ├── acl.go │ ├── acl_test.go │ ├── dbinit.go │ ├── logtunnel.go │ ├── session.go │ ├── shell.go │ ├── ssh.go │ └── telnet.go ├── crypto │ └── crypto.go ├── dbmodels │ ├── dbmodels.go │ └── validator.go └── utils │ ├── emailvalidator.go │ └── emailvalidator_test.go ├── rules.mk ├── server.go ├── testserver.go └── testserver_unsupported.go /.assets/bastion.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/sshportal/f9c8f60365e9861905a52b24c296e11dbddd1444/.assets/bastion.jpg -------------------------------------------------------------------------------- /.assets/client.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/sshportal/f9c8f60365e9861905a52b24c296e11dbddd1444/.assets/client.gif -------------------------------------------------------------------------------- /.assets/cluster-mysql.dot: -------------------------------------------------------------------------------- 1 | graph { 2 | rankdir=LR; 3 | subgraph cluster_sshportal { 4 | label="sshportal cluster"; 5 | edge[style=dashed,color=grey,constraint=false]; 6 | sshportal1; sshportal2; sshportal3; sshportalN; 7 | sshportal1 -- MySQL; 8 | sshportal2 -- MySQL; 9 | sshportal3 -- MySQL; 10 | sshportalN -- MySQL; 11 | } 12 | 13 | subgraph cluster_hosts { 14 | label="hosts"; 15 | host1; host2; host3; hostN; 16 | } 17 | 18 | subgraph cluster_users { 19 | label="users"; 20 | user1; user2; user3; userN; 21 | } 22 | 23 | { 24 | user1 -- sshportal1 -- host1[color=red,penwidth=3.0]; 25 | user2 -- sshportal2 -- host2[color=green,penwidth=3.0]; 26 | user3 -- sshportal3 -- host3[color=blue,penwidth=3.0]; 27 | user3 -- sshportal2 -- host1[color=purple,penwidth=3.0]; 28 | userN -- sshportalN -- hostN[style=dotted]; 29 | } 30 | } -------------------------------------------------------------------------------- /.assets/cluster-mysql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/sshportal/f9c8f60365e9861905a52b24c296e11dbddd1444/.assets/cluster-mysql.png -------------------------------------------------------------------------------- /.assets/cluster-mysql.svg: -------------------------------------------------------------------------------- 1 | %3cluster_sshportalsshportal clustercluster_hostshostscluster_usersuserssshportal1sshportal1MySQLMySQLsshportal1--MySQLhost1host1sshportal1--host1sshportal2sshportal2sshportal2--MySQLsshportal2--host1host2host2sshportal2--host2sshportal3sshportal3sshportal3--MySQLhost3host3sshportal3--host3sshportalNsshportalNsshportalN--MySQLhostNhostNsshportalN--hostNuser1user1user1--sshportal1user2user2user2--sshportal2user3user3user3--sshportal2user3--sshportal3userNuserNuserN--sshportalN -------------------------------------------------------------------------------- /.assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/sshportal/f9c8f60365e9861905a52b24c296e11dbddd1444/.assets/demo.gif -------------------------------------------------------------------------------- /.assets/flow-diagram.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | node[shape=record;style=rounded;fontname="helvetica-bold"]; 3 | graph[layout=dot;rankdir=LR;overlap=prism;splines=true;fontname="helvetica-bold"]; 4 | edge[arrowhead=none;fontname="helvetica"]; 5 | 6 | start[label="\$\> ssh sshportal";color=blue;fontcolor=blue;fontsize=18]; 7 | 8 | subgraph cluster_sshportal { 9 | graph[fontsize=18;color=gray;fontcolor=black]; 10 | label="sshportal"; 11 | { 12 | node[color=darkorange;fontcolor=darkorange]; 13 | known_user_key[label="known user key"]; 14 | unknown_user_key[label="unknown user key"]; 15 | invite_manager[label="invite manager"]; 16 | acl_manager[label="ACL manager"]; 17 | } 18 | { 19 | node[color=darkgreen;fontcolor=darkgreen]; 20 | builtin_shell[label="built-in\nconfig shell"]; 21 | ssh_proxy[label="SSH proxy\nJump-Host"]; 22 | learn_key[label="learn the\npub key"]; 23 | } 24 | err_and_exit[label="\nerror\nand exit\n\n";color=red;fontcolor=red]; 25 | { rank=same; ssh_proxy; builtin_shell; learn_key; err_and_exit; } 26 | { rank=same; known_user_key; unknown_user_key; } 27 | } 28 | 29 | subgraph cluster_hosts { 30 | label="your hosts"; 31 | graph[fontsize=18;color=gray;fontcolor=black]; 32 | node[color=blue;fontcolor=blue]; 33 | 34 | host_1[label="root@host1"]; 35 | host_2[label="user@host2:2222"]; 36 | host_3[label="root@host3:1234"]; 37 | } 38 | 39 | { 40 | edge[color=blue]; 41 | start -> known_user_key; 42 | start -> unknown_user_key; 43 | ssh_proxy -> host_1; 44 | ssh_proxy -> host_2; 45 | ssh_proxy -> host_3; 46 | } 47 | { 48 | edge[color=darkgreen;fontcolor=darkgreen]; 49 | known_user_key -> builtin_shell[label="user=admin"]; 50 | acl_manager -> ssh_proxy[label="authorized"]; 51 | invite_manager -> learn_key[label="valid token"]; 52 | } 53 | { 54 | edge[color=darkorange;fontcolor=darkorange]; 55 | known_user_key -> acl_manager[label="user matches an existing host"]; 56 | unknown_user_key -> invite_manager[label="user=invite:";labelloc=b]; 57 | } 58 | { 59 | edge[color=red;fontcolor=red]; 60 | known_user_key -> err_and_exit[label="invalid user"]; 61 | acl_manager -> err_and_exit[label="unauthorized"]; 62 | unknown_user_key -> err_and_exit[label="any other user";constraint=false]; 63 | invite_manager -> err_and_exit[label="invalid token";constraint=false]; 64 | } 65 | } -------------------------------------------------------------------------------- /.assets/flow-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/sshportal/f9c8f60365e9861905a52b24c296e11dbddd1444/.assets/flow-diagram.png -------------------------------------------------------------------------------- /.assets/flow-diagram.svg: -------------------------------------------------------------------------------- 1 | %3cluster_sshportalsshportalcluster_hostsyour hostsstart$> ssh sshportalknown_user_keyknown user keystart->known_user_keyunknown_user_keyunknown user keystart->unknown_user_keyacl_managerACL managerknown_user_key->acl_manageruser matches an existing hostbuiltin_shellbuilt-inconfig shellknown_user_key->builtin_shelluser=adminerr_and_exiterrorand exitknown_user_key->err_and_exitinvalid userinvite_managerinvite managerunknown_user_key->invite_manageruser=invite:<token>unknown_user_key->err_and_exitany other userlearn_keylearn thepub keyinvite_manager->learn_keyvalid tokeninvite_manager->err_and_exitinvalid tokenssh_proxySSH proxyJump-Hostacl_manager->ssh_proxyauthorizedacl_manager->err_and_exitunauthorizedhost_1root@host1ssh_proxy->host_1host_2user@host2:2222ssh_proxy->host_2host_3root@host3:1234ssh_proxy->host_3 -------------------------------------------------------------------------------- /.assets/overview.dot: -------------------------------------------------------------------------------- 1 | graph { 2 | rankdir=LR; 3 | node[shape=box,style=rounded,style=rounded,fillcolor=gray]; 4 | 5 | 6 | subgraph cluster_sshportal { 7 | sshportal[penwidth=3.0,color=brown,fontcolor=brown,fontsize=20]; 8 | shell[label="built-in\nadmin shell",color=orange,fontcolor=orange]; 9 | db[color=gray,fontcolor=gray,shape=circle]; 10 | { rank=same; db; sshportal; shell } 11 | } 12 | 13 | { 14 | node[color="green"]; 15 | host1; host2; host3; hostN; 16 | } 17 | 18 | { 19 | node[color="blue"]; 20 | user1; user2; user3; userN; 21 | } 22 | 23 | { 24 | edge[penwidth=3.0]; 25 | user1 -- sshportal -- host1[color=red]; 26 | user2 -- sshportal -- host2[color=blue]; 27 | user3 -- sshportal -- host1[color=purple]; 28 | user2 -- sshportal -- host3[color=green]; 29 | user2 -- sshportal -- shell[color=orange,constraint=false]; 30 | } 31 | 32 | userN -- sshportal[style=dotted]; 33 | sshportal -- hostN[style=dotted]; 34 | sshportal -- db[style=dotted,color=grey]; 35 | 36 | } 37 | -------------------------------------------------------------------------------- /.assets/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/sshportal/f9c8f60365e9861905a52b24c296e11dbddd1444/.assets/overview.png -------------------------------------------------------------------------------- /.assets/overview.svg: -------------------------------------------------------------------------------- 1 | %3cluster_sshportalsshportalsshportalshellbuilt-inadmin shellsshportal--shelldbdbsshportal--dbhost1host1sshportal--host1sshportal--host1host2host2sshportal--host2host3host3sshportal--host3hostNhostNsshportal--hostNuser1user1user1--sshportaluser2user2user2--sshportaluser2--sshportaluser2--sshportaluser3user3user3--sshportaluserNuserNuserN--sshportal -------------------------------------------------------------------------------- /.assets/server.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moul/sshportal/f9c8f60365e9861905a52b24c296e11dbddd1444/.assets/server.gif -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: /go/src/moul.io/sshportal 3 | docker: 4 | - image: circleci/golang:1.17.5 5 | environment: 6 | GO111MODULE: "on" 7 | 8 | install_retry: &install_retry 9 | run: 10 | name: install retry 11 | command: | 12 | command -v wget &>/dev/null && wget -O /tmp/retry "https://github.com/moul/retry/releases/download/v0.5.0/retry_$(uname -s)_$(uname -m)" || true 13 | if [ ! -f /tmp/retry ]; then command -v curl &>/dev/null && curl -L -o /tmp/retry "https://github.com/moul/retry/releases/download/v0.5.0/retry_$(uname -s)_$(uname -m)"; fi 14 | chmod +x /tmp/retry 15 | /tmp/retry --version 16 | 17 | version: 2 18 | jobs: 19 | docker.integration: 20 | <<: *defaults 21 | steps: 22 | - checkout 23 | - run: 24 | name: Install Docker Compose 25 | command: | 26 | umask 022 27 | curl -L https://github.com/docker/compose/releases/download/1.11.4/docker-compose-`uname -s`-`uname -m` > ~/docker-compose 28 | - setup_remote_docker: 29 | docker_layer_caching: true 30 | version: 18.09.3 # https://github.com/golang/go/issues/40893 31 | - *install_retry 32 | - run: /tmp/retry -m 3 docker build -t moul/sshportal . 33 | - run: /tmp/retry -m 3 make integration 34 | 35 | 36 | workflows: 37 | version: 2 38 | build_and_integration: 39 | jobs: 40 | - docker.integration 41 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # .git/ # should be kept for git-based versionning 2 | 3 | examples/ 4 | .circleci/ 5 | .assets/ 6 | /sshportal 7 | -------------------------------------------------------------------------------- /.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 | *.pb.gw.go linguist-generated 12 | go.sum linguist-generated 13 | go.mod linguist-generated 14 | gen.sum linguist-generated 15 | 16 | # Reduce conflicts on markdown files 17 | *.md merge=union 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ["moul"] 2 | patreon: moul 3 | open_collective: sshportal 4 | custom: 5 | - "https://www.buymeacoffee.com/moul" 6 | - "https://manfred.life/donate" 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Actual Result / Problem 2 | 3 | When I do Foo, Bar happens... 4 | 5 | ### Expected Result / Suggestion 6 | 7 | I expect that Foobar happens... 8 | 9 | ### Some context 10 | 11 | Any screenshot to share? 12 | `sshportal --version`? 13 | `ssh sshportal info`? 14 | OS/Go version? 15 | ... 16 | -------------------------------------------------------------------------------- /.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@v2 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@v2 22 | - name: lint 23 | uses: golangci/golangci-lint-action@v3 24 | with: 25 | version: v1.50.1 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | tests-on-windows: 28 | needs: golangci-lint # run after golangci-lint action to not produce duplicated errors 29 | runs-on: windows-latest 30 | strategy: 31 | matrix: 32 | golang: 33 | - 1.16.x 34 | steps: 35 | - uses: actions/checkout@v2 36 | - name: Install Go 37 | uses: actions/setup-go@v2 38 | with: 39 | go-version: ${{ matrix.golang }} 40 | - name: Run tests on Windows 41 | run: make.exe unittest 42 | continue-on-error: true 43 | tests-on-mac: 44 | needs: golangci-lint # run after golangci-lint action to not produce duplicated errors 45 | runs-on: macos-latest 46 | strategy: 47 | matrix: 48 | golang: 49 | - 1.16.x 50 | steps: 51 | - uses: actions/checkout@v2 52 | - name: Install Go 53 | uses: actions/setup-go@v2 54 | with: 55 | go-version: ${{ matrix.golang }} 56 | - uses: actions/cache@v2.1.7 57 | with: 58 | path: ~/go/pkg/mod 59 | key: ${{ runner.os }}-go-${{ matrix.golang }}-${{ hashFiles('**/go.sum') }} 60 | restore-keys: | 61 | ${{ runner.os }}-go-${{ matrix.golang }}- 62 | - name: Run tests on Unix-like operating systems 63 | run: make unittest 64 | tests-on-linux: 65 | needs: golangci-lint # run after golangci-lint action to not produce duplicated errors 66 | runs-on: ubuntu-latest 67 | strategy: 68 | matrix: 69 | golang: 70 | - 1.13.x 71 | - 1.14.x 72 | - 1.15.x 73 | - 1.16.x 74 | steps: 75 | - uses: actions/checkout@v2 76 | - name: Install Go 77 | uses: actions/setup-go@v2 78 | with: 79 | go-version: ${{ matrix.golang }} 80 | - uses: actions/cache@v2.1.7 81 | with: 82 | path: ~/go/pkg/mod 83 | key: ${{ runner.os }}-go-${{ matrix.golang }}-${{ hashFiles('**/go.sum') }} 84 | restore-keys: | 85 | ${{ runner.os }}-go-${{ matrix.golang }}- 86 | - name: Run tests on Unix-like operating systems 87 | run: make unittest 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Release 2 | 3 | on: push 4 | 5 | jobs: 6 | semantic-release: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - uses: codfish/semantic-release-action@v1.9.0 11 | if: github.ref == 'refs/heads/master' 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.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 | coverage.txt 2 | dist/ 3 | *~ 4 | *# 5 | .*# 6 | .DS_Store 7 | /log/ 8 | /sshportal 9 | *.db 10 | /data 11 | sshportal.history 12 | .idea 13 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - 3 | goos: [linux, darwin] 4 | goarch: [386, amd64, arm, arm64] 5 | ldflags: 6 | - -s -w -X main.GitSha={{.ShortCommit}} -X main.GitBranch=master -X main.GitTag={{.Version}} 7 | archives: 8 | - wrap_in_directory: true 9 | checksum: 10 | name_template: 'checksums.txt' 11 | snapshot: 12 | name_template: "{{ .Tag }}-next" 13 | changelog: 14 | sort: asc 15 | filters: 16 | exclude: 17 | - '^docs:' 18 | - '^test:' 19 | brews: 20 | - 21 | name: sshportal 22 | github: 23 | owner: moul 24 | name: homebrew-moul 25 | commit_author: 26 | name: moul-bot 27 | email: "m+bot@42.am" 28 | homepage: https://manfred.life/sshportal 29 | description: "Simple, fun and transparent SSH (and telnet) bastion" 30 | -------------------------------------------------------------------------------- /.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 | ahh 5 | Alen Masic 6 | Alexander Turner 7 | bozzo 8 | Darko Djalevski 9 | dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 10 | fossabot 11 | ImgBotApp 12 | Jason Wessel 13 | Jean-Louis Férey 14 | jerard@alfa-safety.fr 15 | Jess 16 | Jonathan Lestrelin 17 | Julien Dessaux 18 | Konstantin Bakaras 19 | Manfred Touron <94029+moul@users.noreply.github.com> 20 | Manfred Touron 21 | Manuel 22 | Manuel Sabban 23 | Manuel Sabban 24 | Mathieu Pasquet 25 | matteyeux 26 | Mikael Rapp 27 | MitaliBo 28 | moul-bot 29 | Nelly Asher 30 | NocFlame 31 | Quentin Perez 32 | Renovate Bot 33 | Sergey Yashchuk <11705746+GreyOBox@users.noreply.github.com> 34 | Sergey Yashchuk 35 | Shawn Wang 36 | Valentin Daviot 37 | valentin.daviot 38 | welderpb 39 | Дмитрий Шульгачик 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Here: https://github.com/moul/sshportal/releases 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build 2 | FROM golang:1.18.0 as builder 3 | ENV GO111MODULE=on 4 | WORKDIR /go/src/moul.io/sshportal 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | COPY . ./ 8 | RUN make _docker_install 9 | 10 | # minimal runtime 11 | FROM alpine 12 | COPY --from=builder /go/bin/sshportal /bin/sshportal 13 | ENTRYPOINT ["/bin/sshportal"] 14 | CMD ["server"] 15 | EXPOSE 2222 16 | HEALTHCHECK CMD /bin/sshportal healthcheck --wait 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017-2021 Manfred Touron 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOPKG ?= moul.io/sshportal 2 | GOBINS ?= . 3 | DOCKER_IMAGE ?= moul/sshportal 4 | 5 | VERSION ?= `git describe --tags --always` 6 | VCS_REF ?= `git rev-parse --short HEAD` 7 | GO_INSTALL_OPTS = -ldflags="-X main.GitSha=$(VCS_REF) -X main.GitTag=$(VERSION)" 8 | PORT ?= 2222 9 | 10 | include rules.mk 11 | 12 | DB_VERSION ?= v$(shell grep -E 'ID: "[0-9]+",' pkg/bastion/dbinit.go | tail -n 1 | cut -d'"' -f2) 13 | AES_KEY ?= my-dummy-aes-key 14 | 15 | .PHONY: integration 16 | integration: 17 | cd ./examples/integration && make 18 | 19 | .PHONY: _docker_install 20 | _docker_install: 21 | CGO_ENABLED=1 $(GO) build -ldflags '-extldflags "-static" $(LDFLAGS)' -tags netgo -v -o /go/bin/sshportal 22 | 23 | .PHONY: dev 24 | dev: 25 | -$(GO) get github.com/githubnemo/CompileDaemon 26 | CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal server --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" . 27 | 28 | .PHONY: backup 29 | backup: 30 | mkdir -p data/backups 31 | cp sshportal.db data/backups/$(shell date +%s)-$(DB_VERSION)-sshportal.sqlite 32 | 33 | doc: 34 | dot -Tsvg ./.assets/overview.dot > ./.assets/overview.svg 35 | dot -Tsvg ./.assets/cluster-mysql.dot > ./.assets/cluster-mysql.svg 36 | dot -Tsvg ./.assets/flow-diagram.dot > ./.assets/flow-diagram.svg 37 | dot -Tpng ./.assets/overview.dot > ./.assets/overview.png 38 | dot -Tpng ./.assets/cluster-mysql.dot > ./.assets/cluster-mysql.png 39 | dot -Tpng ./.assets/flow-diagram.dot > ./.assets/flow-diagram.png 40 | 41 | .PHONY: goreleaser 42 | goreleaser: 43 | GORELEASER_GITHUB_TOKEN=$(GORELEASER_GITHUB_TOKEN) GITHUB_TOKEN=$(GITHUB_TOKEN) goreleaser --rm-dist 44 | 45 | .PHONY: goreleaser-dry-run 46 | goreleaser-dry-run: 47 | goreleaser --snapshot --skip-publish --rm-dist 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshportal 2 | 3 | [![CircleCI](https://circleci.com/gh/moul/sshportal.svg?style=svg)](https://circleci.com/gh/moul/sshportal) 4 | [![Go Report Card](https://goreportcard.com/badge/moul.io/sshportal)](https://goreportcard.com/report/moul.io/sshportal) 5 | [![GoDoc](https://godoc.org/moul.io/sshportal?status.svg)](https://godoc.org/moul.io/sshportal) 6 | [![Financial Contributors on Open Collective](https://opencollective.com/sshportal/all/badge.svg?label=financial+contributors)](https://opencollective.com/sshportal) [![License](https://img.shields.io/github/license/moul/sshportal.svg)](https://github.com/moul/sshportal/blob/master/LICENSE) 7 | [![GitHub release](https://img.shields.io/github/release/moul/sshportal.svg)](https://github.com/moul/sshportal/releases) 8 | 9 | 10 | Jump host/Jump server without the jump, a.k.a Transparent SSH bastion 11 | 12 | 13 | 14 | Features include: independence of users and hosts, convenient user invite system, connecting to servers that don't support SSH keys, various levels of access, and many more. Easy to install, run and configure. 15 | 16 | ![Flow Diagram](https://raw.githubusercontent.com/moul/sshportal/master/.assets/flow-diagram.png) 17 | 18 | --- 19 | 20 | ## Contents 21 | 22 | 23 | 24 | - [Installation and usage](#installation-and-usage) 25 | - [Use cases](#use-cases) 26 | - [Features and limitations](#features-and-limitations) 27 | - [Docker](#docker) 28 | - [Manual Install](#manual-install) 29 | - [Backup / Restore](#backup--restore) 30 | - [built-in shell](#built-in-shell) 31 | - [Demo data](#demo-data) 32 | - [Shell commands](#shell-commands) 33 | - [Healthcheck](#healthcheck) 34 | - [portal alias (.ssh/config)](#portal-alias-sshconfig) 35 | - [Scaling](#scaling) 36 | - [Under the hood](#under-the-hood) 37 | - [Testing](#testing) 38 | 39 | 40 | 41 | --- 42 | 43 | ## Installation and usage 44 | 45 | Start the server 46 | 47 | ```console 48 | $ sshportal server 49 | 2017/11/13 10:58:35 Admin user created, use the user 'invite:BpLnfgDsc2WD8F2q' to associate a public key with this account 50 | 2017/11/13 10:58:35 SSH Server accepting connections on :2222 51 | ``` 52 | 53 | Link your SSH key with the admin account 54 | 55 | ```console 56 | $ ssh localhost -p 2222 -l invite:BpLnfgDsc2WD8F2q 57 | Welcome admin! 58 | 59 | Your key is now associated with the user "admin@sshportal". 60 | Shared connection to localhost closed. 61 | $ 62 | ``` 63 | 64 | If the association fails and you are prompted for a password, verify that the host you're connecting from has a SSH key set up or generate one with ```ssh-keygen -t rsa``` 65 | 66 | Drop an interactive administrator shell 67 | 68 | ```console 69 | ssh localhost -p 2222 -l admin 70 | 71 | 72 | __________ _____ __ __ 73 | / __/ __/ // / _ \___ ____/ /____ _/ / 74 | _\ \_\ \/ _ / ___/ _ \/ __/ __/ _ '/ / 75 | /___/___/_//_/_/ \___/_/ \__/\_,_/_/ 76 | 77 | 78 | config> 79 | ``` 80 | 81 | Create your first host 82 | 83 | ```console 84 | config> host create bart@foo.example.org 85 | 1 86 | config> 87 | ``` 88 | 89 | List hosts 90 | 91 | ```console 92 | config> host ls 93 | ID | NAME | URL | KEY | PASS | GROUPS | COMMENT 94 | +----+------+-------------------------+---------+------+---------+---------+ 95 | 1 | foo | bart@foo.example.org:22 | default | | default | 96 | Total: 1 hosts. 97 | config> 98 | ``` 99 | 100 | Add the key to the server 101 | 102 | ```console 103 | $ ssh bart@foo.example.org "$(ssh localhost -p 2222 -l admin key setup default)" 104 | $ 105 | ``` 106 | 107 | Profit 108 | 109 | ```console 110 | ssh localhost -p 2222 -l foo 111 | bart@foo> 112 | ``` 113 | 114 | Invite friends 115 | 116 | *This command doesn't create a user on the remote server, it only creates an account in the sshportal database.* 117 | 118 | ```console 119 | config> user invite bob@example.com 120 | User 2 created. 121 | To associate this account with a key, use the following SSH user: 'invite:NfHK5a84jjJkwzDk'. 122 | config> 123 | ``` 124 | 125 | Demo gif: 126 | ![sshportal demo](https://github.com/moul/sshportal/raw/master/.assets/demo.gif) 127 | 128 | --- 129 | 130 | ## Use cases 131 | 132 | Used by educators to provide temporary access to students. [Feedback from a teacher](https://github.com/moul/sshportal/issues/64). The author is using it in one of his projects, *pathwar*, to dynamically configure hosts and users, so that he can give temporary accesses for educational purposes. 133 | 134 | *vptech*, the vente-privee.com technical team (a group of over 6000 people) is using it internally to manage access to servers/routers, saving hours on configuration management and not having to share the configuration information. 135 | 136 | There are companies who use a jump host to monitor connections at a single point. 137 | 138 | A hosting company is using SSHportal for its “logging” feature, among others. As every session is logged and introspectable, they have a detailed history of who performed which action. This company made its own contribution to the project, allowing the support of [more than 65.000 sessions in the database](https://github.com/moul/sshportal/pull/76). 139 | 140 | The project has also received [multiple contributions from a security researcher](https://github.com/moul/sshportal/pulls?q=is%3Apr+author%3Asabban+sort%3Aupdated-desc) that made a thesis on quantum cryptography. This person uses SSHportal in their security-hardened hosting company. 141 | 142 | If you need to invite multiple people to an event (hackathon, course, etc), the day before the event you can create multiple accounts at once, print the invite, and distribute the paper. 143 | 144 | --- 145 | 146 | ## Features and limitations 147 | 148 | * Single autonomous binary (~10-20Mb) with no runtime dependencies (embeds ssh server and client) 149 | * Portable / Cross-platform (regularly tested on linux and OSX/darwin) 150 | * Store data in [Sqlite3](https://www.sqlite.org/) or [MySQL](https://www.mysql.com) (probably easy to add postgres, mssql thanks to gorm) 151 | * Stateless -> horizontally scalable when using [MySQL](https://www.mysql.com) as the backend 152 | * Connect to remote host using key or password 153 | * Admin commands can be run directly or in an interactive shell 154 | * Host management 155 | * User management (invite, group, stats) 156 | * Host Key management (create, remove, update, import) 157 | * Automatic remote host key learning 158 | * User Key management (multiple keys per user) 159 | * ACL management (acl+user-groups+host-groups) 160 | * User roles (admin, trusted, standard, ...) 161 | * User invitations (no more "give me your public ssh key please") 162 | * Easy server installation (generate shell command to setup `authorized_keys`) 163 | * Sensitive data encryption 164 | * Session management (see active connections, history, stats, stop) 165 | * Audit log (logging every user action) 166 | * Record TTY Session (with [ttyrec](https://en.wikipedia.org/wiki/Ttyrec) format, use `ttyplay` for replay) 167 | * Tunnels logging 168 | * Host Keys verifications shared across users 169 | * Healthcheck user (replying OK to any user) 170 | * SSH compatibility 171 | * ipv4 and ipv6 support 172 | * [`scp`](https://linux.die.net/man/1/scp) support 173 | * [`rsync`](https://linux.die.net/man/1/rsync) support 174 | * [tunneling](https://www.ssh.com/ssh/tunneling/example) (local forward, remote forward, dynamic forward) support 175 | * [`sftp`](https://www.ssh.com/ssh/sftp/) support 176 | * [`ssh-agent`](https://www.ssh.com/ssh/agent) support 177 | * [`X11 forwarding`](http://en.tldp.org/HOWTO/XDMCP-HOWTO/ssh.html) support 178 | * Git support (can be used to easily use multiple user keys on GitHub, or access your own firewalled gitlab server) 179 | * Do not require any SSH client modification or custom `.ssh/config`, works with every tested SSH programming libraries and every tested SSH clients 180 | * SSH to non-SSH proxy 181 | * [Telnet](https://www.ssh.com/ssh/telnet) support 182 | 183 | **(Known) limitations** 184 | 185 | * Does not work (yet?) with [`mosh`](https://mosh.org/) 186 | * It is not possible for a user to access a host with the same name as the user. This is easily circumvented by changing the user name, especially since the most common use cases does not expose it. 187 | * It is not possible to access a host named `healthcheck` as this is a built-in command. 188 | 189 | --- 190 | 191 | ## Docker 192 | 193 | Docker is the recommended way to run sshportal. 194 | 195 | An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/sshportal/tags/). 196 | 197 | ```console 198 | # Start a server in background 199 | # mount `pwd` to persist the sqlite database file 200 | docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.10.0 201 | 202 | # check logs (mandatory on first run to get the administrator invite token) 203 | docker logs -f sshportal 204 | ``` 205 | 206 | The easier way to upgrade sshportal is to do the following: 207 | 208 | ```sh 209 | # we consider you were using an old version and you want to use the new version v1.10.0 210 | 211 | # stop and rename the last working container + backup the database 212 | docker stop sshportal 213 | docker rename sshportal sshportal_old 214 | cp sshportal.db sshportal.db.bkp 215 | 216 | # run the new version 217 | docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.10.0 218 | # check the logs for migration or cross-version incompatibility errors 219 | docker logs -f sshportal 220 | ``` 221 | 222 | Now you can test ssh-ing to sshportal to check if everything looks OK. 223 | 224 | In case of problem, you can rollback to the latest working version with the latest working backup, using: 225 | 226 | ```sh 227 | docker stop sshportal 228 | docker rm sshportal 229 | cp sshportal.db.bkp sshportal.db 230 | docker rename sshportal_old sshportal 231 | docker start sshportal 232 | docker logs -f sshportal 233 | ``` 234 | 235 | --- 236 | 237 | ## Manual Install 238 | 239 | Get the latest version using GO. 240 | 241 | ```sh 242 | GO111MODULE=on go get -u moul.io/sshportal 243 | ``` 244 | 245 | --- 246 | 247 | ## Backup / Restore 248 | 249 | sshportal embeds built-in backup/restore methods which basically import/export JSON objects: 250 | 251 | ```sh 252 | # Backup 253 | ssh portal config backup > sshportal.bkp 254 | 255 | # Restore 256 | ssh portal config restore < sshportal.bkp 257 | ``` 258 | 259 | This method is particularly useful as it should be resistant against future DB schema changes (expected during development phase). 260 | 261 | I suggest you to be careful during this development phase, and use an additional backup method, for example: 262 | 263 | ```sh 264 | # sqlite dump 265 | sqlite3 sshportal.db .dump > sshportal.sql.bkp 266 | 267 | # or just the immortal cp 268 | cp sshportal.db sshportal.db.bkp 269 | ``` 270 | 271 | --- 272 | 273 | ## built-in shell 274 | 275 | `sshportal` embeds a configuration CLI. 276 | 277 | By default, the configuration user is `admin`, (can be changed using `--config-user=` when starting the server. The shell is also accessible through `ssh [username]@portal.example.org`. 278 | 279 | Each command can be run directly by using this syntax: `ssh admin@portal.example.org [args]`: 280 | 281 | ``` 282 | ssh admin@portal.example.org host inspect toto 283 | ``` 284 | 285 | You can enter in interactive mode using this syntax: `ssh admin@portal.example.org` 286 | 287 | ![sshportal overview](https://raw.github.com/moul/sshportal/master/.assets/overview.png) 288 | 289 | --- 290 | 291 | ## Demo data 292 | 293 | The following servers are freely available, without external registration, 294 | it makes it easier to quickly test `sshportal` without configuring your own servers to accept sshportal connections. 295 | 296 | ``` 297 | ssh portal host create new@sdf.org 298 | ssh sdf@portal 299 | 300 | ssh portal host create test@whoami.filippo.io 301 | ssh whoami@portal 302 | 303 | ssh portal host create test@chat.shazow.net 304 | ssh chat@portal 305 | ``` 306 | 307 | --- 308 | 309 | ## Shell commands 310 | 311 | ```sh 312 | # acl management 313 | acl help 314 | acl create [-h] [--hostgroup=HOSTGROUP...] [--usergroup=USERGROUP...] [--pattern=] [--comment=] [--action=] [--weight=value] 315 | acl inspect [-h] ACL... 316 | acl ls [-h] [--latest] [--quiet] 317 | acl rm [-h] ACL... 318 | acl update [-h] [--comment=] [--action=] [--weight=] [--assign-hostgroup=HOSTGROUP...] [--unassign-hostgroup=HOSTGROUP...] [--assign-usergroup=USERGROUP...] [--unassign-usergroup=USERGROUP...] ACL... 319 | 320 | # config management 321 | config help 322 | config backup [-h] [--indent] [--decrypt] 323 | config restore [-h] [--confirm] [--decrypt] 324 | 325 | # event management 326 | event help 327 | event ls [-h] [--latest] [--quiet] 328 | event inspect [-h] EVENT... 329 | 330 | # host management 331 | host help 332 | host create [-h] [--name=] [--password=] [--comment=] [--key=KEY] [--group=HOSTGROUP...] [--hop=HOST] [--logging=MODE] [:]@[:] 333 | host inspect [-h] [--decrypt] HOST... 334 | host ls [-h] [--latest] [--quiet] 335 | host rm [-h] HOST... 336 | host update [-h] [--name=] [--comment=] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] [--logging-MODE] [--set-hop=HOST] [--unset-hop] HOST... 337 | 338 | # hostgroup management 339 | hostgroup help 340 | hostgroup create [-h] [--name=] [--comment=] 341 | hostgroup inspect [-h] HOSTGROUP... 342 | hostgroup ls [-h] [--latest] [--quiet] 343 | hostgroup rm [-h] HOSTGROUP... 344 | 345 | # key management 346 | key help 347 | key create [-h] [--name=] [--type=] [--length=] [--comment=] 348 | key import [-h] [--name=] [--comment=] 349 | key inspect [-h] [--decrypt] KEY... 350 | key ls [-h] [--latest] [--quiet] 351 | key rm [-h] KEY... 352 | key setup [-h] KEY 353 | key show [-h] KEY 354 | 355 | # session management 356 | session help 357 | session ls [-h] [--latest] [--quiet] 358 | session inspect [-h] SESSION... 359 | 360 | # user management 361 | user help 362 | user invite [-h] [--name=] [--comment=] [--group=USERGROUP...] 363 | user inspect [-h] USER... 364 | user ls [-h] [--latest] [--quiet] 365 | user rm [-h] USER... 366 | user update [-h] [--name=] [--email=] [--set-admin] [--unset-admin] [--assign-group=USERGROUP...] [--unassign-group=USERGROUP...] USER... 367 | 368 | # usergroup management 369 | usergroup help 370 | usergroup create [-h] [--name=] [--comment=] 371 | usergroup inspect [-h] USERGROUP... 372 | usergroup ls [-h] [--latest] [--quiet] 373 | usergroup rm [-h] USERGROUP... 374 | 375 | # other 376 | exit [-h] 377 | help, h 378 | info [-h] 379 | version [-h] 380 | ``` 381 | 382 | --- 383 | 384 | ## Healthcheck 385 | 386 | By default, `sshportal` will return `OK` to anyone sshing using the `healthcheck` user without checking for authentication. 387 | 388 | ```console 389 | $ ssh healthcheck@sshportal 390 | OK 391 | $ 392 | ``` 393 | 394 | the `healtcheck` user can be changed using the `healthcheck-user` option. 395 | 396 | --- 397 | 398 | Alternatively, you can run the built-in healthcheck helper (requiring no ssh client nor ssh key): 399 | 400 | Usage: `sshportal healthcheck [--addr=host:port] [--wait] [--quiet] 401 | 402 | ```console 403 | $ sshportal healthcheck --addr=localhost:2222; echo $? 404 | $ 0 405 | ``` 406 | 407 | --- 408 | 409 | Wait for sshportal to be healthy, then connect 410 | 411 | ```console 412 | $ sshportal healthcheck --wait && ssh sshportal -l admin 413 | config> 414 | ``` 415 | 416 | --- 417 | 418 | ## portal alias (.ssh/config) 419 | 420 | Edit your `~/.ssh/config` file (create it first if needed) 421 | 422 | ```ini 423 | Host portal 424 | User admin 425 | Port 2222 # portal port 426 | HostName 127.0.0.1 # portal hostname 427 | ``` 428 | 429 | ```bash 430 | # you can now run a shell using this: 431 | ssh portal 432 | # instead of this: 433 | ssh localhost -p 2222 -l admin 434 | 435 | # or connect to hosts using this: 436 | ssh hostname@portal 437 | # instead of this: 438 | ssh localhost -p 2222 -l hostname 439 | ``` 440 | 441 | --- 442 | 443 | ## Scaling 444 | 445 | `sshportal` is stateless but relies on a database to store configuration and logs. 446 | 447 | By default, `sshportal` uses a local [sqlite](https://www.sqlite.org/) database which isn't scalable by design. 448 | 449 | You can run multiple instances of `sshportal` sharing the same [MySQL](https://www.mysql.com) database, using `sshportal --db-conn=user:pass@host/dbname?parseTime=true --db-driver=mysql`. 450 | 451 | ![sshportal cluster with MySQL backend](https://raw.github.com/moul/sshportal/master/.assets/cluster-mysql.png) 452 | 453 | See [examples/mysql](http://github.com/moul/sshportal/tree/master/examples/mysql). 454 | 455 | --- 456 | 457 | ## Under the hood 458 | 459 | * Docker first (used in dev, tests, by the CI and in production) 460 | * Backed by (see [dep graph](https://godoc.org/github.com/moul/sshportal?import-graph&hide=2)): 461 | * SSH 462 | * https://github.com/gliderlabs/ssh: SSH server made easy (well-designed golang library to build SSH servers) 463 | * https://godoc.org/golang.org/x/crypto/ssh: both client and server SSH protocol and helpers 464 | * Database 465 | * https://github.com/jinzhu/gorm/: SQL orm 466 | * https://github.com/go-gormigrate/gormigrate: Database migration system 467 | * Built-in shell 468 | * https://github.com/olekukonko/tablewriter: Ascii tables 469 | * https://github.com/asaskevich/govalidator: Valide user inputs 470 | * https://github.com/dustin/go-humanize: Human-friendly representation of technical data (time ago, bytes, ...) 471 | * https://github.com/mgutz/ansi: Terminal color helpers 472 | * https://github.com/urfave/cli: CLI flag parsing with subcommands support 473 | 474 | ![sshportal data model](https://raw.github.com/moul/sshportal/master/.assets/sql-schema.png) 475 | 476 | --- 477 | 478 | ## Testing 479 | 480 | [Install golangci-lint](https://golangci-lint.run/usage/install/#local-installation) and run this in project root: 481 | ``` 482 | golangci-lint run 483 | ``` 484 | --- 485 | Perform integration tests 486 | ``` 487 | make integration 488 | ``` 489 | --- 490 | Perform unit tests 491 | ``` 492 | make unittest 493 | ``` 494 | --- 495 | 496 | ## Contributors 497 | 498 | ### Code Contributors 499 | 500 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 501 | 502 | 503 | ### Financial Contributors 504 | 505 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/sshportal/contribute)] 506 | 507 | #### Individuals 508 | 509 | 510 | 511 | #### Organizations 512 | 513 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/sshportal/contribute)] 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | ### Stargazers over time 527 | 528 | [![Stargazers over time](https://starchart.cc/moul/sshportal.svg)](https://starchart.cc/moul/sshportal) 529 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /depaware.txt: -------------------------------------------------------------------------------- 1 | moul.io/sshportal dependencies: (generated by github.com/tailscale/depaware) 2 | 3 | github.com/anmitsu/go-shlex from github.com/gliderlabs/ssh+ 4 | github.com/asaskevich/govalidator from moul.io/sshportal/pkg/bastion+ 5 | github.com/cpuguy83/go-md2man/v2/md2man from github.com/urfave/cli 6 | LD 💣 github.com/creack/pty from github.com/kr/pty 7 | github.com/docker/docker/pkg/namesgenerator from moul.io/sshportal/pkg/bastion 8 | github.com/docker/docker/pkg/random from github.com/docker/docker/pkg/namesgenerator 9 | github.com/dustin/go-humanize from moul.io/sshportal/pkg/bastion 10 | github.com/gliderlabs/ssh from moul.io/sshportal+ 11 | github.com/go-sql-driver/mysql from github.com/jinzhu/gorm/dialects/mysql+ 12 | github.com/jinzhu/gorm from gopkg.in/gormigrate.v1+ 13 | github.com/jinzhu/gorm/dialects/mysql from moul.io/sshportal 14 | github.com/jinzhu/gorm/dialects/postgres from moul.io/sshportal 15 | github.com/jinzhu/gorm/dialects/sqlite from moul.io/sshportal 16 | github.com/jinzhu/inflection from github.com/jinzhu/gorm 17 | LD github.com/kr/pty from moul.io/sshportal 18 | github.com/lib/pq from github.com/jinzhu/gorm/dialects/postgres 19 | github.com/lib/pq/hstore from github.com/jinzhu/gorm/dialects/postgres 20 | github.com/lib/pq/oid from github.com/lib/pq 21 | github.com/lib/pq/scram from github.com/lib/pq 22 | 💣 github.com/mattn/go-colorable from github.com/mgutz/ansi 23 | 💣 github.com/mattn/go-isatty from github.com/mattn/go-colorable 24 | github.com/mattn/go-runewidth from github.com/olekukonko/tablewriter 25 | 💣 github.com/mattn/go-sqlite3 from github.com/jinzhu/gorm/dialects/sqlite 26 | github.com/mgutz/ansi from moul.io/sshportal/pkg/bastion 27 | github.com/olekukonko/tablewriter from moul.io/sshportal/pkg/bastion 28 | github.com/pkg/errors from moul.io/sshportal/pkg/bastion 29 | github.com/reiver/go-oi from github.com/reiver/go-telnet+ 30 | github.com/reiver/go-telnet from moul.io/sshportal/pkg/bastion 31 | github.com/russross/blackfriday/v2 from github.com/cpuguy83/go-md2man/v2/md2man 32 | github.com/sabban/bastion/pkg/logchannel from moul.io/sshportal/pkg/bastion 33 | github.com/shurcooL/sanitized_anchor_name from github.com/russross/blackfriday/v2 34 | github.com/urfave/cli from moul.io/sshportal+ 35 | gopkg.in/gormigrate.v1 from moul.io/sshportal/pkg/bastion 36 | moul.io/srand from moul.io/sshportal 37 | moul.io/sshportal/pkg/bastion from moul.io/sshportal 38 | moul.io/sshportal/pkg/crypto from moul.io/sshportal/pkg/bastion 39 | moul.io/sshportal/pkg/dbmodels from moul.io/sshportal/pkg/bastion+ 40 | golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf 41 | golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+ 42 | golang.org/x/crypto/chacha20poly1305 from crypto/tls 43 | golang.org/x/crypto/cryptobyte from crypto/ecdsa+ 44 | golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+ 45 | golang.org/x/crypto/curve25519 from crypto/tls+ 46 | golang.org/x/crypto/ed25519 from golang.org/x/crypto/ssh 47 | golang.org/x/crypto/hkdf from crypto/tls 48 | golang.org/x/crypto/poly1305 from golang.org/x/crypto/chacha20poly1305+ 49 | golang.org/x/crypto/ssh from github.com/gliderlabs/ssh+ 50 | golang.org/x/crypto/ssh/terminal from moul.io/sshportal/pkg/bastion 51 | golang.org/x/net/dns/dnsmessage from net 52 | D golang.org/x/net/route from net 53 | golang.org/x/sys/cpu from golang.org/x/crypto/chacha20poly1305 54 | LD golang.org/x/sys/unix from github.com/mattn/go-isatty+ 55 | W golang.org/x/sys/windows from golang.org/x/crypto/ssh/terminal 56 | bufio from crypto/rand+ 57 | bytes from bufio+ 58 | container/list from crypto/tls 59 | context from crypto/tls+ 60 | crypto from crypto/ecdsa+ 61 | crypto/aes from crypto/ecdsa+ 62 | crypto/cipher from crypto/aes+ 63 | crypto/des from crypto/tls+ 64 | crypto/dsa from crypto/x509+ 65 | crypto/ecdsa from crypto/tls+ 66 | crypto/ed25519 from crypto/tls+ 67 | crypto/elliptic from crypto/ecdsa+ 68 | crypto/hmac from crypto/tls+ 69 | crypto/md5 from crypto/tls+ 70 | crypto/rand from crypto/ed25519+ 71 | crypto/rc4 from crypto/tls+ 72 | crypto/rsa from crypto/tls+ 73 | crypto/sha1 from crypto/tls+ 74 | crypto/sha256 from crypto/tls+ 75 | crypto/sha512 from crypto/ecdsa+ 76 | crypto/subtle from crypto/aes+ 77 | crypto/tls from github.com/go-sql-driver/mysql+ 78 | crypto/x509 from crypto/tls+ 79 | crypto/x509/pkix from crypto/x509 80 | database/sql from github.com/go-sql-driver/mysql+ 81 | database/sql/driver from database/sql+ 82 | encoding from encoding/json 83 | encoding/asn1 from crypto/x509+ 84 | encoding/base64 from encoding/json+ 85 | encoding/binary from crypto/aes+ 86 | encoding/csv from github.com/olekukonko/tablewriter 87 | encoding/hex from crypto/x509+ 88 | encoding/json from github.com/asaskevich/govalidator+ 89 | encoding/pem from crypto/tls+ 90 | errors from bufio+ 91 | flag from github.com/urfave/cli 92 | fmt from crypto/tls+ 93 | go/ast from github.com/jinzhu/gorm 94 | go/scanner from go/ast 95 | go/token from go/ast+ 96 | hash from crypto+ 97 | html from github.com/asaskevich/govalidator+ 98 | io from bufio+ 99 | io/fs from crypto/rand+ 100 | io/ioutil from crypto/x509+ 101 | log from github.com/gliderlabs/ssh+ 102 | math from crypto/rsa+ 103 | math/big from crypto/dsa+ 104 | math/bits from crypto/md5+ 105 | math/rand from github.com/docker/docker/pkg/random+ 106 | net from crypto/tls+ 107 | net/url from crypto/x509+ 108 | os from crypto/rand+ 109 | LD os/exec from github.com/creack/pty+ 110 | os/user from github.com/lib/pq+ 111 | path from github.com/asaskevich/govalidator+ 112 | path/filepath from crypto/x509+ 113 | reflect from crypto/x509+ 114 | regexp from github.com/asaskevich/govalidator+ 115 | regexp/syntax from regexp 116 | sort from database/sql+ 117 | strconv from crypto+ 118 | strings from bufio+ 119 | sync from context+ 120 | sync/atomic from context+ 121 | syscall from crypto/rand+ 122 | text/tabwriter from github.com/urfave/cli 123 | text/template from github.com/urfave/cli 124 | text/template/parse from text/template 125 | time from context+ 126 | unicode from bytes+ 127 | unicode/utf16 from encoding/asn1+ 128 | unicode/utf8 from bufio+ 129 | -------------------------------------------------------------------------------- /examples/homebrew/sshportal.rb: -------------------------------------------------------------------------------- 1 | require "language/go" 2 | 3 | class Sshportal < Formula 4 | desc "sshportal: simple, fun and transparent SSH bastion" 5 | homepage "https://github.com/moul/sshportal" 6 | url "https://github.com/moul/sshportal/archive/v1.7.1.tar.gz" 7 | sha256 "4611ae2f30cc595b2fb789bd0c92550533db6d4b63c638dd78cf85517b6aeaf0" 8 | head "https://github.com/moul/sshportal.git" 9 | 10 | depends_on "go" => :build 11 | 12 | def install 13 | ENV["GOPATH"] = buildpath 14 | ENV["GOBIN"] = buildpath 15 | (buildpath/"src/github.com/moul/sshportal").install Dir["*"] 16 | 17 | system "go", "build", "-o", "#{bin}/sshportal", "-v", "github.com/moul/sshportal" 18 | end 19 | 20 | test do 21 | output = shell_output(bin/"sshportal --version") 22 | assert output.include? "sshportal version " 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /examples/integration/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM occitech/ssh-client 2 | ENTRYPOINT ["/bin/sh", "-c"] 3 | CMD ["/integration/_client.sh"] 4 | COPY . /integration 5 | -------------------------------------------------------------------------------- /examples/integration/Makefile: -------------------------------------------------------------------------------- 1 | run: 2 | docker-compose down 3 | docker-compose up -d sshportal 4 | docker-compose build client 5 | docker-compose exec sshportal /bin/sshportal healthcheck --wait --quiet 6 | docker-compose run client /integration/_client.sh 7 | docker-compose down 8 | 9 | build: 10 | docker-compose build 11 | -------------------------------------------------------------------------------- /examples/integration/_client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | mkdir -p ~/.ssh 4 | cp /integration/client_test_rsa ~/.ssh/id_rsa 5 | chmod -R 700 ~/.ssh 6 | cat >~/.ssh/config < backup-1 58 | ssh sshportal -l admin config restore --confirm < backup-1 59 | ssh sshportal -l admin config backup --indent --ignore-events > backup-2 60 | ( 61 | cat backup-1 | grep -v '"date":' | grep -v 'tedAt":' > backup-1.clean 62 | cat backup-2 | grep -v '"date":' | grep -v 'tedAt":' > backup-2.clean 63 | set -xe 64 | diff backup-1.clean backup-2.clean 65 | ) 66 | 67 | if [ "$CIRCLECI" = "true" ]; then 68 | echo "Strage behavior with cross-container communication on CircleCI, skipping some tests..." 69 | else 70 | # bastion 71 | ssh sshportal -l admin host create --name=testserver toto@testserver:2222 72 | out="$(ssh sshportal -l testserver echo hello | head -n 1)" 73 | test "$out" = '{"User":"toto","Environ":null,"Command":["echo","hello"]}' 74 | 75 | out="$(TEST_A=1 TEST_B=2 TEST_C=3 TEST_D=4 TEST_E=5 TEST_F=6 TEST_G=7 TEST_H=8 TEST_I=9 ssh sshportal -l testserver echo hello | head -n 1)" 76 | test "$out" = '{"User":"toto","Environ":["TEST_A=1","TEST_B=2","TEST_C=3","TEST_D=4","TEST_E=5","TEST_F=6","TEST_G=7","TEST_H=8","TEST_I=9"],"Command":["echo","hello"]}' 77 | fi 78 | 79 | # TODO: test more cases (forwards, scp, sftp, interactive, pty, stdin, exit code, ...) 80 | -------------------------------------------------------------------------------- /examples/integration/client_test_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAxV0ds/oMuOw9QVLFgxaM0Js2IdJKiYLnmKq96IuZ/wMqMea3 3 | qi1UfNBPUQ2CojwbJGTea8cA9J1Et+a6v1mL66YG8zyxmhdlKHm2KOMnUXSfWPNg 4 | ZArXH7Uj4Nx1k/O1ujfQFAsYTx63kMqwq1lM9JrExLSdp/8D/zQAyF68c82w8UZH 5 | aIpLZJkM/fgh0VJWiw65NYAzuIkJNBgZR8rEBQU7V3lCqFGcSJ88MoqIdVGy0I4b 6 | GGpO9VppDTf+uYGYDthhXlV0nHM45neWL5hzFK6oqbLFLpsaUOY7C3kKv+8+B3lX 7 | p3OfGVoFy7u3evro+yRQEMQ+myS5UBIHaI3qOwIDAQABAoIBABM7/vASV3kSNOoP 8 | 2gXrha+y4LStHOyH4HBFe5qVOF3c/hi85ntkTY6YcpJwoaGUAAUs+2w/ib1NMmxF 9 | xT9ux68gkB7WdGyTCR3HttQHR0at+fWeSm+Vit+hNKzub1sK7lQGqnW5mxXi5Xrr 10 | 9gnM+y3/g1u0SoUb2lTdyZG9gdo7LnLElzRinraEqTJUowXkqzAhGf1A+Kgp2fkb 11 | /+QP1oiK8QeOFOsITD2UwIVCBRwRl5TjjwfLQ4El6oAWNjcL1ZfSmQLiXZ7U8Smk 12 | Cd+BI+6ZDLA43fBUGDjbg4+2dt2JoKNkS0FfqhCW+Z2A0+ClJ8pwuMqRz8XXaOYr 13 | ONCqOPECgYEA/qyWxSUjEWMvN3tC/mZPEbwHP3m7mbR1KGwhZylWVCmEF7kVC6il 14 | /ICQZUI9ekyGJZ/SKZKwxDe7oeV+vFsus/9FWC5wrp45Xm4kEUwsBr4bWvuNpVOq 15 | jrKecY8NgPZS1X6Uc5BbpiE9/VF2gCdYVVCDXP1NfO2MDhkniXJQUEMCgYEAxmQl 16 | 3s/vih9rXllPZcWHafjnFcGU1AIiJD1c+8lAqwCZzm0Bt0Ex4s1t3lp0ew6YBVXN 17 | yGy+BORxOC9FQGTlKZNk/S705+8iAVNc9Sy7XbgN3GY3eat7XYbNpGbQrjiyZ+7I 18 | pdEnoHWQD4NFXHaVsXaVHcBFUovXKoes2PODeqkCgYEAoN/3Ucv2zgoAjqSfmkKY 19 | mhRT48YLOroi9AjyRM95CCs9lRrGb5n2WH4COOTSHwpuByBhSv+uCBVIwqlNGMDk 20 | zLFpZZ3YcoXiqYMb541dlljKwPt8673hVMkCi6uZFSkFBHY0YpgDPPtsxDOMjsHL 21 | 7ACzKq+cHlmUimdbcViz4S8CgYEAr2+sVYaHixsRtVNA9PxiLQIgR4rx8zEXw/hH 22 | m5hyiUV0vaiDlewfEzMab0CKNK/JGx6vZQdUWbsxq7+Re8o9JDDlY0b854T+CzIO 23 | x/iQj+XMzBPQBtXvt9sXSsRo0Uft7B6qbIeyhSCxDibFVWjAIzh70N1P8BkdYsyr 24 | uwZMRFECgYEA5QuutlFLI7hMPdBQvsEhjdVwKAj7LvpNemgxDpEoMiQWWm51XzcP 25 | IZjlCwl1UvIE0MxowtvNr5lQuGRN8/88Dajpq+W6eeTSCKi67nn0VZh13cQLKvoX 26 | DRZ6nfC3iLnEYKK+KN/I3NY7JcSjHmW6V8WtrCYAi2D5Ns05XJAG6t8= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /examples/integration/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | 3 | services: 4 | sshportal: 5 | image: moul/sshportal 6 | environment: 7 | - SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN=integration 8 | command: server --debug 9 | depends_on: 10 | - testserver 11 | ports: 12 | - 2222 13 | 14 | testserver: 15 | image: moul/sshportal 16 | command: _test_server 17 | ports: 18 | - 2222 19 | 20 | client: 21 | build: . 22 | depends_on: 23 | - sshportal 24 | - testserver 25 | #volumes: 26 | # - .:/integration 27 | tty: true 28 | -------------------------------------------------------------------------------- /examples/mysql/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | sshportal: 5 | build: ../.. 6 | restart: unless-stopped 7 | environment: 8 | SSHPORTAL_DB_DRIVER: mysql 9 | SSHPORTAL_DATABASE_URL: "root:root@tcp(mysql:3306)/db?charset=utf8&parseTime=true&loc=Local" 10 | SSHPORTAL_DEBUG: 1 11 | depends_on: 12 | mysql: 13 | condition: service_healthy 14 | links: 15 | - mysql 16 | command: server 17 | ports: 18 | - 2222:2222 19 | 20 | mysql: 21 | image: mysql:latest 22 | ports: 23 | - 3306 24 | environment: 25 | - MYSQL_ROOT_PASSWORD=root 26 | - MYSQL_DATABASE=db 27 | restart: unless-stopped 28 | command: --log-error-verbosity=3 29 | healthcheck: 30 | test: ["CMD-SHELL", "echo SELECT 1 | mysql -h127.0.0.1 -uroot -proot"] 31 | interval: 5s 32 | timeout: 5s 33 | retries: 5 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module moul.io/sshportal 2 | 3 | require ( 4 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be 5 | github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d 6 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 7 | github.com/creack/pty v1.1.11 // indirect 8 | github.com/docker/docker v20.10.12+incompatible 9 | github.com/dustin/go-humanize v1.0.0 10 | github.com/gliderlabs/ssh v0.3.3 11 | github.com/go-gormigrate/gormigrate/v2 v2.0.0 12 | github.com/kr/pty v1.1.8 13 | github.com/mattn/go-colorable v0.1.8 // indirect 14 | github.com/mattn/go-runewidth v0.0.12 // indirect 15 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d 16 | github.com/olekukonko/tablewriter v0.0.5 17 | github.com/pkg/errors v0.9.1 18 | github.com/reiver/go-oi v1.0.0 19 | github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e 20 | github.com/rivo/uniseg v0.2.0 // indirect 21 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 22 | github.com/sabban/bastion v0.0.0-20180110125408-b9d3c9b1f4d3 23 | github.com/smartystreets/goconvey v1.7.2 24 | github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 25 | github.com/urfave/cli v1.22.5 26 | golang.org/x/crypto v0.0.0-20220208050332-20e1d8d225ab 27 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect 28 | golang.org/x/tools v0.1.10 29 | gorm.io/driver/mysql v1.2.3 30 | gorm.io/driver/postgres v1.2.3 31 | gorm.io/driver/sqlite v1.2.6 32 | gorm.io/gorm v1.22.5 33 | moul.io/srand v1.6.1 34 | ) 35 | 36 | go 1.14 37 | -------------------------------------------------------------------------------- /healthcheck.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net" 8 | "strings" 9 | "time" 10 | 11 | "github.com/urfave/cli" 12 | gossh "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | // perform a healthcheck test without requiring an ssh client or an ssh key (used for Docker's HEALTHCHECK) 16 | func healthcheck(addr string, wait, quiet bool) error { 17 | cfg := gossh.ClientConfig{ 18 | User: "healthcheck", 19 | HostKeyCallback: func(hostname string, remote net.Addr, key gossh.PublicKey) error { return nil }, 20 | Auth: []gossh.AuthMethod{gossh.Password("healthcheck")}, 21 | } 22 | 23 | if wait { 24 | for { 25 | if err := healthcheckOnce(addr, cfg, quiet); err != nil { 26 | if !quiet { 27 | log.Printf("error: %v", err) 28 | } 29 | time.Sleep(time.Second) 30 | continue 31 | } 32 | return nil 33 | } 34 | } 35 | 36 | if err := healthcheckOnce(addr, cfg, quiet); err != nil { 37 | if quiet { 38 | return cli.NewExitError("", 1) 39 | } 40 | return err 41 | } 42 | return nil 43 | } 44 | 45 | func healthcheckOnce(addr string, config gossh.ClientConfig, quiet bool) error { 46 | client, err := gossh.Dial("tcp", addr, &config) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | session, err := client.NewSession() 52 | if err != nil { 53 | return err 54 | } 55 | defer func() { 56 | if err := session.Close(); err != nil { 57 | if !quiet { 58 | log.Printf("failed to close session: %v", err) 59 | } 60 | } 61 | }() 62 | 63 | var b bytes.Buffer 64 | session.Stdout = &b 65 | if err := session.Run(""); err != nil { 66 | return err 67 | } 68 | stdout := strings.TrimSpace(b.String()) 69 | if stdout != "OK" { 70 | return fmt.Errorf("invalid stdout: %q expected 'OK'", stdout) 71 | } 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /helm/sshportal/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /helm/sshportal/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: sshportal 3 | description: A Helm chart for SSHPortal on Kubernetes 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | version: 0.1.0 18 | 19 | # This is the version number of the application being deployed. This version number should be 20 | # incremented each time you make changes to the application. 21 | appVersion: 1.10.0 22 | -------------------------------------------------------------------------------- /helm/sshportal/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the admin invitation token (only on first install): 2 | export INVITE=$(kubectl --namespace sshportal logs -l "app.kubernetes.io/name={{ include "sshportal.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" | grep -Eo "invite:[a-zA-Z0-9]+") 3 | 4 | 2. Get the service IP and Port: 5 | {{- if contains "NodePort" .Values.service.type }} 6 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "sshportal.fullname" . }}) 7 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 8 | {{- else if contains "LoadBalancer" .Values.service.type }} 9 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 10 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "sshportal.fullname" . }}' 11 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "sshportal.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 12 | {{- else if contains "ClusterIP" .Values.service.type }} 13 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "sshportal.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 14 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 2222:{{ .Values.service.port }} 15 | {{- end }} 16 | 17 | 3. Enroll your SSH public key: 18 | {{- if contains "NodePort" .Values.service.type }} 19 | ssh $NODE_IP -p $NODE_PORT -l $INVITE 20 | {{- else if contains "LoadBalancer" .Values.service.type }} 21 | ssh $SERVICE_IP -p {{ .Values.service.port }} -l $INVITE 22 | {{- else if contains "ClusterIP" .Values.service.type }} 23 | ssh localhost -p 2222 -l $INVITE 24 | {{- end }} 25 | 26 | 4. Configure your {{ include "sshportal.name" . }} install: 27 | {{- if contains "NodePort" .Values.service.type }} 28 | ssh admin@$NODE_IP -p $NODE_PORT 29 | {{- else if contains "LoadBalancer" .Values.service.type }} 30 | ssh admin@$SERVICE_IP -p {{ .Values.service.port }} 31 | {{- else if contains "ClusterIP" .Values.service.type }} 32 | ssh admin@localhost -p 2222 33 | {{- end }} 34 | -------------------------------------------------------------------------------- /helm/sshportal/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "sshportal.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "sshportal.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "sshportal.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "sshportal.labels" -}} 38 | helm.sh/chart: {{ include "sshportal.chart" . }} 39 | {{ include "sshportal.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end -}} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "sshportal.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "sshportal.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end -}} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "sshportal.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create -}} 59 | {{ default (include "sshportal.fullname" .) .Values.serviceAccount.name }} 60 | {{- else -}} 61 | {{ default "default" .Values.serviceAccount.name }} 62 | {{- end -}} 63 | {{- end -}} 64 | -------------------------------------------------------------------------------- /helm/sshportal/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "sshportal.fullname" . }} 5 | labels: 6 | {{- include "sshportal.labels" . | nindent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | {{- include "sshportal.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | labels: 15 | {{- include "sshportal.selectorLabels" . | nindent 8 }} 16 | spec: 17 | {{- with .Values.imagePullSecrets }} 18 | imagePullSecrets: 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | securityContext: 22 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 23 | containers: 24 | - name: {{ .Chart.Name }} 25 | securityContext: 26 | {{- toYaml .Values.securityContext | nindent 12 }} 27 | image: "{{ .Values.image.repository }}:v{{ .Chart.AppVersion }}" 28 | imagePullPolicy: {{ .Values.image.pullPolicy }} 29 | ports: 30 | - name: ssh 31 | containerPort: 2222 32 | protocol: TCP 33 | livenessProbe: 34 | exec: 35 | command: 36 | - sshportal 37 | - healthcheck 38 | - --quiet 39 | readinessProbe: 40 | exec: 41 | command: 42 | - sshportal 43 | - healthcheck 44 | - --quiet 45 | resources: 46 | {{- toYaml .Values.resources | nindent 12 }} 47 | env: 48 | {{- if .Values.mysql.enabled }} 49 | - name: SSHPORTAL_DATABASE_URL 50 | value: {{ .Values.mysql.user }}:{{ .Values.mysql.password }}@tcp({{ .Values.mysql.server }}:{{ .Values.mysql.port }})/{{ .Values.mysql.database }}?charset=utf8&parseTime=true&loc=Local 51 | - name: SSHPORTAL_DB_DRIVER 52 | value: mysql 53 | {{- end }} 54 | {{- if .Values.debug}} 55 | - name: SSHPORTAL_DEBUG 56 | value: "1" 57 | {{- end }} 58 | {{- with .Values.nodeSelector }} 59 | nodeSelector: 60 | {{- toYaml . | nindent 8 }} 61 | {{- end }} 62 | {{- with .Values.affinity }} 63 | affinity: 64 | {{- toYaml . | nindent 8 }} 65 | {{- end }} 66 | {{- with .Values.tolerations }} 67 | tolerations: 68 | {{- toYaml . | nindent 8 }} 69 | {{- end }} 70 | -------------------------------------------------------------------------------- /helm/sshportal/templates/horizontal-pod-autoscaling.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.mysql.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "sshportal.fullname" . }} 6 | labels: 7 | {{- include "sshportal.labels" . | nindent 4 }} 8 | spec: 9 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 10 | minReplicas: {{ .Values.autoscaling.minReplicas }} 11 | scaleTargetRef: 12 | apiVersion: apps/v1 13 | kind: Deployment 14 | name: {{ include "sshportal.fullname" . }} 15 | metrics: 16 | - type: Resource 17 | resource: 18 | name: cpu 19 | targetAverageUtilization: {{ .Values.autoscaling.cpuTarget }} 20 | {{- end }} 21 | 22 | -------------------------------------------------------------------------------- /helm/sshportal/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "sshportal.fullname" . }} 5 | annotations: 6 | {{- toYaml .Values.service.annotations | nindent 4 }} 7 | labels: 8 | {{- include "sshportal.labels" . | nindent 4 }} 9 | spec: 10 | type: {{ .Values.service.type }} 11 | ports: 12 | - port: {{ .Values.service.port }} 13 | targetPort: 2222 14 | protocol: TCP 15 | name: ssh 16 | selector: 17 | {{- include "sshportal.selectorLabels" . | nindent 4 }} -------------------------------------------------------------------------------- /helm/sshportal/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "sshportal.fullname" . }}-test-connection" 5 | labels: 6 | {{ include "sshportal.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test-success 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "sshportal.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /helm/sshportal/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for sshportal. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | ## Enable SSHPortal debug mode 6 | ## 7 | debug: false 8 | 9 | ## SSH Portal Docker image 10 | ## 11 | image: 12 | repository: moul/sshportal 13 | pullPolicy: IfNotPresent 14 | 15 | ## Reference to one or more secrets to be used when pulling images 16 | ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ 17 | ## 18 | imagePullSecrets: [] 19 | 20 | ## Provide a name in place of sshportal for `app:` labels 21 | ## 22 | nameOverride: "" 23 | 24 | ## Provide a name to substitute for the full names of resources 25 | ## 26 | fullnameOverride: "" 27 | 28 | ## PodSecurityContext holds pod-level security attributes. 29 | ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ 30 | ## 31 | podSecurityContext: {} 32 | # fsGroup: 2000 33 | 34 | ## SecurityContext holds container-level security attributes. 35 | ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ 36 | ## 37 | securityContext: {} 38 | # capabilities: 39 | # drop: 40 | # - ALL 41 | # readOnlyRootFilesystem: true 42 | # runAsNonRoot: true 43 | # runAsUser: 1000 44 | 45 | ## Service 46 | ## 47 | service: 48 | ## Configure additional annotations for SSHPortal service 49 | ## 50 | annotations: {} 51 | # service.beta.kubernetes.io/openstack-internal-load-balancer: "true" 52 | 53 | ## Service type, one of 54 | ## NodePort, ClusterIP, LoadBalancer 55 | ## 56 | type: LoadBalancer 57 | 58 | ## Port to expose on the service 59 | ## 60 | port: 22 61 | 62 | ## Define resources requests and limits 63 | ## ref: https://kubernetes.io/docs/user-guide/compute-resources/ 64 | ## 65 | resources: {} 66 | # requests: 67 | # cpu: 100m 68 | # memory: 128Mi 69 | # limits: 70 | # cpu: 2 71 | # memory: 2Gi 72 | 73 | ## Mysql/MariaDB configuration for HA 74 | ## 75 | mysql: 76 | enabled: false 77 | 78 | ## Database user 79 | ## 80 | user: sshportal 81 | 82 | ## Database password 83 | ## 84 | password: change_me 85 | 86 | ## Database name 87 | ## 88 | database: sshportal 89 | 90 | ## Database server FQDN or IP 91 | ## 92 | server: mariadb-mariadb-galera 93 | 94 | ## Database port 95 | ## 96 | port: 3306 97 | 98 | ## Define which Nodes the Pods are scheduled on. 99 | ## ref: https://kubernetes.io/docs/user-guide/node-selection/ 100 | ## 101 | nodeSelector: {} 102 | 103 | ## The pod's tolerations. 104 | ## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ 105 | ## 106 | tolerations: [] 107 | 108 | ## Assign custom affinity rules 109 | ## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/ 110 | ## 111 | affinity: {} 112 | 113 | ## HPA support, require `mysql.enable: true` 114 | ## This section enables sshportal to autoscale based on metrics. 115 | ## 116 | autoscaling: 117 | maxReplicas: 4 118 | minReplicas: 2 119 | cpuTarget: 60 120 | -------------------------------------------------------------------------------- /internal/tools/tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package tools 4 | 5 | import ( 6 | // required by depaware 7 | _ "github.com/tailscale/depaware/depaware" 8 | 9 | // required by goimports 10 | _ "golang.org/x/tools/cover" 11 | ) 12 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main // import "moul.io/sshportal" 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "os" 7 | "path" 8 | 9 | "github.com/urfave/cli" 10 | "moul.io/srand" 11 | ) 12 | 13 | var ( 14 | // GitTag will be overwritten automatically by the build system 15 | GitTag = "n/a" 16 | // GitSha will be overwritten automatically by the build system 17 | GitSha = "n/a" 18 | ) 19 | 20 | func main() { 21 | rand.Seed(srand.MustSecure()) 22 | 23 | app := cli.NewApp() 24 | app.Name = path.Base(os.Args[0]) 25 | app.Author = "Manfred Touron" 26 | app.Version = GitTag + " (" + GitSha + ")" 27 | app.Email = "https://moul.io/sshportal" 28 | app.Commands = []cli.Command{ 29 | { 30 | Name: "server", 31 | Usage: "Start sshportal server", 32 | Action: func(c *cli.Context) error { 33 | if err := ensureLogDirectory(c.String("logs-location")); err != nil { 34 | return err 35 | } 36 | cfg, err := parseServerConfig(c) 37 | if err != nil { 38 | return err 39 | } 40 | return server(cfg) 41 | }, 42 | Flags: []cli.Flag{ 43 | cli.StringFlag{ 44 | Name: "bind-address, b", 45 | EnvVar: "SSHPORTAL_BIND", 46 | Value: ":2222", 47 | Usage: "SSH server bind address", 48 | }, 49 | cli.StringFlag{ 50 | Name: "db-driver", 51 | EnvVar: "SSHPORTAL_DB_DRIVER", 52 | Value: "sqlite3", 53 | Usage: "GORM driver (sqlite3)", 54 | }, 55 | cli.StringFlag{ 56 | Name: "db-conn", 57 | EnvVar: "SSHPORTAL_DATABASE_URL", 58 | Value: "./sshportal.db", 59 | Usage: "GORM connection string", 60 | }, 61 | cli.BoolFlag{ 62 | Name: "debug, D", 63 | EnvVar: "SSHPORTAL_DEBUG", 64 | Usage: "Display debug information", 65 | }, 66 | cli.StringFlag{ 67 | Name: "aes-key", 68 | EnvVar: "SSHPORTAL_AES_KEY", 69 | Usage: "Encrypt sensitive data in database (length: 16, 24 or 32)", 70 | }, 71 | cli.StringFlag{ 72 | Name: "logs-location", 73 | EnvVar: "SSHPORTAL_LOGS_LOCATION", 74 | Value: "./log", 75 | Usage: "Store user session files", 76 | }, 77 | cli.DurationFlag{ 78 | Name: "idle-timeout", 79 | Value: 0, 80 | Usage: "Duration before an inactive connection is timed out (0 to disable)", 81 | }, 82 | cli.StringFlag{ 83 | Name: "acl-check-cmd", 84 | EnvVar: "SSHPORTAL_ACL_CHECK_CMD", 85 | Usage: "Execute external command to check ACL", 86 | }, 87 | }, 88 | }, { 89 | Name: "healthcheck", 90 | Action: func(c *cli.Context) error { return healthcheck(c.String("addr"), c.Bool("wait"), c.Bool("quiet")) }, 91 | Flags: []cli.Flag{ 92 | cli.StringFlag{ 93 | Name: "addr, a", 94 | Value: "localhost:2222", 95 | Usage: "sshportal server address", 96 | }, 97 | cli.BoolFlag{ 98 | Name: "wait, w", 99 | Usage: "Loop indefinitely until sshportal is ready", 100 | }, 101 | cli.BoolFlag{ 102 | Name: "quiet, q", 103 | Usage: "Do not print errors, if any", 104 | }, 105 | }, 106 | }, { 107 | Name: "_test_server", 108 | Hidden: true, 109 | Action: testServer, 110 | }, 111 | } 112 | if err := app.Run(os.Args); err != nil { 113 | log.Fatalf("error: %v", err) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /pkg/bastion/acl.go: -------------------------------------------------------------------------------- 1 | package bastion 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os/exec" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "moul.io/sshportal/pkg/dbmodels" 14 | ) 15 | 16 | // ACLHookTimeout is timeout for external ACL hook execution 17 | const ACLHookTimeout = 2 * time.Second 18 | 19 | type byWeight []*dbmodels.ACL 20 | 21 | func (a byWeight) Len() int { return len(a) } 22 | func (a byWeight) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 23 | func (a byWeight) Less(i, j int) bool { return a[i].Weight < a[j].Weight } 24 | 25 | func checkACLs(user dbmodels.User, host dbmodels.Host, aclCheckCmd string) string { 26 | currentTime := time.Now() 27 | 28 | // shared ACLs between user and host 29 | aclMap := map[uint]*dbmodels.ACL{} 30 | for _, userGroup := range user.Groups { 31 | for _, userGroupACL := range userGroup.ACLs { 32 | for _, hostGroup := range host.Groups { 33 | for _, hostGroupACL := range hostGroup.ACLs { 34 | if userGroupACL.ID == hostGroupACL.ID { 35 | if (userGroupACL.Inception == nil || currentTime.After(*userGroupACL.Inception)) && 36 | (userGroupACL.Expiration == nil || currentTime.Before(*userGroupACL.Expiration)) { 37 | aclMap[userGroupACL.ID] = userGroupACL 38 | } 39 | } 40 | } 41 | } 42 | } 43 | } 44 | // FIXME: add ACLs that match host pattern 45 | 46 | // if no shared ACL then execute ACLs hook if it exists and return its result 47 | if len(aclMap) == 0 { 48 | action, err := checkACLsHook(aclCheckCmd, string(dbmodels.ACLActionDeny), user, host) 49 | if err != nil { 50 | log.Println(err) 51 | } 52 | return action 53 | } 54 | 55 | // transform map to slice and sort it 56 | acls := make([]*dbmodels.ACL, 0, len(aclMap)) 57 | for _, acl := range aclMap { 58 | acls = append(acls, acl) 59 | } 60 | sort.Sort(byWeight(acls)) 61 | 62 | action, err := checkACLsHook(aclCheckCmd, acls[0].Action, user, host) 63 | if err != nil { 64 | log.Println(err) 65 | } 66 | 67 | return action 68 | } 69 | 70 | // checkACLsHook executes external command to check ACL and passes following parameters: 71 | // $1 - SSH Portal `action` (`allow` or `deny`) 72 | // $2 - User as JSON string 73 | // $3 - Host as JSON string 74 | // External program has to return `allow` or `deny` in stdout. 75 | // In case of any error function returns `action`. 76 | func checkACLsHook(aclCheckCmd string, action string, user dbmodels.User, host dbmodels.Host) (string, error) { 77 | if aclCheckCmd == "" { 78 | return action, nil 79 | } 80 | 81 | ctx, cancel := context.WithTimeout(context.Background(), ACLHookTimeout) 82 | defer cancel() 83 | 84 | jsonUser, err := json.Marshal(user) 85 | if err != nil { 86 | return action, err 87 | } 88 | 89 | jsonHost, err := json.Marshal(host) 90 | if err != nil { 91 | return action, err 92 | } 93 | 94 | args := []string{ 95 | action, 96 | string(jsonUser), 97 | string(jsonHost), 98 | } 99 | 100 | cmd := exec.CommandContext(ctx, aclCheckCmd, args...) 101 | out, err := cmd.Output() 102 | if err != nil { 103 | return action, err 104 | } 105 | 106 | if ctx.Err() == context.DeadlineExceeded { 107 | return action, fmt.Errorf("external ACL hook command timed out") 108 | } 109 | 110 | outStr := strings.TrimSuffix(string(out), "\n") 111 | 112 | switch outStr { 113 | case string(dbmodels.ACLActionAllow): 114 | return string(dbmodels.ACLActionAllow), nil 115 | case string(dbmodels.ACLActionDeny): 116 | return string(dbmodels.ACLActionDeny), nil 117 | default: 118 | return action, fmt.Errorf("acl-check-cmd wrong output '%s'", outStr) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /pkg/bastion/acl_test.go: -------------------------------------------------------------------------------- 1 | package bastion // import "moul.io/sshportal/pkg/bastion" 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | . "github.com/smartystreets/goconvey/convey" 10 | "gorm.io/driver/sqlite" 11 | "gorm.io/gorm" 12 | "moul.io/sshportal/pkg/dbmodels" 13 | ) 14 | 15 | func TestCheckACLs(t *testing.T) { 16 | Convey("Testing CheckACLs", t, func(c C) { 17 | // create tmp dir 18 | tempDir, err := ioutil.TempDir("", "sshportal") 19 | c.So(err, ShouldBeNil) 20 | defer func() { 21 | c.So(os.RemoveAll(tempDir), ShouldBeNil) 22 | }() 23 | 24 | // create sqlite db 25 | db, err := gorm.Open(sqlite.Open(filepath.Join(tempDir, "sshportal.db")), &gorm.Config{}) 26 | c.So(err, ShouldBeNil) 27 | c.So(DBInit(db), ShouldBeNil) 28 | 29 | // create dummy objects 30 | var hostGroup dbmodels.HostGroup 31 | err = dbmodels.HostGroupsByIdentifiers(db, []string{"default"}).First(&hostGroup).Error 32 | c.So(err, ShouldBeNil) 33 | db.Create(&dbmodels.Host{Groups: []*dbmodels.HostGroup{&hostGroup}}) 34 | 35 | //. load db 36 | var ( 37 | hosts []dbmodels.Host 38 | users []dbmodels.User 39 | ) 40 | db.Preload("Groups").Preload("Groups.ACLs").Find(&hosts) 41 | db.Preload("Groups").Preload("Groups.ACLs").Find(&users) 42 | 43 | // test 44 | action := checkACLs(users[0], hosts[0], "") 45 | c.So(action, ShouldEqual, dbmodels.ACLActionAllow) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/bastion/dbinit.go: -------------------------------------------------------------------------------- 1 | package bastion // import "moul.io/sshportal/pkg/bastion" 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "math/big" 9 | "os" 10 | "os/user" 11 | "strings" 12 | "time" 13 | 14 | gormigrate "github.com/go-gormigrate/gormigrate/v2" 15 | gossh "golang.org/x/crypto/ssh" 16 | "gorm.io/gorm" 17 | "moul.io/sshportal/pkg/crypto" 18 | "moul.io/sshportal/pkg/dbmodels" 19 | ) 20 | 21 | func DBInit(db *gorm.DB) error { 22 | log.SetOutput(ioutil.Discard) 23 | log.SetOutput(os.Stderr) 24 | 25 | m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{ 26 | { 27 | ID: "1", 28 | Migrate: func(tx *gorm.DB) error { 29 | type Setting struct { 30 | gorm.Model 31 | Name string `gorm:"index:uix_settings_name,unique"` 32 | Value string 33 | } 34 | return tx.AutoMigrate(&Setting{}) 35 | }, 36 | Rollback: func(tx *gorm.DB) error { 37 | return tx.Migrator().DropTable("settings") 38 | }, 39 | }, { 40 | ID: "2", 41 | Migrate: func(tx *gorm.DB) error { 42 | type SSHKey struct { 43 | gorm.Model 44 | Name string 45 | Type string 46 | Length uint 47 | Fingerprint string 48 | PrivKey string `sql:"size:5000"` 49 | PubKey string `sql:"size:1000"` 50 | Hosts []*dbmodels.Host `gorm:"ForeignKey:SSHKeyID"` 51 | Comment string 52 | } 53 | return tx.AutoMigrate(&SSHKey{}) 54 | }, 55 | Rollback: func(tx *gorm.DB) error { 56 | return tx.Migrator().DropTable("ssh_keys") 57 | }, 58 | }, { 59 | ID: "3", 60 | Migrate: func(tx *gorm.DB) error { 61 | type Host struct { 62 | gorm.Model 63 | Name string `gorm:"size:32"` 64 | Addr string 65 | User string 66 | Password string 67 | SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"` 68 | SSHKeyID uint `gorm:"index"` 69 | Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"` 70 | Fingerprint string 71 | Comment string 72 | } 73 | return tx.AutoMigrate(&Host{}) 74 | }, 75 | Rollback: func(tx *gorm.DB) error { 76 | return tx.Migrator().DropTable("hosts") 77 | }, 78 | }, { 79 | ID: "4", 80 | Migrate: func(tx *gorm.DB) error { 81 | type UserKey struct { 82 | gorm.Model 83 | Key []byte `sql:"size:1000"` 84 | UserID uint `` 85 | User *dbmodels.User `gorm:"ForeignKey:UserID"` 86 | Comment string 87 | } 88 | return tx.AutoMigrate(&UserKey{}) 89 | }, 90 | Rollback: func(tx *gorm.DB) error { 91 | return tx.Migrator().DropTable("user_keys") 92 | }, 93 | }, { 94 | ID: "5", 95 | Migrate: func(tx *gorm.DB) error { 96 | type User struct { 97 | gorm.Model 98 | IsAdmin bool 99 | Email string 100 | Name string 101 | Keys []*dbmodels.UserKey `gorm:"ForeignKey:UserID"` 102 | Groups []*dbmodels.UserGroup `gorm:"many2many:user_user_groups;"` 103 | Comment string 104 | InviteToken string 105 | } 106 | return tx.AutoMigrate(&User{}) 107 | }, 108 | Rollback: func(tx *gorm.DB) error { 109 | return tx.Migrator().DropTable("users") 110 | }, 111 | }, { 112 | ID: "6", 113 | Migrate: func(tx *gorm.DB) error { 114 | type UserGroup struct { 115 | gorm.Model 116 | Name string 117 | Users []*dbmodels.User `gorm:"many2many:user_user_groups;"` 118 | ACLs []*dbmodels.ACL `gorm:"many2many:user_group_acls;"` 119 | Comment string 120 | } 121 | return tx.AutoMigrate(&UserGroup{}) 122 | }, 123 | Rollback: func(tx *gorm.DB) error { 124 | return tx.Migrator().DropTable("user_groups") 125 | }, 126 | }, { 127 | ID: "7", 128 | Migrate: func(tx *gorm.DB) error { 129 | type HostGroup struct { 130 | gorm.Model 131 | Name string 132 | Hosts []*dbmodels.Host `gorm:"many2many:host_host_groups;"` 133 | ACLs []*dbmodels.ACL `gorm:"many2many:host_group_acls;"` 134 | Comment string 135 | } 136 | return tx.AutoMigrate(&HostGroup{}) 137 | }, 138 | Rollback: func(tx *gorm.DB) error { 139 | return tx.Migrator().DropTable("host_groups") 140 | }, 141 | }, { 142 | ID: "8", 143 | Migrate: func(tx *gorm.DB) error { 144 | type ACL struct { 145 | gorm.Model 146 | HostGroups []*dbmodels.HostGroup `gorm:"many2many:host_group_acls;"` 147 | UserGroups []*dbmodels.UserGroup `gorm:"many2many:user_group_acls;"` 148 | HostPattern string 149 | Action string 150 | Weight uint 151 | Comment string 152 | } 153 | 154 | return tx.AutoMigrate(&ACL{}) 155 | }, 156 | Rollback: func(tx *gorm.DB) error { 157 | return tx.Migrator().DropTable("acls") 158 | }, 159 | }, { 160 | ID: "9", 161 | Migrate: func(tx *gorm.DB) error { 162 | if err := tx.Migrator().DropIndex(&dbmodels.Setting{}, "uix_settings_name"); err != nil { 163 | return err 164 | } 165 | return tx.Migrator().CreateIndex(&dbmodels.Setting{}, "uix_settings_name") 166 | }, 167 | Rollback: func(tx *gorm.DB) error { 168 | return tx.Migrator().DropIndex(&dbmodels.Setting{}, "uix_settings_name") 169 | }, 170 | }, { 171 | ID: "10", 172 | Migrate: func(tx *gorm.DB) error { 173 | if err := tx.Migrator().DropIndex(&dbmodels.SSHKey{}, "uix_keys_name"); err != nil { 174 | return err 175 | } 176 | return tx.Migrator().CreateIndex(&dbmodels.SSHKey{}, "uix_keys_name") 177 | }, 178 | Rollback: func(tx *gorm.DB) error { 179 | return tx.Migrator().DropIndex(&dbmodels.SSHKey{}, "uix_keys_name") 180 | }, 181 | }, { 182 | ID: "11", 183 | Migrate: func(tx *gorm.DB) error { 184 | if err := tx.Migrator().DropIndex(&dbmodels.Host{}, "uix_hosts_name"); err != nil { 185 | return err 186 | } 187 | return tx.Migrator().CreateIndex(&dbmodels.Host{}, "uix_hosts_name") 188 | }, 189 | Rollback: func(tx *gorm.DB) error { 190 | return tx.Migrator().DropIndex(&dbmodels.Host{}, "uix_hosts_name") 191 | }, 192 | }, { 193 | ID: "12", 194 | Migrate: func(tx *gorm.DB) error { 195 | if err := tx.Migrator().DropIndex(&dbmodels.User{}, "uix_users_name"); err != nil { 196 | return err 197 | } 198 | return tx.Migrator().CreateIndex(&dbmodels.User{}, "uix_users_name") 199 | }, 200 | Rollback: func(tx *gorm.DB) error { 201 | return tx.Migrator().DropIndex(&dbmodels.User{}, "uix_users_name") 202 | }, 203 | }, { 204 | ID: "13", 205 | Migrate: func(tx *gorm.DB) error { 206 | if err := tx.Migrator().DropIndex(&dbmodels.UserGroup{}, "uix_usergroups_name"); err != nil { 207 | return err 208 | } 209 | return tx.Migrator().CreateIndex(&dbmodels.UserGroup{}, "uix_usergroups_name") 210 | }, 211 | Rollback: func(tx *gorm.DB) error { 212 | return tx.Migrator().DropIndex(&dbmodels.UserGroup{}, "uix_usergroups_name") 213 | }, 214 | }, { 215 | ID: "14", 216 | Migrate: func(tx *gorm.DB) error { 217 | if err := tx.Migrator().DropIndex(&dbmodels.HostGroup{}, "uix_hostgroups_name"); err != nil { 218 | return err 219 | } 220 | return tx.Migrator().CreateIndex(&dbmodels.HostGroup{}, "uix_hostgroups_name") 221 | }, 222 | Rollback: func(tx *gorm.DB) error { 223 | return tx.Migrator().DropIndex(&dbmodels.HostGroup{}, "uix_hostgroups_name") 224 | }, 225 | }, { 226 | ID: "15", 227 | Migrate: func(tx *gorm.DB) error { 228 | type UserRole struct { 229 | gorm.Model 230 | Name string `valid:"required,length(1|32),unix_user"` 231 | Users []*dbmodels.User `gorm:"many2many:user_user_roles"` 232 | } 233 | return tx.AutoMigrate(&UserRole{}) 234 | }, 235 | Rollback: func(tx *gorm.DB) error { 236 | return tx.Migrator().DropTable("user_roles") 237 | }, 238 | }, { 239 | ID: "16", 240 | Migrate: func(tx *gorm.DB) error { 241 | type User struct { 242 | gorm.Model 243 | IsAdmin bool 244 | Roles []*dbmodels.UserRole `gorm:"many2many:user_user_roles"` 245 | Email string `valid:"required,email"` 246 | Name string `valid:"required,length(1|32),unix_user"` 247 | Keys []*dbmodels.UserKey `gorm:"ForeignKey:UserID"` 248 | Groups []*dbmodels.UserGroup `gorm:"many2many:user_user_groups;"` 249 | Comment string `valid:"optional"` 250 | InviteToken string `valid:"optional,length(10|60)"` 251 | } 252 | return tx.AutoMigrate(&User{}) 253 | }, 254 | Rollback: func(tx *gorm.DB) error { 255 | return fmt.Errorf("not implemented") 256 | }, 257 | }, { 258 | ID: "17", 259 | Migrate: func(tx *gorm.DB) error { 260 | return tx.Create(&dbmodels.UserRole{Name: "admin"}).Error 261 | }, 262 | Rollback: func(tx *gorm.DB) error { 263 | return tx.Where("name = ?", "admin").Unscoped().Delete(&dbmodels.UserRole{}).Error 264 | }, 265 | }, { 266 | ID: "18", 267 | Migrate: func(tx *gorm.DB) error { 268 | var adminRole dbmodels.UserRole 269 | if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil { 270 | return err 271 | } 272 | 273 | var users []*dbmodels.User 274 | if err := db.Preload("Roles").Where("is_admin = ?", true).Find(&users).Error; err != nil { 275 | return err 276 | } 277 | 278 | for _, user := range users { 279 | user.Roles = append(user.Roles, &adminRole) 280 | if err := tx.Save(user).Error; err != nil { 281 | return err 282 | } 283 | } 284 | return nil 285 | }, 286 | Rollback: func(tx *gorm.DB) error { 287 | return fmt.Errorf("not implemented") 288 | }, 289 | }, { 290 | ID: "19", 291 | Migrate: func(tx *gorm.DB) error { 292 | type User struct { 293 | gorm.Model 294 | Roles []*dbmodels.UserRole `gorm:"many2many:user_user_roles"` 295 | Email string `valid:"required,email"` 296 | Name string `valid:"required,length(1|32),unix_user"` 297 | Keys []*dbmodels.UserKey `gorm:"ForeignKey:UserID"` 298 | Groups []*dbmodels.UserGroup `gorm:"many2many:user_user_groups;"` 299 | Comment string `valid:"optional"` 300 | InviteToken string `valid:"optional,length(10|60)"` 301 | } 302 | return tx.AutoMigrate(&User{}) 303 | }, 304 | Rollback: func(tx *gorm.DB) error { 305 | return fmt.Errorf("not implemented") 306 | }, 307 | }, { 308 | ID: "20", 309 | Migrate: func(tx *gorm.DB) error { 310 | return tx.Create(&dbmodels.UserRole{Name: "listhosts"}).Error 311 | }, 312 | Rollback: func(tx *gorm.DB) error { 313 | return tx.Where("name = ?", "listhosts").Unscoped().Delete(&dbmodels.UserRole{}).Error 314 | }, 315 | }, { 316 | ID: "21", 317 | Migrate: func(tx *gorm.DB) error { 318 | type Session struct { 319 | gorm.Model 320 | StoppedAt time.Time `valid:"optional"` 321 | Status string `valid:"required"` 322 | User *dbmodels.User `gorm:"ForeignKey:UserID"` 323 | Host *dbmodels.Host `gorm:"ForeignKey:HostID"` 324 | UserID uint `valid:"optional"` 325 | HostID uint `valid:"optional"` 326 | ErrMsg string `valid:"optional"` 327 | Comment string `valid:"optional"` 328 | } 329 | return tx.AutoMigrate(&Session{}) 330 | }, 331 | Rollback: func(tx *gorm.DB) error { 332 | return tx.Migrator().DropTable("sessions") 333 | }, 334 | }, { 335 | ID: "22", 336 | Migrate: func(tx *gorm.DB) error { 337 | type Event struct { 338 | gorm.Model 339 | Author *dbmodels.User `gorm:"ForeignKey:AuthorID"` 340 | AuthorID uint `valid:"optional"` 341 | Domain string `valid:"required"` 342 | Action string `valid:"required"` 343 | Entity string `valid:"optional"` 344 | Args []byte `sql:"size:10000" valid:"optional,length(1|10000)"` 345 | } 346 | return tx.AutoMigrate(&Event{}) 347 | }, 348 | Rollback: func(tx *gorm.DB) error { 349 | return tx.Migrator().DropTable("events") 350 | }, 351 | }, { 352 | ID: "23", 353 | Migrate: func(tx *gorm.DB) error { 354 | type UserKey struct { 355 | gorm.Model 356 | Key []byte `sql:"size:1000" valid:"required,length(1|1000)"` 357 | AuthorizedKey string `sql:"size:1000" valid:"required,length(1|1000)"` 358 | UserID uint `` 359 | User *dbmodels.User `gorm:"ForeignKey:UserID"` 360 | Comment string `valid:"optional"` 361 | } 362 | return tx.AutoMigrate(&UserKey{}) 363 | }, 364 | Rollback: func(tx *gorm.DB) error { 365 | return fmt.Errorf("not implemented") 366 | }, 367 | }, { 368 | ID: "24", 369 | Migrate: func(tx *gorm.DB) error { 370 | var userKeys []*dbmodels.UserKey 371 | if err := db.Find(&userKeys).Error; err != nil { 372 | return err 373 | } 374 | 375 | for _, userKey := range userKeys { 376 | key, err := gossh.ParsePublicKey(userKey.Key) 377 | if err != nil { 378 | return err 379 | } 380 | userKey.AuthorizedKey = string(gossh.MarshalAuthorizedKey(key)) 381 | if err := db.Model(userKey).Updates(userKey).Error; err != nil { 382 | return err 383 | } 384 | } 385 | return nil 386 | }, 387 | Rollback: func(tx *gorm.DB) error { 388 | return fmt.Errorf("not implemented") 389 | }, 390 | }, { 391 | ID: "25", 392 | Migrate: func(tx *gorm.DB) error { 393 | type Host struct { 394 | gorm.Model 395 | Name string `gorm:"size:32" valid:"required,length(1|32),unix_user"` 396 | Addr string `valid:"required"` 397 | User string `valid:"optional"` 398 | Password string `valid:"optional"` 399 | SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"` 400 | SSHKeyID uint `gorm:"index"` 401 | HostKey []byte `sql:"size:1000" valid:"optional"` 402 | Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"` 403 | Fingerprint string `valid:"optional"` 404 | Comment string `valid:"optional"` 405 | } 406 | return tx.AutoMigrate(&Host{}) 407 | }, 408 | Rollback: func(tx *gorm.DB) error { 409 | return fmt.Errorf("not implemented") 410 | }, 411 | }, { 412 | ID: "26", 413 | Migrate: func(tx *gorm.DB) error { 414 | type Session struct { 415 | gorm.Model 416 | StoppedAt *time.Time `sql:"index" valid:"optional"` 417 | Status string `valid:"required"` 418 | User *dbmodels.User `gorm:"ForeignKey:UserID"` 419 | Host *dbmodels.Host `gorm:"ForeignKey:HostID"` 420 | UserID uint `valid:"optional"` 421 | HostID uint `valid:"optional"` 422 | ErrMsg string `valid:"optional"` 423 | Comment string `valid:"optional"` 424 | } 425 | return tx.AutoMigrate(&Session{}) 426 | }, 427 | Rollback: func(tx *gorm.DB) error { 428 | return fmt.Errorf("not implemented") 429 | }, 430 | }, { 431 | ID: "27", 432 | Migrate: func(tx *gorm.DB) error { 433 | var sessions []*dbmodels.Session 434 | if err := db.Find(&sessions).Error; err != nil { 435 | return err 436 | } 437 | 438 | for _, session := range sessions { 439 | if session.StoppedAt != nil && session.StoppedAt.IsZero() { 440 | if err := db.Model(session).Updates(map[string]interface{}{"stopped_at": nil}).Error; err != nil { 441 | return err 442 | } 443 | } 444 | } 445 | return nil 446 | }, 447 | Rollback: func(tx *gorm.DB) error { 448 | return fmt.Errorf("not implemented") 449 | }, 450 | }, { 451 | ID: "28", 452 | Migrate: func(tx *gorm.DB) error { 453 | type Host struct { 454 | gorm.Model 455 | Name string `gorm:"size:32"` 456 | Addr string 457 | User string 458 | Password string 459 | URL string 460 | SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"` 461 | SSHKeyID uint `gorm:"index"` 462 | HostKey []byte `sql:"size:1000"` 463 | Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"` 464 | Comment string 465 | } 466 | return tx.AutoMigrate(&Host{}) 467 | }, 468 | Rollback: func(tx *gorm.DB) error { 469 | return fmt.Errorf("not implemented") 470 | }, 471 | }, { 472 | ID: "29", 473 | Migrate: func(tx *gorm.DB) error { 474 | type Host struct { 475 | gorm.Model 476 | Name string `gorm:"size:32"` 477 | Addr string 478 | User string 479 | Password string 480 | URL string 481 | SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"` 482 | SSHKeyID uint `gorm:"index"` 483 | HostKey []byte `sql:"size:1000"` 484 | Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"` 485 | Comment string 486 | Hop *dbmodels.Host 487 | HopID uint 488 | } 489 | return tx.AutoMigrate(&Host{}) 490 | }, 491 | Rollback: func(tx *gorm.DB) error { 492 | return fmt.Errorf("not implemented") 493 | }, 494 | }, { 495 | ID: "30", 496 | Migrate: func(tx *gorm.DB) error { 497 | type Host struct { 498 | gorm.Model 499 | Name string `gorm:"size:32"` 500 | Addr string 501 | User string 502 | Password string 503 | URL string 504 | SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"` 505 | SSHKeyID uint `gorm:"index"` 506 | HostKey []byte `sql:"size:10000"` 507 | Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"` 508 | Comment string 509 | Hop *dbmodels.Host 510 | Logging string 511 | HopID uint 512 | } 513 | return tx.AutoMigrate(&Host{}) 514 | }, 515 | Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") }, 516 | }, { 517 | ID: "31", 518 | Migrate: func(tx *gorm.DB) error { 519 | return tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&dbmodels.Host{}).Updates(&dbmodels.Host{Logging: "everything"}).Error 520 | }, 521 | Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") }, 522 | }, { 523 | ID: "32", 524 | Migrate: func(tx *gorm.DB) error { 525 | type ACL struct { 526 | gorm.Model 527 | HostGroups []*dbmodels.HostGroup `gorm:"many2many:host_group_acls;"` 528 | UserGroups []*dbmodels.UserGroup `gorm:"many2many:user_group_acls;"` 529 | HostPattern string `valid:"optional"` 530 | Action string `valid:"required"` 531 | Weight uint `` 532 | Comment string `valid:"optional"` 533 | Inception *time.Time 534 | Expiration *time.Time 535 | } 536 | return tx.AutoMigrate(&ACL{}) 537 | }, 538 | Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") }, 539 | }, 540 | }) 541 | if err := m.Migrate(); err != nil { 542 | return err 543 | } 544 | dbmodels.NewEvent("system", "migrated").Log(db) 545 | 546 | // create default ssh key 547 | var count int64 548 | if err := db.Table("ssh_keys").Where("name = ?", "default").Count(&count).Error; err != nil { 549 | return err 550 | } 551 | if count == 0 { 552 | key, err := crypto.NewSSHKey("ed25519", 1) 553 | if err != nil { 554 | return err 555 | } 556 | key.Name = "default" 557 | key.Comment = "created by sshportal" 558 | if err := db.Create(&key).Error; err != nil { 559 | return err 560 | } 561 | } 562 | 563 | // create default host group 564 | if err := db.Table("host_groups").Where("name = ?", "default").Count(&count).Error; err != nil { 565 | return err 566 | } 567 | if count == 0 { 568 | hostGroup := dbmodels.HostGroup{ 569 | Name: "default", 570 | Comment: "created by sshportal", 571 | } 572 | if err := db.Create(&hostGroup).Error; err != nil { 573 | return err 574 | } 575 | } 576 | 577 | // create default user group 578 | if err := db.Table("user_groups").Where("name = ?", "default").Count(&count).Error; err != nil { 579 | return err 580 | } 581 | if count == 0 { 582 | userGroup := dbmodels.UserGroup{ 583 | Name: "default", 584 | Comment: "created by sshportal", 585 | } 586 | if err := db.Create(&userGroup).Error; err != nil { 587 | return err 588 | } 589 | } 590 | 591 | // create default acl 592 | if err := db.Table("acls").Count(&count).Error; err != nil { 593 | return err 594 | } 595 | if count == 0 { 596 | var defaultUserGroup dbmodels.UserGroup 597 | db.Where("name = ?", "default").First(&defaultUserGroup) 598 | var defaultHostGroup dbmodels.HostGroup 599 | db.Where("name = ?", "default").First(&defaultHostGroup) 600 | acl := dbmodels.ACL{ 601 | UserGroups: []*dbmodels.UserGroup{&defaultUserGroup}, 602 | HostGroups: []*dbmodels.HostGroup{&defaultHostGroup}, 603 | Action: "allow", 604 | //HostPattern: "", 605 | //Weight: 0, 606 | Comment: "created by sshportal", 607 | } 608 | if err := db.Create(&acl).Error; err != nil { 609 | return err 610 | } 611 | } 612 | 613 | // create admin user 614 | var defaultUserGroup dbmodels.UserGroup 615 | db.Where("name = ?", "default").First(&defaultUserGroup) 616 | if err := db.Table("users").Count(&count).Error; err != nil { 617 | return err 618 | } 619 | if count == 0 { 620 | // if no admin, create an account for the first connection 621 | inviteToken, err := randStringBytes(16) 622 | if err != nil { 623 | return err 624 | } 625 | if os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN") != "" { 626 | inviteToken = os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN") 627 | } 628 | var adminRole dbmodels.UserRole 629 | if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil { 630 | return err 631 | } 632 | var username string 633 | if currentUser, err := user.Current(); err == nil { 634 | username = currentUser.Username 635 | } 636 | if username == "" { 637 | username = os.Getenv("USER") 638 | } 639 | username = strings.ToLower(username) 640 | if username == "" { 641 | username = "admin" // fallback username 642 | } 643 | user := dbmodels.User{ 644 | Name: username, 645 | Email: fmt.Sprintf("%s@localhost", username), 646 | Comment: "created by sshportal", 647 | Roles: []*dbmodels.UserRole{&adminRole}, 648 | InviteToken: inviteToken, 649 | Groups: []*dbmodels.UserGroup{&defaultUserGroup}, 650 | } 651 | if err := db.Create(&user).Error; err != nil { 652 | return err 653 | } 654 | log.Printf("info 'admin' user created, use the user 'invite:%s' to associate a public key with this account", user.InviteToken) 655 | } 656 | 657 | // create host ssh key 658 | if err := db.Table("ssh_keys").Where("name = ?", "host").Count(&count).Error; err != nil { 659 | return err 660 | } 661 | if count == 0 { 662 | key, err := crypto.NewSSHKey("ed25519", 1) 663 | if err != nil { 664 | return err 665 | } 666 | key.Name = "host" 667 | key.Comment = "created by sshportal" 668 | if err := db.Create(&key).Error; err != nil { 669 | return err 670 | } 671 | } 672 | 673 | // close unclosed connections 674 | return db.Table("sessions").Where("status = ?", "active").Updates(&dbmodels.Session{ 675 | Status: string(dbmodels.SessionStatusClosed), 676 | ErrMsg: "sshportal was halted while the connection was still active", 677 | }).Error 678 | } 679 | 680 | func randStringBytes(n int) (string, error) { 681 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 682 | 683 | b := make([]byte, n) 684 | for i := range b { 685 | r, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterBytes)))) 686 | if err != nil { 687 | return "", fmt.Errorf("failed to generate random string: %s", err) 688 | } 689 | b[i] = letterBytes[r.Int64()] 690 | } 691 | return string(b), nil 692 | } 693 | -------------------------------------------------------------------------------- /pkg/bastion/logtunnel.go: -------------------------------------------------------------------------------- 1 | package bastion // import "moul.io/sshportal/pkg/bastion" 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "io" 7 | "log" 8 | "syscall" 9 | "time" 10 | 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | type logTunnel struct { 15 | host string 16 | channel ssh.Channel 17 | writer io.WriteCloser 18 | } 19 | 20 | type logTunnelForwardData struct { 21 | DestinationHost string 22 | DestinationPort uint32 23 | SourceHost string 24 | SourcePort uint32 25 | } 26 | 27 | func writeHeader(fd io.Writer, length int) { 28 | t := time.Now() 29 | 30 | tv := syscall.NsecToTimeval(t.UnixNano()) 31 | 32 | if err := binary.Write(fd, binary.LittleEndian, int32(tv.Sec)); err != nil { 33 | log.Printf("failed to write log header: %v", err) 34 | } 35 | if err := binary.Write(fd, binary.LittleEndian, tv.Usec); err != nil { 36 | log.Printf("failed to write log header: %v", err) 37 | } 38 | if err := binary.Write(fd, binary.LittleEndian, int32(length)); err != nil { 39 | log.Printf("failed to write log header: %v", err) 40 | } 41 | } 42 | 43 | func newLogTunnel(channel ssh.Channel, writer io.WriteCloser, host string) io.ReadWriteCloser { 44 | return &logTunnel{ 45 | host: host, 46 | channel: channel, 47 | writer: writer, 48 | } 49 | } 50 | 51 | func (l *logTunnel) Read(data []byte) (int, error) { 52 | return 0, errors.New("logTunnel.Read is not implemented") 53 | } 54 | 55 | func (l *logTunnel) Write(data []byte) (int, error) { 56 | writeHeader(l.writer, len(data)+len(l.host+": ")) 57 | if _, err := l.writer.Write([]byte(l.host + ": ")); err != nil { 58 | log.Printf("failed to write log: %v", err) 59 | } 60 | if _, err := l.writer.Write(data); err != nil { 61 | log.Printf("failed to write log: %v", err) 62 | } 63 | 64 | return l.channel.Write(data) 65 | } 66 | 67 | func (l *logTunnel) Close() error { 68 | l.writer.Close() 69 | 70 | return l.channel.Close() 71 | } 72 | -------------------------------------------------------------------------------- /pkg/bastion/session.go: -------------------------------------------------------------------------------- 1 | package bastion // import "moul.io/sshportal/pkg/bastion" 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/gliderlabs/ssh" 13 | "github.com/pkg/errors" 14 | "github.com/sabban/bastion/pkg/logchannel" 15 | gossh "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | type sessionConfig struct { 19 | Addr string 20 | LogsLocation string 21 | ClientConfig *gossh.ClientConfig 22 | LoggingMode string 23 | } 24 | 25 | func multiChannelHandler(conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, configs []sessionConfig, sessionID uint) error { 26 | var lastClient *gossh.Client 27 | switch newChan.ChannelType() { 28 | case "session": 29 | lch, lreqs, err := newChan.Accept() 30 | // TODO: defer clean closer 31 | if err != nil { 32 | // TODO: trigger event callback 33 | return nil 34 | } 35 | 36 | // go through all the hops 37 | for _, config := range configs { 38 | var client *gossh.Client 39 | if lastClient == nil { 40 | client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig) 41 | } else { 42 | rconn, err := lastClient.Dial("tcp", config.Addr) 43 | if err != nil { 44 | return err 45 | } 46 | ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig) 47 | if err != nil { 48 | return err 49 | } 50 | client = gossh.NewClient(ncc, chans, reqs) 51 | } 52 | if err != nil { 53 | lch.Close() // fix #56 54 | return err 55 | } 56 | defer func() { _ = client.Close() }() 57 | lastClient = client 58 | } 59 | 60 | rch, rreqs, err := lastClient.OpenChannel("session", []byte{}) 61 | if err != nil { 62 | return err 63 | } 64 | user := conn.User() 65 | actx := ctx.Value(authContextKey).(*authContext) 66 | username := actx.user.Name 67 | // pipe everything 68 | return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1], user, username, sessionID, newChan) 69 | case "direct-tcpip": 70 | lch, lreqs, err := newChan.Accept() 71 | // TODO: defer clean closer 72 | if err != nil { 73 | // TODO: trigger event callback 74 | return nil 75 | } 76 | 77 | // go through all the hops 78 | for _, config := range configs { 79 | var client *gossh.Client 80 | if lastClient == nil { 81 | client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig) 82 | } else { 83 | rconn, err := lastClient.Dial("tcp", config.Addr) 84 | if err != nil { 85 | return err 86 | } 87 | ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig) 88 | if err != nil { 89 | return err 90 | } 91 | client = gossh.NewClient(ncc, chans, reqs) 92 | } 93 | if err != nil { 94 | lch.Close() 95 | return err 96 | } 97 | defer func() { _ = client.Close() }() 98 | lastClient = client 99 | } 100 | 101 | d := logTunnelForwardData{} 102 | if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil { 103 | return err 104 | } 105 | rch, rreqs, err := lastClient.OpenChannel("direct-tcpip", newChan.ExtraData()) 106 | if err != nil { 107 | return err 108 | } 109 | user := conn.User() 110 | actx := ctx.Value(authContextKey).(*authContext) 111 | username := actx.user.Name 112 | // pipe everything 113 | return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1], user, username, sessionID, newChan) 114 | default: 115 | if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil { 116 | log.Printf("failed to reject chan: %v", err) 117 | } 118 | return nil 119 | } 120 | } 121 | 122 | func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, sessConfig sessionConfig, user string, username string, sessionID uint, newChan gossh.NewChannel) error { 123 | defer func() { 124 | _ = lch.Close() 125 | _ = rch.Close() 126 | }() 127 | 128 | errch := make(chan error, 1) 129 | quit := make(chan string, 1) 130 | channeltype := newChan.ChannelType() 131 | 132 | var logWriter io.WriteCloser = newDiscardWriteCloser() 133 | if sessConfig.LoggingMode != "disabled" { 134 | filename := filepath.Join(sessConfig.LogsLocation, fmt.Sprintf("%s-%s-%s-%d-%s", user, username, channeltype, sessionID, time.Now().Format(time.RFC3339))) 135 | f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0440) 136 | if err != nil { 137 | return errors.Wrap(err, "open log file") 138 | } 139 | defer func() { 140 | _ = f.Close() 141 | }() 142 | log.Printf("Session %v is recorded in %v", channeltype, filename) 143 | logWriter = f 144 | } 145 | 146 | if channeltype == "session" { 147 | switch sessConfig.LoggingMode { 148 | case "input": 149 | wrappedrch := logchannel.New(rch, logWriter) 150 | go func(quit chan string) { 151 | _, _ = io.Copy(lch, rch) 152 | quit <- "rch" 153 | }(quit) 154 | go func(quit chan string) { 155 | _, _ = io.Copy(wrappedrch, lch) 156 | quit <- "lch" 157 | }(quit) 158 | default: // everything, disabled 159 | wrappedlch := logchannel.New(lch, logWriter) 160 | go func(quit chan string) { 161 | _, _ = io.Copy(wrappedlch, rch) 162 | quit <- "rch" 163 | }(quit) 164 | go func(quit chan string) { 165 | _, _ = io.Copy(rch, lch) 166 | quit <- "lch" 167 | }(quit) 168 | } 169 | } 170 | if channeltype == "direct-tcpip" { 171 | d := logTunnelForwardData{} 172 | if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil { 173 | return err 174 | } 175 | wrappedlch := newLogTunnel(lch, logWriter, d.SourceHost) 176 | wrappedrch := newLogTunnel(rch, logWriter, d.DestinationHost) 177 | go func(quit chan string) { 178 | _, _ = io.Copy(wrappedlch, rch) 179 | quit <- "rch" 180 | }(quit) 181 | 182 | go func(quit chan string) { 183 | _, _ = io.Copy(wrappedrch, lch) 184 | quit <- "lch" 185 | }(quit) 186 | } 187 | 188 | go func(quit chan string) { 189 | for req := range lreqs { 190 | b, err := rch.SendRequest(req.Type, req.WantReply, req.Payload) 191 | if req.Type == "exec" { 192 | wrappedlch := logchannel.New(lch, logWriter) 193 | req.Payload = append(req.Payload, []byte("\n")...) 194 | if _, err := wrappedlch.LogWrite(req.Payload); err != nil { 195 | log.Printf("failed to write log: %v", err) 196 | } 197 | } 198 | 199 | if err != nil { 200 | errch <- err 201 | } 202 | if err2 := req.Reply(b, nil); err2 != nil { 203 | errch <- err2 204 | } 205 | } 206 | quit <- "lreqs" 207 | }(quit) 208 | 209 | go func(quit chan string) { 210 | for req := range rreqs { 211 | b, err := lch.SendRequest(req.Type, req.WantReply, req.Payload) 212 | if err != nil { 213 | errch <- err 214 | } 215 | if err2 := req.Reply(b, nil); err2 != nil { 216 | errch <- err2 217 | } 218 | } 219 | quit <- "rreqs" 220 | }(quit) 221 | 222 | lchEOF, rchEOF, lchClosed, rchClosed := false, false, false, false 223 | for { 224 | select { 225 | case err := <-errch: 226 | return err 227 | case q := <-quit: 228 | switch q { 229 | case "lch": 230 | lchEOF = true 231 | _ = rch.CloseWrite() 232 | case "rch": 233 | rchEOF = true 234 | _ = lch.CloseWrite() 235 | case "lreqs": 236 | lchClosed = true 237 | case "rreqs": 238 | rchClosed = true 239 | } 240 | 241 | if lchEOF && lchClosed && !rchClosed { 242 | rch.Close() 243 | } 244 | 245 | if rchEOF && rchClosed && !lchClosed { 246 | lch.Close() 247 | } 248 | 249 | if lchEOF && rchEOF && lchClosed && rchClosed { 250 | return nil 251 | } 252 | } 253 | } 254 | } 255 | 256 | func newDiscardWriteCloser() io.WriteCloser { return &discardWriteCloser{ioutil.Discard} } 257 | 258 | type discardWriteCloser struct { 259 | io.Writer 260 | } 261 | 262 | func (discardWriteCloser) Close() error { 263 | return nil 264 | } 265 | -------------------------------------------------------------------------------- /pkg/bastion/ssh.go: -------------------------------------------------------------------------------- 1 | package bastion // import "moul.io/sshportal/pkg/bastion" 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net" 9 | "strings" 10 | "time" 11 | 12 | "github.com/gliderlabs/ssh" 13 | gossh "golang.org/x/crypto/ssh" 14 | "gorm.io/gorm" 15 | "moul.io/sshportal/pkg/crypto" 16 | "moul.io/sshportal/pkg/dbmodels" 17 | ) 18 | 19 | type sshportalContextKey string 20 | 21 | var authContextKey = sshportalContextKey("auth") 22 | 23 | type authContext struct { 24 | message string 25 | err error 26 | user dbmodels.User 27 | inputUsername string 28 | db *gorm.DB 29 | userKey dbmodels.UserKey 30 | logsLocation string 31 | aclCheckCmd string 32 | aesKey string 33 | dbDriver, dbURL string 34 | bindAddr string 35 | demo, debug bool 36 | authMethod string 37 | authSuccess bool 38 | } 39 | 40 | type userType string 41 | 42 | const ( 43 | userTypeHealthcheck userType = "healthcheck" 44 | userTypeBastion userType = "bastion" 45 | userTypeInvite userType = "invite" 46 | userTypeShell userType = "shell" 47 | ) 48 | 49 | func (c authContext) userType() userType { 50 | switch { 51 | case c.inputUsername == "healthcheck": 52 | return userTypeHealthcheck 53 | case c.inputUsername == c.user.Name || c.inputUsername == c.user.Email || c.inputUsername == "admin": 54 | return userTypeShell 55 | case strings.HasPrefix(c.inputUsername, "invite:"): 56 | return userTypeInvite 57 | default: 58 | return userTypeBastion 59 | } 60 | } 61 | 62 | func dynamicHostKey(db *gorm.DB, host *dbmodels.Host) gossh.HostKeyCallback { 63 | return func(hostname string, remote net.Addr, key gossh.PublicKey) error { 64 | if len(host.HostKey) == 0 { 65 | log.Println("Discovering host fingerprint...") 66 | return db.Model(host).Update("HostKey", key.Marshal()).Error 67 | } 68 | 69 | if !bytes.Equal(host.HostKey, key.Marshal()) { 70 | return fmt.Errorf("ssh: host key mismatch") 71 | } 72 | return nil 73 | } 74 | } 75 | 76 | var DefaultChannelHandler ssh.ChannelHandler = func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {} 77 | 78 | func ChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { 79 | switch newChan.ChannelType() { 80 | case "session": 81 | case "direct-tcpip": 82 | default: 83 | // TODO: handle direct-tcp (only for ssh scheme) 84 | if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil { 85 | log.Printf("error: failed to reject channel: %v", err) 86 | } 87 | return 88 | } 89 | 90 | actx := ctx.Value(authContextKey).(*authContext) 91 | 92 | if actx.user.ID == 0 && actx.userType() != userTypeHealthcheck { 93 | ip, err := net.ResolveTCPAddr(conn.RemoteAddr().Network(), conn.RemoteAddr().String()) 94 | if err == nil { 95 | log.Printf("Auth failed: sshUser=%q remote=%q", conn.User(), ip.IP.String()) 96 | actx.err = errors.New("access denied") 97 | 98 | ch, _, err2 := newChan.Accept() 99 | if err2 != nil { 100 | return 101 | } 102 | fmt.Fprintf(ch, "error: %v\n", actx.err) 103 | _ = ch.Close() 104 | return 105 | } 106 | } 107 | 108 | switch actx.userType() { 109 | case userTypeBastion: 110 | log.Printf("New connection(bastion): sshUser=%q remote=%q local=%q dbUser=id:%d,email:%s", conn.User(), conn.RemoteAddr(), conn.LocalAddr(), actx.user.ID, actx.user.Email) 111 | host, err := dbmodels.HostByName(actx.db, actx.inputUsername) 112 | if err != nil { 113 | ch, _, err2 := newChan.Accept() 114 | if err2 != nil { 115 | return 116 | } 117 | fmt.Fprintf(ch, "error: %v\n", err) 118 | // FIXME: force close all channels 119 | _ = ch.Close() 120 | return 121 | } 122 | 123 | switch host.Scheme() { 124 | case dbmodels.BastionSchemeSSH: 125 | sessionConfigs := make([]sessionConfig, 0) 126 | currentHost := host 127 | for currentHost != nil { 128 | clientConfig, err2 := bastionClientConfig(ctx, currentHost) 129 | if err2 != nil { 130 | ch, _, err3 := newChan.Accept() 131 | if err3 != nil { 132 | return 133 | } 134 | fmt.Fprintf(ch, "error: %v\n", err2) 135 | // FIXME: force close all channels 136 | _ = ch.Close() 137 | return 138 | } 139 | sessionConfigs = append([]sessionConfig{{ 140 | Addr: currentHost.DialAddr(), 141 | ClientConfig: clientConfig, 142 | LogsLocation: actx.logsLocation, 143 | LoggingMode: currentHost.Logging, 144 | }}, sessionConfigs...) 145 | if currentHost.HopID != 0 { 146 | var newHost dbmodels.Host 147 | if err := actx.db.Model(currentHost).Association("HopID").Find(&newHost); err != nil { 148 | log.Printf("Error: %v", err) 149 | return 150 | } 151 | hostname := newHost.Name 152 | currentHost, _ = dbmodels.HostByName(actx.db, hostname) 153 | } else { 154 | currentHost = nil 155 | } 156 | } 157 | 158 | sess := dbmodels.Session{ 159 | UserID: actx.user.ID, 160 | HostID: host.ID, 161 | Status: string(dbmodels.SessionStatusActive), 162 | } 163 | if err = actx.db.Create(&sess).Error; err != nil { 164 | ch, _, err2 := newChan.Accept() 165 | if err2 != nil { 166 | return 167 | } 168 | fmt.Fprintf(ch, "error: %v\n", err) 169 | _ = ch.Close() 170 | return 171 | } 172 | go func() { 173 | err = multiChannelHandler(conn, newChan, ctx, sessionConfigs, sess.ID) 174 | if err != nil { 175 | log.Printf("Error: %v", err) 176 | } 177 | 178 | now := time.Now() 179 | sessUpdate := dbmodels.Session{ 180 | Status: string(dbmodels.SessionStatusClosed), 181 | ErrMsg: fmt.Sprintf("%v", err), 182 | StoppedAt: &now, 183 | } 184 | if err == nil { 185 | sessUpdate.ErrMsg = "" 186 | } 187 | actx.db.Model(&sess).Updates(&sessUpdate) 188 | }() 189 | case dbmodels.BastionSchemeTelnet: 190 | tmpSrv := ssh.Server{ 191 | // PtyCallback: srv.PtyCallback, 192 | Handler: telnetHandler(host), 193 | } 194 | DefaultChannelHandler(&tmpSrv, conn, newChan, ctx) 195 | default: 196 | ch, _, err2 := newChan.Accept() 197 | if err2 != nil { 198 | return 199 | } 200 | fmt.Fprintf(ch, "error: unknown bastion scheme: %q\n", host.Scheme()) 201 | // FIXME: force close all channels 202 | _ = ch.Close() 203 | } 204 | default: // shell 205 | DefaultChannelHandler(srv, conn, newChan, ctx) 206 | } 207 | } 208 | 209 | func bastionClientConfig(ctx ssh.Context, host *dbmodels.Host) (*gossh.ClientConfig, error) { 210 | actx := ctx.Value(authContextKey).(*authContext) 211 | 212 | crypto.HostDecrypt(actx.aesKey, host) 213 | crypto.SSHKeyDecrypt(actx.aesKey, host.SSHKey) 214 | 215 | clientConfig, err := host.ClientConfig(dynamicHostKey(actx.db, host)) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | var tmpUser dbmodels.User 221 | if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", actx.user.ID).First(&tmpUser).Error; err != nil { 222 | return nil, err 223 | } 224 | var tmpHost dbmodels.Host 225 | if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err != nil { 226 | return nil, err 227 | } 228 | 229 | action := checkACLs(tmpUser, tmpHost, actx.aclCheckCmd) 230 | switch action { 231 | case string(dbmodels.ACLActionAllow): 232 | // do nothing 233 | case string(dbmodels.ACLActionDeny): 234 | return nil, fmt.Errorf("you don't have permission to that host") 235 | default: 236 | return nil, fmt.Errorf("invalid ACL action: %q", action) 237 | } 238 | return clientConfig, nil 239 | } 240 | 241 | func ShellHandler(s ssh.Session, version, gitSha, gitTag string) { 242 | actx := s.Context().Value(authContextKey).(*authContext) 243 | if actx.userType() != userTypeHealthcheck { 244 | log.Printf("New connection(shell): sshUser=%q remote=%q local=%q command=%q dbUser=id:%d,email:%s", s.User(), s.RemoteAddr(), s.LocalAddr(), s.Command(), actx.user.ID, actx.user.Email) 245 | } 246 | 247 | if actx.err != nil { 248 | fmt.Fprintf(s, "error: %v\n", actx.err) 249 | _ = s.Exit(1) 250 | return 251 | } 252 | 253 | if actx.message != "" { 254 | fmt.Fprint(s, actx.message) 255 | } 256 | 257 | switch actx.userType() { 258 | case userTypeHealthcheck: 259 | fmt.Fprintln(s, "OK") 260 | return 261 | case userTypeShell: 262 | if err := shell(s, version, gitSha, gitTag); err != nil { 263 | fmt.Fprintf(s, "error: %v\n", err) 264 | _ = s.Exit(1) 265 | } 266 | return 267 | case userTypeInvite: 268 | // do nothing (message was printed at the beginning of the function) 269 | return 270 | } 271 | panic("should not happen") 272 | } 273 | 274 | func PasswordAuthHandler(db *gorm.DB, logsLocation, aclCheckCmd, aesKey, dbDriver, dbURL, bindAddr string, demo bool) ssh.PasswordHandler { 275 | return func(ctx ssh.Context, pass string) bool { 276 | actx := &authContext{ 277 | db: db, 278 | inputUsername: ctx.User(), 279 | logsLocation: logsLocation, 280 | aclCheckCmd: aclCheckCmd, 281 | aesKey: aesKey, 282 | dbDriver: dbDriver, 283 | dbURL: dbURL, 284 | bindAddr: bindAddr, 285 | demo: demo, 286 | authMethod: "password", 287 | } 288 | actx.authSuccess = actx.userType() == userTypeHealthcheck 289 | ctx.SetValue(authContextKey, actx) 290 | return actx.authSuccess 291 | } 292 | } 293 | 294 | func PrivateKeyFromDB(db *gorm.DB, aesKey string) func(*ssh.Server) error { 295 | return func(srv *ssh.Server) error { 296 | var key dbmodels.SSHKey 297 | if err := dbmodels.SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil { 298 | return err 299 | } 300 | crypto.SSHKeyDecrypt(aesKey, &key) 301 | 302 | signer, err := gossh.ParsePrivateKey([]byte(key.PrivKey)) 303 | if err != nil { 304 | return err 305 | } 306 | srv.AddHostKey(signer) 307 | return nil 308 | } 309 | } 310 | 311 | func PublicKeyAuthHandler(db *gorm.DB, logsLocation, aclCheckCmd, aesKey, dbDriver, dbURL, bindAddr string, demo bool) ssh.PublicKeyHandler { 312 | return func(ctx ssh.Context, key ssh.PublicKey) bool { 313 | actx := &authContext{ 314 | db: db, 315 | inputUsername: ctx.User(), 316 | logsLocation: logsLocation, 317 | aclCheckCmd: aclCheckCmd, 318 | aesKey: aesKey, 319 | dbDriver: dbDriver, 320 | dbURL: dbURL, 321 | bindAddr: bindAddr, 322 | demo: demo, 323 | authMethod: "pubkey", 324 | authSuccess: true, 325 | } 326 | ctx.SetValue(authContextKey, actx) 327 | 328 | // lookup user by key 329 | db.Where("authorized_key = ?", string(gossh.MarshalAuthorizedKey(key))).First(&actx.userKey) 330 | if actx.userKey.UserID > 0 { 331 | db.Preload("Roles").Where("id = ?", actx.userKey.UserID).First(&actx.user) 332 | if actx.userType() == userTypeInvite { 333 | actx.err = fmt.Errorf("invites are only supported for new SSH keys; your ssh key is already associated with the user %q", actx.user.Email) 334 | } 335 | return true 336 | } 337 | 338 | // handle invite "links" 339 | if actx.userType() == userTypeInvite { 340 | inputToken := strings.Split(actx.inputUsername, ":")[1] 341 | if len(inputToken) > 0 { 342 | db.Where("invite_token = ?", inputToken).First(&actx.user) 343 | } 344 | if actx.user.ID > 0 { 345 | actx.userKey = dbmodels.UserKey{ 346 | UserID: actx.user.ID, 347 | Key: key.Marshal(), 348 | Comment: "created by sshportal", 349 | AuthorizedKey: string(gossh.MarshalAuthorizedKey(key)), 350 | } 351 | db.Create(&actx.userKey) 352 | 353 | // token is only usable once 354 | actx.user.InviteToken = "" 355 | db.Model(&actx.user).Updates(&actx.user) 356 | 357 | actx.message = fmt.Sprintf("Welcome %s!\n\nYour key is now associated with the user %q.\n", actx.user.Name, actx.user.Email) 358 | } else { 359 | actx.user = dbmodels.User{Name: "Anonymous"} 360 | actx.err = errors.New("your token is invalid or expired") 361 | } 362 | return true 363 | } 364 | 365 | // fallback 366 | actx.err = errors.New("unknown ssh key") 367 | actx.user = dbmodels.User{Name: "Anonymous"} 368 | return true 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /pkg/bastion/telnet.go: -------------------------------------------------------------------------------- 1 | package bastion // import "moul.io/sshportal/pkg/bastion" 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "log" 9 | "time" 10 | 11 | "github.com/gliderlabs/ssh" 12 | oi "github.com/reiver/go-oi" 13 | telnet "github.com/reiver/go-telnet" 14 | "moul.io/sshportal/pkg/dbmodels" 15 | ) 16 | 17 | type bastionTelnetCaller struct { 18 | ssh ssh.Session 19 | } 20 | 21 | func (caller bastionTelnetCaller) CallTELNET(ctx telnet.Context, w telnet.Writer, r telnet.Reader) { 22 | go func(writer io.Writer, reader io.Reader) { 23 | var buffer [1]byte // Seems like the length of the buffer needs to be small, otherwise will have to wait for buffer to fill up. 24 | p := buffer[:] 25 | 26 | for { 27 | // Read 1 byte. 28 | n, err := reader.Read(p) 29 | if n <= 0 && err == nil { 30 | continue 31 | } else if n <= 0 && err != nil { 32 | break 33 | } 34 | 35 | if _, err = oi.LongWrite(writer, p); err != nil { 36 | log.Printf("telnet longwrite failed: %v", err) 37 | } 38 | } 39 | }(caller.ssh, r) 40 | 41 | var buffer bytes.Buffer 42 | var p []byte 43 | 44 | var crlfBuffer = [2]byte{'\r', '\n'} 45 | crlf := crlfBuffer[:] 46 | 47 | scanner := bufio.NewScanner(caller.ssh) 48 | scanner.Split(scannerSplitFunc) 49 | 50 | for scanner.Scan() { 51 | buffer.Write(scanner.Bytes()) 52 | buffer.Write(crlf) 53 | 54 | p = buffer.Bytes() 55 | 56 | n, err := oi.LongWrite(w, p) 57 | if nil != err { 58 | break 59 | } 60 | if expected, actual := int64(len(p)), n; expected != actual { 61 | err := fmt.Errorf("transmission problem: tried sending %d bytes, but actually only sent %d bytes", expected, actual) 62 | fmt.Fprint(caller.ssh, err.Error()) 63 | return 64 | } 65 | buffer.Reset() 66 | } 67 | 68 | // Wait a bit to receive data from the server (that we would send to io.Stdout). 69 | time.Sleep(3 * time.Millisecond) 70 | } 71 | 72 | func scannerSplitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) { 73 | if atEOF { 74 | return 0, nil, nil 75 | } 76 | return bufio.ScanLines(data, atEOF) 77 | } 78 | 79 | func telnetHandler(host *dbmodels.Host) ssh.Handler { 80 | return func(s ssh.Session) { 81 | // FIXME: log session in db 82 | // actx := s.Context().Value(authContextKey).(*authContext) 83 | caller := bastionTelnetCaller{ssh: s} 84 | if err := telnet.DialToAndCall(host.DialAddr(), caller); err != nil { 85 | fmt.Fprintf(s, "error: %v", err) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto // import "moul.io/sshportal/pkg/crypto" 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/ecdsa" 8 | "crypto/ed25519" 9 | "crypto/elliptic" 10 | "crypto/rand" 11 | "crypto/rsa" 12 | "crypto/x509" 13 | "encoding/base64" 14 | "encoding/pem" 15 | "errors" 16 | "fmt" 17 | "io" 18 | "strings" 19 | 20 | gossh "golang.org/x/crypto/ssh" 21 | "moul.io/sshportal/pkg/dbmodels" 22 | ) 23 | 24 | func NewSSHKey(keyType string, length uint) (*dbmodels.SSHKey, error) { 25 | key := dbmodels.SSHKey{ 26 | Type: keyType, 27 | Length: length, 28 | } 29 | 30 | // generate the private key 31 | var err error 32 | var pemKey *pem.Block 33 | var publicKey gossh.PublicKey 34 | switch keyType { 35 | case "rsa": 36 | pemKey, publicKey, err = NewRSAKey(length) 37 | case "ecdsa": 38 | pemKey, publicKey, err = NewECDSAKey(length) 39 | case "ed25519": 40 | pemKey, publicKey, err = NewEd25519Key() 41 | default: 42 | return nil, fmt.Errorf("key type not supported: %q, supported types are: rsa, ecdsa, ed25519", key.Type) 43 | } 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | buf := bytes.NewBufferString("") 49 | if err = pem.Encode(buf, pemKey); err != nil { 50 | return nil, err 51 | } 52 | key.PrivKey = buf.String() 53 | 54 | // generate authorized-key formatted pubkey output 55 | key.PubKey = strings.TrimSpace(string(gossh.MarshalAuthorizedKey(publicKey))) 56 | 57 | return &key, nil 58 | } 59 | 60 | func NewRSAKey(length uint) (*pem.Block, gossh.PublicKey, error) { 61 | if length < 1024 || length > 16384 { 62 | return nil, nil, fmt.Errorf("key length not supported: %d, supported values are between 1024 and 16384", length) 63 | } 64 | privateKey, err := rsa.GenerateKey(rand.Reader, int(length)) 65 | if err != nil { 66 | return nil, nil, err 67 | } 68 | // convert priv key to x509 format 69 | pemKey := &pem.Block{ 70 | Type: "RSA PRIVATE KEY", 71 | Bytes: x509.MarshalPKCS1PrivateKey(privateKey), 72 | } 73 | publicKey, err := gossh.NewPublicKey(&privateKey.PublicKey) 74 | if err != nil { 75 | return nil, nil, err 76 | } 77 | return pemKey, publicKey, err 78 | } 79 | 80 | func NewECDSAKey(length uint) (*pem.Block, gossh.PublicKey, error) { 81 | var curve elliptic.Curve 82 | switch length { 83 | case 256: 84 | curve = elliptic.P256() 85 | case 384: 86 | curve = elliptic.P384() 87 | case 521: 88 | curve = elliptic.P521() 89 | default: 90 | return nil, nil, fmt.Errorf("key length not supported: %d, supported values are 256, 384, 521", length) 91 | } 92 | privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) 93 | if err != nil { 94 | return nil, nil, err 95 | } 96 | // convert priv key to x509 format 97 | marshaledKey, err := x509.MarshalPKCS8PrivateKey(privateKey) 98 | pemKey := &pem.Block{ 99 | Type: "PRIVATE KEY", 100 | Bytes: marshaledKey, 101 | } 102 | if err != nil { 103 | return nil, nil, err 104 | } 105 | publicKey, err := gossh.NewPublicKey(&privateKey.PublicKey) 106 | if err != nil { 107 | return nil, nil, err 108 | } 109 | return pemKey, publicKey, err 110 | } 111 | 112 | func NewEd25519Key() (*pem.Block, gossh.PublicKey, error) { 113 | publicKeyEd25519, privateKey, err := ed25519.GenerateKey(rand.Reader) 114 | if err != nil { 115 | return nil, nil, err 116 | } 117 | // convert priv key to x509 format 118 | marshaledKey, err := x509.MarshalPKCS8PrivateKey(privateKey) 119 | pemKey := &pem.Block{ 120 | Type: "PRIVATE KEY", 121 | Bytes: marshaledKey, 122 | } 123 | if err != nil { 124 | return nil, nil, err 125 | } 126 | publicKey, err := gossh.NewPublicKey(publicKeyEd25519) 127 | if err != nil { 128 | return nil, nil, err 129 | } 130 | return pemKey, publicKey, err 131 | } 132 | 133 | func ImportSSHKey(keyValue string) (*dbmodels.SSHKey, error) { 134 | key := dbmodels.SSHKey{ 135 | Type: "rsa", 136 | } 137 | 138 | parsedKey, err := gossh.ParseRawPrivateKey([]byte(keyValue)) 139 | if err != nil { 140 | return nil, err 141 | } 142 | var privateKey *rsa.PrivateKey 143 | var ok bool 144 | if privateKey, ok = parsedKey.(*rsa.PrivateKey); !ok { 145 | return nil, errors.New("key type not supported") 146 | } 147 | key.Length = uint(privateKey.PublicKey.N.BitLen()) 148 | // convert priv key to x509 format 149 | var pemKey = &pem.Block{ 150 | Type: "RSA PRIVATE KEY", 151 | Bytes: x509.MarshalPKCS1PrivateKey(privateKey), 152 | } 153 | buf := bytes.NewBufferString("") 154 | if err = pem.Encode(buf, pemKey); err != nil { 155 | return nil, err 156 | } 157 | key.PrivKey = buf.String() 158 | 159 | // generte authorized-key formatted pubkey output 160 | pub, err := gossh.NewPublicKey(&privateKey.PublicKey) 161 | if err != nil { 162 | return nil, err 163 | } 164 | key.PubKey = strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub))) 165 | 166 | return &key, nil 167 | } 168 | 169 | func encrypt(key []byte, text string) (string, error) { 170 | plaintext := []byte(text) 171 | block, err := aes.NewCipher(key) 172 | if err != nil { 173 | return "", err 174 | } 175 | ciphertext := make([]byte, aes.BlockSize+len(plaintext)) 176 | iv := ciphertext[:aes.BlockSize] 177 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 178 | return "", err 179 | } 180 | stream := cipher.NewCFBEncrypter(block, iv) 181 | stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext) 182 | return base64.URLEncoding.EncodeToString(ciphertext), nil 183 | } 184 | 185 | func decrypt(key []byte, cryptoText string) (string, error) { 186 | ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText) 187 | block, err := aes.NewCipher(key) 188 | if err != nil { 189 | return "", err 190 | } 191 | if len(ciphertext) < aes.BlockSize { 192 | return "", fmt.Errorf("ciphertext too short") 193 | } 194 | iv := ciphertext[:aes.BlockSize] 195 | ciphertext = ciphertext[aes.BlockSize:] 196 | stream := cipher.NewCFBDecrypter(block, iv) 197 | stream.XORKeyStream(ciphertext, ciphertext) 198 | return string(ciphertext), nil 199 | } 200 | 201 | func safeDecrypt(key []byte, cryptoText string) string { 202 | if len(key) == 0 { 203 | return cryptoText 204 | } 205 | out, err := decrypt(key, cryptoText) 206 | if err != nil { 207 | return cryptoText 208 | } 209 | return out 210 | } 211 | 212 | func HostEncrypt(aesKey string, host *dbmodels.Host) (err error) { 213 | if aesKey == "" { 214 | return nil 215 | } 216 | if host.Password != "" { 217 | host.Password, err = encrypt([]byte(aesKey), host.Password) 218 | } 219 | return 220 | } 221 | func HostDecrypt(aesKey string, host *dbmodels.Host) { 222 | if aesKey == "" { 223 | return 224 | } 225 | if host.Password != "" { 226 | host.Password = safeDecrypt([]byte(aesKey), host.Password) 227 | } 228 | } 229 | 230 | func SSHKeyEncrypt(aesKey string, key *dbmodels.SSHKey) (err error) { 231 | if aesKey == "" { 232 | return nil 233 | } 234 | key.PrivKey, err = encrypt([]byte(aesKey), key.PrivKey) 235 | return 236 | } 237 | func SSHKeyDecrypt(aesKey string, key *dbmodels.SSHKey) { 238 | if aesKey == "" { 239 | return 240 | } 241 | key.PrivKey = safeDecrypt([]byte(aesKey), key.PrivKey) 242 | } 243 | -------------------------------------------------------------------------------- /pkg/dbmodels/dbmodels.go: -------------------------------------------------------------------------------- 1 | package dbmodels // import "moul.io/sshportal/pkg/dbmodels" 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | gossh "golang.org/x/crypto/ssh" 13 | "gorm.io/gorm" 14 | ) 15 | 16 | type Config struct { 17 | SSHKeys []*SSHKey `json:"keys"` 18 | Hosts []*Host `json:"hosts"` 19 | UserKeys []*UserKey `json:"user_keys"` 20 | Users []*User `json:"users"` 21 | UserGroups []*UserGroup `json:"user_groups"` 22 | HostGroups []*HostGroup `json:"host_groups"` 23 | ACLs []*ACL `json:"acls"` 24 | Settings []*Setting `json:"settings"` 25 | Events []*Event `json:"events"` 26 | Sessions []*Session `json:"sessions"` 27 | // FIXME: add latest migration 28 | Date time.Time `json:"date"` 29 | } 30 | 31 | type Setting struct { 32 | gorm.Model 33 | Name string `valid:"required" gorm:"index:uix_settings_name,unique"` 34 | Value string `valid:"required"` 35 | } 36 | 37 | // SSHKey defines a ssh client key (used by sshportal to connect to remote hosts) 38 | type SSHKey struct { 39 | // FIXME: use uuid for ID 40 | gorm.Model 41 | Name string `valid:"required,length(1|255),unix_user" gorm:"index:uix_keys_name,unique"` 42 | Type string `valid:"required"` 43 | Length uint `valid:"required"` 44 | Fingerprint string `valid:"optional"` 45 | PrivKey string `sql:"size:5000" valid:"required"` 46 | PubKey string `sql:"size:1000" valid:"optional"` 47 | Hosts []*Host `gorm:"ForeignKey:SSHKeyID"` 48 | Comment string `valid:"optional"` 49 | } 50 | 51 | type Host struct { 52 | // FIXME: use uuid for ID 53 | gorm.Model 54 | Name string `gorm:"index:uix_hosts_name,unique;type:varchar(255)" valid:"required,length(1|255)"` 55 | Addr string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL 56 | User string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL 57 | Password string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL 58 | URL string `valid:"optional"` 59 | SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"` // SSHKey used to connect by the client 60 | SSHKeyID uint `gorm:"index"` 61 | HostKey []byte `sql:"size:1000" valid:"optional"` 62 | Groups []*HostGroup `gorm:"many2many:host_host_groups;"` 63 | Comment string `valid:"optional"` 64 | Logging string `valid:"optional,host_logging_mode"` 65 | Hop *Host 66 | HopID uint 67 | } 68 | 69 | // UserKey defines a user public key used by sshportal to identify the user 70 | type UserKey struct { 71 | gorm.Model 72 | Key []byte `sql:"size:1000" valid:"length(1|1000)"` 73 | AuthorizedKey string `sql:"size:1000" valid:"required,length(1|1000)"` 74 | UserID uint `` 75 | User *User `gorm:"ForeignKey:UserID"` 76 | Comment string `valid:"optional"` 77 | } 78 | 79 | type UserRole struct { 80 | gorm.Model 81 | Name string `valid:"required,length(1|255),unix_user"` 82 | Users []*User `gorm:"many2many:user_user_roles"` 83 | } 84 | 85 | type User struct { 86 | // FIXME: use uuid for ID 87 | gorm.Model 88 | Roles []*UserRole `gorm:"many2many:user_user_roles"` 89 | Email string `valid:"required,email"` 90 | Name string `valid:"required,length(1|255),unix_user" gorm:"index:uix_users_name,unique"` 91 | Keys []*UserKey `gorm:"ForeignKey:UserID"` 92 | Groups []*UserGroup `gorm:"many2many:user_user_groups;"` 93 | Comment string `valid:"optional"` 94 | InviteToken string `valid:"optional,length(10|60)"` 95 | } 96 | 97 | type UserGroup struct { 98 | gorm.Model 99 | Name string `valid:"required,length(1|255),unix_user" gorm:"index:uix_usergroups_name,unique"` 100 | Users []*User `gorm:"many2many:user_user_groups;"` 101 | ACLs []*ACL `gorm:"many2many:user_group_acls;"` 102 | Comment string `valid:"optional"` 103 | } 104 | 105 | type HostGroup struct { 106 | gorm.Model 107 | Name string `valid:"required,length(1|255),unix_user" gorm:"index:uix_hostgroups_name,unique"` 108 | Hosts []*Host `gorm:"many2many:host_host_groups;"` 109 | ACLs []*ACL `gorm:"many2many:host_group_acls;"` 110 | Comment string `valid:"optional"` 111 | } 112 | 113 | type ACL struct { 114 | gorm.Model 115 | HostGroups []*HostGroup `gorm:"many2many:host_group_acls;"` 116 | UserGroups []*UserGroup `gorm:"many2many:user_group_acls;"` 117 | HostPattern string `valid:"optional"` 118 | Action string `valid:"required"` 119 | Weight uint `` 120 | Comment string `valid:"optional"` 121 | Inception *time.Time 122 | Expiration *time.Time 123 | } 124 | 125 | type Session struct { 126 | gorm.Model 127 | StoppedAt *time.Time `sql:"index" valid:"optional"` 128 | Status string `valid:"required"` 129 | User *User `gorm:"ForeignKey:UserID"` 130 | Host *Host `gorm:"ForeignKey:HostID"` 131 | UserID uint `valid:"optional"` 132 | HostID uint `valid:"optional"` 133 | ErrMsg string `valid:"optional"` 134 | Comment string `valid:"optional"` 135 | } 136 | 137 | type Event struct { 138 | gorm.Model 139 | Author *User `gorm:"ForeignKey:AuthorID"` 140 | AuthorID uint `valid:"optional"` 141 | Domain string `valid:"required"` 142 | Action string `valid:"required"` 143 | Entity string `valid:"optional"` 144 | Args []byte `sql:"size:10000" valid:"optional,length(1|10000)" json:"-"` 145 | ArgsMap map[string]interface{} `gorm:"-" json:"Args"` 146 | } 147 | 148 | type SessionStatus string 149 | 150 | const ( 151 | SessionStatusUnknown SessionStatus = "unknown" 152 | SessionStatusActive SessionStatus = "active" 153 | SessionStatusClosed SessionStatus = "closed" 154 | ) 155 | 156 | type ACLAction string 157 | 158 | const ( 159 | ACLActionAllow ACLAction = "allow" 160 | ACLActionDeny ACLAction = "deny" 161 | ) 162 | 163 | type BastionScheme string 164 | 165 | const ( 166 | BastionSchemeSSH BastionScheme = "ssh" 167 | BastionSchemeTelnet BastionScheme = "telnet" 168 | ) 169 | 170 | // Generic Helper 171 | func GenericNameOrID(db *gorm.DB, identifiers []string) *gorm.DB { 172 | var ids []string 173 | var names []string 174 | for _, s := range identifiers { 175 | if _, err := strconv.Atoi(s); err == nil { 176 | ids = append(ids, s) 177 | } else { 178 | names = append(names, s) 179 | } 180 | } 181 | if len(ids) > 0 && len(names) > 0 { 182 | return db.Where("id IN (?)", ids).Or("name IN (?)", names) 183 | } else if len(ids) > 0 { 184 | return db.Where("id IN (?)", ids) 185 | } 186 | return db.Where("name IN (?)", names) 187 | } 188 | 189 | // Host helpers 190 | 191 | func (host *Host) DialAddr() string { 192 | return fmt.Sprintf("%s:%d", host.Hostname(), host.Port()) 193 | } 194 | func (host *Host) String() string { 195 | if host.URL != "" { 196 | return host.URL 197 | } else if host.Addr != "" { // to be removed in a future version in favor of URL 198 | if host.Password != "" { 199 | return fmt.Sprintf("ssh://%s:%s@%s", host.User, strings.Repeat("*", 4), host.Addr) 200 | } 201 | return fmt.Sprintf("ssh://%s@%s", host.User, host.Addr) 202 | } 203 | return "" 204 | } 205 | func (host *Host) Scheme() BastionScheme { 206 | if host.URL != "" { 207 | u, err := url.Parse(host.URL) 208 | if err != nil { 209 | return BastionSchemeSSH 210 | } 211 | return BastionScheme(u.Scheme) 212 | } else if host.Addr != "" { 213 | return BastionSchemeSSH 214 | } 215 | return "" 216 | } 217 | func (host *Host) Hostname() string { 218 | if host.URL != "" { 219 | u, err := url.Parse(host.URL) 220 | if err != nil { 221 | return "" 222 | } 223 | return u.Hostname() 224 | } else if host.Addr != "" { // to be removed in a future version in favor of URL 225 | return strings.Split(host.Addr, ":")[0] 226 | } 227 | return "" 228 | } 229 | func (host *Host) Username() string { 230 | if host.URL != "" { 231 | u, err := url.Parse(host.URL) 232 | if err != nil { 233 | return "root" 234 | } 235 | if u.User != nil { 236 | return u.User.Username() 237 | } 238 | } else if host.User != "" { // to be removed in a future version in favor of URL 239 | return host.User 240 | } 241 | return "root" 242 | } 243 | func (host *Host) Passwd() string { 244 | if host.URL != "" { 245 | u, err := url.Parse(host.URL) 246 | if err != nil { 247 | return "" 248 | } 249 | if u.User != nil { 250 | password, _ := u.User.Password() 251 | return password 252 | } 253 | } else if host.Password != "" { // to be removed in a future version in favor of URL 254 | return host.Password 255 | } 256 | return "" 257 | } 258 | func (host *Host) Port() uint64 { 259 | var portString string 260 | if host.URL != "" { 261 | u, err := url.Parse(host.URL) 262 | if err != nil { 263 | goto defaultPort 264 | } 265 | portString = u.Port() 266 | } else if host.Addr != "" { // to be removed in a future version in favor of URL 267 | portString = strings.Split(host.Addr, ":")[1] 268 | } 269 | if portString != "" { 270 | port, err := strconv.ParseUint(portString, 10, 64) 271 | if err != nil { 272 | goto defaultPort 273 | } 274 | return port 275 | } 276 | defaultPort: 277 | switch host.Scheme() { 278 | case BastionSchemeSSH: 279 | return 22 280 | case BastionSchemeTelnet: 281 | return 23 282 | default: 283 | return 0 284 | } 285 | } 286 | func HostsPreload(db *gorm.DB) *gorm.DB { 287 | return db.Preload("Groups").Preload("SSHKey") 288 | } 289 | func HostsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 290 | return GenericNameOrID(db, identifiers) 291 | } 292 | func HostByName(db *gorm.DB, name string) (*Host, error) { 293 | var host Host 294 | db.Preload("SSHKey").Where("name = ?", name).Find(&host) 295 | if host.Name == "" { 296 | // FIXME: add available hosts 297 | return nil, fmt.Errorf("no such target: %q", name) 298 | } 299 | return &host, nil 300 | } 301 | 302 | func (host *Host) ClientConfig(hk gossh.HostKeyCallback) (*gossh.ClientConfig, error) { 303 | config := gossh.ClientConfig{ 304 | User: host.Username(), 305 | HostKeyCallback: hk, 306 | Auth: []gossh.AuthMethod{}, 307 | } 308 | if host.SSHKey != nil { 309 | signer, err := gossh.ParsePrivateKey([]byte(host.SSHKey.PrivKey)) 310 | if err != nil { 311 | return nil, err 312 | } 313 | config.Auth = append(config.Auth, gossh.PublicKeys(signer)) 314 | } 315 | if host.Passwd() != "" { 316 | config.Auth = append(config.Auth, gossh.Password(host.Passwd())) 317 | } 318 | if len(config.Auth) == 0 { 319 | return nil, fmt.Errorf("no valid authentication method for host %q", host.Name) 320 | } 321 | return &config, nil 322 | } 323 | 324 | // SSHKey helpers 325 | 326 | func SSHKeysPreload(db *gorm.DB) *gorm.DB { 327 | return db.Preload("Hosts") 328 | } 329 | func SSHKeysByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 330 | return GenericNameOrID(db, identifiers) 331 | } 332 | 333 | // HostGroup helpers 334 | 335 | func HostGroupsPreload(db *gorm.DB) *gorm.DB { 336 | return db.Preload("ACLs").Preload("Hosts") 337 | } 338 | func HostGroupsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 339 | return GenericNameOrID(db, identifiers) 340 | } 341 | 342 | // UserGroup helpers 343 | 344 | func UserGroupsPreload(db *gorm.DB) *gorm.DB { 345 | return db.Preload("ACLs").Preload("Users") 346 | } 347 | func UserGroupsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 348 | return GenericNameOrID(db, identifiers) 349 | } 350 | 351 | // User helpers 352 | 353 | func UsersPreload(db *gorm.DB) *gorm.DB { 354 | return db.Preload("Groups").Preload("Keys").Preload("Roles") 355 | } 356 | func UsersByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 357 | var ids []string 358 | var names []string 359 | for _, s := range identifiers { 360 | if _, err := strconv.Atoi(s); err == nil { 361 | ids = append(ids, s) 362 | } else { 363 | names = append(names, s) 364 | } 365 | } 366 | if len(ids) > 0 && len(names) > 0 { 367 | db.Where("id IN (?)", identifiers).Or("email IN (?)", identifiers).Or("name IN (?)", identifiers) 368 | } else if len(ids) > 0 { 369 | return db.Where("id IN (?)", ids) 370 | } 371 | return db.Where("email IN (?)", identifiers).Or("name IN (?)", identifiers) 372 | } 373 | func (u *User) HasRole(name string) bool { 374 | for _, role := range u.Roles { 375 | if role.Name == name { 376 | return true 377 | } 378 | } 379 | return false 380 | } 381 | func (u *User) CheckRoles(names []string) error { 382 | for _, name := range names { 383 | if u.HasRole(name) { 384 | return nil 385 | } 386 | } 387 | return fmt.Errorf("you don't have permission to access this feature (requires any of these roles: '%s')", strings.Join(names, "', '")) 388 | } 389 | 390 | // ACL helpers 391 | 392 | func ACLsPreload(db *gorm.DB) *gorm.DB { 393 | return db.Preload("UserGroups").Preload("HostGroups") 394 | } 395 | func ACLsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 396 | return db.Where("id IN (?)", identifiers) 397 | } 398 | 399 | // UserKey helpers 400 | 401 | func UserKeysPreload(db *gorm.DB) *gorm.DB { 402 | return db.Preload("User") 403 | } 404 | func UserKeysByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 405 | return db.Where("id IN (?)", identifiers) 406 | } 407 | func UserKeysByUserID(db *gorm.DB, identifiers []string) *gorm.DB { 408 | return db.Where("user_id IN (?)", identifiers) 409 | } 410 | 411 | // UserRole helpers 412 | 413 | func UserRolesByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 414 | return GenericNameOrID(db, identifiers) 415 | } 416 | 417 | // Session helpers 418 | 419 | func SessionsPreload(db *gorm.DB) *gorm.DB { 420 | return db.Preload("User").Preload("Host") 421 | } 422 | func SessionsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 423 | return db.Where("id IN (?)", identifiers) 424 | } 425 | 426 | // Events helpers 427 | 428 | func EventsPreload(db *gorm.DB) *gorm.DB { 429 | return db.Preload("Author") 430 | } 431 | func EventsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB { 432 | return db.Where("id IN (?)", identifiers) 433 | } 434 | 435 | func NewEvent(domain, action string) *Event { 436 | return &Event{ 437 | Domain: domain, 438 | Action: action, 439 | ArgsMap: map[string]interface{}{}, 440 | } 441 | } 442 | 443 | func (e *Event) String() string { 444 | return fmt.Sprintf("%s %s %s %s", e.Domain, e.Action, e.Entity, string(e.Args)) 445 | } 446 | 447 | func (e *Event) Log(db *gorm.DB) { 448 | if len(e.ArgsMap) > 0 { 449 | var err error 450 | if e.Args, err = json.Marshal(e.ArgsMap); err != nil { 451 | log.Printf("error: %v", err) 452 | } 453 | } 454 | log.Printf("info: %s", e) 455 | if err := db.Create(e).Error; err != nil { 456 | log.Printf("warning: %v", err) 457 | } 458 | } 459 | 460 | func (e *Event) SetAuthor(user *User) *Event { 461 | e.AuthorID = user.ID 462 | return e 463 | } 464 | 465 | func (e *Event) SetArg(name string, value interface{}) *Event { 466 | e.ArgsMap[name] = value 467 | return e 468 | } 469 | -------------------------------------------------------------------------------- /pkg/dbmodels/validator.go: -------------------------------------------------------------------------------- 1 | package dbmodels 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/asaskevich/govalidator" 7 | ) 8 | 9 | func InitValidator() { 10 | unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*") 11 | 12 | govalidator.CustomTypeTagMap.Set("unix_user", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool { 13 | name, ok := i.(string) 14 | if !ok { 15 | return false 16 | } 17 | return unixUserRegexp.MatchString(name) 18 | })) 19 | govalidator.CustomTypeTagMap.Set("host_logging_mode", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool { 20 | name, ok := i.(string) 21 | if !ok { 22 | return false 23 | } 24 | if name == "" { 25 | return true 26 | } 27 | return IsValidHostLoggingMode(name) 28 | })) 29 | } 30 | 31 | func IsValidHostLoggingMode(name string) bool { 32 | return name == "disabled" || name == "input" || name == "everything" 33 | } 34 | -------------------------------------------------------------------------------- /pkg/utils/emailvalidator.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "regexp" 4 | 5 | var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 6 | 7 | // ValidateEmail validates email. 8 | func ValidateEmail(e string) bool { 9 | if len(e) < 3 || len(e) > 254 { 10 | return false 11 | } 12 | return emailRegex.MatchString(e) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/utils/emailvalidator_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "moul.io/sshportal/pkg/utils" 7 | ) 8 | 9 | func TestValidateEmail(t *testing.T) { 10 | tests := []struct { 11 | input string 12 | expected bool 13 | }{ 14 | {"goodemail@email.com", true}, 15 | {"b@2323.22", true}, 16 | {"b@2322.", false}, 17 | {"", false}, 18 | {"blah", false}, 19 | {"blah.com", false}, 20 | } 21 | 22 | for _, test := range tests { 23 | t.Run(test.input, func(t *testing.T) { 24 | got := utils.ValidateEmail(test.input) 25 | if got != test.expected { 26 | t.Errorf("expected %v, got %v", test.expected, got) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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.fmt 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 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "net" 8 | "os" 9 | "time" 10 | 11 | "gorm.io/driver/mysql" 12 | "gorm.io/driver/postgres" 13 | "gorm.io/driver/sqlite" 14 | "gorm.io/gorm" 15 | "gorm.io/gorm/logger" 16 | 17 | "moul.io/sshportal/pkg/bastion" 18 | 19 | "github.com/gliderlabs/ssh" 20 | "github.com/urfave/cli" 21 | gossh "golang.org/x/crypto/ssh" 22 | ) 23 | 24 | type serverConfig struct { 25 | aesKey string 26 | dbDriver, dbURL string 27 | logsLocation string 28 | bindAddr string 29 | debug, demo bool 30 | idleTimeout time.Duration 31 | aclCheckCmd string 32 | } 33 | 34 | func parseServerConfig(c *cli.Context) (*serverConfig, error) { 35 | ret := &serverConfig{ 36 | aesKey: c.String("aes-key"), 37 | dbDriver: c.String("db-driver"), 38 | dbURL: c.String("db-conn"), 39 | bindAddr: c.String("bind-address"), 40 | debug: c.Bool("debug"), 41 | demo: c.Bool("demo"), 42 | logsLocation: c.String("logs-location"), 43 | idleTimeout: c.Duration("idle-timeout"), 44 | aclCheckCmd: c.String("acl-check-cmd"), 45 | } 46 | switch len(ret.aesKey) { 47 | case 0, 16, 24, 32: 48 | default: 49 | return nil, fmt.Errorf("invalid aes key size, should be 16 or 24, 32") 50 | } 51 | return ret, nil 52 | } 53 | 54 | func ensureLogDirectory(location string) error { 55 | // check for the logdir existence 56 | logsLocation, err := os.Stat(location) 57 | if err != nil { 58 | if os.IsNotExist(err) { 59 | return os.MkdirAll(location, os.ModeDir|os.FileMode(0750)) 60 | } 61 | return err 62 | } 63 | if !logsLocation.IsDir() { 64 | return fmt.Errorf("log directory cannot be created") 65 | } 66 | return nil 67 | } 68 | 69 | func dbConnect(c *serverConfig, config gorm.Option) (*gorm.DB, error) { 70 | var dbOpen func(string) gorm.Dialector 71 | if c.dbDriver == "sqlite3" { 72 | dbOpen = sqlite.Open 73 | } 74 | if c.dbDriver == "postgres" { 75 | dbOpen = postgres.Open 76 | } 77 | 78 | if c.dbDriver == "mysql" { 79 | dbOpen = mysql.Open 80 | } 81 | return gorm.Open(dbOpen(c.dbURL), config) 82 | } 83 | 84 | func server(c *serverConfig) (err error) { 85 | // configure db logging 86 | 87 | db, _ := dbConnect(c, &gorm.Config{ 88 | Logger: logger.Default.LogMode(logger.Silent), 89 | }) 90 | sqlDB, err := db.DB() 91 | 92 | defer func() { 93 | origErr := err 94 | err = sqlDB.Close() 95 | if origErr != nil { 96 | err = origErr 97 | } 98 | }() 99 | 100 | if err = sqlDB.Ping(); err != nil { 101 | return 102 | } 103 | 104 | if err = bastion.DBInit(db); err != nil { 105 | return 106 | } 107 | 108 | // create TCP listening socket 109 | ln, err := net.Listen("tcp", c.bindAddr) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | // configure server 115 | srv := &ssh.Server{ 116 | Addr: c.bindAddr, 117 | Handler: func(s ssh.Session) { bastion.ShellHandler(s, GitTag, GitSha, GitTag) }, // ssh.Server.Handler is the handler for the DefaultSessionHandler 118 | Version: fmt.Sprintf("sshportal-%s", GitTag), 119 | ChannelHandlers: map[string]ssh.ChannelHandler{ 120 | "default": bastion.ChannelHandler, 121 | }, 122 | } 123 | 124 | // configure channel handler 125 | bastion.DefaultChannelHandler = func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { 126 | switch newChan.ChannelType() { 127 | case "session": 128 | go ssh.DefaultSessionHandler(srv, conn, newChan, ctx) 129 | case "direct-tcpip": 130 | go ssh.DirectTCPIPHandler(srv, conn, newChan, ctx) 131 | default: 132 | if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil { 133 | log.Printf("failed to reject chan: %v", err) 134 | } 135 | } 136 | } 137 | 138 | if c.idleTimeout != 0 { 139 | srv.IdleTimeout = c.idleTimeout 140 | // gliderlabs/ssh requires MaxTimeout to be non-zero if we want to use IdleTimeout. 141 | // So, set it to the max value, because we don't want a max timeout. 142 | srv.MaxTimeout = math.MaxInt64 143 | } 144 | 145 | for _, opt := range []ssh.Option{ 146 | // custom PublicKeyAuth handler 147 | ssh.PublicKeyAuth(bastion.PublicKeyAuthHandler(db, c.logsLocation, c.aclCheckCmd, c.aesKey, c.dbDriver, c.dbURL, c.bindAddr, c.demo)), 148 | ssh.PasswordAuth(bastion.PasswordAuthHandler(db, c.logsLocation, c.aclCheckCmd, c.aesKey, c.dbDriver, c.dbURL, c.bindAddr, c.demo)), 149 | // retrieve sshportal SSH private key from database 150 | bastion.PrivateKeyFromDB(db, c.aesKey), 151 | } { 152 | if err := srv.SetOption(opt); err != nil { 153 | return err 154 | } 155 | } 156 | 157 | log.Printf("info: SSH Server accepting connections on %s, idle-timout=%v", c.bindAddr, c.idleTimeout) 158 | return srv.Serve(ln) 159 | } 160 | -------------------------------------------------------------------------------- /testserver.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package main 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "log" 11 | "os/exec" 12 | "syscall" 13 | "unsafe" 14 | 15 | "github.com/gliderlabs/ssh" 16 | "github.com/kr/pty" 17 | "github.com/urfave/cli" 18 | ) 19 | 20 | // testServer is an hidden handler used for integration tests 21 | func testServer(c *cli.Context) error { 22 | ssh.Handle(func(s ssh.Session) { 23 | helloMsg := struct { 24 | User string 25 | Environ []string 26 | Command []string 27 | }{ 28 | User: s.User(), 29 | Environ: s.Environ(), 30 | Command: s.Command(), 31 | } 32 | 33 | if err := json.NewEncoder(s).Encode(&helloMsg); err != nil { 34 | log.Fatalf("failed to write helloMsg: %v", err) 35 | } 36 | cmd := exec.Command(s.Command()[0], s.Command()[1:]...) // #nosec 37 | if s.Command() == nil { 38 | cmd = exec.Command("/bin/sh") // #nosec 39 | } 40 | ptyReq, winCh, isPty := s.Pty() 41 | var cmdErr error 42 | if isPty { 43 | cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) 44 | f, err := pty.Start(cmd) 45 | if err != nil { 46 | fmt.Fprintf(s, "failed to run command: %v\n", err) // #nosec 47 | _ = s.Exit(1) // #nosec 48 | return 49 | } 50 | go func() { 51 | for win := range winCh { 52 | _, _, _ = syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ), 53 | uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(win.Height), uint16(win.Width), 0, 0}))) // #nosec 54 | } 55 | }() 56 | go func() { 57 | // stdin 58 | _, _ = io.Copy(f, s) // #nosec 59 | }() 60 | // stdout 61 | _, _ = io.Copy(s, f) // #nosec 62 | cmdErr = cmd.Wait() 63 | } else { 64 | // cmd.Stdin = s 65 | cmd.Stdout = s 66 | cmd.Stderr = s 67 | cmdErr = cmd.Run() 68 | } 69 | 70 | if cmdErr != nil { 71 | if exitError, ok := cmdErr.(*exec.ExitError); ok { 72 | _ = s.Exit(exitError.Sys().(syscall.WaitStatus).ExitStatus()) // #nosec 73 | return 74 | } 75 | } 76 | _ = s.Exit(cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus()) // #nosec 77 | }) 78 | 79 | log.Println("starting ssh server on port 2222...") 80 | return ssh.ListenAndServe(":2222", nil) 81 | } 82 | -------------------------------------------------------------------------------- /testserver_unsupported.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/urfave/cli" 9 | ) 10 | 11 | // testServer is an hidden handler used for integration tests 12 | func testServer(c *cli.Context) error { 13 | return fmt.Errorf("not available on windows") 14 | } 15 | --------------------------------------------------------------------------------