Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ce353c5d | ||
|
|
f7ed3a66f2 | ||
|
|
4e9c5205c7 | ||
|
|
63b4aa5533 | ||
|
|
868be6af11 | ||
|
|
a710e50b1e | ||
|
|
55010dcc09 | ||
|
|
9413b75dc8 | ||
|
|
2648418463 | ||
|
|
79cbaa3afe | ||
|
|
2def328f6a | ||
|
|
ab9c53f1b0 | ||
|
|
614418e7be | ||
|
|
a5bade8761 | ||
|
|
7404704bfe | ||
|
|
84a0a31eda | ||
|
|
40bbea590c | ||
|
|
e455d50db9 | ||
|
|
be3f215e24 | ||
|
|
c290253546 | ||
|
|
28a5fd1846 | ||
|
|
19605f0054 | ||
|
|
5b4332072c | ||
|
|
c1c4c556b4 | ||
|
|
3c32177213 | ||
|
|
762736d622 | ||
|
|
bbbc484fe8 | ||
|
|
e1602364c8 | ||
|
|
2540d1e861 | ||
|
|
177a198420 | ||
|
|
51612aab13 | ||
|
|
e20af1dde5 | ||
|
|
6caa1f1657 | ||
|
|
e0f76d15ec | ||
|
|
05225a4b25 | ||
|
|
bcc150727f | ||
|
|
9062417d13 | ||
|
|
baeade4043 | ||
|
|
b9552e98b5 | ||
|
|
715ccde829 | ||
|
|
f5dc1bd1b9 | ||
|
|
c79c50aeb6 | ||
|
|
df3542c6ee | ||
|
|
e40f5307a3 | ||
|
|
6e6045306b | ||
|
|
874467b1e6 | ||
|
|
5c1c559a9a | ||
|
|
6872c727ef | ||
|
|
cae996d041 | ||
|
|
a23b77282c | ||
|
|
24814c4152 | ||
|
|
07359988d0 | ||
|
|
db6eb63297 | ||
|
|
5fdb31b97d | ||
|
|
bce6b1998b | ||
|
|
f7fa60da97 | ||
|
|
d2cd6b64a3 | ||
|
|
1ef0cc8725 | ||
|
|
d894005c3f | ||
|
|
af7206d114 | ||
|
|
1f9d962cd6 | ||
|
|
460041c6e3 | ||
|
|
7068565ab1 | ||
|
|
74bd885c1d | ||
|
|
9317f206d1 | ||
|
|
6c3f803dc6 | ||
|
|
9c3d29eb83 | ||
|
|
e339a73931 | ||
|
|
0dcab1b380 | ||
|
|
032f802348 | ||
|
|
7fd9be9058 | ||
|
|
83b54aeeff | ||
|
|
2323d6fd1e | ||
|
|
4c947ce391 | ||
|
|
44559f0547 | ||
|
|
8234119cd4 | ||
|
|
7a75c13ac4 | ||
|
|
4b10131790 | ||
|
|
a29c6e8338 | ||
|
|
198e0717b5 | ||
|
|
d8fa2f6925 | ||
|
|
16c8c0092e | ||
|
|
b0dfff2d90 | ||
|
|
9d2badf253 | ||
|
|
428344da17 | ||
|
|
0c07ac790a | ||
|
|
365a37959a | ||
|
|
90fd6057cf | ||
|
|
4220f3fb89 | ||
|
|
3e2acfc992 | ||
|
|
9c464b2610 | ||
|
|
5760aece65 | ||
|
|
a24e20252a | ||
|
|
37a7fa1917 | ||
|
|
f1b28b0363 | ||
|
|
e43bb55e70 | ||
|
|
763ced7524 | ||
|
|
54128beb12 | ||
|
|
64ba179cc7 | ||
|
|
bbdb4851a5 | ||
|
|
63719ec00e | ||
|
|
0722497336 | ||
|
|
e74f7221b5 | ||
|
|
f4fc3a90bc | ||
|
|
df3aa6e165 | ||
|
|
986bcd7971 | ||
|
|
7f3ea431a1 | ||
|
|
dae0252857 | ||
|
|
33b8e5272c | ||
|
|
21e73757ac | ||
|
|
bcb5d3b7ef | ||
|
|
d2f3f460b2 | ||
|
|
e06fe6f5a3 | ||
|
|
fb9dabfe6b | ||
|
|
0e0cd8fed5 | ||
|
|
8959e1782f | ||
|
|
33151105e0 | ||
|
|
77b40eb9ed | ||
|
|
075dfd0aa7 | ||
|
|
5cf6b1c218 | ||
|
|
6527746a91 | ||
|
|
020ca9c6b3 | ||
|
|
8c7831480b | ||
|
|
e399dfd8e4 | ||
|
|
be83c7148d | ||
|
|
ce187e8675 | ||
|
|
f13ede4ba7 | ||
|
|
fb061ed419 | ||
|
|
b4a377f269 | ||
|
|
de6f37aa64 | ||
|
|
32219577b8 | ||
|
|
abc7329a71 | ||
|
|
675942e967 | ||
|
|
5b20cd501e | ||
|
|
b6aaf4d7cf | ||
|
|
972e232559 | ||
|
|
851a91b1a0 | ||
|
|
6a068dc430 | ||
|
|
2cdfcf60fe | ||
|
|
5d9e0c367a | ||
|
|
cbf8263033 | ||
|
|
846c73d9bc | ||
|
|
e0b43b1976 | ||
|
|
6a6e788968 | ||
|
|
4754cad42a | ||
|
|
db58e53f3b | ||
|
|
b31acb4348 | ||
|
|
c794c2c076 | ||
|
|
42d6cd44bb | ||
|
|
f9057ca56a | ||
|
|
c2f1999037 | ||
|
|
44b386f7a7 | ||
|
|
89b296db4e | ||
|
|
c16403fb3f | ||
|
|
5e21fb72e6 | ||
|
|
c5681bf880 | ||
|
|
db85d6545d | ||
|
|
9912c3deba | ||
|
|
fc5c342e40 | ||
|
|
60707b3faa | ||
|
|
f36845ac6b | ||
|
|
9f76bd6cad | ||
|
|
c53d5d9964 | ||
|
|
171d461ea5 | ||
|
|
b2b04a1155 | ||
|
|
671ba03b78 | ||
|
|
9095725778 | ||
|
|
8b2e5daba3 | ||
|
|
75b7a5f571 | ||
|
|
4b9e881ad0 | ||
|
|
59f8f52cca | ||
|
|
4adaf83fd3 | ||
|
|
84464a4ea6 | ||
|
|
cafac0b8b5 | ||
|
|
5346300a64 | ||
|
|
1d4554eabc | ||
|
|
50bdba8b70 | ||
|
|
8c785f6dea | ||
|
|
93e6abc9ba | ||
|
|
60d7c85c11 | ||
|
|
883bad2ee5 | ||
|
|
7d68e144f8 | ||
|
|
7f32e38cf8 | ||
|
|
43a96d1636 | ||
|
|
00e7d2e45d | ||
|
|
2e711c3591 | ||
|
|
5d147fc03b | ||
|
|
3dccefbbcb | ||
|
|
7c4995fa4a | ||
|
|
2b8f051414 | ||
|
|
4e17c81d63 | ||
|
|
8b4b677d6a | ||
|
|
47229bf473 | ||
|
|
ec5b567da9 | ||
|
|
03b59fae1c | ||
|
|
ede8b3ecf2 | ||
|
|
7ae90b9199 | ||
|
|
a651da451e | ||
|
|
f220af5c54 | ||
|
|
eebf987900 | ||
|
|
3d5101011f | ||
|
|
2cdc19dfdd | ||
|
|
d8a7b1e16c | ||
|
|
d7490d089c | ||
|
|
774c6c0f64 | ||
|
|
92d11c53de | ||
|
|
c509f65a27 | ||
|
|
20b9e839d3 | ||
|
|
6f4fb24cd0 | ||
|
|
23a89fe1de | ||
|
|
559df1f523 | ||
|
|
ad2b8ebc38 | ||
|
|
3824629d4d |
BIN
.assets/bastion.jpg
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
.assets/cluster-mysql.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 7.1 KiB |
BIN
.assets/demo.gif
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 171 KiB |
@@ -1,12 +1,12 @@
|
||||
digraph {
|
||||
rankdir=LR;
|
||||
layout=dot;
|
||||
node[shape=record];
|
||||
|
||||
start[label="ssh sshportal";color=blue;fontcolor=blue;fontsize=20];
|
||||
|
||||
node[shape=record;style=rounded;fontname="helvetica-bold"];
|
||||
graph[layout=dot;rankdir=LR;overlap=prism;splines=true;fontname="helvetica-bold"];
|
||||
edge[arrowhead=none;fontname="helvetica"];
|
||||
|
||||
start[label="\$\> ssh sshportal";color=blue;fontcolor=blue;fontsize=18];
|
||||
|
||||
subgraph cluster_sshportal {
|
||||
graph[fontsize=20;style=dashed;color=purple;fontcolor=purple];
|
||||
graph[fontsize=18;color=gray;fontcolor=black];
|
||||
label="sshportal";
|
||||
{
|
||||
node[color=darkorange;fontcolor=darkorange];
|
||||
@@ -17,25 +17,25 @@ digraph {
|
||||
}
|
||||
{
|
||||
node[color=darkgreen;fontcolor=darkgreen];
|
||||
builtin_shell[label="built-in shell"];
|
||||
ssh_proxy[label="SSH proxy"];
|
||||
learn_key[label="learn key"];
|
||||
builtin_shell[label="built-in\nconfig shell"];
|
||||
ssh_proxy[label="SSH proxy\nJump-Host"];
|
||||
learn_key[label="learn the\npub key"];
|
||||
}
|
||||
err_and_exit[label="error and exit";color=red;fontcolor=red];
|
||||
err_and_exit[label="\nerror\nand exit\n\n";color=red;fontcolor=red];
|
||||
{ rank=same; ssh_proxy; builtin_shell; learn_key; err_and_exit; }
|
||||
{ rank=same; known_user_key; unknown_user_key; }
|
||||
}
|
||||
|
||||
|
||||
subgraph cluster_hosts {
|
||||
label="your hosts";
|
||||
graph[fontsize=20;style=dashed;color=purple;fontcolor=purple];
|
||||
graph[fontsize=18;color=gray;fontcolor=black];
|
||||
node[color=blue;fontcolor=blue];
|
||||
|
||||
|
||||
host_1[label="root@host1"];
|
||||
host_2[label="user@host2:2222"];
|
||||
host_3[label="root@host3:1234"];
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
edge[color=blue];
|
||||
start -> known_user_key;
|
||||
@@ -53,13 +53,13 @@ digraph {
|
||||
{
|
||||
edge[color=darkorange;fontcolor=darkorange];
|
||||
known_user_key -> acl_manager[label="user matches an existing host"];
|
||||
unknown_user_key -> invite_manager[headlabel="user=invite:<token>"];
|
||||
unknown_user_key -> invite_manager[label="user=invite:<token>";labelloc=b];
|
||||
}
|
||||
{
|
||||
edge[color=red;fontcolor=red];
|
||||
known_user_key -> err_and_exit[label="invalid user"];
|
||||
acl_manager -> err_and_exit[label="unauthorized"];
|
||||
unknown_user_key -> err_and_exit[label="any other user"];
|
||||
invite_manager -> err_and_exit[label="invalid token"];
|
||||
unknown_user_key -> err_and_exit[label="any other user";constraint=false];
|
||||
invite_manager -> err_and_exit[label="invalid token";constraint=false];
|
||||
}
|
||||
}
|
||||
BIN
.assets/flow-diagram.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
BIN
.assets/overview.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,7 +1,7 @@
|
||||
defaults: &defaults
|
||||
working_directory: /go/src/moul.io/sshportal
|
||||
docker:
|
||||
- image: circleci/golang:1.11
|
||||
- image: circleci/golang:1.16.2
|
||||
environment:
|
||||
GO111MODULE: "on"
|
||||
|
||||
@@ -16,18 +16,6 @@ install_retry: &install_retry
|
||||
|
||||
version: 2
|
||||
jobs:
|
||||
go.build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- *install_retry
|
||||
- run: /tmp/retry -m 3 go mod download
|
||||
- run: /tmp/retry -m 3 go mod vendor
|
||||
- run: /tmp/retry -m 3 make install
|
||||
- run: GO111MODULE=off /tmp/retry -m 3 go test -v ./...
|
||||
- run: /tmp/retry -m 3 curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s v1.12.2
|
||||
- run: PATH=$PATH:$(pwd)/bin /tmp/retry -m 3 make lint
|
||||
|
||||
docker.integration:
|
||||
<<: *defaults
|
||||
steps:
|
||||
@@ -36,9 +24,10 @@ jobs:
|
||||
name: Install Docker Compose
|
||||
command: |
|
||||
umask 022
|
||||
curl -L https://github.com/docker/compose/releases/download/1.11.2/docker-compose-`uname -s`-`uname -m` > ~/docker-compose
|
||||
curl -L https://github.com/docker/compose/releases/download/1.11.4/docker-compose-`uname -s`-`uname -m` > ~/docker-compose
|
||||
- setup_remote_docker:
|
||||
docker_layer_caching: true
|
||||
version: 18.09.3 # https://github.com/golang/go/issues/40893
|
||||
- *install_retry
|
||||
- run: /tmp/retry -m 3 docker build -t moul/sshportal .
|
||||
- run: /tmp/retry -m 3 make integration
|
||||
@@ -48,6 +37,4 @@ workflows:
|
||||
version: 2
|
||||
build_and_integration:
|
||||
jobs:
|
||||
- go.build
|
||||
- docker.integration
|
||||
# requires: docker.build?
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# .git/ # should be kept for git-based versionning
|
||||
|
||||
examples/
|
||||
.circleci/
|
||||
.assets/
|
||||
|
||||
17
.gitattributes
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Collapse vendored and generated files on GitHub
|
||||
AUTHORS linguist-generated
|
||||
vendor/* linguist-vendored
|
||||
rules.mk linguist-vendored
|
||||
*/vendor/* linguist-vendored
|
||||
*.gen.* linguist-generated
|
||||
*.pb.go linguist-generated
|
||||
*.pb.gw.go linguist-generated
|
||||
go.sum linguist-generated
|
||||
go.mod linguist-generated
|
||||
gen.sum linguist-generated
|
||||
|
||||
# Reduce conflicts on markdown files
|
||||
*.md merge=union
|
||||
6
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
github: ["moul"]
|
||||
patreon: moul
|
||||
open_collective: sshportal
|
||||
custom:
|
||||
- "https://www.buymeacoffee.com/moul"
|
||||
- "https://manfred.life/donate"
|
||||
30
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,25 +1,15 @@
|
||||
<!-- Thanks for filling an issue!
|
||||
### Actual Result / Problem
|
||||
|
||||
If this is a BUG REPORT, please:
|
||||
- Fill in as much of the template below as you can
|
||||
When I do Foo, Bar happens...
|
||||
|
||||
If this is a FEATURE REQUEST, please:
|
||||
- Describe *in detail* the feature/behavior/change you would like to see
|
||||
-->
|
||||
### Expected Result / Suggestion
|
||||
|
||||
**What happened**:
|
||||
I expect that Foobar happens...
|
||||
|
||||
**What you expected to happen**:
|
||||
### Some context
|
||||
|
||||
**How to reproduce it (as minimally and precisely as possible)**:
|
||||
|
||||
**Anything else we need to know?**:
|
||||
|
||||
<!--
|
||||
**Environment**:
|
||||
- sshportal --version
|
||||
- ssh sshportal info
|
||||
- OS (e.g. from /etc/os-release):
|
||||
- install method (e.g. go/docker/brew/...):
|
||||
- others:
|
||||
-->
|
||||
Any screenshot to share?
|
||||
`sshportal --version`?
|
||||
`ssh sshportal info`?
|
||||
OS/Go version?
|
||||
...
|
||||
|
||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,7 +1 @@
|
||||
<!-- Thanks for sending a pull request! Here are some tips for you -->
|
||||
|
||||
**What this PR does / why we need it**:
|
||||
|
||||
**Which issue this PR fixes**: fixes #xxx, fixes #xxx...
|
||||
|
||||
**Special notes for your reviewer**:
|
||||
<!-- thank you for your contribution! ❤️ -->
|
||||
|
||||
20
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
7
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base"
|
||||
],
|
||||
"groupName": "all",
|
||||
"gomodTidy": true
|
||||
}
|
||||
87
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build the Docker image
|
||||
run: docker build . --file Dockerfile
|
||||
golangci-lint:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: lint
|
||||
uses: golangci/golangci-lint-action@v2.5.1
|
||||
with:
|
||||
version: v1.38
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tests-on-windows:
|
||||
needs: golangci-lint # run after golangci-lint action to not produce duplicated errors
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
golang:
|
||||
- 1.16.x
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.golang }}
|
||||
- name: Run tests on Windows
|
||||
run: make.exe unittest
|
||||
continue-on-error: true
|
||||
tests-on-mac:
|
||||
needs: golangci-lint # run after golangci-lint action to not produce duplicated errors
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
golang:
|
||||
- 1.16.x
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.golang }}
|
||||
- uses: actions/cache@v2.1.5
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ matrix.golang }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ matrix.golang }}-
|
||||
- name: Run tests on Unix-like operating systems
|
||||
run: make unittest
|
||||
tests-on-linux:
|
||||
needs: golangci-lint # run after golangci-lint action to not produce duplicated errors
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
golang:
|
||||
- 1.13.x
|
||||
- 1.14.x
|
||||
- 1.15.x
|
||||
- 1.16.x
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.golang }}
|
||||
- uses: actions/cache@v2.1.5
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ matrix.golang }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ matrix.golang }}-
|
||||
- name: Run tests on Unix-like operating systems
|
||||
run: make unittest
|
||||
13
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: Semantic Release
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
semantic-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: codfish/semantic-release-action@v1
|
||||
if: github.ref == 'refs/heads/master'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
10
.gitignore
vendored
@@ -1,4 +1,12 @@
|
||||
coverage.txt
|
||||
dist/
|
||||
*~
|
||||
*#
|
||||
.*#
|
||||
.DS_Store
|
||||
/log/
|
||||
/sshportal
|
||||
*.db
|
||||
/data
|
||||
/data
|
||||
sshportal.history
|
||||
.idea
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
run:
|
||||
deadline: 1m
|
||||
tests: false
|
||||
#skip-files:
|
||||
# - ".*\\.gen\\.go"
|
||||
skip-files:
|
||||
- "testing.go"
|
||||
- ".*\\.pb\\.go"
|
||||
- ".*\\.gen\\.go"
|
||||
|
||||
linters-settings:
|
||||
golint:
|
||||
@@ -18,17 +20,36 @@ linters-settings:
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- goconst
|
||||
- misspell
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- misspell
|
||||
- structcheck
|
||||
- depguard
|
||||
- dogsled
|
||||
#- dupl
|
||||
- errcheck
|
||||
- unused
|
||||
- varcheck
|
||||
- staticcheck
|
||||
- unconvert
|
||||
#- funlen
|
||||
- gochecknoinits
|
||||
#- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
#- gocyclo
|
||||
- gofmt
|
||||
- goimports
|
||||
- golint
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- interfacer
|
||||
#- maligned
|
||||
- misspell
|
||||
- nakedret
|
||||
- prealloc
|
||||
- scopelint
|
||||
- staticcheck
|
||||
- structcheck
|
||||
#- stylecheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
|
||||
29
.goreleaser.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
builds:
|
||||
-
|
||||
goos: [linux, darwin]
|
||||
goarch: [386, amd64, arm, arm64]
|
||||
ldflags:
|
||||
- -s -w -X main.GitSha={{.ShortCommit}} -X main.GitBranch=master -X main.GitTag={{.Version}}
|
||||
archives:
|
||||
- wrap_in_directory: true
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
brews:
|
||||
-
|
||||
name: sshportal
|
||||
github:
|
||||
owner: moul
|
||||
name: homebrew-moul
|
||||
commit_author:
|
||||
name: moul-bot
|
||||
email: "m+bot@42.am"
|
||||
homepage: https://manfred.life/sshportal
|
||||
description: "Simple, fun and transparent SSH (and telnet) bastion"
|
||||
8
.releaserc.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
branch: 'master',
|
||||
plugins: [
|
||||
'@semantic-release/commit-analyzer',
|
||||
'@semantic-release/release-notes-generator',
|
||||
'@semantic-release/github',
|
||||
],
|
||||
};
|
||||
38
AUTHORS
generated
Normal file
@@ -0,0 +1,38 @@
|
||||
# This file lists all individuals having contributed content to the repository.
|
||||
# For how it is generated, see 'https://github.com/moul/rules.mk'
|
||||
|
||||
ahh <ahamidullah@gmail.com>
|
||||
Alen Masic <alenn.masic@gmail.com>
|
||||
Alexander Turner <me@alexturner.co>
|
||||
bozzo <bozzo@users.noreply.github.com>
|
||||
Darko Djalevski <darko.djalevski@inplayer.com>
|
||||
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
|
||||
fossabot <badges@fossa.io>
|
||||
ImgBotApp <ImgBotHelp@gmail.com>
|
||||
Jason Wessel <jason.wessel@windriver.com>
|
||||
Jean-Louis Férey <jeanlouis.ferey@orange.com>
|
||||
jerard@alfa-safety.fr <jrrdev@users.noreply.github.com>
|
||||
Jess <jessachandler@gmail.com>
|
||||
Jonathan Lestrelin <jonathan.lestrelin@gmail.com>
|
||||
Julien Dessaux <julien.dessaux@adyxax.org>
|
||||
Konstantin Bakaras <k.bakaras@voskhod.ru>
|
||||
Manfred Touron <94029+moul@users.noreply.github.com>
|
||||
Manfred Touron <m@42.am>
|
||||
Manuel <manuel.sabban@nbs-system.com>
|
||||
Manuel Sabban <manu@sabban.eu>
|
||||
Manuel Sabban <msa@nbs-system.com>
|
||||
Mathieu Pasquet <mathieu.pasquet@alterway.fr>
|
||||
matteyeux <matteyeux@users.noreply.github.com>
|
||||
Mikael Rapp <micke.rapp@gmail.com>
|
||||
MitaliBo <mitali.bisht14@gmail.com>
|
||||
moul-bot <bot@moul.io>
|
||||
Nelly Asher <karmelylle@rambler.ru>
|
||||
NocFlame <aad@nocflame.se>
|
||||
Quentin Perez <qperez42@gmail.com>
|
||||
Renovate Bot <bot@renovateapp.com>
|
||||
Sergey Yashchuk <sergey.yashchuk@coins.ph>
|
||||
Shawn Wang <shawn111@gmail.com>
|
||||
Valentin Daviot <valentin.daviot@alterway.fr>
|
||||
valentin.daviot <valentin.daviot@alterway.fr>
|
||||
welderpb <welderpb@users.noreply.github.com>
|
||||
Дмитрий Шульгачик <tech@uniplug.ru>
|
||||
102
CHANGELOG.md
@@ -1,103 +1,3 @@
|
||||
# Changelog
|
||||
|
||||
## v1.9.0 (2018-11-18)
|
||||
|
||||
* Add `hostgroup update` and `usergroup update` commands ([#58](https://github.com/moul/sshportal/pull/58)) by [@adyxax](https://github.com/adyxax)
|
||||
* Add socket timeout ([#80](https://github.com/moul/sshportal/pull/80)) by [@ahhx](https://github.com/ahhx)
|
||||
* Add a flag to list only active sessions ([#76](https://github.com/moul/sshportal/pull/76)) by [@vdaviot](https://github.com/vdaviot)
|
||||
* Unset hop on host ([#74](https://github.com/moul/sshportal/pull/74)) by [@vdaviot](https://github.com/vdaviot)
|
||||
* Fix session status and duration display ([#75](https://github.com/moul/sshportal/pull/75)) by [@vdaviot](https://github.com/vdaviot)
|
||||
* Fix log path and filename on Windows ([#78](https://github.com/moul/sshportal/pull/78)) by [@Raerten](https://github.com/Raerten)
|
||||
* Admin user is not editable ([#69](https://github.com/moul/sshportal/pull/69)) by [@alenn-m](https://github.com/alenn-m)
|
||||
* Switch to go modules (go1.11) ([#83](https://github.com/moul/sshportal/pull/83))
|
||||
* Switch to moul.io/sshportal canonical URL ([#86](https://github.com/moul/sshportal/pull/86))
|
||||
* Switch to golangci-lint ([#87](https://github.com/moul/sshportal/pull/87))
|
||||
|
||||
## v1.8.0 (2018-04-02)
|
||||
|
||||
* The default created user now has the same username as the user starting sshportal (was hardcoded "admin")
|
||||
* Add Telnet support
|
||||
* Add TTY audit feature ([#23](https://github.com/moul/sshportal/issues/23)) by [@sabban](https://github.com/sabban)
|
||||
* Fix `--assign-*` commands when using MySQL driver ([#45](https://github.com/moul/sshportal/issues/45))
|
||||
* Add *HOP* support, an efficient and integrated way of using a jump host transparently ([#47](https://github.com/moul/sshportal/issues/47)) by [@mathieui](https://github.com/mathieui)
|
||||
* Fix panic on some `ls` commands ([#54](https://github.com/moul/sshportal/pull/54)) by [@jle64](https://github.com/jle64)
|
||||
* Add tunnels (`direct-tcp`) support with logging ([#44](https://github.com/moul/sshportal/issues/44)) by [@sabban](https://github.com/sabban)
|
||||
* Add `key import` command ([#52](https://github.com/moul/sshportal/issues/52)) by [@adyxax](https://github.com/adyxax)
|
||||
* Add 'exec' logging ([#40](https://github.com/moul/sshportal/issues/40)) by [@sabban](https://github.com/sabban)
|
||||
|
||||
## v1.7.1 (2018-01-03)
|
||||
|
||||
* Return non-null exit-code on authentication error
|
||||
* **hotfix**: repair invite system (broken in v1.7.0)
|
||||
|
||||
## v1.7.0 (2018-01-02)
|
||||
|
||||
Breaking changes:
|
||||
* Use `sshportal server` instead of `sshportal` to start a new server (nothing to change if using the docker image)
|
||||
* Remove `--config-user` and `--healthcheck-user` global options
|
||||
|
||||
Changes:
|
||||
* Fix connection failure when sending too many environment variables (fix [#22](https://github.com/moul/sshportal/issues/22))
|
||||
* Fix panic when entering empty command (fix [#13](https://github.com/moul/sshportal/issues/13))
|
||||
* Add `config backup --ignore-events` option
|
||||
* Add `sshportal healthcheck [--addr=] [--wait] [--quiet]` cli command
|
||||
* Add [Docker Healthcheck](https://docs.docker.com/engine/reference/builder/#healthcheck) helper
|
||||
* Support Putty (fix [#24](https://github.com/moul/sshportal/issues/24))
|
||||
|
||||
## v1.6.0 (2017-12-12)
|
||||
|
||||
* Add `--latest` and `--quiet` options to `ls` commands
|
||||
* Add `healthcheck` user
|
||||
* Add `key show KEY` command
|
||||
|
||||
## v1.5.0 (2017-12-02)
|
||||
|
||||
* Create Session objects on each connections (history)
|
||||
* Connection history
|
||||
* Audit log
|
||||
* Add dynamic strict host key checking (learning on the first time, strict on the next ones)
|
||||
* Add-back MySQL support (experimental)
|
||||
* Fix some backup/restore bugs
|
||||
|
||||
## v1.4.0 (2017-11-24)
|
||||
|
||||
* Add 'key setup' command (easy SSH key installation)
|
||||
* Add Updated and Created fields in 'ls' commands
|
||||
* Add `--aes-key` option to encrypt sensitive data
|
||||
|
||||
## v1.3.0 (2017-11-23)
|
||||
|
||||
* More details in 'ls' commands
|
||||
* Add 'host update' command (fix [#2](https://github.com/moul/sshportal/issues/2))
|
||||
* Add 'user update' command (fix [#3](https://github.com/moul/sshportal/issues/3))
|
||||
* Add 'acl update' command (fix [#4](https://github.com/moul/sshportal/issues/4))
|
||||
* Allow connecting to the shell mode with the registered username or email (fix [#5](https://github.com/moul/sshportal/issues/5))
|
||||
* Add 'listhosts' role (fix [#5](https://github.com/moul/sshportal/issues/5))
|
||||
|
||||
## v1.2.0 (2017-11-22)
|
||||
|
||||
* Support adding multiple `--group` links on `host create` and `user create`
|
||||
* Use govalidator to perform more consistent input validation
|
||||
* Use a database migration system
|
||||
|
||||
## v1.1.0 (2017-11-15)
|
||||
|
||||
* Improve versionning (static VERSION + dynamic GIT_* info)
|
||||
* Configuration management (backup + restore)
|
||||
* Implement Exit (fix [#6](https://github.com/moul/sshportal/pull/6))
|
||||
* Disable mysql support (not fully working right now)
|
||||
* Set random seed properly
|
||||
|
||||
## v1.0.0 (2017-11-14)
|
||||
|
||||
Initial version
|
||||
|
||||
* Host management
|
||||
* User management
|
||||
* User Group management
|
||||
* Host Group management
|
||||
* Host Key management
|
||||
* User Key management
|
||||
* ACL management
|
||||
* Connect to host using key or password
|
||||
* Admin commands can be run directly or in an interactive shell
|
||||
Here: https://github.com/moul/sshportal/releases
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# build
|
||||
FROM golang:1.11 as builder
|
||||
COPY . /go/src/moul.io/sshportal
|
||||
FROM golang:1.16.2 as builder
|
||||
ENV GO111MODULE=on
|
||||
WORKDIR /go/src/moul.io/sshportal
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . ./
|
||||
RUN make _docker_install
|
||||
|
||||
# minimal runtime
|
||||
|
||||
2
LICENSE
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2017 Manfred Touron <m@42.am>
|
||||
Copyright 2017-2021 Manfred Touron <m@42.am>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
48
Makefile
@@ -1,18 +1,16 @@
|
||||
GIT_SHA ?= $(shell git rev-parse HEAD)
|
||||
GIT_TAG ?= $(shell git describe --tags --always)
|
||||
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
LDFLAGS ?= -X main.GitSha=$(GIT_SHA) -X main.GitTag=$(GIT_TAG) -X main.GitBranch=$(GIT_BRANCH)
|
||||
VERSION ?= $(shell grep 'VERSION =' main.go | cut -d'"' -f2)
|
||||
GOPKG ?= moul.io/sshportal
|
||||
GOBINS ?= .
|
||||
DOCKER_IMAGE ?= moul/sshportal
|
||||
|
||||
VERSION ?= `git describe --tags --always`
|
||||
VCS_REF ?= `git rev-parse --short HEAD`
|
||||
GO_INSTALL_OPTS = -ldflags="-X main.GitSha=$(VCS_REF) -X main.GitTag=$(VERSION)"
|
||||
PORT ?= 2222
|
||||
|
||||
include rules.mk
|
||||
|
||||
DB_VERSION ?= v$(shell grep -E 'ID: "[0-9]+",' pkg/bastion/dbinit.go | tail -n 1 | cut -d'"' -f2)
|
||||
AES_KEY ?= my-dummy-aes-key
|
||||
GO ?= GO111MODULE=on go
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
$(GO) install -v -ldflags '$(LDFLAGS)' .
|
||||
|
||||
.PHONY: docker.build
|
||||
docker.build:
|
||||
docker build -t moul/sshportal .
|
||||
|
||||
.PHONY: integration
|
||||
integration:
|
||||
@@ -27,21 +25,23 @@ dev:
|
||||
-$(GO) get github.com/githubnemo/CompileDaemon
|
||||
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal server --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" .
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
$(GO) test -i ./...
|
||||
$(GO) test -v ./...
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run --verbose ./...
|
||||
|
||||
.PHONY: backup
|
||||
backup:
|
||||
mkdir -p data/backups
|
||||
cp sshportal.db data/backups/$(shell date +%s)-$(VERSION)-sshportal.sqlite
|
||||
cp sshportal.db data/backups/$(shell date +%s)-$(DB_VERSION)-sshportal.sqlite
|
||||
|
||||
doc:
|
||||
dot -Tsvg ./.assets/overview.dot > ./.assets/overview.svg
|
||||
dot -Tsvg ./.assets/cluster-mysql.dot > ./.assets/cluster-mysql.svg
|
||||
dot -Tsvg ./.assets/flow-diagram.dot > ./.assets/flow-diagram.svg
|
||||
dot -Tpng ./.assets/overview.dot > ./.assets/overview.png
|
||||
dot -Tpng ./.assets/cluster-mysql.dot > ./.assets/cluster-mysql.png
|
||||
dot -Tpng ./.assets/flow-diagram.dot > ./.assets/flow-diagram.png
|
||||
|
||||
.PHONY: goreleaser
|
||||
goreleaser:
|
||||
GORELEASER_GITHUB_TOKEN=$(GORELEASER_GITHUB_TOKEN) GITHUB_TOKEN=$(GITHUB_TOKEN) goreleaser --rm-dist
|
||||
|
||||
.PHONY: goreleaser-dry-run
|
||||
goreleaser-dry-run:
|
||||
goreleaser --snapshot --skip-publish --rm-dist
|
||||
|
||||
436
README.md
@@ -1,65 +1,45 @@
|
||||
# sshportal
|
||||
|
||||
[](https://circleci.com/gh/moul/sshportal)
|
||||
[](https://hub.docker.com/r/moul/sshportal/)
|
||||
[](https://goreportcard.com/report/moul.io/sshportal)
|
||||
[](https://goreportcard.com/report/moul.io/sshportal)
|
||||
[](https://godoc.org/moul.io/sshportal)
|
||||
[](https://github.com/moul/sshportal/blob/master/LICENSE)
|
||||
[](https://opencollective.com/sshportal) [](https://github.com/moul/sshportal/blob/master/LICENSE)
|
||||
[](https://github.com/moul/sshportal/releases)
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2Fmoul%2Fsshportal?ref=badge_shield)
|
||||
<!-- temporarily broken? [](https://hub.docker.com/r/moul/sshportal/) -->
|
||||
|
||||
Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
|
||||
|
||||

|
||||
<img src="https://raw.githubusercontent.com/moul/sshportal/master/.assets/bastion.jpg" width="50%">
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## Contents
|
||||
|
||||

|
||||
<!-- toc -->
|
||||
|
||||
## Features
|
||||
- [Installation and usage](#installation-and-usage)
|
||||
- [Use cases](#use-cases)
|
||||
- [Features and limitations](#features-and-limitations)
|
||||
- [Docker](#docker)
|
||||
- [Manual Install](#manual-install)
|
||||
- [Backup / Restore](#backup--restore)
|
||||
- [built-in shell](#built-in-shell)
|
||||
- [Demo data](#demo-data)
|
||||
- [Shell commands](#shell-commands)
|
||||
- [Healthcheck](#healthcheck)
|
||||
- [portal alias (.ssh/config)](#portal-alias-sshconfig)
|
||||
- [Scaling](#scaling)
|
||||
- [Under the hood](#under-the-hood)
|
||||
|
||||
* Single autonomous binary (~10-20Mb) with no runtime dependencies (embeds ssh server and client)
|
||||
* Portable / Cross-platform (regularly tested on linux and OSX/darwin)
|
||||
* Store data in [Sqlite3](https://www.sqlite.org/) or [MySQL](https://www.mysql.com) (probably easy to add postgres, mssql thanks to gorm)
|
||||
* Stateless -> horizontally scalable when using [MySQL](https://www.mysql.com) as the backend
|
||||
* Connect to remote host using key or password
|
||||
* Admin commands can be run directly or in an interactive shell
|
||||
* Host management
|
||||
* User management (invite, group, stats)
|
||||
* Host Key management (create, remove, update, import)
|
||||
* Automatic remote host key learning
|
||||
* User Key management (multile keys per user)
|
||||
* ACL management (acl+user-groups+host-groups)
|
||||
* User roles (admin, trusted, standard, ...)
|
||||
* User invitations (no more "give me your public ssh key please")
|
||||
* Easy server installation (generate shell command to setup `authorized_keys`)
|
||||
* Sensitive data encryption
|
||||
* Session management (see active connections, history, stats, stop)
|
||||
* Audit log (logging every user action)
|
||||
* Record TTY Session
|
||||
* Tunnels logging
|
||||
* Host Keys verifications shared across users
|
||||
* Healthcheck user (replying OK to any user)
|
||||
* SSH compatibility
|
||||
* ipv4 and ipv6 support
|
||||
* [`scp`](https://linux.die.net/man/1/scp) support
|
||||
* [`rsync`](https://linux.die.net/man/1/rsync) support
|
||||
* [tunneling](https://www.ssh.com/ssh/tunneling/example) (local forward, remote forward, dynamic forward) support
|
||||
* [`sftp`](https://www.ssh.com/ssh/sftp/) support
|
||||
* [`ssh-agent`](https://www.ssh.com/ssh/agent) support
|
||||
* [`X11 forwarding`](http://en.tldp.org/HOWTO/XDMCP-HOWTO/ssh.html) support
|
||||
* Git support (can be used to easily use multiple user keys on GitHub, or access your own firewalled gitlab server)
|
||||
* Do not require any SSH client modification or custom `.ssh/config`, works with every tested SSH programming libraries and every tested SSH clients
|
||||
* SSH to non-SSH proxy
|
||||
* [Telnet](https://www.ssh.com/ssh/telnet) support
|
||||
<!-- tocstop -->
|
||||
|
||||
## (Known) limitations
|
||||
---
|
||||
|
||||
* Does not work (yet?) with [`mosh`](https://mosh.org/)
|
||||
|
||||
## Usage
|
||||
## Installation and usage
|
||||
|
||||
Start the server
|
||||
|
||||
@@ -80,6 +60,8 @@ Shared connection to localhost closed.
|
||||
$
|
||||
```
|
||||
|
||||
If the association fails and you are promted 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```
|
||||
|
||||
Drop an interactive administrator shell
|
||||
|
||||
```console
|
||||
@@ -139,15 +121,159 @@ To associate this account with a key, use the following SSH user: 'invite:NfHK5a
|
||||
config>
|
||||
```
|
||||
|
||||
## Flow Diagram
|
||||
Demo gif:
|
||||

|
||||
|
||||

|
||||
---
|
||||
|
||||
## Use cases
|
||||
|
||||
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.
|
||||
|
||||
*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.
|
||||
|
||||
There are companies who use a jump host to monitor connections at a single point.
|
||||
|
||||
A hosting company is using SSHportal for its “logging” feature, among the others. As every session is logged and introspectable, they have a detailed history of who performed which action. This company made its own contribution on the project, allowing the support of [more than 65.000 sessions in the database](https://github.com/moul/sshportal/pull/76).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Features and limitations
|
||||
|
||||
* Single autonomous binary (~10-20Mb) with no runtime dependencies (embeds ssh server and client)
|
||||
* Portable / Cross-platform (regularly tested on linux and OSX/darwin)
|
||||
* Store data in [Sqlite3](https://www.sqlite.org/) or [MySQL](https://www.mysql.com) (probably easy to add postgres, mssql thanks to gorm)
|
||||
* Stateless -> horizontally scalable when using [MySQL](https://www.mysql.com) as the backend
|
||||
* Connect to remote host using key or password
|
||||
* Admin commands can be run directly or in an interactive shell
|
||||
* Host management
|
||||
* User management (invite, group, stats)
|
||||
* Host Key management (create, remove, update, import)
|
||||
* Automatic remote host key learning
|
||||
* User Key management (multile keys per user)
|
||||
* ACL management (acl+user-groups+host-groups)
|
||||
* User roles (admin, trusted, standard, ...)
|
||||
* User invitations (no more "give me your public ssh key please")
|
||||
* Easy server installation (generate shell command to setup `authorized_keys`)
|
||||
* Sensitive data encryption
|
||||
* Session management (see active connections, history, stats, stop)
|
||||
* Audit log (logging every user action)
|
||||
* Record TTY Session (with [ttyrec](https://en.wikipedia.org/wiki/Ttyrec) format, use `ttyplay` for replay)
|
||||
* Tunnels logging
|
||||
* Host Keys verifications shared across users
|
||||
* Healthcheck user (replying OK to any user)
|
||||
* SSH compatibility
|
||||
* ipv4 and ipv6 support
|
||||
* [`scp`](https://linux.die.net/man/1/scp) support
|
||||
* [`rsync`](https://linux.die.net/man/1/rsync) support
|
||||
* [tunneling](https://www.ssh.com/ssh/tunneling/example) (local forward, remote forward, dynamic forward) support
|
||||
* [`sftp`](https://www.ssh.com/ssh/sftp/) support
|
||||
* [`ssh-agent`](https://www.ssh.com/ssh/agent) support
|
||||
* [`X11 forwarding`](http://en.tldp.org/HOWTO/XDMCP-HOWTO/ssh.html) support
|
||||
* Git support (can be used to easily use multiple user keys on GitHub, or access your own firewalled gitlab server)
|
||||
* Do not require any SSH client modification or custom `.ssh/config`, works with every tested SSH programming libraries and every tested SSH clients
|
||||
* SSH to non-SSH proxy
|
||||
* [Telnet](https://www.ssh.com/ssh/telnet) support
|
||||
|
||||
**(Known) limitations**
|
||||
|
||||
* Does not work (yet?) with [`mosh`](https://mosh.org/)
|
||||
* 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.
|
||||
* It is not possible access a host named `healthcheck` as this is a built in command.
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
Docker is the recommended way to run sshportal.
|
||||
|
||||
An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/sshportal/tags/).
|
||||
|
||||
```console
|
||||
# Start a server in background
|
||||
# mount `pwd` to persist the sqlite database file
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.10.0
|
||||
|
||||
# check logs (mandatory on first run to get the administrator invite token)
|
||||
docker logs -f sshportal
|
||||
```
|
||||
|
||||
The easier way to upgrade sshportal is to do the following:
|
||||
|
||||
```sh
|
||||
# we consider you were using an old version and you want to use the new version v1.10.0
|
||||
|
||||
# stop and rename the last working container + backup the database
|
||||
docker stop sshportal
|
||||
docker rename sshportal sshportal_old
|
||||
cp sshportal.db sshportal.db.bkp
|
||||
|
||||
# run the new version
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.10.0
|
||||
# check the logs for migration or cross-version incompabitility errors
|
||||
docker logs -f sshportal
|
||||
```
|
||||
|
||||
Now you can test ssh-ing to sshportal to check if everything looks OK.
|
||||
|
||||
In case of problem, you can rollback to the latest working version with the latest working backup, using:
|
||||
|
||||
```sh
|
||||
docker stop sshportal
|
||||
docker rm sshportal
|
||||
cp sshportal.db.bkp sshportal.db
|
||||
docker rename sshportal_old sshportal
|
||||
docker start sshportal
|
||||
docker logs -f sshportal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Install
|
||||
|
||||
Get the latest version using GO.
|
||||
|
||||
```sh
|
||||
GO111MODULE=on go get -u moul.io/sshportal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup / Restore
|
||||
|
||||
sshportal embeds built-in backup/restore methods which basically import/export JSON objects:
|
||||
|
||||
```sh
|
||||
# Backup
|
||||
ssh portal config backup > sshportal.bkp
|
||||
|
||||
# Restore
|
||||
ssh portal config restore < sshportal.bkp
|
||||
```
|
||||
|
||||
This method is particularly useful as it should be resistant against future DB schema changes (expected during development phase).
|
||||
|
||||
I suggest you to be careful during this development phase, and use an additional backup method, for example:
|
||||
|
||||
```sh
|
||||
# sqlite dump
|
||||
sqlite3 sshportal.db .dump > sshportal.sql.bkp
|
||||
|
||||
# or just the immortal cp
|
||||
cp sshportal.db sshportal.db.bkp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## built-in shell
|
||||
|
||||
`sshportal` embeds a configuration CLI.
|
||||
|
||||
By default, the configuration user is `admin`, (can be changed using `--config-user=<value>` when starting the server.
|
||||
By default, the configuration user is `admin`, (can be changed using `--config-user=<value>` when starting the server. The shell is also accessible through `ssh [username]@portal.example.org`.
|
||||
|
||||
Each commands can be run directly by using this syntax: `ssh admin@portal.example.org <command> [args]`:
|
||||
|
||||
@@ -157,7 +283,29 @@ ssh admin@portal.example.org host inspect toto
|
||||
|
||||
You can enter in interactive mode using this syntax: `ssh admin@portal.example.org`
|
||||
|
||||
### Synopsis
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Demo data
|
||||
|
||||
The following servers are freely available, without external registration,
|
||||
it makes it easier to quickly test `sshportal` without configuring your own servers to accept sshportal connections.
|
||||
|
||||
```
|
||||
ssh portal host create new@sdf.org
|
||||
ssh sdf@portal
|
||||
|
||||
ssh portal host create test@whoami.filippo.io
|
||||
ssh whoami@portal
|
||||
|
||||
ssh portal host create test@chat.shazow.net
|
||||
ssh chat@portal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell commands
|
||||
|
||||
```sh
|
||||
# acl management
|
||||
@@ -180,11 +328,11 @@ event inspect [-h] EVENT...
|
||||
|
||||
# host management
|
||||
host help
|
||||
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] [--hop=HOST] <username>[:<password>]@<host>[:<port>]
|
||||
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] [--hop=HOST] [--logging=MODE] <username>[:<password>]@<host>[:<port>]
|
||||
host inspect [-h] [--decrypt] HOST...
|
||||
host ls [-h] [--latest] [--quiet]
|
||||
host rm [-h] HOST...
|
||||
host update [-h] [--name=<value>] [--comment=<value>] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] [--set-hop=HOST] [--unset-hop] HOST...
|
||||
host update [-h] [--name=<value>] [--comment=<value>] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] [--logging-MODE] [--set-hop=HOST] [--unset-hop] HOST...
|
||||
|
||||
# hostgroup management
|
||||
hostgroup help
|
||||
@@ -218,7 +366,7 @@ user update [-h] [--name=<value>] [--email=<value>] [--set-admin] [--unset-admin
|
||||
|
||||
# usergroup management
|
||||
usergroup help
|
||||
hostgroup create [-h] [--name=<value>] [--comment=<value>]
|
||||
usergroup create [-h] [--name=<value>] [--comment=<value>]
|
||||
usergroup inspect [-h] USERGROUP...
|
||||
usergroup ls [-h] [--latest] [--quiet]
|
||||
usergroup rm [-h] USERGROUP...
|
||||
@@ -230,120 +378,7 @@ info [-h]
|
||||
version [-h]
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Docker is the recommended way to run sshportal.
|
||||
|
||||
An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/sshportal/tags/).
|
||||
|
||||
```console
|
||||
# Start a server in background
|
||||
# mount `pwd` to persist the sqlite database file
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.9.0
|
||||
|
||||
# check logs (mandatory on first run to get the administrator invite token)
|
||||
docker logs -f sshportal
|
||||
```
|
||||
|
||||
The easier way to upgrade sshportal is to do the following:
|
||||
|
||||
```sh
|
||||
# we consider you were using an old version and you want to use the new version v1.9.0
|
||||
|
||||
# stop and rename the last working container + backup the database
|
||||
docker stop sshportal
|
||||
docker rename sshportal sshportal_old
|
||||
cp sshportal.db sshportal.db.bkp
|
||||
|
||||
# run the new version
|
||||
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.9.0
|
||||
# check the logs for migration or cross-version incompabitility errors
|
||||
docker logs -f sshportal
|
||||
```
|
||||
|
||||
Now you can test ssh-ing to sshportal to check if everything looks OK.
|
||||
|
||||
In case of problem, you can rollback to the latest working version with the latest working backup, using:
|
||||
|
||||
```sh
|
||||
docker stop sshportal
|
||||
docker rm sshportal
|
||||
cp sshportal.db.bkp sshportal.db
|
||||
docker rename sshportal_old sshportal
|
||||
docker start sshportal
|
||||
docker logs -f sshportal
|
||||
```
|
||||
|
||||
## Manual Install
|
||||
|
||||
Get the latest version using GO.
|
||||
|
||||
```sh
|
||||
go get -u moul.io/sshportal
|
||||
```
|
||||
|
||||
## portal alias (.ssh/config)
|
||||
|
||||
Edit your `~/.ssh/config` file (create it first if needed)
|
||||
|
||||
```ini
|
||||
Host portal
|
||||
User admin
|
||||
Port 2222 # portal port
|
||||
HostName 127.0.0.1 # portal hostname
|
||||
```
|
||||
|
||||
```bash
|
||||
# you can now run a shell using this:
|
||||
ssh portal
|
||||
# instead of this:
|
||||
ssh localhost -p 2222 -l admin
|
||||
|
||||
# or connect to hosts using this:
|
||||
ssh hostname@portal
|
||||
# instead of this:
|
||||
ssh localhost -p 2222 -l hostname
|
||||
```
|
||||
|
||||
## Backup / Restore
|
||||
|
||||
sshportal embeds built-in backup/restore methods which basically import/export JSON objects:
|
||||
|
||||
```sh
|
||||
# Backup
|
||||
ssh portal config backup > sshportal.bkp
|
||||
|
||||
# Restore
|
||||
ssh portal config restore < sshportal.bkp
|
||||
```
|
||||
|
||||
This method is particularly useful as it should be resistant against future DB schema changes (expected during development phase).
|
||||
|
||||
I suggest you to be careful during this development phase, and use an additional backup method, for example:
|
||||
|
||||
```sh
|
||||
# sqlite dump
|
||||
sqlite3 sshportal.db .dump > sshportal.sql.bkp
|
||||
|
||||
# or just the immortal cp
|
||||
cp sshportal.db sshportal.db.bkp
|
||||
```
|
||||
|
||||
## Demo data
|
||||
|
||||
The following servers are freely available, without external registration,
|
||||
it makes it easier to quickly test `sshportal` without configuring your own servers to accept sshportal connections.
|
||||
|
||||
```
|
||||
ssh portal host create new@sdf.org
|
||||
ssh sdf@portal
|
||||
|
||||
ssh portal host create test@whoami.filippo.io
|
||||
ssh whoami@portal
|
||||
|
||||
ssh portal host create test@chat.shazow.net
|
||||
ssh chat@portal
|
||||
```
|
||||
---
|
||||
|
||||
## Healthcheck
|
||||
|
||||
@@ -377,6 +412,33 @@ $ sshportal healthcheck --wait && ssh sshportal -l admin
|
||||
config>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## portal alias (.ssh/config)
|
||||
|
||||
Edit your `~/.ssh/config` file (create it first if needed)
|
||||
|
||||
```ini
|
||||
Host portal
|
||||
User admin
|
||||
Port 2222 # portal port
|
||||
HostName 127.0.0.1 # portal hostname
|
||||
```
|
||||
|
||||
```bash
|
||||
# you can now run a shell using this:
|
||||
ssh portal
|
||||
# instead of this:
|
||||
ssh localhost -p 2222 -l admin
|
||||
|
||||
# or connect to hosts using this:
|
||||
ssh hostname@portal
|
||||
# instead of this:
|
||||
ssh localhost -p 2222 -l hostname
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scaling
|
||||
|
||||
`sshportal` is stateless but relies on a database to store configuration and logs.
|
||||
@@ -385,10 +447,12 @@ By default, `sshportal` uses a local [sqlite](https://www.sqlite.org/) database
|
||||
|
||||
You can run multiple instances of `sshportal` sharing a same [MySQL](https://www.mysql.com) database, using `sshportal --db-conn=user:pass@host/dbname?parseTime=true --db-driver=mysql`.
|
||||
|
||||

|
||||

|
||||
|
||||
See [examples/mysql](http://github.com/moul/sshportal/tree/master/examples/mysql).
|
||||
|
||||
---
|
||||
|
||||
## Under the hood
|
||||
|
||||
* Docker first (used in dev, tests, by the CI and in production)
|
||||
@@ -406,12 +470,38 @@ See [examples/mysql](http://github.com/moul/sshportal/tree/master/examples/mysql
|
||||
* https://github.com/mgutz/ansi: Terminal color helpers
|
||||
* https://github.com/urfave/cli: CLI flag parsing with subcommands support
|
||||
|
||||

|
||||

|
||||
|
||||
## Note
|
||||
## Contributors
|
||||
|
||||
This is totally experimental for now, so please file issues to let me know what you think about it!
|
||||
### Code Contributors
|
||||
|
||||
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
|
||||
<a href="https://github.com/moul/sshportal/graphs/contributors"><img src="https://opencollective.com/sshportal/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
## License
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2Fmoul%2Fsshportal?ref=badge_large)
|
||||
### Financial Contributors
|
||||
|
||||
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/sshportal/contribute)]
|
||||
|
||||
#### Individuals
|
||||
|
||||
<a href="https://opencollective.com/sshportal"><img src="https://opencollective.com/sshportal/individuals.svg?width=890"></a>
|
||||
|
||||
#### Organizations
|
||||
|
||||
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/sshportal/contribute)]
|
||||
|
||||
<a href="https://opencollective.com/sshportal/organization/0/website"><img src="https://opencollective.com/sshportal/organization/0/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/1/website"><img src="https://opencollective.com/sshportal/organization/1/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/2/website"><img src="https://opencollective.com/sshportal/organization/2/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/3/website"><img src="https://opencollective.com/sshportal/organization/3/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/4/website"><img src="https://opencollective.com/sshportal/organization/4/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/5/website"><img src="https://opencollective.com/sshportal/organization/5/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/6/website"><img src="https://opencollective.com/sshportal/organization/6/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/7/website"><img src="https://opencollective.com/sshportal/organization/7/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/8/website"><img src="https://opencollective.com/sshportal/organization/8/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/sshportal/organization/9/website"><img src="https://opencollective.com/sshportal/organization/9/avatar.svg"></a>
|
||||
|
||||
### Stargazers over time
|
||||
|
||||
[](https://starchart.cc/moul/sshportal)
|
||||
|
||||
1
_config.yml
Normal file
@@ -0,0 +1 @@
|
||||
theme: jekyll-theme-slate
|
||||
40
acl.go
@@ -1,40 +0,0 @@
|
||||
package main
|
||||
|
||||
import "sort"
|
||||
|
||||
type ByWeight []*ACL
|
||||
|
||||
func (a ByWeight) Len() int { return len(a) }
|
||||
func (a ByWeight) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a ByWeight) Less(i, j int) bool { return a[i].Weight < a[j].Weight }
|
||||
|
||||
func CheckACLs(user User, host Host) (string, error) {
|
||||
// shared ACLs between user and host
|
||||
aclMap := map[uint]*ACL{}
|
||||
for _, userGroup := range user.Groups {
|
||||
for _, userGroupACL := range userGroup.ACLs {
|
||||
for _, hostGroup := range host.Groups {
|
||||
for _, hostGroupACL := range hostGroup.ACLs {
|
||||
if userGroupACL.ID == hostGroupACL.ID {
|
||||
aclMap[userGroupACL.ID] = userGroupACL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// FIXME: add ACLs that match host pattern
|
||||
|
||||
// deny by default if no shared ACL
|
||||
if len(aclMap) == 0 {
|
||||
return string(ACLActionDeny), nil // default action
|
||||
}
|
||||
|
||||
// transform map to slice and sort it
|
||||
acls := make([]*ACL, 0, len(aclMap))
|
||||
for _, acl := range aclMap {
|
||||
acls = append(acls, acl)
|
||||
}
|
||||
sort.Sort(ByWeight(acls))
|
||||
|
||||
return acls[0].Action, nil
|
||||
}
|
||||
47
acl_test.go
@@ -1,47 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestCheckACLs(t *testing.T) {
|
||||
Convey("Testing CheckACLs", t, func() {
|
||||
// create tmp dir
|
||||
tempDir, err := ioutil.TempDir("", "sshportal")
|
||||
So(err, ShouldBeNil)
|
||||
defer func() {
|
||||
So(os.RemoveAll(tempDir), ShouldBeNil)
|
||||
}()
|
||||
|
||||
// create sqlite db
|
||||
db, err := gorm.Open("sqlite3", filepath.Join(tempDir, "sshportal.db"))
|
||||
So(err, ShouldBeNil)
|
||||
db.LogMode(false)
|
||||
So(dbInit(db), ShouldBeNil)
|
||||
|
||||
// create dummy objects
|
||||
var hostGroup HostGroup
|
||||
err = HostGroupsByIdentifiers(db, []string{"default"}).First(&hostGroup).Error
|
||||
So(err, ShouldBeNil)
|
||||
db.Create(&Host{Groups: []*HostGroup{&hostGroup}})
|
||||
|
||||
//. load db
|
||||
var (
|
||||
hosts []Host
|
||||
users []User
|
||||
)
|
||||
db.Preload("Groups").Preload("Groups.ACLs").Find(&hosts)
|
||||
db.Preload("Groups").Preload("Groups.ACLs").Find(&users)
|
||||
|
||||
// test
|
||||
action, err := CheckACLs(users[0], hosts[0])
|
||||
So(err, ShouldBeNil)
|
||||
So(action, ShouldEqual, ACLActionAllow)
|
||||
})
|
||||
}
|
||||
52
config.go
@@ -1,52 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
type configServe struct {
|
||||
aesKey string
|
||||
dbDriver, dbURL string
|
||||
logsLocation string
|
||||
bindAddr string
|
||||
debug, demo bool
|
||||
idleTimeout time.Duration
|
||||
}
|
||||
|
||||
func parseServeConfig(c *cli.Context) (*configServe, error) {
|
||||
ret := &configServe{
|
||||
aesKey: c.String("aes-key"),
|
||||
dbDriver: c.String("db-driver"),
|
||||
dbURL: c.String("db-conn"),
|
||||
bindAddr: c.String("bind-address"),
|
||||
debug: c.Bool("debug"),
|
||||
demo: c.Bool("demo"),
|
||||
logsLocation: c.String("logs-location"),
|
||||
idleTimeout: c.Duration("idle-timeout"),
|
||||
}
|
||||
switch len(ret.aesKey) {
|
||||
case 0, 16, 24, 32:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid aes key size, should be 16 or 24, 32")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func ensureLogDirectory(location string) error {
|
||||
// check for the logdir existence
|
||||
logsLocation, err := os.Stat(location)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.MkdirAll(location, os.ModeDir|os.FileMode(0750))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !logsLocation.IsDir() {
|
||||
return fmt.Errorf("log directory cannot be created")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
128
depaware.txt
Normal file
@@ -0,0 +1,128 @@
|
||||
moul.io/sshportal dependencies: (generated by github.com/tailscale/depaware)
|
||||
|
||||
github.com/anmitsu/go-shlex from github.com/gliderlabs/ssh+
|
||||
github.com/asaskevich/govalidator from moul.io/sshportal/pkg/bastion+
|
||||
github.com/cpuguy83/go-md2man/v2/md2man from github.com/urfave/cli
|
||||
LD 💣 github.com/creack/pty from github.com/kr/pty
|
||||
github.com/docker/docker/pkg/namesgenerator from moul.io/sshportal/pkg/bastion
|
||||
github.com/docker/docker/pkg/random from github.com/docker/docker/pkg/namesgenerator
|
||||
github.com/dustin/go-humanize from moul.io/sshportal/pkg/bastion
|
||||
github.com/gliderlabs/ssh from moul.io/sshportal+
|
||||
github.com/go-sql-driver/mysql from github.com/jinzhu/gorm/dialects/mysql+
|
||||
github.com/jinzhu/gorm from gopkg.in/gormigrate.v1+
|
||||
github.com/jinzhu/gorm/dialects/mysql from moul.io/sshportal
|
||||
github.com/jinzhu/gorm/dialects/postgres from moul.io/sshportal
|
||||
github.com/jinzhu/gorm/dialects/sqlite from moul.io/sshportal
|
||||
github.com/jinzhu/inflection from github.com/jinzhu/gorm
|
||||
LD github.com/kr/pty from moul.io/sshportal
|
||||
github.com/lib/pq from github.com/jinzhu/gorm/dialects/postgres
|
||||
github.com/lib/pq/hstore from github.com/jinzhu/gorm/dialects/postgres
|
||||
github.com/lib/pq/oid from github.com/lib/pq
|
||||
github.com/lib/pq/scram from github.com/lib/pq
|
||||
💣 github.com/mattn/go-colorable from github.com/mgutz/ansi
|
||||
💣 github.com/mattn/go-isatty from github.com/mattn/go-colorable
|
||||
github.com/mattn/go-runewidth from github.com/olekukonko/tablewriter
|
||||
💣 github.com/mattn/go-sqlite3 from github.com/jinzhu/gorm/dialects/sqlite
|
||||
github.com/mgutz/ansi from moul.io/sshportal/pkg/bastion
|
||||
github.com/olekukonko/tablewriter from moul.io/sshportal/pkg/bastion
|
||||
github.com/pkg/errors from moul.io/sshportal/pkg/bastion
|
||||
github.com/reiver/go-oi from github.com/reiver/go-telnet+
|
||||
github.com/reiver/go-telnet from moul.io/sshportal/pkg/bastion
|
||||
github.com/russross/blackfriday/v2 from github.com/cpuguy83/go-md2man/v2/md2man
|
||||
github.com/sabban/bastion/pkg/logchannel from moul.io/sshportal/pkg/bastion
|
||||
github.com/shurcooL/sanitized_anchor_name from github.com/russross/blackfriday/v2
|
||||
github.com/urfave/cli from moul.io/sshportal+
|
||||
gopkg.in/gormigrate.v1 from moul.io/sshportal/pkg/bastion
|
||||
moul.io/srand from moul.io/sshportal
|
||||
moul.io/sshportal/pkg/bastion from moul.io/sshportal
|
||||
moul.io/sshportal/pkg/crypto from moul.io/sshportal/pkg/bastion
|
||||
moul.io/sshportal/pkg/dbmodels from moul.io/sshportal/pkg/bastion+
|
||||
golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
|
||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+
|
||||
golang.org/x/crypto/chacha20poly1305 from crypto/tls
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from crypto/tls+
|
||||
golang.org/x/crypto/ed25519 from golang.org/x/crypto/ssh
|
||||
golang.org/x/crypto/hkdf from crypto/tls
|
||||
golang.org/x/crypto/poly1305 from golang.org/x/crypto/chacha20poly1305+
|
||||
golang.org/x/crypto/ssh from github.com/gliderlabs/ssh+
|
||||
golang.org/x/crypto/ssh/terminal from moul.io/sshportal/pkg/bastion
|
||||
golang.org/x/net/dns/dnsmessage from net
|
||||
D golang.org/x/net/route from net
|
||||
golang.org/x/sys/cpu from golang.org/x/crypto/chacha20poly1305
|
||||
LD golang.org/x/sys/unix from github.com/mattn/go-isatty+
|
||||
W golang.org/x/sys/windows from golang.org/x/crypto/ssh/terminal
|
||||
bufio from crypto/rand+
|
||||
bytes from bufio+
|
||||
container/list from crypto/tls
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdsa+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509+
|
||||
crypto/ecdsa from crypto/tls+
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls+
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/tls from github.com/go-sql-driver/mysql+
|
||||
crypto/x509 from crypto/tls+
|
||||
crypto/x509/pkix from crypto/x509
|
||||
database/sql from github.com/go-sql-driver/mysql+
|
||||
database/sql/driver from database/sql+
|
||||
encoding from encoding/json
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base64 from encoding/json+
|
||||
encoding/binary from crypto/aes+
|
||||
encoding/csv from github.com/olekukonko/tablewriter
|
||||
encoding/hex from crypto/x509+
|
||||
encoding/json from github.com/asaskevich/govalidator+
|
||||
encoding/pem from crypto/tls+
|
||||
errors from bufio+
|
||||
flag from github.com/urfave/cli
|
||||
fmt from crypto/tls+
|
||||
go/ast from github.com/jinzhu/gorm
|
||||
go/scanner from go/ast
|
||||
go/token from go/ast+
|
||||
hash from crypto+
|
||||
html from github.com/asaskevich/govalidator+
|
||||
io from bufio+
|
||||
io/fs from crypto/rand+
|
||||
io/ioutil from crypto/x509+
|
||||
log from github.com/gliderlabs/ssh+
|
||||
math from crypto/rsa+
|
||||
math/big from crypto/dsa+
|
||||
math/bits from crypto/md5+
|
||||
math/rand from github.com/docker/docker/pkg/random+
|
||||
net from crypto/tls+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
LD os/exec from github.com/creack/pty+
|
||||
os/user from github.com/lib/pq+
|
||||
path from github.com/asaskevich/govalidator+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from crypto/x509+
|
||||
regexp from github.com/asaskevich/govalidator+
|
||||
regexp/syntax from regexp
|
||||
sort from database/sql+
|
||||
strconv from crypto+
|
||||
strings from bufio+
|
||||
sync from context+
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
text/tabwriter from github.com/urfave/cli
|
||||
text/template from github.com/urfave/cli
|
||||
text/template/parse from text/template
|
||||
time from context+
|
||||
unicode from bytes+
|
||||
unicode/utf16 from encoding/asn1+
|
||||
unicode/utf8 from bufio+
|
||||
@@ -5,3 +5,6 @@ run:
|
||||
docker-compose exec sshportal /bin/sshportal healthcheck --wait --quiet
|
||||
docker-compose run client /integration/_client.sh
|
||||
docker-compose down
|
||||
|
||||
build:
|
||||
docker-compose build
|
||||
|
||||
@@ -28,7 +28,7 @@ ssh sshportal -l invite:integration
|
||||
ssh sshportal -l admin hostgroup create
|
||||
ssh sshportal -l admin hostgroup create --name=hg1
|
||||
ssh sshportal -l admin hostgroup create --name=hg2 --comment=test
|
||||
ssh sshportal -l admin usergroup inspect hg1 hg2
|
||||
ssh sshportal -l admin hostgroup inspect hg1 hg2
|
||||
ssh sshportal -l admin hostgroup ls
|
||||
|
||||
ssh sshportal -l admin usergroup create
|
||||
|
||||
@@ -4,12 +4,16 @@ services:
|
||||
sshportal:
|
||||
build: ../..
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SSHPORTAL_DB_DRIVER: mysql
|
||||
SSHPORTAL_DATABASE_URL: "root:root@tcp(mysql:3306)/db?charset=utf8&parseTime=true&loc=Local"
|
||||
SSHPORTAL_DEBUG: 1
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
links:
|
||||
- mysql
|
||||
command: server --db-driver=mysql --debug --db-conn="root:root@tcp(mysql:3306)/db?charset=utf8&parseTime=true&loc=Local"
|
||||
command: server
|
||||
ports:
|
||||
- 2222:2222
|
||||
|
||||
|
||||
57
go.mod
generated
@@ -1,43 +1,28 @@
|
||||
module moul.io/sshportal
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.33.1 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239
|
||||
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
||||
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef
|
||||
github.com/docker/docker v1.13.1
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
|
||||
github.com/gliderlabs/ssh v0.1.1 // indirect
|
||||
github.com/go-gormigrate/gormigrate v1.2.1
|
||||
github.com/go-sql-driver/mysql v1.4.1 // indirect
|
||||
github.com/google/go-cmp v0.2.0 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||
github.com/jinzhu/gorm v1.9.1
|
||||
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect
|
||||
github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3 // indirect
|
||||
github.com/joho/godotenv v1.3.0 // indirect
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/kr/pty v1.1.3
|
||||
github.com/lib/pq v1.0.0 // indirect
|
||||
github.com/mattn/go-colorable v0.0.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.4 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.3 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.10.0 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
|
||||
github.com/moby/moby v0.0.0-20171102073902-76531ccdeb58
|
||||
github.com/moul/ssh v0.1.1-0.20181116135657-8b3cdd49b6d2
|
||||
github.com/olekukonko/tablewriter v0.0.1
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/reiver/go-oi v0.0.0-20160325061615-431c83978379
|
||||
github.com/gliderlabs/ssh v0.3.2
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
github.com/jinzhu/gorm v1.9.16
|
||||
github.com/kr/pty v1.1.8
|
||||
github.com/mattn/go-colorable v0.1.8 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
|
||||
github.com/olekukonko/tablewriter v0.0.5
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/reiver/go-oi v1.0.0
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
|
||||
github.com/sabban/bastion v0.0.0-20180110125408-b9d3c9b1f4d3
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c
|
||||
github.com/urfave/cli v0.0.0-20171031025534-7f4b273a0585
|
||||
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8 // indirect
|
||||
google.golang.org/appengine v1.3.0 // indirect
|
||||
gopkg.in/stretchr/testify.v1 v1.2.2 // indirect
|
||||
github.com/smartystreets/goconvey v1.6.4
|
||||
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027
|
||||
github.com/urfave/cli v1.22.5
|
||||
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9
|
||||
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58
|
||||
gopkg.in/gormigrate.v1 v1.6.0
|
||||
moul.io/srand v1.6.1
|
||||
)
|
||||
|
||||
go 1.14
|
||||
|
||||
176
go.sum
generated
@@ -1,85 +1,145 @@
|
||||
cloud.google.com/go v0.33.1 h1:fmJQWZ1w9PGkHR1YL/P7HloDvqlmKQ4Vpb7PC2e+aCk=
|
||||
cloud.google.com/go v0.33.1/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
||||
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf h1:eg0MeVzsP1G42dRafH3vf+al2vQIJU0YHX+1Tw87oco=
|
||||
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f h1:WH0w/R4Yoey+04HhFxqZ6VX6I0d7RMyw5aXQ9UTvQPs=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo=
|
||||
github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
github.com/gliderlabs/ssh v0.1.1 h1:j3L6gSLQalDETeEg/Jg0mGY0/y/N6zI2xX1978P0Uqw=
|
||||
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-gormigrate/gormigrate v1.2.1 h1:y3jmLDVVxVkuIR4CR5Qu+lLiUUOtpGt+4zjkLH53Bls=
|
||||
github.com/go-gormigrate/gormigrate v1.2.1/go.mod h1:EmaYTk8H9TxcUD9nFzNPaHlDUCePc1EstS+HTwcGNhE=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/gliderlabs/ssh v0.3.2 h1:gcfd1Aj/9RQxvygu4l3sak711f/5+VOwBw9C/7+N4EI=
|
||||
github.com/gliderlabs/ssh v0.3.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/jinzhu/gorm v1.9.1 h1:lDSDtsCt5AGGSKTs8AHlSDbbgif4G4+CKJ8ETBDVHTA=
|
||||
github.com/jinzhu/gorm v1.9.1/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
|
||||
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
|
||||
github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
|
||||
github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
|
||||
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3 h1:xvj06l8iSwiWpYgm8MbPp+naBg+pwfqmdXabzqPCn/8=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3/go.mod h1:oHTiXerJ20+SfYcrdlBO7rzZRJWGwSTQ0iUY2jI6Gfc=
|
||||
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kr/pty v1.1.3 h1:/Um6a/ZmD5tF7peoOJ5oN5KMQ0DrGVQSXLNwyckutPk=
|
||||
github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-colorable v0.0.0-20171111065953-6fcc0c1fd9b6 h1:G4Z3Qt5LMB7t8O2mvgRGe5Napynl/AXz+kEPvYXaggQ=
|
||||
github.com/mattn/go-colorable v0.0.0-20171111065953-6fcc0c1fd9b6/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
|
||||
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
|
||||
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
|
||||
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/moby/moby v0.0.0-20171102073902-76531ccdeb58 h1:ce/WsOd8CTi+SX+mtZolkjdHRFh4WSqqV9pnedmqY1w=
|
||||
github.com/moby/moby v0.0.0-20171102073902-76531ccdeb58/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
|
||||
github.com/moul/ssh v0.1.1-0.20181116134500-51417a721208 h1:Y97oa5mCq1XZ+46noGJySDjs6Kf8iY0FqfEa4wPutdc=
|
||||
github.com/moul/ssh v0.1.1-0.20181116134500-51417a721208/go.mod h1:7g1Z1WW1l5W9MgjgsE6ehNzvjmA8qe9kJ/G8kdanYEg=
|
||||
github.com/moul/ssh v0.1.1-0.20181116135657-8b3cdd49b6d2 h1:IAH3/wuCKXdfGf4zrH2PtTnp0PhWtL+Cld840EfLQ5o=
|
||||
github.com/moul/ssh v0.1.1-0.20181116135657-8b3cdd49b6d2/go.mod h1:7g1Z1WW1l5W9MgjgsE6ehNzvjmA8qe9kJ/G8kdanYEg=
|
||||
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
|
||||
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
|
||||
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7 h1:+/+DxvQaYifJ+grD4klzrS5y+KJXldn/2YTl5JG+vZ8=
|
||||
github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/reiver/go-oi v0.0.0-20160325061615-431c83978379 h1:NBPkf14RzPYmr3478XQcmQyMKkxSvguL7+cyKKNvGxY=
|
||||
github.com/reiver/go-oi v0.0.0-20160325061615-431c83978379/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM=
|
||||
github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl+f5AQ93FMcadG19WkS7MO6TXFOSas=
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sabban/bastion v0.0.0-20180110125408-b9d3c9b1f4d3 h1:yxUGvEatvDMO6gkhwx82Va+Czdyui9LiCw6a5YB/2f8=
|
||||
github.com/sabban/bastion v0.0.0-20180110125408-b9d3c9b1f4d3/go.mod h1:1Q04m7wmv/IMoZU9t8UkH+n9McWn4i3H9v9LnMgqloo=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/urfave/cli v0.0.0-20171031025534-7f4b273a0585 h1:fKnLpe72GC+2GbMpMp0AmcqVvJGW5GBaWD5C2gomMEg=
|
||||
github.com/urfave/cli v0.0.0-20171031025534-7f4b273a0585/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869 h1:kkXA53yGe04D0adEYJwEVQjeBppL01Exg+fnMjfUraU=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027 h1:lK99QQdH3yBWY6aGilF+IRlQIdmhzLrsEmF6JgN+Ryw=
|
||||
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
|
||||
github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU=
|
||||
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 h1:umElSU9WZirRdgu2yFHY0ayQkEnKiOC1TtM3fWXFnoU=
|
||||
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0 h1:8pl+sMODzuvGJkmj2W4kZihvVb5mKm8pB/X44PIQHv8=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8 h1:YoY1wS6JYVRpIfFngRf2HHo9R9dAne3xbkGOQ5rJXjU=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58 h1:1Bs6RVeBFtLZ8Yi1Hk07DiOqzvwLD/4hln4iahvFlag=
|
||||
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M=
|
||||
gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/gormigrate.v1 v1.6.0 h1:XpYM6RHQPmzwY7Uyu+t+xxMXc86JYFJn4nEc9HzQjsI=
|
||||
gopkg.in/gormigrate.v1 v1.6.0/go.mod h1:Lf00lQrHqfSYWiTtPcyQabsDdM6ejZaMgV0OU6JMSlw=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
moul.io/srand v1.6.1 h1:SJ335F+54ivLdlH7wH52Rtyv0Ffos6DpsF5wu3ZVMXU=
|
||||
moul.io/srand v1.6.1/go.mod h1:P2uaZB+GFstFNo8sEj6/U8FRV1n25kD0LLckFpJ+qvc=
|
||||
|
||||
22
helm/sshportal/.helmignore
Normal file
@@ -0,0 +1,22 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
21
helm/sshportal/Chart.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
apiVersion: v2
|
||||
name: sshportal
|
||||
description: A Helm chart for SSHPortal on Kubernetes
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
version: 0.1.0
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application.
|
||||
appVersion: 1.10.0
|
||||
33
helm/sshportal/templates/NOTES.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
1. Get the admin invitation token (only on first install):
|
||||
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]+")
|
||||
|
||||
2. Get the service IP and Port:
|
||||
{{- if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "sshportal.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "sshportal.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "sshportal.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
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}")
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 2222:{{ .Values.service.port }}
|
||||
{{- end }}
|
||||
|
||||
3. Enroll your SSH public key:
|
||||
{{- if contains "NodePort" .Values.service.type }}
|
||||
ssh $NODE_IP -p $NODE_PORT -l $INVITE
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
ssh $SERVICE_IP -p {{ .Values.service.port }} -l $INVITE
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
ssh localhost -p 2222 -l $INVITE
|
||||
{{- end }}
|
||||
|
||||
4. Configure your {{ include "sshportal.name" . }} install:
|
||||
{{- if contains "NodePort" .Values.service.type }}
|
||||
ssh admin@$NODE_IP -p $NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
ssh admin@$SERVICE_IP -p {{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
ssh admin@localhost -p 2222
|
||||
{{- end }}
|
||||
63
helm/sshportal/templates/_helpers.tpl
Normal file
@@ -0,0 +1,63 @@
|
||||
{{/* vim: set filetype=mustache: */}}
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "sshportal.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "sshportal.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "sshportal.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "sshportal.labels" -}}
|
||||
helm.sh/chart: {{ include "sshportal.chart" . }}
|
||||
{{ include "sshportal.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "sshportal.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "sshportal.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "sshportal.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
{{ default (include "sshportal.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else -}}
|
||||
{{ default "default" .Values.serviceAccount.name }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
69
helm/sshportal/templates/deployment.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "sshportal.fullname" . }}
|
||||
labels:
|
||||
{{- include "sshportal.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "sshportal.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "sshportal.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:v{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: ssh
|
||||
containerPort: 2222
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sshportal
|
||||
- healthcheck
|
||||
- --quiet
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sshportal
|
||||
- healthcheck
|
||||
- --quiet
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
env:
|
||||
{{- if .Values.mysql.enabled }}
|
||||
- name: SSHPORTAL_DATABASE_URL
|
||||
value: {{ .Values.mysql.user }}:{{ .Values.mysql.password }}@tcp({{ .Values.mysql.server }}:{{ .Values.mysql.port }})/{{ .Values.mysql.database }}?charset=utf8&parseTime=true&loc=Local
|
||||
- name: SSHPORTAL_DB_DRIVER
|
||||
value: mysql
|
||||
{{- end }}
|
||||
{{- if .Values.debug}}
|
||||
- name: SSHPORTAL_DEBUG
|
||||
value: "1"
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
21
helm/sshportal/templates/horizontal-pod-autoscaling.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
{{- if .Values.mysql.enabled }}
|
||||
apiVersion: autoscaling/v2beta1
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "sshportal.fullname" . }}
|
||||
labels:
|
||||
{{- include "sshportal.labels" . | nindent 4 }}
|
||||
spec:
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "sshportal.fullname" . }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
targetAverageUtilization: {{ .Values.autoscaling.cpuTarget }}
|
||||
{{- end }}
|
||||
|
||||
17
helm/sshportal/templates/service.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "sshportal.fullname" . }}
|
||||
annotations:
|
||||
{{- toYaml .Values.service.annotations | nindent 4 }}
|
||||
labels:
|
||||
{{- include "sshportal.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: 2222
|
||||
protocol: TCP
|
||||
name: ssh
|
||||
selector:
|
||||
{{- include "sshportal.selectorLabels" . | nindent 4 }}
|
||||
15
helm/sshportal/templates/tests/test-connection.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ include "sshportal.fullname" . }}-test-connection"
|
||||
labels:
|
||||
{{ include "sshportal.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": test-success
|
||||
spec:
|
||||
containers:
|
||||
- name: wget
|
||||
image: busybox
|
||||
command: ['wget']
|
||||
args: ['{{ include "sshportal.fullname" . }}:{{ .Values.service.port }}']
|
||||
restartPolicy: Never
|
||||
119
helm/sshportal/values.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
# Default values for sshportal.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
## Enable SSHPortal debug mode
|
||||
##
|
||||
debug: false
|
||||
|
||||
## SSH Portal Docker image
|
||||
##
|
||||
image:
|
||||
repository: moul/sshportal
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
## Reference to one or more secrets to be used when pulling images
|
||||
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
||||
##
|
||||
imagePullSecrets: []
|
||||
|
||||
## Provide a name in place of sshportal for `app:` labels
|
||||
##
|
||||
nameOverride: ""
|
||||
|
||||
## Provide a name to substitute for the full names of resources
|
||||
##
|
||||
fullnameOverride: ""
|
||||
|
||||
## PodSecurityContext holds pod-level security attributes.
|
||||
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
|
||||
##
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
## SecurityContext holds container-level security attributes.
|
||||
## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
|
||||
##
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
## Service
|
||||
##
|
||||
service:
|
||||
## Configure additional annotations for SSHPortal service
|
||||
##
|
||||
annotations: {}
|
||||
# service.beta.kubernetes.io/openstack-internal-load-balancer: "true"
|
||||
|
||||
## Service type, one of
|
||||
## NodePort, ClusterIP, LoadBalancer
|
||||
##
|
||||
type: LoadBalancer
|
||||
|
||||
## Port to expose on the service
|
||||
##
|
||||
port: 22
|
||||
|
||||
## Define resources requests and limits
|
||||
## ref: https://kubernetes.io/docs/user-guide/compute-resources/
|
||||
##
|
||||
resources: {}
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# limits:
|
||||
# cpu: 2
|
||||
# memory: 2Gi
|
||||
|
||||
## Mysql/MariaDB configuration for HA
|
||||
##
|
||||
mysql:
|
||||
enabled: false
|
||||
|
||||
## Database user
|
||||
##
|
||||
user: sshportal
|
||||
|
||||
## Database password
|
||||
##
|
||||
password: change_me
|
||||
|
||||
## Database name
|
||||
##
|
||||
database: sshportal
|
||||
|
||||
## Database server FQDN or IP
|
||||
##
|
||||
server: mariadb-mariadb-galera
|
||||
|
||||
## Database port
|
||||
##
|
||||
port: 3306
|
||||
|
||||
## Define which Nodes the Pods are scheduled on.
|
||||
## ref: https://kubernetes.io/docs/user-guide/node-selection/
|
||||
##
|
||||
nodeSelector: {}
|
||||
|
||||
## The pod's tolerations.
|
||||
## ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/
|
||||
##
|
||||
tolerations: []
|
||||
|
||||
## Assign custom affinity rules
|
||||
## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/
|
||||
##
|
||||
affinity: {}
|
||||
|
||||
## HPA support, require `mysql.enable: true`
|
||||
## This section enables sshportal to autoscale based on metrics.
|
||||
##
|
||||
autoscaling:
|
||||
maxReplicas: 4
|
||||
minReplicas: 2
|
||||
cpuTarget: 60
|
||||
11
internal/tools/tools.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// +build tools
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
// required by depaware
|
||||
_ "github.com/tailscale/depaware/depaware"
|
||||
|
||||
// required by goimports
|
||||
_ "golang.org/x/tools/cover"
|
||||
)
|
||||
134
main.go
@@ -1,41 +1,33 @@
|
||||
package main // import "moul.io/sshportal"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/jinzhu/gorm/dialects/mysql"
|
||||
_ "github.com/jinzhu/gorm/dialects/postgres"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
"github.com/moul/ssh"
|
||||
"github.com/urfave/cli"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"moul.io/srand"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version should be updated by hand at each release
|
||||
Version = "1.9.0"
|
||||
// GitTag will be overwritten automatically by the build system
|
||||
GitTag string
|
||||
GitTag = "n/a"
|
||||
// GitSha will be overwritten automatically by the build system
|
||||
GitSha string
|
||||
// GitBranch will be overwritten automatically by the build system
|
||||
GitBranch string
|
||||
GitSha = "n/a"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
rand.Seed(srand.MustSecure())
|
||||
|
||||
app := cli.NewApp()
|
||||
app.Name = path.Base(os.Args[0])
|
||||
app.Author = "Manfred Touron"
|
||||
app.Version = Version + " (" + GitSha + ")"
|
||||
app.Version = GitTag + " (" + GitSha + ")"
|
||||
app.Email = "https://moul.io/sshportal"
|
||||
app.Commands = []cli.Command{
|
||||
{
|
||||
@@ -45,7 +37,7 @@ func main() {
|
||||
if err := ensureLogDirectory(c.String("logs-location")); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := parseServeConfig(c)
|
||||
cfg, err := parseServerConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -59,27 +51,32 @@ func main() {
|
||||
Usage: "SSH server bind address",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "db-driver",
|
||||
Value: "sqlite3",
|
||||
Usage: "GORM driver (sqlite3)",
|
||||
Name: "db-driver",
|
||||
EnvVar: "SSHPORTAL_DB_DRIVER",
|
||||
Value: "sqlite3",
|
||||
Usage: "GORM driver (sqlite3)",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "db-conn",
|
||||
Value: "./sshportal.db",
|
||||
Usage: "GORM connection string",
|
||||
Name: "db-conn",
|
||||
EnvVar: "SSHPORTAL_DATABASE_URL",
|
||||
Value: "./sshportal.db",
|
||||
Usage: "GORM connection string",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "debug, D",
|
||||
Usage: "Display debug information",
|
||||
Name: "debug, D",
|
||||
EnvVar: "SSHPORTAL_DEBUG",
|
||||
Usage: "Display debug information",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "aes-key",
|
||||
Usage: "Encrypt sensitive data in database (length: 16, 24 or 32)",
|
||||
Name: "aes-key",
|
||||
EnvVar: "SSHPORTAL_AES_KEY",
|
||||
Usage: "Encrypt sensitive data in database (length: 16, 24 or 32)",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "logs-location",
|
||||
Value: "./log",
|
||||
Usage: "Store user session files",
|
||||
Name: "logs-location",
|
||||
EnvVar: "SSHPORTAL_LOGS_LOCATION",
|
||||
Value: "./log",
|
||||
Usage: "Store user session files",
|
||||
},
|
||||
cli.DurationFlag{
|
||||
Name: "idle-timeout",
|
||||
@@ -115,82 +112,3 @@ func main() {
|
||||
log.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var defaultChannelHandler ssh.ChannelHandler
|
||||
|
||||
func server(c *configServe) (err error) {
|
||||
var db = (*gorm.DB)(nil)
|
||||
|
||||
// try to setup the local DB
|
||||
if db, err = gorm.Open(c.dbDriver, c.dbURL); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
origErr := err
|
||||
err = db.Close()
|
||||
if origErr != nil {
|
||||
err = origErr
|
||||
}
|
||||
}()
|
||||
if err = db.DB().Ping(); err != nil {
|
||||
return
|
||||
}
|
||||
db.LogMode(c.debug)
|
||||
if err = dbInit(db); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// create TCP listening socket
|
||||
ln, err := net.Listen("tcp", c.bindAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// configure server
|
||||
srv := &ssh.Server{
|
||||
Addr: c.bindAddr,
|
||||
Handler: shellHandler, // ssh.Server.Handler is the handler for the DefaultSessionHandler
|
||||
Version: fmt.Sprintf("sshportal-%s", Version),
|
||||
}
|
||||
|
||||
// configure channel handler
|
||||
defaultSessionHandler := srv.GetChannelHandler("session")
|
||||
defaultDirectTcpipHandler := srv.GetChannelHandler("direct-tcpip")
|
||||
defaultChannelHandler = func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
switch newChan.ChannelType() {
|
||||
case "session":
|
||||
go defaultSessionHandler(srv, conn, newChan, ctx)
|
||||
case "direct-tcpip":
|
||||
go defaultDirectTcpipHandler(srv, conn, newChan, ctx)
|
||||
default:
|
||||
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
|
||||
log.Printf("failed to reject chan: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
srv.SetChannelHandler("session", nil)
|
||||
srv.SetChannelHandler("direct-tcpip", nil)
|
||||
srv.SetChannelHandler("default", channelHandler)
|
||||
|
||||
if c.idleTimeout != 0 {
|
||||
srv.IdleTimeout = c.idleTimeout
|
||||
// gliderlabs/ssh requires MaxTimeout to be non-zero if we want to use IdleTimeout.
|
||||
// So, set it to the max value, because we don't want a max timeout.
|
||||
srv.MaxTimeout = math.MaxInt64
|
||||
}
|
||||
|
||||
for _, opt := range []ssh.Option{
|
||||
// custom PublicKeyAuth handler
|
||||
ssh.PublicKeyAuth(publicKeyAuthHandler(db, c)),
|
||||
ssh.PasswordAuth(passwordAuthHandler(db, c)),
|
||||
// retrieve sshportal SSH private key from database
|
||||
privateKeyFromDB(db, c.aesKey),
|
||||
} {
|
||||
if err := srv.SetOption(opt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("info: SSH Server accepting connections on %s, idle-timout=%v", c.bindAddr, c.idleTimeout)
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
|
||||
50
pkg/bastion/acl.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package bastion
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"moul.io/sshportal/pkg/dbmodels"
|
||||
)
|
||||
|
||||
type byWeight []*dbmodels.ACL
|
||||
|
||||
func (a byWeight) Len() int { return len(a) }
|
||||
func (a byWeight) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||
func (a byWeight) Less(i, j int) bool { return a[i].Weight < a[j].Weight }
|
||||
|
||||
func checkACLs(user dbmodels.User, host dbmodels.Host) string {
|
||||
currentTime := time.Now()
|
||||
|
||||
// shared ACLs between user and host
|
||||
aclMap := map[uint]*dbmodels.ACL{}
|
||||
for _, userGroup := range user.Groups {
|
||||
for _, userGroupACL := range userGroup.ACLs {
|
||||
for _, hostGroup := range host.Groups {
|
||||
for _, hostGroupACL := range hostGroup.ACLs {
|
||||
if userGroupACL.ID == hostGroupACL.ID {
|
||||
if (userGroupACL.Inception == nil || currentTime.After(*userGroupACL.Inception)) &&
|
||||
(userGroupACL.Expiration == nil || currentTime.Before(*userGroupACL.Expiration)) {
|
||||
aclMap[userGroupACL.ID] = userGroupACL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// FIXME: add ACLs that match host pattern
|
||||
|
||||
// deny by default if no shared ACL
|
||||
if len(aclMap) == 0 {
|
||||
return string(dbmodels.ACLActionDeny) // default action
|
||||
}
|
||||
|
||||
// transform map to slice and sort it
|
||||
acls := make([]*dbmodels.ACL, 0, len(aclMap))
|
||||
for _, acl := range aclMap {
|
||||
acls = append(acls, acl)
|
||||
}
|
||||
sort.Sort(byWeight(acls))
|
||||
|
||||
return acls[0].Action
|
||||
}
|
||||
49
pkg/bastion/acl_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package bastion // import "moul.io/sshportal/pkg/bastion"
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/mysql"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"moul.io/sshportal/pkg/dbmodels"
|
||||
)
|
||||
|
||||
func TestCheckACLs(t *testing.T) {
|
||||
Convey("Testing CheckACLs", t, func(c C) {
|
||||
// create tmp dir
|
||||
tempDir, err := ioutil.TempDir("", "sshportal")
|
||||
c.So(err, ShouldBeNil)
|
||||
defer func() {
|
||||
c.So(os.RemoveAll(tempDir), ShouldBeNil)
|
||||
}()
|
||||
|
||||
// create sqlite db
|
||||
db, err := gorm.Open("sqlite3", filepath.Join(tempDir, "sshportal.db"))
|
||||
c.So(err, ShouldBeNil)
|
||||
db.LogMode(false)
|
||||
c.So(DBInit(db), ShouldBeNil)
|
||||
|
||||
// create dummy objects
|
||||
var hostGroup dbmodels.HostGroup
|
||||
err = dbmodels.HostGroupsByIdentifiers(db, []string{"default"}).First(&hostGroup).Error
|
||||
c.So(err, ShouldBeNil)
|
||||
db.Create(&dbmodels.Host{Groups: []*dbmodels.HostGroup{&hostGroup}})
|
||||
|
||||
//. load db
|
||||
var (
|
||||
hosts []dbmodels.Host
|
||||
users []dbmodels.User
|
||||
)
|
||||
db.Preload("Groups").Preload("Groups.ACLs").Find(&hosts)
|
||||
db.Preload("Groups").Preload("Groups.ACLs").Find(&users)
|
||||
|
||||
// test
|
||||
action := checkACLs(users[0], hosts[0])
|
||||
c.So(action, ShouldEqual, dbmodels.ACLActionAllow)
|
||||
})
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
package main
|
||||
package bastion // import "moul.io/sshportal/pkg/bastion"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate"
|
||||
"github.com/jinzhu/gorm"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
gormigrate "gopkg.in/gormigrate.v1"
|
||||
"moul.io/sshportal/pkg/crypto"
|
||||
"moul.io/sshportal/pkg/dbmodels"
|
||||
)
|
||||
|
||||
func dbInit(db *gorm.DB) error {
|
||||
func DBInit(db *gorm.DB) error {
|
||||
log.SetOutput(ioutil.Discard)
|
||||
db.Callback().Delete().Replace("gorm:delete", hardDeleteCallback)
|
||||
log.SetOutput(os.Stderr)
|
||||
@@ -37,15 +40,14 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "2",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type SSHKey struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string
|
||||
Type string
|
||||
Length uint
|
||||
Fingerprint string
|
||||
PrivKey string `sql:"size:10000"`
|
||||
PubKey string `sql:"size:10000"`
|
||||
Hosts []*Host `gorm:"ForeignKey:SSHKeyID"`
|
||||
PrivKey string `sql:"size:5000"`
|
||||
PubKey string `sql:"size:1000"`
|
||||
Hosts []*dbmodels.Host `gorm:"ForeignKey:SSHKeyID"`
|
||||
Comment string
|
||||
}
|
||||
return tx.AutoMigrate(&SSHKey{}).Error
|
||||
@@ -57,15 +59,14 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "3",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32"`
|
||||
Addr string
|
||||
User string
|
||||
Password string
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Fingerprint string
|
||||
Comment string
|
||||
}
|
||||
@@ -79,9 +80,9 @@ func dbInit(db *gorm.DB) error {
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type UserKey struct {
|
||||
gorm.Model
|
||||
Key []byte `sql:"size:10000"`
|
||||
UserID uint ``
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Key []byte `sql:"size:1000"`
|
||||
UserID uint ``
|
||||
User *dbmodels.User `gorm:"ForeignKey:UserID"`
|
||||
Comment string
|
||||
}
|
||||
return tx.AutoMigrate(&UserKey{}).Error
|
||||
@@ -93,13 +94,12 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "5",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type User struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
IsAdmin bool
|
||||
Email string
|
||||
Name string
|
||||
Keys []*UserKey `gorm:"ForeignKey:UserID"`
|
||||
Groups []*UserGroup `gorm:"many2many:user_user_groups;"`
|
||||
Keys []*dbmodels.UserKey `gorm:"ForeignKey:UserID"`
|
||||
Groups []*dbmodels.UserGroup `gorm:"many2many:user_user_groups;"`
|
||||
Comment string
|
||||
InviteToken string
|
||||
}
|
||||
@@ -114,8 +114,8 @@ func dbInit(db *gorm.DB) error {
|
||||
type UserGroup struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
Users []*User `gorm:"many2many:user_user_groups;"`
|
||||
ACLs []*ACL `gorm:"many2many:user_group_acls;"`
|
||||
Users []*dbmodels.User `gorm:"many2many:user_user_groups;"`
|
||||
ACLs []*dbmodels.ACL `gorm:"many2many:user_group_acls;"`
|
||||
Comment string
|
||||
}
|
||||
return tx.AutoMigrate(&UserGroup{}).Error
|
||||
@@ -129,8 +129,8 @@ func dbInit(db *gorm.DB) error {
|
||||
type HostGroup struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
Hosts []*Host `gorm:"many2many:host_host_groups;"`
|
||||
ACLs []*ACL `gorm:"many2many:host_group_acls;"`
|
||||
Hosts []*dbmodels.Host `gorm:"many2many:host_host_groups;"`
|
||||
ACLs []*dbmodels.ACL `gorm:"many2many:host_group_acls;"`
|
||||
Comment string
|
||||
}
|
||||
return tx.AutoMigrate(&HostGroup{}).Error
|
||||
@@ -143,8 +143,8 @@ func dbInit(db *gorm.DB) error {
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type ACL struct {
|
||||
gorm.Model
|
||||
HostGroups []*HostGroup `gorm:"many2many:host_group_acls;"`
|
||||
UserGroups []*UserGroup `gorm:"many2many:user_group_acls;"`
|
||||
HostGroups []*dbmodels.HostGroup `gorm:"many2many:host_group_acls;"`
|
||||
UserGroups []*dbmodels.UserGroup `gorm:"many2many:user_group_acls;"`
|
||||
HostPattern string
|
||||
Action string
|
||||
Weight uint
|
||||
@@ -159,64 +159,64 @@ func dbInit(db *gorm.DB) error {
|
||||
}, {
|
||||
ID: "9",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&Setting{}).RemoveIndex("uix_settings_name")
|
||||
return db.Model(&Setting{}).AddUniqueIndex("uix_settings_name", "name").Error
|
||||
db.Model(&dbmodels.Setting{}).RemoveIndex("uix_settings_name")
|
||||
return db.Model(&dbmodels.Setting{}).AddUniqueIndex("uix_settings_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&Setting{}).RemoveIndex("uix_settings_name").Error
|
||||
return db.Model(&dbmodels.Setting{}).RemoveIndex("uix_settings_name").Error
|
||||
},
|
||||
}, {
|
||||
ID: "10",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&SSHKey{}).RemoveIndex("uix_keys_name")
|
||||
return db.Model(&SSHKey{}).AddUniqueIndex("uix_keys_name", "name").Error
|
||||
db.Model(&dbmodels.SSHKey{}).RemoveIndex("uix_keys_name")
|
||||
return db.Model(&dbmodels.SSHKey{}).AddUniqueIndex("uix_keys_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&SSHKey{}).RemoveIndex("uix_keys_name").Error
|
||||
return db.Model(&dbmodels.SSHKey{}).RemoveIndex("uix_keys_name").Error
|
||||
},
|
||||
}, {
|
||||
ID: "11",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&Host{}).RemoveIndex("uix_hosts_name")
|
||||
return db.Model(&Host{}).AddUniqueIndex("uix_hosts_name", "name").Error
|
||||
db.Model(&dbmodels.Host{}).RemoveIndex("uix_hosts_name")
|
||||
return db.Model(&dbmodels.Host{}).AddUniqueIndex("uix_hosts_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&Host{}).RemoveIndex("uix_hosts_name").Error
|
||||
return db.Model(&dbmodels.Host{}).RemoveIndex("uix_hosts_name").Error
|
||||
},
|
||||
}, {
|
||||
ID: "12",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&User{}).RemoveIndex("uix_users_name")
|
||||
return db.Model(&User{}).AddUniqueIndex("uix_users_name", "name").Error
|
||||
db.Model(&dbmodels.User{}).RemoveIndex("uix_users_name")
|
||||
return db.Model(&dbmodels.User{}).AddUniqueIndex("uix_users_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&User{}).RemoveIndex("uix_users_name").Error
|
||||
return db.Model(&dbmodels.User{}).RemoveIndex("uix_users_name").Error
|
||||
},
|
||||
}, {
|
||||
ID: "13",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&UserGroup{}).RemoveIndex("uix_usergroups_name")
|
||||
return db.Model(&UserGroup{}).AddUniqueIndex("uix_usergroups_name", "name").Error
|
||||
db.Model(&dbmodels.UserGroup{}).RemoveIndex("uix_usergroups_name")
|
||||
return db.Model(&dbmodels.UserGroup{}).AddUniqueIndex("uix_usergroups_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&UserGroup{}).RemoveIndex("uix_usergroups_name").Error
|
||||
return db.Model(&dbmodels.UserGroup{}).RemoveIndex("uix_usergroups_name").Error
|
||||
},
|
||||
}, {
|
||||
ID: "14",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
db.Model(&HostGroup{}).RemoveIndex("uix_hostgroups_name")
|
||||
return db.Model(&HostGroup{}).AddUniqueIndex("uix_hostgroups_name", "name").Error
|
||||
db.Model(&dbmodels.HostGroup{}).RemoveIndex("uix_hostgroups_name")
|
||||
return db.Model(&dbmodels.HostGroup{}).AddUniqueIndex("uix_hostgroups_name", "name").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return db.Model(&HostGroup{}).RemoveIndex("uix_hostgroups_name").Error
|
||||
return db.Model(&dbmodels.HostGroup{}).RemoveIndex("uix_hostgroups_name").Error
|
||||
},
|
||||
}, {
|
||||
ID: "15",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type UserRole struct {
|
||||
gorm.Model
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Users []*User `gorm:"many2many:user_user_roles"`
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Users []*dbmodels.User `gorm:"many2many:user_user_roles"`
|
||||
}
|
||||
return tx.AutoMigrate(&UserRole{}).Error
|
||||
},
|
||||
@@ -229,13 +229,13 @@ func dbInit(db *gorm.DB) error {
|
||||
type User struct {
|
||||
gorm.Model
|
||||
IsAdmin bool
|
||||
Roles []*UserRole `gorm:"many2many:user_user_roles"`
|
||||
Email string `valid:"required,email"`
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Keys []*UserKey `gorm:"ForeignKey:UserID"`
|
||||
Groups []*UserGroup `gorm:"many2many:user_user_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
InviteToken string `valid:"optional,length(10|60)"`
|
||||
Roles []*dbmodels.UserRole `gorm:"many2many:user_user_roles"`
|
||||
Email string `valid:"required,email"`
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Keys []*dbmodels.UserKey `gorm:"ForeignKey:UserID"`
|
||||
Groups []*dbmodels.UserGroup `gorm:"many2many:user_user_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
InviteToken string `valid:"optional,length(10|60)"`
|
||||
}
|
||||
return tx.AutoMigrate(&User{}).Error
|
||||
},
|
||||
@@ -245,27 +245,27 @@ func dbInit(db *gorm.DB) error {
|
||||
}, {
|
||||
ID: "17",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Create(&UserRole{Name: "admin"}).Error
|
||||
return tx.Create(&dbmodels.UserRole{Name: "admin"}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Where("name = ?", "admin").Delete(&UserRole{}).Error
|
||||
return tx.Where("name = ?", "admin").Delete(&dbmodels.UserRole{}).Error
|
||||
},
|
||||
}, {
|
||||
ID: "18",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
var adminRole UserRole
|
||||
var adminRole dbmodels.UserRole
|
||||
if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var users []User
|
||||
var users []*dbmodels.User
|
||||
if err := db.Preload("Roles").Where("is_admin = ?", true).Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
user.Roles = append(user.Roles, &adminRole)
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
if err := tx.Save(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -279,13 +279,13 @@ func dbInit(db *gorm.DB) error {
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Roles []*UserRole `gorm:"many2many:user_user_roles"`
|
||||
Email string `valid:"required,email"`
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Keys []*UserKey `gorm:"ForeignKey:UserID"`
|
||||
Groups []*UserGroup `gorm:"many2many:user_user_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
InviteToken string `valid:"optional,length(10|60)"`
|
||||
Roles []*dbmodels.UserRole `gorm:"many2many:user_user_roles"`
|
||||
Email string `valid:"required,email"`
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Keys []*dbmodels.UserKey `gorm:"ForeignKey:UserID"`
|
||||
Groups []*dbmodels.UserGroup `gorm:"many2many:user_user_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
InviteToken string `valid:"optional,length(10|60)"`
|
||||
}
|
||||
return tx.AutoMigrate(&User{}).Error
|
||||
},
|
||||
@@ -295,24 +295,24 @@ func dbInit(db *gorm.DB) error {
|
||||
}, {
|
||||
ID: "20",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Create(&UserRole{Name: "listhosts"}).Error
|
||||
return tx.Create(&dbmodels.UserRole{Name: "listhosts"}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Where("name = ?", "listhosts").Delete(&UserRole{}).Error
|
||||
return tx.Where("name = ?", "listhosts").Delete(&dbmodels.UserRole{}).Error
|
||||
},
|
||||
}, {
|
||||
ID: "21",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Session struct {
|
||||
gorm.Model
|
||||
StoppedAt time.Time `valid:"optional"`
|
||||
Status string `valid:"required"`
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Host *Host `gorm:"ForeignKey:HostID"`
|
||||
UserID uint `valid:"optional"`
|
||||
HostID uint `valid:"optional"`
|
||||
ErrMsg string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
StoppedAt time.Time `valid:"optional"`
|
||||
Status string `valid:"required"`
|
||||
User *dbmodels.User `gorm:"ForeignKey:UserID"`
|
||||
Host *dbmodels.Host `gorm:"ForeignKey:HostID"`
|
||||
UserID uint `valid:"optional"`
|
||||
HostID uint `valid:"optional"`
|
||||
ErrMsg string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&Session{}).Error
|
||||
},
|
||||
@@ -324,12 +324,12 @@ func dbInit(db *gorm.DB) error {
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Event struct {
|
||||
gorm.Model
|
||||
Author *User `gorm:"ForeignKey:AuthorID"`
|
||||
AuthorID uint `valid:"optional"`
|
||||
Domain string `valid:"required"`
|
||||
Action string `valid:"required"`
|
||||
Entity string `valid:"optional"`
|
||||
Args []byte `sql:"size:10000" valid:"optional,length(1|10000)"`
|
||||
Author *dbmodels.User `gorm:"ForeignKey:AuthorID"`
|
||||
AuthorID uint `valid:"optional"`
|
||||
Domain string `valid:"required"`
|
||||
Action string `valid:"required"`
|
||||
Entity string `valid:"optional"`
|
||||
Args []byte `sql:"size:10000" valid:"optional,length(1|10000)"`
|
||||
}
|
||||
return tx.AutoMigrate(&Event{}).Error
|
||||
},
|
||||
@@ -341,11 +341,11 @@ func dbInit(db *gorm.DB) error {
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type UserKey struct {
|
||||
gorm.Model
|
||||
Key []byte `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
AuthorizedKey string `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
UserID uint ``
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Comment string `valid:"optional"`
|
||||
Key []byte `sql:"size:1000" valid:"required,length(1|1000)"`
|
||||
AuthorizedKey string `sql:"size:1000" valid:"required,length(1|1000)"`
|
||||
UserID uint ``
|
||||
User *dbmodels.User `gorm:"ForeignKey:UserID"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&UserKey{}).Error
|
||||
},
|
||||
@@ -355,7 +355,7 @@ func dbInit(db *gorm.DB) error {
|
||||
}, {
|
||||
ID: "24",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
var userKeys []UserKey
|
||||
var userKeys []*dbmodels.UserKey
|
||||
if err := db.Find(&userKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -366,7 +366,7 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
userKey.AuthorizedKey = string(gossh.MarshalAuthorizedKey(key))
|
||||
if err := db.Model(&userKey).Updates(&userKey).Error; err != nil {
|
||||
if err := db.Model(userKey).Updates(userKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -379,18 +379,17 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "25",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32" valid:"required,length(1|32),unix_user"`
|
||||
Addr string `valid:"required"`
|
||||
User string `valid:"optional"`
|
||||
Password string `valid:"optional"`
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"` // SSHKey used to connect by the client
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000" valid:"optional"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Fingerprint string `valid:"optional"` // FIXME: replace with hostKey ?
|
||||
Comment string `valid:"optional"`
|
||||
Name string `gorm:"size:32" valid:"required,length(1|32),unix_user"`
|
||||
Addr string `valid:"required"`
|
||||
User string `valid:"optional"`
|
||||
Password string `valid:"optional"`
|
||||
SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:1000" valid:"optional"`
|
||||
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Fingerprint string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&Host{}).Error
|
||||
},
|
||||
@@ -402,14 +401,14 @@ func dbInit(db *gorm.DB) error {
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Session struct {
|
||||
gorm.Model
|
||||
StoppedAt *time.Time `sql:"index" valid:"optional"`
|
||||
Status string `valid:"required"`
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Host *Host `gorm:"ForeignKey:HostID"`
|
||||
UserID uint `valid:"optional"`
|
||||
HostID uint `valid:"optional"`
|
||||
ErrMsg string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
StoppedAt *time.Time `sql:"index" valid:"optional"`
|
||||
Status string `valid:"required"`
|
||||
User *dbmodels.User `gorm:"ForeignKey:UserID"`
|
||||
Host *dbmodels.Host `gorm:"ForeignKey:HostID"`
|
||||
UserID uint `valid:"optional"`
|
||||
HostID uint `valid:"optional"`
|
||||
ErrMsg string `valid:"optional"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
return tx.AutoMigrate(&Session{}).Error
|
||||
},
|
||||
@@ -419,14 +418,14 @@ func dbInit(db *gorm.DB) error {
|
||||
}, {
|
||||
ID: "27",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
var sessions []Session
|
||||
var sessions []*dbmodels.Session
|
||||
if err := db.Find(&sessions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
if session.StoppedAt != nil && session.StoppedAt.IsZero() {
|
||||
if err := db.Model(&session).Updates(map[string]interface{}{"stopped_at": nil}).Error; err != nil {
|
||||
if err := db.Model(session).Updates(map[string]interface{}{"stopped_at": nil}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -440,17 +439,16 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "28",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32"`
|
||||
Addr string
|
||||
User string
|
||||
Password string
|
||||
URL string
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:1000"`
|
||||
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Comment string
|
||||
}
|
||||
return tx.AutoMigrate(&Host{}).Error
|
||||
@@ -462,19 +460,18 @@ func dbInit(db *gorm.DB) error {
|
||||
ID: "29",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32"`
|
||||
Addr string
|
||||
User string
|
||||
Password string
|
||||
URL string
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:1000"`
|
||||
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Comment string
|
||||
Hop *Host
|
||||
Hop *dbmodels.Host
|
||||
HopID uint
|
||||
}
|
||||
return tx.AutoMigrate(&Host{}).Error
|
||||
@@ -482,12 +479,57 @@ func dbInit(db *gorm.DB) error {
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}, {
|
||||
ID: "30",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Host struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32"`
|
||||
Addr string
|
||||
User string
|
||||
Password string
|
||||
URL string
|
||||
SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000"`
|
||||
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Comment string
|
||||
Hop *dbmodels.Host
|
||||
Logging string
|
||||
HopID uint
|
||||
}
|
||||
return tx.AutoMigrate(&Host{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") },
|
||||
}, {
|
||||
ID: "31",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Model(&dbmodels.Host{}).Updates(&dbmodels.Host{Logging: "everything"}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") },
|
||||
}, {
|
||||
ID: "32",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type ACL struct {
|
||||
gorm.Model
|
||||
HostGroups []*dbmodels.HostGroup `gorm:"many2many:host_group_acls;"`
|
||||
UserGroups []*dbmodels.UserGroup `gorm:"many2many:user_group_acls;"`
|
||||
HostPattern string `valid:"optional"`
|
||||
Action string `valid:"required"`
|
||||
Weight uint ``
|
||||
Comment string `valid:"optional"`
|
||||
Inception *time.Time
|
||||
Expiration *time.Time
|
||||
}
|
||||
return tx.AutoMigrate(&ACL{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") },
|
||||
},
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
NewEvent("system", "migrated").Log(db)
|
||||
dbmodels.NewEvent("system", "migrated").Log(db)
|
||||
|
||||
// create default ssh key
|
||||
var count uint
|
||||
@@ -495,7 +537,7 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
key, err := NewSSHKey("rsa", 2048)
|
||||
key, err := crypto.NewSSHKey("ed25519", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -511,7 +553,7 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
hostGroup := HostGroup{
|
||||
hostGroup := dbmodels.HostGroup{
|
||||
Name: "default",
|
||||
Comment: "created by sshportal",
|
||||
}
|
||||
@@ -525,7 +567,7 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
userGroup := UserGroup{
|
||||
userGroup := dbmodels.UserGroup{
|
||||
Name: "default",
|
||||
Comment: "created by sshportal",
|
||||
}
|
||||
@@ -539,13 +581,13 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
var defaultUserGroup UserGroup
|
||||
var defaultUserGroup dbmodels.UserGroup
|
||||
db.Where("name = ?", "default").First(&defaultUserGroup)
|
||||
var defaultHostGroup HostGroup
|
||||
var defaultHostGroup dbmodels.HostGroup
|
||||
db.Where("name = ?", "default").First(&defaultHostGroup)
|
||||
acl := ACL{
|
||||
UserGroups: []*UserGroup{&defaultUserGroup},
|
||||
HostGroups: []*HostGroup{&defaultHostGroup},
|
||||
acl := dbmodels.ACL{
|
||||
UserGroups: []*dbmodels.UserGroup{&defaultUserGroup},
|
||||
HostGroups: []*dbmodels.HostGroup{&defaultHostGroup},
|
||||
Action: "allow",
|
||||
//HostPattern: "",
|
||||
//Weight: 0,
|
||||
@@ -557,7 +599,7 @@ func dbInit(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// create admin user
|
||||
var defaultUserGroup UserGroup
|
||||
var defaultUserGroup dbmodels.UserGroup
|
||||
db.Where("name = ?", "default").First(&defaultUserGroup)
|
||||
if err := db.Table("users").Count(&count).Error; err != nil {
|
||||
return err
|
||||
@@ -568,7 +610,7 @@ func dbInit(db *gorm.DB) error {
|
||||
if os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN") != "" {
|
||||
inviteToken = os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN")
|
||||
}
|
||||
var adminRole UserRole
|
||||
var adminRole dbmodels.UserRole
|
||||
if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -583,13 +625,13 @@ func dbInit(db *gorm.DB) error {
|
||||
if username == "" {
|
||||
username = "admin" // fallback username
|
||||
}
|
||||
user := User{
|
||||
user := dbmodels.User{
|
||||
Name: username,
|
||||
Email: fmt.Sprintf("%s@localhost", username),
|
||||
Comment: "created by sshportal",
|
||||
Roles: []*UserRole{&adminRole},
|
||||
Roles: []*dbmodels.UserRole{&adminRole},
|
||||
InviteToken: inviteToken,
|
||||
Groups: []*UserGroup{&defaultUserGroup},
|
||||
Groups: []*dbmodels.UserGroup{&defaultUserGroup},
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return err
|
||||
@@ -602,7 +644,7 @@ func dbInit(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
key, err := NewSSHKey("rsa", 2048)
|
||||
key, err := crypto.NewSSHKey("ed25519", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -614,8 +656,8 @@ func dbInit(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// close unclosed connections
|
||||
return db.Table("sessions").Where("status = ?", "active").Updates(&Session{
|
||||
Status: string(SessionStatusClosed),
|
||||
return db.Table("sessions").Where("status = ?", "active").Updates(&dbmodels.Session{
|
||||
Status: string(dbmodels.SessionStatusClosed),
|
||||
ErrMsg: "sshportal was halted while the connection was still active",
|
||||
}).Error
|
||||
}
|
||||
@@ -643,3 +685,13 @@ func addExtraSpaceIfExist(str string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func randStringBytes(n int) string {
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package logtunnel // import "moul.io/sshportal/pkg/logtunnel"
|
||||
package bastion // import "moul.io/sshportal/pkg/bastion"
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -17,7 +17,7 @@ type logTunnel struct {
|
||||
writer io.WriteCloser
|
||||
}
|
||||
|
||||
type ForwardData struct {
|
||||
type logTunnelForwardData struct {
|
||||
DestinationHost string
|
||||
DestinationPort uint32
|
||||
SourceHost string
|
||||
@@ -40,7 +40,7 @@ func writeHeader(fd io.Writer, length int) {
|
||||
}
|
||||
}
|
||||
|
||||
func New(channel ssh.Channel, writer io.WriteCloser, host string) io.ReadWriteCloser {
|
||||
func newLogTunnel(channel ssh.Channel, writer io.WriteCloser, host string) io.ReadWriteCloser {
|
||||
return &logTunnel{
|
||||
host: host,
|
||||
channel: channel,
|
||||
@@ -1,34 +1,28 @@
|
||||
package bastionsession // import "moul.io/sshportal/pkg/bastionsession"
|
||||
package bastion // import "moul.io/sshportal/pkg/bastion"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/moul/ssh"
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sabban/bastion/pkg/logchannel"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"moul.io/sshportal/pkg/logtunnel"
|
||||
)
|
||||
|
||||
type ForwardData struct {
|
||||
DestinationHost string
|
||||
DestinationPort uint32
|
||||
SourceHost string
|
||||
SourcePort uint32
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
type sessionConfig struct {
|
||||
Addr string
|
||||
Logs string
|
||||
LogsLocation string
|
||||
ClientConfig *gossh.ClientConfig
|
||||
LoggingMode string
|
||||
}
|
||||
|
||||
func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, configs []Config) error {
|
||||
func multiChannelHandler(conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, configs []sessionConfig, sessionID uint) error {
|
||||
var lastClient *gossh.Client
|
||||
switch newChan.ChannelType() {
|
||||
case "session":
|
||||
@@ -56,6 +50,7 @@ func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.
|
||||
client = gossh.NewClient(ncc, chans, reqs)
|
||||
}
|
||||
if err != nil {
|
||||
lch.Close() // fix #56
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
@@ -67,8 +62,10 @@ func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.
|
||||
return err
|
||||
}
|
||||
user := conn.User()
|
||||
actx := ctx.Value(authContextKey).(*authContext)
|
||||
username := actx.user.Name
|
||||
// pipe everything
|
||||
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1].Logs, user, newChan)
|
||||
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1], user, username, sessionID, newChan)
|
||||
case "direct-tcpip":
|
||||
lch, lreqs, err := newChan.Accept()
|
||||
// TODO: defer clean closer
|
||||
@@ -94,13 +91,14 @@ func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.
|
||||
client = gossh.NewClient(ncc, chans, reqs)
|
||||
}
|
||||
if err != nil {
|
||||
lch.Close()
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
lastClient = client
|
||||
}
|
||||
|
||||
d := logtunnel.ForwardData{}
|
||||
d := logTunnelForwardData{}
|
||||
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,8 +107,10 @@ func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.
|
||||
return err
|
||||
}
|
||||
user := conn.User()
|
||||
actx := ctx.Value(authContextKey).(*authContext)
|
||||
username := actx.user.Name
|
||||
// pipe everything
|
||||
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1].Logs, user, newChan)
|
||||
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1], user, username, sessionID, newChan)
|
||||
default:
|
||||
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
|
||||
log.Printf("failed to reject chan: %v", err)
|
||||
@@ -119,65 +119,77 @@ func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.
|
||||
}
|
||||
}
|
||||
|
||||
func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, logsLocation string, user string, newChan gossh.NewChannel) error {
|
||||
func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, sessConfig sessionConfig, user string, username string, sessionID uint, newChan gossh.NewChannel) error {
|
||||
defer func() {
|
||||
_ = lch.Close()
|
||||
_ = rch.Close()
|
||||
}()
|
||||
|
||||
errch := make(chan error, 1)
|
||||
quit := make(chan string, 1)
|
||||
channeltype := newChan.ChannelType()
|
||||
|
||||
filename := strings.Join([]string{logsLocation, "/", user, "-", channeltype, "-", time.Now().Format(time.RFC3339)}, "") // get user
|
||||
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("error: %v", err)
|
||||
var logWriter io.WriteCloser = newDiscardWriteCloser()
|
||||
if sessConfig.LoggingMode != "disabled" {
|
||||
filename := filepath.Join(sessConfig.LogsLocation, fmt.Sprintf("%s-%s-%s-%d-%s", user, username, channeltype, sessionID, time.Now().Format(time.RFC3339)))
|
||||
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0440)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open log file")
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
log.Printf("Session %v is recorded in %v", channeltype, filename)
|
||||
logWriter = f
|
||||
}
|
||||
|
||||
log.Printf("Session %v is recorded in %v", channeltype, filename)
|
||||
if channeltype == "session" {
|
||||
wrappedlch := logchannel.New(lch, f)
|
||||
go func() {
|
||||
_, _ = io.Copy(wrappedlch, rch)
|
||||
errch <- errors.New("lch closed the connection")
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(rch, lch)
|
||||
errch <- errors.New("rch closed the connection")
|
||||
}()
|
||||
switch sessConfig.LoggingMode {
|
||||
case "input":
|
||||
wrappedrch := logchannel.New(rch, logWriter)
|
||||
go func(quit chan string) {
|
||||
_, _ = io.Copy(lch, rch)
|
||||
quit <- "rch"
|
||||
}(quit)
|
||||
go func(quit chan string) {
|
||||
_, _ = io.Copy(wrappedrch, lch)
|
||||
quit <- "lch"
|
||||
}(quit)
|
||||
default: // everything, disabled
|
||||
wrappedlch := logchannel.New(lch, logWriter)
|
||||
go func(quit chan string) {
|
||||
_, _ = io.Copy(wrappedlch, rch)
|
||||
quit <- "rch"
|
||||
}(quit)
|
||||
go func(quit chan string) {
|
||||
_, _ = io.Copy(rch, lch)
|
||||
quit <- "lch"
|
||||
}(quit)
|
||||
}
|
||||
}
|
||||
if channeltype == "direct-tcpip" {
|
||||
d := logtunnel.ForwardData{}
|
||||
d := logTunnelForwardData{}
|
||||
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
|
||||
return err
|
||||
}
|
||||
wrappedlch := logtunnel.New(lch, f, d.SourceHost)
|
||||
wrappedrch := logtunnel.New(rch, f, d.DestinationHost)
|
||||
go func() {
|
||||
wrappedlch := newLogTunnel(lch, logWriter, d.SourceHost)
|
||||
wrappedrch := newLogTunnel(rch, logWriter, d.DestinationHost)
|
||||
go func(quit chan string) {
|
||||
_, _ = io.Copy(wrappedlch, rch)
|
||||
errch <- errors.New("lch closed the connection")
|
||||
}()
|
||||
quit <- "rch"
|
||||
}(quit)
|
||||
|
||||
go func() {
|
||||
go func(quit chan string) {
|
||||
_, _ = io.Copy(wrappedrch, lch)
|
||||
errch <- errors.New("rch closed the connection")
|
||||
}()
|
||||
quit <- "lch"
|
||||
}(quit)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case req := <-lreqs: // forward ssh requests from local to remote
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
go func(quit chan string) {
|
||||
for req := range lreqs {
|
||||
b, err := rch.SendRequest(req.Type, req.WantReply, req.Payload)
|
||||
if req.Type == "exec" {
|
||||
wrappedlch := logchannel.New(lch, f)
|
||||
wrappedlch := logchannel.New(lch, logWriter)
|
||||
command := append(req.Payload, []byte("\n")...)
|
||||
if _, err := wrappedlch.LogWrite(command); err != nil {
|
||||
log.Printf("failed to write log: %v", err)
|
||||
@@ -185,24 +197,68 @@ func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, logsLocati
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
errch <- err
|
||||
}
|
||||
if err2 := req.Reply(b, nil); err2 != nil {
|
||||
return err2
|
||||
}
|
||||
case req := <-rreqs: // forward ssh requests from remote to local
|
||||
if req == nil {
|
||||
return nil
|
||||
errch <- err2
|
||||
}
|
||||
}
|
||||
quit <- "lreqs"
|
||||
}(quit)
|
||||
|
||||
go func(quit chan string) {
|
||||
for req := range rreqs {
|
||||
b, err := lch.SendRequest(req.Type, req.WantReply, req.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
errch <- err
|
||||
}
|
||||
if err2 := req.Reply(b, nil); err2 != nil {
|
||||
return err2
|
||||
errch <- err2
|
||||
}
|
||||
}
|
||||
quit <- "rreqs"
|
||||
}(quit)
|
||||
|
||||
lchEOF, rchEOF, lchClosed, rchClosed := false, false, false, false
|
||||
for {
|
||||
select {
|
||||
case err := <-errch:
|
||||
return err
|
||||
case q := <-quit:
|
||||
switch q {
|
||||
case "lch":
|
||||
lchEOF = true
|
||||
_ = rch.CloseWrite()
|
||||
case "rch":
|
||||
rchEOF = true
|
||||
_ = lch.CloseWrite()
|
||||
case "lreqs":
|
||||
lchClosed = true
|
||||
case "rreqs":
|
||||
rchClosed = true
|
||||
}
|
||||
|
||||
if lchEOF && lchClosed && !rchClosed {
|
||||
rch.Close()
|
||||
}
|
||||
|
||||
if rchEOF && rchClosed && !lchClosed {
|
||||
lch.Close()
|
||||
}
|
||||
|
||||
if lchEOF && rchEOF && lchClosed && rchClosed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDiscardWriteCloser() io.WriteCloser { return &discardWriteCloser{ioutil.Discard} }
|
||||
|
||||
type discardWriteCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (discardWriteCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package bastion // import "moul.io/sshportal/pkg/bastion"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/moul/ssh"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"moul.io/sshportal/pkg/bastionsession"
|
||||
"moul.io/sshportal/pkg/crypto"
|
||||
"moul.io/sshportal/pkg/dbmodels"
|
||||
)
|
||||
|
||||
type sshportalContextKey string
|
||||
@@ -21,40 +21,44 @@ type sshportalContextKey string
|
||||
var authContextKey = sshportalContextKey("auth")
|
||||
|
||||
type authContext struct {
|
||||
message string
|
||||
err error
|
||||
user User
|
||||
inputUsername string
|
||||
db *gorm.DB
|
||||
userKey UserKey
|
||||
config *configServe
|
||||
authMethod string
|
||||
authSuccess bool
|
||||
message string
|
||||
err error
|
||||
user dbmodels.User
|
||||
inputUsername string
|
||||
db *gorm.DB
|
||||
userKey dbmodels.UserKey
|
||||
logsLocation string
|
||||
aesKey string
|
||||
dbDriver, dbURL string
|
||||
bindAddr string
|
||||
demo, debug bool
|
||||
authMethod string
|
||||
authSuccess bool
|
||||
}
|
||||
|
||||
type UserType string
|
||||
type userType string
|
||||
|
||||
const (
|
||||
UserTypeHealthcheck UserType = "healthcheck"
|
||||
UserTypeBastion UserType = "bastion"
|
||||
UserTypeInvite UserType = "invite"
|
||||
UserTypeShell UserType = "shell"
|
||||
userTypeHealthcheck userType = "healthcheck"
|
||||
userTypeBastion userType = "bastion"
|
||||
userTypeInvite userType = "invite"
|
||||
userTypeShell userType = "shell"
|
||||
)
|
||||
|
||||
func (c authContext) userType() UserType {
|
||||
func (c authContext) userType() userType {
|
||||
switch {
|
||||
case c.inputUsername == "healthcheck":
|
||||
return UserTypeHealthcheck
|
||||
return userTypeHealthcheck
|
||||
case c.inputUsername == c.user.Name || c.inputUsername == c.user.Email || c.inputUsername == "admin":
|
||||
return UserTypeShell
|
||||
return userTypeShell
|
||||
case strings.HasPrefix(c.inputUsername, "invite:"):
|
||||
return UserTypeInvite
|
||||
return userTypeInvite
|
||||
default:
|
||||
return UserTypeBastion
|
||||
return userTypeBastion
|
||||
}
|
||||
}
|
||||
|
||||
func dynamicHostKey(db *gorm.DB, host *Host) gossh.HostKeyCallback {
|
||||
func dynamicHostKey(db *gorm.DB, host *dbmodels.Host) gossh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
|
||||
if len(host.HostKey) == 0 {
|
||||
log.Println("Discovering host fingerprint...")
|
||||
@@ -68,7 +72,9 @@ func dynamicHostKey(db *gorm.DB, host *Host) gossh.HostKeyCallback {
|
||||
}
|
||||
}
|
||||
|
||||
func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
var DefaultChannelHandler ssh.ChannelHandler = func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {}
|
||||
|
||||
func ChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
switch newChan.ChannelType() {
|
||||
case "session":
|
||||
case "direct-tcpip":
|
||||
@@ -83,9 +89,9 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
||||
actx := ctx.Value(authContextKey).(*authContext)
|
||||
|
||||
switch actx.userType() {
|
||||
case UserTypeBastion:
|
||||
log.Printf("New connection(bastion): sshUser=%q remote=%q local=%q dbUser=id:%q,email:%s", conn.User(), conn.RemoteAddr(), conn.LocalAddr(), actx.user.ID, actx.user.Email)
|
||||
host, err := HostByName(actx.db, actx.inputUsername)
|
||||
case userTypeBastion:
|
||||
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)
|
||||
host, err := dbmodels.HostByName(actx.db, actx.inputUsername)
|
||||
if err != nil {
|
||||
ch, _, err2 := newChan.Accept()
|
||||
if err2 != nil {
|
||||
@@ -98,8 +104,8 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
||||
}
|
||||
|
||||
switch host.Scheme() {
|
||||
case BastionSchemeSSH:
|
||||
sessionConfigs := make([]bastionsession.Config, 0)
|
||||
case dbmodels.BastionSchemeSSH:
|
||||
sessionConfigs := make([]sessionConfig, 0)
|
||||
currentHost := host
|
||||
for currentHost != nil {
|
||||
clientConfig, err2 := bastionClientConfig(ctx, currentHost)
|
||||
@@ -113,25 +119,26 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
sessionConfigs = append([]bastionsession.Config{{
|
||||
sessionConfigs = append([]sessionConfig{{
|
||||
Addr: currentHost.DialAddr(),
|
||||
ClientConfig: clientConfig,
|
||||
Logs: actx.config.logsLocation,
|
||||
LogsLocation: actx.logsLocation,
|
||||
LoggingMode: currentHost.Logging,
|
||||
}}, sessionConfigs...)
|
||||
if currentHost.HopID != 0 {
|
||||
var newHost Host
|
||||
var newHost dbmodels.Host
|
||||
actx.db.Model(currentHost).Related(&newHost, "HopID")
|
||||
hostname := newHost.Name
|
||||
currentHost, _ = HostByName(actx.db, hostname)
|
||||
currentHost, _ = dbmodels.HostByName(actx.db, hostname)
|
||||
} else {
|
||||
currentHost = nil
|
||||
}
|
||||
}
|
||||
|
||||
sess := Session{
|
||||
sess := dbmodels.Session{
|
||||
UserID: actx.user.ID,
|
||||
HostID: host.ID,
|
||||
Status: string(SessionStatusActive),
|
||||
Status: string(dbmodels.SessionStatusActive),
|
||||
}
|
||||
if err = actx.db.Create(&sess).Error; err != nil {
|
||||
ch, _, err2 := newChan.Accept()
|
||||
@@ -142,31 +149,29 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
err = bastionsession.MultiChannelHandler(srv, conn, newChan, ctx, sessionConfigs)
|
||||
err = multiChannelHandler(conn, newChan, ctx, sessionConfigs, sess.ID)
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
sessUpdate := Session{
|
||||
Status: string(SessionStatusClosed),
|
||||
sessUpdate := dbmodels.Session{
|
||||
Status: string(dbmodels.SessionStatusClosed),
|
||||
ErrMsg: fmt.Sprintf("%v", err),
|
||||
StoppedAt: &now,
|
||||
}
|
||||
switch sessUpdate.ErrMsg {
|
||||
case "lch closed the connection", "rch closed the connection":
|
||||
if err == nil {
|
||||
sessUpdate.ErrMsg = ""
|
||||
}
|
||||
actx.db.Model(&sess).Updates(&sessUpdate)
|
||||
}()
|
||||
case BastionSchemeTelnet:
|
||||
case dbmodels.BastionSchemeTelnet:
|
||||
tmpSrv := ssh.Server{
|
||||
// PtyCallback: srv.PtyCallback,
|
||||
Handler: telnetHandler(host),
|
||||
}
|
||||
defaultChannelHandler(&tmpSrv, conn, newChan, ctx)
|
||||
DefaultChannelHandler(&tmpSrv, conn, newChan, ctx)
|
||||
default:
|
||||
ch, _, err2 := newChan.Accept()
|
||||
if err2 != nil {
|
||||
@@ -177,37 +182,35 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
||||
_ = ch.Close()
|
||||
}
|
||||
default: // shell
|
||||
defaultChannelHandler(srv, conn, newChan, ctx)
|
||||
DefaultChannelHandler(srv, conn, newChan, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func bastionClientConfig(ctx ssh.Context, host *Host) (*gossh.ClientConfig, error) {
|
||||
func bastionClientConfig(ctx ssh.Context, host *dbmodels.Host) (*gossh.ClientConfig, error) {
|
||||
actx := ctx.Value(authContextKey).(*authContext)
|
||||
|
||||
clientConfig, err := host.clientConfig(dynamicHostKey(actx.db, host))
|
||||
crypto.HostDecrypt(actx.aesKey, host)
|
||||
crypto.SSHKeyDecrypt(actx.aesKey, host.SSHKey)
|
||||
|
||||
clientConfig, err := host.ClientConfig(dynamicHostKey(actx.db, host))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tmpUser User
|
||||
var tmpUser dbmodels.User
|
||||
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", actx.user.ID).First(&tmpUser).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tmpHost Host
|
||||
var tmpHost dbmodels.Host
|
||||
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
action, err2 := CheckACLs(tmpUser, tmpHost)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
|
||||
HostDecrypt(actx.config.aesKey, host)
|
||||
SSHKeyDecrypt(actx.config.aesKey, host.SSHKey)
|
||||
|
||||
action := checkACLs(tmpUser, tmpHost)
|
||||
switch action {
|
||||
case string(ACLActionAllow):
|
||||
case string(ACLActionDeny):
|
||||
case string(dbmodels.ACLActionAllow):
|
||||
// do nothing
|
||||
case string(dbmodels.ACLActionDeny):
|
||||
return nil, fmt.Errorf("you don't have permission to that host")
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid ACL action: %q", action)
|
||||
@@ -215,10 +218,10 @@ func bastionClientConfig(ctx ssh.Context, host *Host) (*gossh.ClientConfig, erro
|
||||
return clientConfig, nil
|
||||
}
|
||||
|
||||
func shellHandler(s ssh.Session) {
|
||||
func ShellHandler(s ssh.Session, version, gitSha, gitTag string) {
|
||||
actx := s.Context().Value(authContextKey).(*authContext)
|
||||
if actx.userType() != UserTypeHealthcheck {
|
||||
log.Printf("New connection(shell): sshUser=%q remote=%q local=%q command=%q dbUser=id:%q,email:%s", s.User(), s.RemoteAddr(), s.LocalAddr(), s.Command(), actx.user.ID, actx.user.Email)
|
||||
if actx.userType() != userTypeHealthcheck {
|
||||
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)
|
||||
}
|
||||
|
||||
if actx.err != nil {
|
||||
@@ -232,43 +235,48 @@ func shellHandler(s ssh.Session) {
|
||||
}
|
||||
|
||||
switch actx.userType() {
|
||||
case UserTypeHealthcheck:
|
||||
case userTypeHealthcheck:
|
||||
fmt.Fprintln(s, "OK")
|
||||
return
|
||||
case UserTypeShell:
|
||||
if err := shell(s); err != nil {
|
||||
case userTypeShell:
|
||||
if err := shell(s, version, gitSha, gitTag); err != nil {
|
||||
fmt.Fprintf(s, "error: %v\n", err)
|
||||
_ = s.Exit(1)
|
||||
}
|
||||
return
|
||||
case UserTypeInvite:
|
||||
case userTypeInvite:
|
||||
// do nothing (message was printed at the beginning of the function)
|
||||
return
|
||||
}
|
||||
panic("should not happen")
|
||||
}
|
||||
|
||||
func passwordAuthHandler(db *gorm.DB, cfg *configServe) ssh.PasswordHandler {
|
||||
func PasswordAuthHandler(db *gorm.DB, logsLocation, aesKey, dbDriver, dbURL, bindAddr string, demo bool) ssh.PasswordHandler {
|
||||
return func(ctx ssh.Context, pass string) bool {
|
||||
actx := &authContext{
|
||||
db: db,
|
||||
inputUsername: ctx.User(),
|
||||
config: cfg,
|
||||
logsLocation: logsLocation,
|
||||
aesKey: aesKey,
|
||||
dbDriver: dbDriver,
|
||||
dbURL: dbURL,
|
||||
bindAddr: bindAddr,
|
||||
demo: demo,
|
||||
authMethod: "password",
|
||||
}
|
||||
actx.authSuccess = actx.userType() == UserTypeHealthcheck
|
||||
actx.authSuccess = actx.userType() == userTypeHealthcheck
|
||||
ctx.SetValue(authContextKey, actx)
|
||||
return actx.authSuccess
|
||||
}
|
||||
}
|
||||
|
||||
func privateKeyFromDB(db *gorm.DB, aesKey string) func(*ssh.Server) error {
|
||||
func PrivateKeyFromDB(db *gorm.DB, aesKey string) func(*ssh.Server) error {
|
||||
return func(srv *ssh.Server) error {
|
||||
var key SSHKey
|
||||
if err := SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
|
||||
var key dbmodels.SSHKey
|
||||
if err := dbmodels.SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
SSHKeyDecrypt(aesKey, &key)
|
||||
crypto.SSHKeyDecrypt(aesKey, &key)
|
||||
|
||||
signer, err := gossh.ParsePrivateKey([]byte(key.PrivKey))
|
||||
if err != nil {
|
||||
@@ -279,12 +287,17 @@ func privateKeyFromDB(db *gorm.DB, aesKey string) func(*ssh.Server) error {
|
||||
}
|
||||
}
|
||||
|
||||
func publicKeyAuthHandler(db *gorm.DB, cfg *configServe) ssh.PublicKeyHandler {
|
||||
func PublicKeyAuthHandler(db *gorm.DB, logsLocation, aesKey, dbDriver, dbURL, bindAddr string, demo bool) ssh.PublicKeyHandler {
|
||||
return func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
actx := &authContext{
|
||||
db: db,
|
||||
inputUsername: ctx.User(),
|
||||
config: cfg,
|
||||
logsLocation: logsLocation,
|
||||
aesKey: aesKey,
|
||||
dbDriver: dbDriver,
|
||||
dbURL: dbURL,
|
||||
bindAddr: bindAddr,
|
||||
demo: demo,
|
||||
authMethod: "pubkey",
|
||||
authSuccess: true,
|
||||
}
|
||||
@@ -294,20 +307,20 @@ func publicKeyAuthHandler(db *gorm.DB, cfg *configServe) ssh.PublicKeyHandler {
|
||||
db.Where("authorized_key = ?", string(gossh.MarshalAuthorizedKey(key))).First(&actx.userKey)
|
||||
if actx.userKey.UserID > 0 {
|
||||
db.Preload("Roles").Where("id = ?", actx.userKey.UserID).First(&actx.user)
|
||||
if actx.userType() == UserTypeInvite {
|
||||
if actx.userType() == userTypeInvite {
|
||||
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)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handle invite "links"
|
||||
if actx.userType() == UserTypeInvite {
|
||||
if actx.userType() == userTypeInvite {
|
||||
inputToken := strings.Split(actx.inputUsername, ":")[1]
|
||||
if len(inputToken) > 0 {
|
||||
db.Where("invite_token = ?", inputToken).First(&actx.user)
|
||||
}
|
||||
if actx.user.ID > 0 {
|
||||
actx.userKey = UserKey{
|
||||
actx.userKey = dbmodels.UserKey{
|
||||
UserID: actx.user.ID,
|
||||
Key: key.Marshal(),
|
||||
Comment: "created by sshportal",
|
||||
@@ -321,7 +334,7 @@ func publicKeyAuthHandler(db *gorm.DB, cfg *configServe) ssh.PublicKeyHandler {
|
||||
|
||||
actx.message = fmt.Sprintf("Welcome %s!\n\nYour key is now associated with the user %q.\n", actx.user.Name, actx.user.Email)
|
||||
} else {
|
||||
actx.user = User{Name: "Anonymous"}
|
||||
actx.user = dbmodels.User{Name: "Anonymous"}
|
||||
actx.err = errors.New("your token is invalid or expired")
|
||||
}
|
||||
return true
|
||||
@@ -329,7 +342,7 @@ func publicKeyAuthHandler(db *gorm.DB, cfg *configServe) ssh.PublicKeyHandler {
|
||||
|
||||
// fallback
|
||||
actx.err = errors.New("unknown ssh key")
|
||||
actx.user = User{Name: "Anonymous"}
|
||||
actx.user = dbmodels.User{Name: "Anonymous"}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package bastion // import "moul.io/sshportal/pkg/bastion"
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/moul/ssh"
|
||||
"github.com/gliderlabs/ssh"
|
||||
oi "github.com/reiver/go-oi"
|
||||
telnet "github.com/reiver/go-telnet"
|
||||
"moul.io/sshportal/pkg/dbmodels"
|
||||
)
|
||||
|
||||
type bastionTelnetCaller struct {
|
||||
@@ -75,10 +76,10 @@ func scannerSplitFunc(data []byte, atEOF bool) (advance int, token []byte, err e
|
||||
return bufio.ScanLines(data, atEOF)
|
||||
}
|
||||
|
||||
func telnetHandler(host *Host) ssh.Handler {
|
||||
func telnetHandler(host *dbmodels.Host) ssh.Handler {
|
||||
return func(s ssh.Session) {
|
||||
// FIXME: log session in db
|
||||
//actx := s.Context().Value(authContextKey).(*authContext)
|
||||
// actx := s.Context().Value(authContextKey).(*authContext)
|
||||
caller := bastionTelnetCaller{ssh: s}
|
||||
if err := telnet.DialToAndCall(host.DialAddr(), caller); err != nil {
|
||||
fmt.Fprintf(s, "error: %v", err)
|
||||
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
package crypto // import "moul.io/sshportal/pkg/crypto"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -15,46 +18,120 @@ import (
|
||||
"strings"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"moul.io/sshportal/pkg/dbmodels"
|
||||
)
|
||||
|
||||
func NewSSHKey(keyType string, length uint) (*SSHKey, error) {
|
||||
key := SSHKey{
|
||||
func NewSSHKey(keyType string, length uint) (*dbmodels.SSHKey, error) {
|
||||
key := dbmodels.SSHKey{
|
||||
Type: keyType,
|
||||
Length: length,
|
||||
}
|
||||
|
||||
// generate the private key
|
||||
if keyType != "rsa" {
|
||||
return nil, fmt.Errorf("key type not supported: %q", key.Type)
|
||||
var err error
|
||||
var pemKey *pem.Block
|
||||
var publicKey gossh.PublicKey
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
pemKey, publicKey, err = NewRSAKey(length)
|
||||
case "ecdsa":
|
||||
pemKey, publicKey, err = NewECDSAKey(length)
|
||||
case "ed25519":
|
||||
pemKey, publicKey, err = NewEd25519Key()
|
||||
default:
|
||||
return nil, fmt.Errorf("key type not supported: %q, supported types are: rsa, ecdsa, ed25519", key.Type)
|
||||
}
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// convert priv key to x509 format
|
||||
var pemKey = &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
}
|
||||
buf := bytes.NewBufferString("")
|
||||
if err = pem.Encode(buf, pemKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key.PrivKey = buf.String()
|
||||
|
||||
// generte authorized-key formatted pubkey output
|
||||
pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key.PubKey = strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub)))
|
||||
// generate authorized-key formatted pubkey output
|
||||
key.PubKey = strings.TrimSpace(string(gossh.MarshalAuthorizedKey(publicKey)))
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func ImportSSHKey(keyValue string) (*SSHKey, error) {
|
||||
key := SSHKey{
|
||||
func NewRSAKey(length uint) (*pem.Block, gossh.PublicKey, error) {
|
||||
if length < 1024 || length > 16384 {
|
||||
return nil, nil, fmt.Errorf("key length not supported: %d, supported values are between 1024 and 16384", length)
|
||||
}
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, int(length))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// convert priv key to x509 format
|
||||
pemKey := &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
}
|
||||
publicKey, err := gossh.NewPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pemKey, publicKey, err
|
||||
}
|
||||
|
||||
func NewECDSAKey(length uint) (*pem.Block, gossh.PublicKey, error) {
|
||||
var curve elliptic.Curve
|
||||
switch length {
|
||||
case 256:
|
||||
curve = elliptic.P256()
|
||||
case 384:
|
||||
curve = elliptic.P384()
|
||||
case 521:
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("key length not supported: %d, supported values are 256, 384, 521", length)
|
||||
}
|
||||
privateKey, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// convert priv key to x509 format
|
||||
marshaledKey, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
pemKey := &pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: marshaledKey,
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
publicKey, err := gossh.NewPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pemKey, publicKey, err
|
||||
}
|
||||
|
||||
func NewEd25519Key() (*pem.Block, gossh.PublicKey, error) {
|
||||
publicKeyEd25519, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// convert priv key to x509 format
|
||||
marshaledKey, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
pemKey := &pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: marshaledKey,
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
publicKey, err := gossh.NewPublicKey(publicKeyEd25519)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pemKey, publicKey, err
|
||||
}
|
||||
|
||||
func ImportSSHKey(keyValue string) (*dbmodels.SSHKey, error) {
|
||||
key := dbmodels.SSHKey{
|
||||
Type: "rsa",
|
||||
}
|
||||
|
||||
@@ -132,7 +209,7 @@ func safeDecrypt(key []byte, cryptoText string) string {
|
||||
return out
|
||||
}
|
||||
|
||||
func HostEncrypt(aesKey string, host *Host) (err error) {
|
||||
func HostEncrypt(aesKey string, host *dbmodels.Host) (err error) {
|
||||
if aesKey == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -141,7 +218,7 @@ func HostEncrypt(aesKey string, host *Host) (err error) {
|
||||
}
|
||||
return
|
||||
}
|
||||
func HostDecrypt(aesKey string, host *Host) {
|
||||
func HostDecrypt(aesKey string, host *dbmodels.Host) {
|
||||
if aesKey == "" {
|
||||
return
|
||||
}
|
||||
@@ -150,14 +227,14 @@ func HostDecrypt(aesKey string, host *Host) {
|
||||
}
|
||||
}
|
||||
|
||||
func SSHKeyEncrypt(aesKey string, key *SSHKey) (err error) {
|
||||
func SSHKeyEncrypt(aesKey string, key *dbmodels.SSHKey) (err error) {
|
||||
if aesKey == "" {
|
||||
return nil
|
||||
}
|
||||
key.PrivKey, err = encrypt([]byte(aesKey), key.PrivKey)
|
||||
return
|
||||
}
|
||||
func SSHKeyDecrypt(aesKey string, key *SSHKey) {
|
||||
func SSHKeyDecrypt(aesKey string, key *dbmodels.SSHKey) {
|
||||
if aesKey == "" {
|
||||
return
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package main
|
||||
package dbmodels // import "moul.io/sshportal/pkg/dbmodels"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/jinzhu/gorm"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -40,12 +38,12 @@ type Setting struct {
|
||||
type SSHKey struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Name string `valid:"required,length(1|255),unix_user"`
|
||||
Type string `valid:"required"`
|
||||
Length uint `valid:"required"`
|
||||
Fingerprint string `valid:"optional"`
|
||||
PrivKey string `sql:"size:10000" valid:"required"`
|
||||
PubKey string `sql:"size:10000" valid:"optional"`
|
||||
PrivKey string `sql:"size:5000" valid:"required"`
|
||||
PubKey string `sql:"size:1000" valid:"optional"`
|
||||
Hosts []*Host `gorm:"ForeignKey:SSHKeyID"`
|
||||
Comment string `valid:"optional"`
|
||||
}
|
||||
@@ -53,16 +51,17 @@ type SSHKey struct {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32" valid:"required,length(1|32),unix_user"`
|
||||
Name string `gorm:"size:255" valid:"required,length(1|255)"`
|
||||
Addr string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL
|
||||
User string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL
|
||||
Password string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL
|
||||
URL string `valid:"optional"`
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"` // SSHKey used to connect by the client
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000" valid:"optional"`
|
||||
HostKey []byte `sql:"size:1000" valid:"optional"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
Logging string `valid:"optional,host_logging_mode"`
|
||||
Hop *Host
|
||||
HopID uint
|
||||
}
|
||||
@@ -70,8 +69,8 @@ type Host struct {
|
||||
// UserKey defines a user public key used by sshportal to identify the user
|
||||
type UserKey struct {
|
||||
gorm.Model
|
||||
Key []byte `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
AuthorizedKey string `sql:"size:10000" valid:"required,length(1|10000)"`
|
||||
Key []byte `sql:"size:1000" valid:"length(1|1000)"`
|
||||
AuthorizedKey string `sql:"size:1000" valid:"required,length(1|1000)"`
|
||||
UserID uint ``
|
||||
User *User `gorm:"ForeignKey:UserID"`
|
||||
Comment string `valid:"optional"`
|
||||
@@ -79,7 +78,7 @@ type UserKey struct {
|
||||
|
||||
type UserRole struct {
|
||||
gorm.Model
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Name string `valid:"required,length(1|255),unix_user"`
|
||||
Users []*User `gorm:"many2many:user_user_roles"`
|
||||
}
|
||||
|
||||
@@ -88,7 +87,7 @@ type User struct {
|
||||
gorm.Model
|
||||
Roles []*UserRole `gorm:"many2many:user_user_roles"`
|
||||
Email string `valid:"required,email"`
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Name string `valid:"required,length(1|255),unix_user"`
|
||||
Keys []*UserKey `gorm:"ForeignKey:UserID"`
|
||||
Groups []*UserGroup `gorm:"many2many:user_user_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
@@ -97,7 +96,7 @@ type User struct {
|
||||
|
||||
type UserGroup struct {
|
||||
gorm.Model
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Name string `valid:"required,length(1|255),unix_user"`
|
||||
Users []*User `gorm:"many2many:user_user_groups;"`
|
||||
ACLs []*ACL `gorm:"many2many:user_group_acls;"`
|
||||
Comment string `valid:"optional"`
|
||||
@@ -105,7 +104,7 @@ type UserGroup struct {
|
||||
|
||||
type HostGroup struct {
|
||||
gorm.Model
|
||||
Name string `valid:"required,length(1|32),unix_user"`
|
||||
Name string `valid:"required,length(1|255),unix_user"`
|
||||
Hosts []*Host `gorm:"many2many:host_host_groups;"`
|
||||
ACLs []*ACL `gorm:"many2many:host_group_acls;"`
|
||||
Comment string `valid:"optional"`
|
||||
@@ -119,6 +118,8 @@ type ACL struct {
|
||||
Action string `valid:"required"`
|
||||
Weight uint ``
|
||||
Comment string `valid:"optional"`
|
||||
Inception *time.Time
|
||||
Expiration *time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
@@ -166,30 +167,27 @@ const (
|
||||
BastionSchemeTelnet BastionScheme = "telnet"
|
||||
)
|
||||
|
||||
func init() {
|
||||
unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*")
|
||||
|
||||
govalidator.CustomTypeTagMap.Set("unix_user", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool {
|
||||
name, ok := i.(string)
|
||||
if !ok {
|
||||
return false
|
||||
// Generic Helper
|
||||
func GenericNameOrID(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
var ids []string
|
||||
var names []string
|
||||
for _, s := range identifiers {
|
||||
if _, err := strconv.Atoi(s); err == nil {
|
||||
ids = append(ids, s)
|
||||
} else {
|
||||
names = append(names, s)
|
||||
}
|
||||
return unixUserRegexp.MatchString(name)
|
||||
}))
|
||||
}
|
||||
if len(ids) > 0 && len(names) > 0 {
|
||||
return db.Where("id IN (?)", ids).Or("name IN (?)", names)
|
||||
} else if len(ids) > 0 {
|
||||
return db.Where("id IN (?)", ids)
|
||||
}
|
||||
return db.Where("name IN (?)", names)
|
||||
}
|
||||
|
||||
// Host helpers
|
||||
|
||||
func ParseInputURL(input string) (*url.URL, error) {
|
||||
if !strings.Contains(input, "://") {
|
||||
input = "ssh://" + input
|
||||
}
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
func (host *Host) DialAddr() string {
|
||||
return fmt.Sprintf("%s:%d", host.Hostname(), host.Port())
|
||||
}
|
||||
@@ -289,7 +287,7 @@ func HostsPreload(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Groups").Preload("SSHKey")
|
||||
}
|
||||
func HostsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
return GenericNameOrID(db, identifiers)
|
||||
}
|
||||
func HostByName(db *gorm.DB, name string) (*Host, error) {
|
||||
var host Host
|
||||
@@ -301,7 +299,7 @@ func HostByName(db *gorm.DB, name string) (*Host, error) {
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
func (host *Host) clientConfig(hk gossh.HostKeyCallback) (*gossh.ClientConfig, error) {
|
||||
func (host *Host) ClientConfig(hk gossh.HostKeyCallback) (*gossh.ClientConfig, error) {
|
||||
config := gossh.ClientConfig{
|
||||
User: host.Username(),
|
||||
HostKeyCallback: hk,
|
||||
@@ -329,7 +327,7 @@ func SSHKeysPreload(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Hosts")
|
||||
}
|
||||
func SSHKeysByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
return GenericNameOrID(db, identifiers)
|
||||
}
|
||||
|
||||
// HostGroup helpers
|
||||
@@ -338,7 +336,7 @@ func HostGroupsPreload(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("ACLs").Preload("Hosts")
|
||||
}
|
||||
func HostGroupsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
return GenericNameOrID(db, identifiers)
|
||||
}
|
||||
|
||||
// UserGroup helpers
|
||||
@@ -347,7 +345,7 @@ func UserGroupsPreload(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("ACLs").Preload("Users")
|
||||
}
|
||||
func UserGroupsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
return GenericNameOrID(db, identifiers)
|
||||
}
|
||||
|
||||
// User helpers
|
||||
@@ -356,7 +354,21 @@ func UsersPreload(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Groups").Preload("Keys").Preload("Roles")
|
||||
}
|
||||
func UsersByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers).Or("email IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
var ids []string
|
||||
var names []string
|
||||
for _, s := range identifiers {
|
||||
if _, err := strconv.Atoi(s); err == nil {
|
||||
ids = append(ids, s)
|
||||
} else {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 && len(names) > 0 {
|
||||
db.Where("id IN (?)", identifiers).Or("email IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
} else if len(ids) > 0 {
|
||||
return db.Where("id IN (?)", ids)
|
||||
}
|
||||
return db.Where("email IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
}
|
||||
func (u *User) HasRole(name string) bool {
|
||||
for _, role := range u.Roles {
|
||||
@@ -392,14 +404,14 @@ func UserKeysPreload(db *gorm.DB) *gorm.DB {
|
||||
func UserKeysByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers)
|
||||
}
|
||||
func UserKeysByUserID(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("user_id IN (?)", identifiers)
|
||||
}
|
||||
|
||||
// UserRole helpers
|
||||
|
||||
//func UserRolesPreload(db *gorm.DB) *gorm.DB {
|
||||
// return db.Preload("Users")
|
||||
//}
|
||||
func UserRolesByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
|
||||
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
|
||||
return GenericNameOrID(db, identifiers)
|
||||
}
|
||||
|
||||
// Session helpers
|
||||
@@ -446,7 +458,6 @@ func (e *Event) Log(db *gorm.DB) {
|
||||
}
|
||||
|
||||
func (e *Event) SetAuthor(user *User) *Event {
|
||||
//e.Author = user
|
||||
e.AuthorID = user.ID
|
||||
return e
|
||||
}
|
||||
33
pkg/dbmodels/validator.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package dbmodels
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
)
|
||||
|
||||
func InitValidator() {
|
||||
unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*")
|
||||
|
||||
govalidator.CustomTypeTagMap.Set("unix_user", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool {
|
||||
name, ok := i.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return unixUserRegexp.MatchString(name)
|
||||
}))
|
||||
govalidator.CustomTypeTagMap.Set("host_logging_mode", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool {
|
||||
name, ok := i.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
return IsValidHostLoggingMode(name)
|
||||
}))
|
||||
}
|
||||
|
||||
func IsValidHostLoggingMode(name string) bool {
|
||||
return name == "disabled" || name == "input" || name == "everything"
|
||||
}
|
||||
13
pkg/utils/emailvalidator.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package utils
|
||||
|
||||
import "regexp"
|
||||
|
||||
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])?)*$")
|
||||
|
||||
// ValidateEmail validates email.
|
||||
func ValidateEmail(e string) bool {
|
||||
if len(e) < 3 || len(e) > 254 {
|
||||
return false
|
||||
}
|
||||
return emailRegex.MatchString(e)
|
||||
}
|
||||
30
pkg/utils/emailvalidator_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"moul.io/sshportal/pkg/utils"
|
||||
)
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"goodemail@email.com", true},
|
||||
{"b@2323.22", true},
|
||||
{"b@2322.", false},
|
||||
{"", false},
|
||||
{"blah", false},
|
||||
{"blah.com", false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
got := utils.ValidateEmail(test.input)
|
||||
if got != test.expected {
|
||||
t.Errorf("expected %v, got %v", test.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base"
|
||||
]
|
||||
}
|
||||
361
rules.mk
vendored
Normal file
@@ -0,0 +1,361 @@
|
||||
# +--------------------------------------------------------------+
|
||||
# | * * * moul.io/rules.mk |
|
||||
# +--------------------------------------------------------------+
|
||||
# | |
|
||||
# | ++ ______________________________________ |
|
||||
# | ++++ / \ |
|
||||
# | ++++ | | |
|
||||
# | ++++++++++ | https://moul.io/rules.mk is a set | |
|
||||
# | +++ | | of common Makefile rules that can | |
|
||||
# | ++ | | be configured from the Makefile | |
|
||||
# | + -== ==| | or with environment variables. | |
|
||||
# | ( <*> <*> | | |
|
||||
# | | | /| Manfred Touron | |
|
||||
# | | _) / | manfred.life | |
|
||||
# | | +++ / \______________________________________/ |
|
||||
# | \ =+ / |
|
||||
# | \ + |
|
||||
# | |\++++++ |
|
||||
# | | ++++ ||// |
|
||||
# | ___| |___ _||/__ __|
|
||||
# | / --- \ \| ||| __ _ ___ __ __/ /|
|
||||
# |/ | | \ \ / / ' \/ _ \/ // / / |
|
||||
# || | | | | | /_/_/_/\___/\_,_/_/ |
|
||||
# +--------------------------------------------------------------+
|
||||
|
||||
.PHONY: _default_entrypoint
|
||||
_default_entrypoint: help
|
||||
|
||||
##
|
||||
## Common helpers
|
||||
##
|
||||
|
||||
rwildcard = $(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2) $(filter $(subst *,%,$2),$d))
|
||||
check-program = $(foreach exec,$(1),$(if $(shell PATH="$(PATH)" which $(exec)),,$(error "No $(exec) in PATH")))
|
||||
my-filter-out = $(foreach v,$(2),$(if $(findstring $(1),$(v)),,$(v)))
|
||||
novendor = $(call my-filter-out,vendor/,$(1))
|
||||
|
||||
##
|
||||
## rules.mk
|
||||
##
|
||||
ifneq ($(wildcard rules.mk),)
|
||||
.PHONY: rulesmk.bumpdeps
|
||||
rulesmk.bumpdeps:
|
||||
wget -O rules.mk https://raw.githubusercontent.com/moul/rules.mk/master/rules.mk
|
||||
BUMPDEPS_STEPS += rulesmk.bumpdeps
|
||||
endif
|
||||
|
||||
##
|
||||
## Maintainer
|
||||
##
|
||||
|
||||
ifneq ($(wildcard .git/HEAD),)
|
||||
.PHONY: generate.authors
|
||||
generate.authors: AUTHORS
|
||||
AUTHORS: .git/
|
||||
echo "# This file lists all individuals having contributed content to the repository." > AUTHORS
|
||||
echo "# For how it is generated, see 'https://github.com/moul/rules.mk'" >> AUTHORS
|
||||
echo >> AUTHORS
|
||||
git log --format='%aN <%aE>' | LC_ALL=C.UTF-8 sort -uf >> AUTHORS
|
||||
GENERATE_STEPS += generate.authors
|
||||
endif
|
||||
|
||||
##
|
||||
## Golang
|
||||
##
|
||||
|
||||
ifndef GOPKG
|
||||
ifneq ($(wildcard go.mod),)
|
||||
GOPKG = $(shell sed '/module/!d;s/^omdule\ //' go.mod)
|
||||
endif
|
||||
endif
|
||||
ifdef GOPKG
|
||||
GO ?= go
|
||||
GOPATH ?= $(HOME)/go
|
||||
GO_INSTALL_OPTS ?=
|
||||
GO_TEST_OPTS ?= -test.timeout=30s
|
||||
GOMOD_DIRS ?= $(sort $(call novendor,$(dir $(call rwildcard,*,*/go.mod go.mod))))
|
||||
GOCOVERAGE_FILE ?= ./coverage.txt
|
||||
GOTESTJSON_FILE ?= ./go-test.json
|
||||
GOBUILDLOG_FILE ?= ./go-build.log
|
||||
GOINSTALLLOG_FILE ?= ./go-install.log
|
||||
|
||||
ifdef GOBINS
|
||||
.PHONY: go.install
|
||||
go.install:
|
||||
ifeq ($(CI),true)
|
||||
@rm -f /tmp/goinstall.log
|
||||
@set -e; for dir in $(GOBINS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) install -v $(GO_INSTALL_OPTS) .; \
|
||||
); done 2>&1 | tee $(GOINSTALLLOG_FILE)
|
||||
|
||||
else
|
||||
@set -e; for dir in $(GOBINS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) install $(GO_INSTALL_OPTS) .; \
|
||||
); done
|
||||
endif
|
||||
INSTALL_STEPS += go.install
|
||||
|
||||
.PHONY: go.release
|
||||
go.release:
|
||||
$(call check-program, goreleaser)
|
||||
goreleaser --snapshot --skip-publish --rm-dist
|
||||
@echo -n "Do you want to release? [y/N] " && read ans && \
|
||||
if [ $${ans:-N} = y ]; then set -xe; goreleaser --rm-dist; fi
|
||||
RELEASE_STEPS += go.release
|
||||
endif
|
||||
|
||||
.PHONY: go.unittest
|
||||
go.unittest:
|
||||
ifeq ($(CI),true)
|
||||
@echo "mode: atomic" > /tmp/gocoverage
|
||||
@rm -f $(GOTESTJSON_FILE)
|
||||
@set -e; for dir in $(GOMOD_DIRS); do (set -e; (set -euf pipefail; \
|
||||
cd $$dir; \
|
||||
(($(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); \
|
||||
); \
|
||||
rm $@.ok 2>/dev/null || exit 1; \
|
||||
if [ -f /tmp/profile.out ]; then \
|
||||
cat /tmp/profile.out | sed "/mode: atomic/d" >> /tmp/gocoverage; \
|
||||
rm -f /tmp/profile.out; \
|
||||
fi)); done
|
||||
@mv /tmp/gocoverage $(GOCOVERAGE_FILE)
|
||||
else
|
||||
@echo "mode: atomic" > /tmp/gocoverage
|
||||
@set -e; for dir in $(GOMOD_DIRS); do (set -e; (set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) test ./... $(GO_TEST_OPTS) -cover -coverprofile=/tmp/profile.out -covermode=atomic -race); \
|
||||
if [ -f /tmp/profile.out ]; then \
|
||||
cat /tmp/profile.out | sed "/mode: atomic/d" >> /tmp/gocoverage; \
|
||||
rm -f /tmp/profile.out; \
|
||||
fi); done
|
||||
@mv /tmp/gocoverage $(GOCOVERAGE_FILE)
|
||||
endif
|
||||
|
||||
.PHONY: go.checkdoc
|
||||
go.checkdoc:
|
||||
go doc $(first $(GOMOD_DIRS))
|
||||
|
||||
.PHONY: go.coverfunc
|
||||
go.coverfunc: go.unittest
|
||||
go tool cover -func=$(GOCOVERAGE_FILE) | grep -v .pb.go: | grep -v .pb.gw.go:
|
||||
|
||||
.PHONY: go.lint
|
||||
go.lint:
|
||||
@set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
golangci-lint run --verbose ./...; \
|
||||
); done
|
||||
|
||||
.PHONY: go.tidy
|
||||
go.tidy:
|
||||
@# tidy dirs with go.mod files
|
||||
@set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) mod tidy; \
|
||||
); done
|
||||
|
||||
.PHONY: go.depaware-update
|
||||
go.depaware-update: go.tidy
|
||||
@# gen depaware for bins
|
||||
@set -e; for dir in $(GOBINS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) run github.com/tailscale/depaware --update .; \
|
||||
); done
|
||||
@# tidy unused depaware deps if not in a tools_test.go file
|
||||
@set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) mod tidy; \
|
||||
); done
|
||||
|
||||
.PHONY: go.depaware-check
|
||||
go.depaware-check: go.tidy
|
||||
@# gen depaware for bins
|
||||
@set -e; for dir in $(GOBINS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) run github.com/tailscale/depaware --check .; \
|
||||
); done
|
||||
|
||||
|
||||
.PHONY: go.build
|
||||
go.build:
|
||||
@set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) build ./...; \
|
||||
); done
|
||||
|
||||
.PHONY: go.bump-deps
|
||||
go.bumpdeps:
|
||||
@set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) get -u ./...; \
|
||||
); done
|
||||
|
||||
.PHONY: go.bump-deps
|
||||
go.fmt:
|
||||
@set -e; for dir in $(GOMOD_DIRS); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
$(GO) run golang.org/x/tools/cmd/goimports -w `go list -f '{{.Dir}}' ./...` \
|
||||
); done
|
||||
|
||||
VERIFY_STEPS += go.depaware-check
|
||||
BUILD_STEPS += go.build
|
||||
BUMPDEPS_STEPS += go.bumpdeps go.depaware-update
|
||||
TIDY_STEPS += go.tidy
|
||||
LINT_STEPS += go.lint
|
||||
UNITTEST_STEPS += go.unittest
|
||||
FMT_STEPS += go.fmt
|
||||
|
||||
# FIXME: disabled, because currently slow
|
||||
# new rule that is manually run sometimes, i.e. `make pre-release` or `make maintenance`.
|
||||
# alternative: run it each time the go.mod is changed
|
||||
#GENERATE_STEPS += go.depaware-update
|
||||
endif
|
||||
|
||||
##
|
||||
## Gitattributes
|
||||
##
|
||||
|
||||
ifneq ($(wildcard .gitattributes),)
|
||||
.PHONY: _linguist-ignored
|
||||
_linguist-kept:
|
||||
@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
|
||||
|
||||
.PHONY: _linguist-kept
|
||||
_linguist-ignored:
|
||||
@git check-attr linguist-vendored linguist-ignored `find . -not -path './.git/*' -type f` | grep '\ set$$' | cut -d: -f1 | sort -u
|
||||
endif
|
||||
|
||||
##
|
||||
## Node
|
||||
##
|
||||
|
||||
ifndef NPM_PACKAGES
|
||||
ifneq ($(wildcard package.json),)
|
||||
NPM_PACKAGES = .
|
||||
endif
|
||||
endif
|
||||
ifdef NPM_PACKAGES
|
||||
.PHONY: npm.publish
|
||||
npm.publish:
|
||||
@echo -n "Do you want to npm publish? [y/N] " && read ans && \
|
||||
@if [ $${ans:-N} = y ]; then \
|
||||
set -e; for dir in $(NPM_PACKAGES); do ( set -xe; \
|
||||
cd $$dir; \
|
||||
npm publish --access=public; \
|
||||
); done; \
|
||||
fi
|
||||
RELEASE_STEPS += npm.publish
|
||||
endif
|
||||
|
||||
##
|
||||
## Docker
|
||||
##
|
||||
|
||||
docker_build = docker build \
|
||||
--build-arg VCS_REF=`git rev-parse --short HEAD` \
|
||||
--build-arg BUILD_DATE=`date -u +"%Y-%m-%dT%H:%M:%SZ"` \
|
||||
--build-arg VERSION=`git describe --tags --always` \
|
||||
-t "$2" -f "$1" "$(dir $1)"
|
||||
|
||||
ifndef DOCKERFILE_PATH
|
||||
DOCKERFILE_PATH = ./Dockerfile
|
||||
endif
|
||||
ifndef DOCKER_IMAGE
|
||||
ifneq ($(wildcard Dockerfile),)
|
||||
DOCKER_IMAGE = $(notdir $(PWD))
|
||||
endif
|
||||
endif
|
||||
ifdef DOCKER_IMAGE
|
||||
ifneq ($(DOCKER_IMAGE),none)
|
||||
.PHONY: docker.build
|
||||
docker.build:
|
||||
$(call check-program, docker)
|
||||
$(call docker_build,$(DOCKERFILE_PATH),$(DOCKER_IMAGE))
|
||||
|
||||
BUILD_STEPS += docker.build
|
||||
endif
|
||||
endif
|
||||
|
||||
##
|
||||
## Common
|
||||
##
|
||||
|
||||
TEST_STEPS += $(UNITTEST_STEPS)
|
||||
TEST_STEPS += $(LINT_STEPS)
|
||||
TEST_STEPS += $(TIDY_STEPS)
|
||||
|
||||
ifneq ($(strip $(TEST_STEPS)),)
|
||||
.PHONY: test
|
||||
test: $(PRE_TEST_STEPS) $(TEST_STEPS)
|
||||
endif
|
||||
|
||||
ifdef INSTALL_STEPS
|
||||
.PHONY: install
|
||||
install: $(PRE_INSTALL_STEPS) $(INSTALL_STEPS)
|
||||
endif
|
||||
|
||||
ifdef UNITTEST_STEPS
|
||||
.PHONY: unittest
|
||||
unittest: $(PRE_UNITTEST_STEPS) $(UNITTEST_STEPS)
|
||||
endif
|
||||
|
||||
ifdef LINT_STEPS
|
||||
.PHONY: lint
|
||||
lint: $(PRE_LINT_STEPS) $(FMT_STEPS) $(LINT_STEPS)
|
||||
endif
|
||||
|
||||
ifdef TIDY_STEPS
|
||||
.PHONY: tidy
|
||||
tidy: $(PRE_TIDY_STEPS) $(TIDY_STEPS)
|
||||
endif
|
||||
|
||||
ifdef BUILD_STEPS
|
||||
.PHONY: build
|
||||
build: $(PRE_BUILD_STEPS) $(BUILD_STEPS)
|
||||
endif
|
||||
|
||||
ifdef VERIFY_STEPS
|
||||
.PHONY: verify
|
||||
verify: $(PRE_VERIFY_STEPS) $(VERIFY_STEPS)
|
||||
endif
|
||||
|
||||
ifdef RELEASE_STEPS
|
||||
.PHONY: release
|
||||
release: $(PRE_RELEASE_STEPS) $(RELEASE_STEPS)
|
||||
endif
|
||||
|
||||
ifdef BUMPDEPS_STEPS
|
||||
.PHONY: bumpdeps
|
||||
bumpdeps: $(PRE_BUMDEPS_STEPS) $(BUMPDEPS_STEPS)
|
||||
endif
|
||||
|
||||
ifdef FMT_STEPS
|
||||
.PHONY: fmt
|
||||
fmt: $(PRE_FMT_STEPS) $(FMT_STEPS)
|
||||
endif
|
||||
|
||||
ifdef GENERATE_STEPS
|
||||
.PHONY: generate
|
||||
generate: $(PRE_GENERATE_STEPS) $(GENERATE_STEPS)
|
||||
endif
|
||||
|
||||
.PHONY: help
|
||||
help::
|
||||
@echo "General commands:"
|
||||
@[ "$(BUILD_STEPS)" != "" ] && echo " build" || true
|
||||
@[ "$(BUMPDEPS_STEPS)" != "" ] && echo " bumpdeps" || true
|
||||
@[ "$(FMT_STEPS)" != "" ] && echo " fmt" || true
|
||||
@[ "$(GENERATE_STEPS)" != "" ] && echo " generate" || true
|
||||
@[ "$(INSTALL_STEPS)" != "" ] && echo " install" || true
|
||||
@[ "$(LINT_STEPS)" != "" ] && echo " lint" || true
|
||||
@[ "$(RELEASE_STEPS)" != "" ] && echo " release" || true
|
||||
@[ "$(TEST_STEPS)" != "" ] && echo " test" || true
|
||||
@[ "$(TIDY_STEPS)" != "" ] && echo " tidy" || true
|
||||
@[ "$(UNITTEST_STEPS)" != "" ] && echo " unittest" || true
|
||||
@[ "$(VERIFY_STEPS)" != "" ] && echo " verify" || true
|
||||
@# FIXME: list other commands
|
||||
|
||||
print-% : ; $(info $* is a $(flavor $*) variable set to [$($*)]) @true
|
||||
134
server.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/urfave/cli"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"moul.io/sshportal/pkg/bastion"
|
||||
)
|
||||
|
||||
type serverConfig struct {
|
||||
aesKey string
|
||||
dbDriver, dbURL string
|
||||
logsLocation string
|
||||
bindAddr string
|
||||
debug, demo bool
|
||||
idleTimeout time.Duration
|
||||
}
|
||||
|
||||
func parseServerConfig(c *cli.Context) (*serverConfig, error) {
|
||||
ret := &serverConfig{
|
||||
aesKey: c.String("aes-key"),
|
||||
dbDriver: c.String("db-driver"),
|
||||
dbURL: c.String("db-conn"),
|
||||
bindAddr: c.String("bind-address"),
|
||||
debug: c.Bool("debug"),
|
||||
demo: c.Bool("demo"),
|
||||
logsLocation: c.String("logs-location"),
|
||||
idleTimeout: c.Duration("idle-timeout"),
|
||||
}
|
||||
switch len(ret.aesKey) {
|
||||
case 0, 16, 24, 32:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid aes key size, should be 16 or 24, 32")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func ensureLogDirectory(location string) error {
|
||||
// check for the logdir existence
|
||||
logsLocation, err := os.Stat(location)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.MkdirAll(location, os.ModeDir|os.FileMode(0750))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !logsLocation.IsDir() {
|
||||
return fmt.Errorf("log directory cannot be created")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func server(c *serverConfig) (err error) {
|
||||
var db *gorm.DB
|
||||
|
||||
// try to setup the local DB
|
||||
if db, err = gorm.Open(c.dbDriver, c.dbURL); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
origErr := err
|
||||
err = db.Close()
|
||||
if origErr != nil {
|
||||
err = origErr
|
||||
}
|
||||
}()
|
||||
if err = db.DB().Ping(); err != nil {
|
||||
return
|
||||
}
|
||||
db.LogMode(c.debug)
|
||||
if err = bastion.DBInit(db); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// create TCP listening socket
|
||||
ln, err := net.Listen("tcp", c.bindAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// configure server
|
||||
srv := &ssh.Server{
|
||||
Addr: c.bindAddr,
|
||||
Handler: func(s ssh.Session) { bastion.ShellHandler(s, GitTag, GitSha, GitTag) }, // ssh.Server.Handler is the handler for the DefaultSessionHandler
|
||||
Version: fmt.Sprintf("sshportal-%s", GitTag),
|
||||
ChannelHandlers: map[string]ssh.ChannelHandler{
|
||||
"default": bastion.ChannelHandler,
|
||||
},
|
||||
}
|
||||
|
||||
// configure channel handler
|
||||
bastion.DefaultChannelHandler = func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
switch newChan.ChannelType() {
|
||||
case "session":
|
||||
go ssh.DefaultSessionHandler(srv, conn, newChan, ctx)
|
||||
case "direct-tcpip":
|
||||
go ssh.DirectTCPIPHandler(srv, conn, newChan, ctx)
|
||||
default:
|
||||
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
|
||||
log.Printf("failed to reject chan: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.idleTimeout != 0 {
|
||||
srv.IdleTimeout = c.idleTimeout
|
||||
// gliderlabs/ssh requires MaxTimeout to be non-zero if we want to use IdleTimeout.
|
||||
// So, set it to the max value, because we don't want a max timeout.
|
||||
srv.MaxTimeout = math.MaxInt64
|
||||
}
|
||||
|
||||
for _, opt := range []ssh.Option{
|
||||
// custom PublicKeyAuth handler
|
||||
ssh.PublicKeyAuth(bastion.PublicKeyAuthHandler(db, c.logsLocation, c.aesKey, c.dbDriver, c.dbURL, c.bindAddr, c.demo)),
|
||||
ssh.PasswordAuth(bastion.PasswordAuthHandler(db, c.logsLocation, c.aesKey, c.dbDriver, c.dbURL, c.bindAddr, c.demo)),
|
||||
// retrieve sshportal SSH private key from database
|
||||
bastion.PrivateKeyFromDB(db, c.aesKey),
|
||||
} {
|
||||
if err := srv.SetOption(opt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("info: SSH Server accepting connections on %s, idle-timout=%v", c.bindAddr, c.idleTimeout)
|
||||
return srv.Serve(ln)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// +build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -9,8 +11,8 @@ import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/kr/pty"
|
||||
"github.com/moul/ssh"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
@@ -58,7 +60,7 @@ func testServer(c *cli.Context) error {
|
||||
_, _ = io.Copy(s, f) // #nosec
|
||||
cmdErr = cmd.Wait()
|
||||
} else {
|
||||
//cmd.Stdin = s
|
||||
// cmd.Stdin = s
|
||||
cmd.Stdout = s
|
||||
cmd.Stderr = s
|
||||
cmdErr = cmd.Run()
|
||||
14
testserver_unsupported.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// testServer is an hidden handler used for integration tests
|
||||
func testServer(c *cli.Context) error {
|
||||
return fmt.Errorf("not available on windows")
|
||||
}
|
||||
20
util.go
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import "math/rand"
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func randStringBytes(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func wrapText(in string, length int) string {
|
||||
if len(in) <= length {
|
||||
return in
|
||||
}
|
||||
return in[0:length-3] + "..."
|
||||
}
|
||||