├── .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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 | [](https://circleci.com/gh/moul/sshportal)
4 | [](https://goreportcard.com/report/moul.io/sshportal)
5 | [](https://godoc.org/moul.io/sshportal)
6 | [](https://opencollective.com/sshportal) [](https://github.com/moul/sshportal/blob/master/LICENSE)
7 | [](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 | 
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 | 
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 | 
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 | 
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 | 
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 | [](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 |
--------------------------------------------------------------------------------