Compare commits

..

333 Commits

Author SHA1 Message Date
Manfred Touron
ef0083ed71 Merge pull request #304 from moul/renovate/all
fix(deps): update all
2021-11-21 08:36:55 +01:00
Renovate Bot
2215b5e1a7 fix(deps): update all 2021-11-17 20:25:01 +00:00
Manfred Touron
f72618618b Merge pull request #299 from moul/renovate/all 2021-11-14 08:35:48 +01:00
Renovate Bot
9f5b05461b chore(deps): update all docker tags 2021-11-13 09:49:43 +00:00
b0undl3ss
4c7fcec0e3 docs: add testing info to readme (#301)
* refactor: Upgrade to Gorm 2.0

* Clean up dependencies

* Fix linting errors

Remove gorm v1 delete callback

* Resolve unittest failures

* Fix association query

* Add testing section to readme

Co-authored-by: Jordan Craven <jordan.craven@wearepop.com>
2021-11-04 20:25:17 +01:00
b0undl3ss
6068e6e48e refactor: upgrade to Gorm 2.0 (#300)
Co-authored-by: Jordan Craven <jordan.craven@wearepop.com>
2021-11-02 08:31:49 +01:00
Manfred Touron
01e4dcb686 Merge pull request #282 from moul/renovate/all 2021-10-15 11:32:06 +02:00
Renovate Bot
b140f70abd chore(deps): update all docker tags 2021-10-15 09:27:54 +00:00
Manfred Touron
5fc247810b Merge pull request #296 from moul/dependabot/docker/golang-1.17.2 2021-10-15 11:27:00 +02:00
Manfred Touron
9205d8faca Merge pull request #284 from moul/dependabot/go_modules/github.com/gliderlabs/ssh-0.3.3 2021-10-15 11:26:51 +02:00
Manfred Touron
71bb63d122 Merge pull request #283 from moul/dependabot/go_modules/golang.org/x/tools-0.1.4 2021-10-15 11:26:41 +02:00
dependabot[bot]
daf4af9225 chore(deps): bump golang from 1.16.5 to 1.17.2
Bumps golang from 1.16.5 to 1.17.2.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-10-08 04:04:45 +00:00
dependabot[bot]
f366e52d9e chore(deps): bump github.com/gliderlabs/ssh from 0.3.2 to 0.3.3
Bumps [github.com/gliderlabs/ssh](https://github.com/gliderlabs/ssh) from 0.3.2 to 0.3.3.
- [Release notes](https://github.com/gliderlabs/ssh/releases)
- [Commits](https://github.com/gliderlabs/ssh/compare/v0.3.2...v0.3.3)

---
updated-dependencies:
- dependency-name: github.com/gliderlabs/ssh
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-29 04:07:42 +00:00
dependabot[bot]
f1137fd16b chore(deps): bump golang.org/x/tools from 0.1.3 to 0.1.4
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.1.3 to 0.1.4.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.1.3...v0.1.4)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-24 04:07:41 +00:00
Manfred Touron
65de503b2e Merge pull request #280 from moul/renovate/all
fix(deps): update module golang.org/x/tools to v0.1.3
2021-06-11 06:08:25 +02:00
Renovate Bot
728cb237a0 fix(deps): update module golang.org/x/tools to v0.1.3 2021-06-10 18:36:37 +00:00
Manfred Touron
3c701b2405 Merge pull request #271 from moul/renovate/all
chore(deps): update all docker tags
2021-06-05 16:20:23 +02:00
Renovate Bot
173839c7c1 chore(deps): update all docker tags 2021-06-05 01:03:25 +00:00
Manfred Touron
caf80be30c Merge pull request #277 from moul/dependabot/github_actions/actions/cache-2.1.6
chore(deps): bump actions/cache from 2.1.5 to 2.1.6
2021-05-29 01:52:52 +02:00
Manfred Touron
c57cb0073a Merge pull request #273 from moul/dependabot/github_actions/codfish/semantic-release-action-1.9.0 2021-05-29 01:52:36 +02:00
dependabot[bot]
a496f3ce36 chore(deps): bump actions/cache from 2.1.5 to 2.1.6
Bumps [actions/cache](https://github.com/actions/cache) from 2.1.5 to 2.1.6.
- [Release notes](https://github.com/actions/cache/releases)
- [Commits](https://github.com/actions/cache/compare/v2.1.5...v2.1.6)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-28 04:03:32 +00:00
dependabot[bot]
e83216a34f chore(deps): bump codfish/semantic-release-action from 1 to 1.9.0
Bumps [codfish/semantic-release-action](https://github.com/codfish/semantic-release-action) from 1 to 1.9.0.
- [Release notes](https://github.com/codfish/semantic-release-action/releases)
- [Commits](https://github.com/codfish/semantic-release-action/compare/v1...v1.9.0)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-12 04:03:20 +00:00
Manfred Touron
b0ec9c99f9 Merge pull request #272 from moul/dependabot/docker/golang-1.16.4
chore(deps): bump golang from 1.16.3 to 1.16.4
2021-05-07 08:18:19 +02:00
dependabot[bot]
cfaf215713 chore(deps): bump golang from 1.16.3 to 1.16.4
Bumps golang from 1.16.3 to 1.16.4.

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-07 04:04:32 +00:00
Manfred Touron
debb5bf179 Merge pull request #266 from moul/dependabot/docker/golang-1.16.3
chore(deps): bump golang from 1.16.2 to 1.16.3
2021-05-01 09:32:27 +02:00
dependabot[bot]
d5bf550e96 chore(deps): bump golang from 1.16.2 to 1.16.3
Bumps golang from 1.16.2 to 1.16.3.

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-27 08:37:08 +00:00
Manfred Touron
7c68c861bd Merge pull request #267 from moul/dependabot/github_actions/golangci/golangci-lint-action-v2.5.2
chore(deps): bump golangci/golangci-lint-action from v2.5.1 to v2.5.2
2021-04-27 10:11:38 +02:00
Manfred Touron
bae5ad94a9 Merge pull request #227 from moul/renovate/all
fix(deps): update all
2021-04-27 10:03:47 +02:00
Renovate Bot
5369a4f966 fix(deps): update all 2021-04-25 16:18:08 +00:00
Manfred Touron
1c98ef283e Merge pull request #270 from moul/dev/moul/maintenance
chore: repo maintenance 🤖
2021-04-25 18:15:51 +02:00
moul-bot
3dc2801c60 chore: repo maintenance 🤖
more details: https://github.com/moul/repoman

Signed-off-by: moul-bot <bot@moul.io>
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2021-04-25 16:08:45 +00:00
dependabot[bot]
04913f1f4c chore(deps): bump golangci/golangci-lint-action from v2.5.1 to v2.5.2
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from v2.5.1 to v2.5.2.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v2.5.1...5c56cd6c9dc07901af25baab6f2b0d9f3b7c3018)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-25 09:53:46 +00:00
Manfred Touron
0415f116ea Merge pull request #212 from GreyOBox/dev/GreyOBox/acls-cmd-hook 2021-04-25 11:50:28 +02:00
Manfred Touron
68ce353c5d Merge pull request #269 from moul/dev/moul/maintenance 2021-04-25 11:47:36 +02:00
Manfred Touron
f7ed3a66f2 fix: email address validator 2021-04-24 12:54:42 +00:00
moul-bot
4e9c5205c7 chore: repo maintenance 🤖
more details: https://github.com/moul/repoman

Signed-off-by: moul-bot <bot@moul.io>
2021-04-24 12:35:39 +00:00
Manfred Touron
63b4aa5533 Merge pull request #268 from moul/dependabot/github_actions/actions/cache-v2.1.5
chore(deps): bump actions/cache from v2.1.4 to v2.1.5
2021-04-21 09:09:52 +02:00
Sergey Yashchuk
d580b14d62 Merge branch 'master' into dev/GreyOBox/acls-cmd-hook 2021-04-20 18:42:36 +07:00
Sergey Yashchuk
669577de47 Merge pull request #1 from moul/master
Update from upstream master
2021-04-20 18:28:12 +07:00
dependabot[bot]
868be6af11 chore(deps): bump actions/cache from v2.1.4 to v2.1.5
Bumps [actions/cache](https://github.com/actions/cache) from v2.1.4 to v2.1.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Commits](https://github.com/actions/cache/compare/v2.1.4...1a9e2138d905efd099035b49d8b7a3888c653ca8)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-13 04:13:10 +00:00
Sergey Yashchuk
97bf5d3168 lint fix 2021-04-02 10:49:18 +07:00
Sergey Yashchuk
32fcfa370c Fixes related to comments in PR 2021-04-02 10:29:46 +07:00
Manfred Touron
a710e50b1e Merge pull request #239 from moul/dependabot/github_actions/actions/cache-v2.1.4
chore(deps): bump actions/cache from v2.1.3 to v2.1.4
2021-03-31 13:50:53 +02:00
dependabot[bot]
55010dcc09 chore(deps): bump actions/cache from v2.1.3 to v2.1.4
Bumps [actions/cache](https://github.com/actions/cache) from v2.1.3 to v2.1.4.
- [Release notes](https://github.com/actions/cache/releases)
- [Commits](https://github.com/actions/cache/compare/v2.1.3...26968a09c0ea4f3e233fdddbafd1166051a095f6)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-31 10:56:17 +00:00
Manfred Touron
9413b75dc8 Merge pull request #263 from moul/dev/moul/bump-ci-go
chore: bump CI's go version
2021-03-31 12:55:17 +02:00
Manfred Touron
2648418463 chore: bump CI's go version 2021-03-31 10:52:14 +00:00
Manfred Touron
79cbaa3afe Merge pull request #262 from moul/fix/email-validator
feat: New Email validator
2021-03-28 22:46:58 +02:00
Darko Djalevski
2def328f6a fix: fix email validating in shell input
fix: test cases

fix feedback

fix: validate email with custom validator in shell input
2021-03-28 22:25:25 +02:00
Manfred Touron
ab9c53f1b0 chore: maintenance (#260) 2021-03-26 17:26:10 +01:00
Manfred Touron
614418e7be Merge pull request #243 from matteyeux/master
Fix typo in "shell commands" section in README.md
2021-03-26 16:29:29 +01:00
Manfred Touron
a5bade8761 Merge pull request #249 from jwessel/fix_host_inspect
fix: host inspect causes db errors with later operations
2021-03-26 16:28:44 +01:00
Manfred Touron
7404704bfe Merge pull request #254 from jwessel/feat_userkey_create
feat: Allow user multiple keys with userkey create
2021-03-26 16:27:43 +01:00
Manfred Touron
84a0a31eda Merge pull request #253 from jwessel/feat_postgres
feat: Add postgres support
2021-03-26 16:26:05 +01:00
Manfred Touron
40bbea590c Merge pull request #248 from jwessel/master
feat: Allow removal by user for 'userkey rm'
2021-03-26 16:18:27 +01:00
Manfred Touron
e455d50db9 Merge pull request #251 from jwessel/feat_user_udpate
feat: Allow for update or removal of the invite token
2021-03-26 16:11:28 +01:00
Manfred Touron
be3f215e24 Merge pull request #256 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.16.2
2021-03-26 11:46:56 +01:00
Renovate Bot
c290253546 chore(deps): update all docker tags to v1.16.2 2021-03-12 02:24:27 +00:00
Jason Wessel
28a5fd1846 feat: Allow user multiple keys with userkey create
And end user may have more than one ssh key, the userkey create
command should be able to accept more than one key so you can do
something like:

   curl https://github.com/USER.keys | ssh sshportal -p 2222 -l admin userkey create USER

The userkey create command also does not work properly from an
interactive shell due to the use of bufio.  This patch adds the
ability to use either the interactive shell or direct ssh command to
input one or more keys.

Signed-off-by: Jason Wessel <jason.wessel@windriver.com>
2021-03-09 09:29:10 -06:00
Jason Wessel
19605f0054 feat: Add postgres support
Postgres is more picky about submitting a string to the id column in a
table.  Postgres requires the use of only integers for the array of
values in a select statement containing: where id IN (...array...)

This patch fixes all the following class of problems:

   SELECT * FROM "ssh_keys" WHERE
   "ssh_keys"."deleted_at" IS NULL AND ((id IN ('host')) OR (name IN
   ('host'))) ORDER BY "ssh_keys"."id" ASC LIMIT 1 [0 rows affected or
   returned ] error: pq: invalid input syntax for
   type integer: "host"

Signed-off-by: Jason Wessel <jason.wessel@windriver.com>
2021-03-09 08:57:08 -06:00
Jason Wessel
5b4332072c feat: Allow for update or removal of the invite token
If the invite leaks for the admin user it is possible for the admin
user to be compromised by another invite request.  It needs to be
possible to entirely remove the invite capability for any given user.

New arguments added to user update:

   --invite_token value, -i value            Updates the invite token
   --remove_invite, -R                       Remove invite token

Signed-off-by: Jason Wessel <jason.wessel@windriver.com>
2021-03-09 08:51:45 -06:00
Jason Wessel
c1c4c556b4 feat: Allow removal by user for 'userkey rm'
The userkey rm command implies that it can remove a key by user or the
id key, but it only works against the data base id of the key.  This
patch allows the userkey rm command to work with the user name, so
that all the keys for the user can be cleared out in one command.

Signed-off-by: Jason Wessel <jason.wessel@windriver.com>
2021-03-08 11:22:38 -06:00
Jason Wessel
3c32177213 fix: host inspect causes db errors with later operations
The most simple case with a fresh install of sshportal using the
following commands put the shell into a unrecoverable state.

config> host create test1@test1
1
config> host inspect 1
config> host create test2@test2
error: can't preload field Groups for dbmodels.SSHKey

The issue is caused because the global db handle is replaced with the
inspect command.

Signed-off-by: Jason Wessel <jason.wessel@windriver.com>
2021-03-01 07:37:15 -08:00
Manfred Touron
762736d622 Merge pull request #216 from jle64/ecdsa 2021-02-28 07:46:06 +01:00
Manfred Touron
bbbc484fe8 Merge pull request #247 from moul/dependabot/github_actions/golangci/golangci-lint-action-v2.5.1
chore(deps): bump golangci/golangci-lint-action from v2.3.0 to v2.5.1
2021-02-28 07:44:57 +01:00
Manfred Touron
e1602364c8 Merge pull request #235 from moul/dependabot/go_modules/github.com/gliderlabs/ssh-0.3.2
chore(deps): bump github.com/gliderlabs/ssh from 0.3.1 to 0.3.2
2021-02-28 07:44:52 +01:00
Manfred Touron
2540d1e861 Merge pull request #241 from moul/dependabot/go_modules/github.com/olekukonko/tablewriter-0.0.5
chore(deps): bump github.com/olekukonko/tablewriter from 0.0.4 to 0.0.5
2021-02-28 07:44:48 +01:00
Manfred Touron
177a198420 Merge pull request #244 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.16.0
2021-02-28 07:44:44 +01:00
Manfred Touron
51612aab13 Merge pull request #245 from moul/dependabot/docker/golang-1.16.0
chore(deps): bump golang from 1.15.8 to 1.16.0
2021-02-28 07:44:38 +01:00
dependabot[bot]
e20af1dde5 chore(deps): bump golangci/golangci-lint-action from v2.3.0 to v2.5.1
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from v2.3.0 to v2.5.1.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v2.3.0...d9f0e73c0497685d68af8c58280f49fcaf0545ff)

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-25 04:18:52 +00:00
Renovate Bot
6caa1f1657 chore(deps): update all docker tags to v1.16.0 2021-02-18 03:57:04 +00:00
dependabot[bot]
e0f76d15ec chore(deps): bump golang from 1.15.8 to 1.16.0
Bumps golang from 1.15.8 to 1.16.0.

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-17 04:17:32 +00:00
matteyeux
05225a4b25 Fix typo in "shell commands" section in README.md 2021-02-15 11:27:12 +01:00
dependabot[bot]
bcc150727f chore(deps): bump github.com/olekukonko/tablewriter from 0.0.4 to 0.0.5
Bumps [github.com/olekukonko/tablewriter](https://github.com/olekukonko/tablewriter) from 0.0.4 to 0.0.5.
- [Release notes](https://github.com/olekukonko/tablewriter/releases)
- [Commits](https://github.com/olekukonko/tablewriter/compare/v0.0.4...v0.0.5)

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-11 04:21:57 +00:00
Manfred Touron
9062417d13 Merge pull request #237 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.15.8
2021-02-07 12:12:52 +01:00
Manfred Touron
baeade4043 Merge pull request #238 from moul/dependabot/docker/golang-1.15.8
chore(deps): bump golang from 1.15.7 to 1.15.8
2021-02-07 12:12:41 +01:00
Renovate Bot
b9552e98b5 chore(deps): update all docker tags to v1.15.8 2021-02-06 02:14:21 +00:00
dependabot[bot]
715ccde829 chore(deps): bump golang from 1.15.7 to 1.15.8
Bumps golang from 1.15.7 to 1.15.8.

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-05 04:16:20 +00:00
dependabot[bot]
f5dc1bd1b9 chore(deps): bump github.com/gliderlabs/ssh from 0.3.1 to 0.3.2
Bumps [github.com/gliderlabs/ssh](https://github.com/gliderlabs/ssh) from 0.3.1 to 0.3.2.
- [Release notes](https://github.com/gliderlabs/ssh/releases)
- [Commits](https://github.com/gliderlabs/ssh/compare/v0.3.1...v0.3.2)

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-02 04:22:33 +00:00
Jonathan Lestrelin
c79c50aeb6 Remove go versions with missing requirements for ecdsa/ed2519 from CI. 2021-01-25 18:21:44 +01:00
Manfred Touron
df3542c6ee Merge pull request #233 from moul/dependabot/docker/golang-1.15.7
chore(deps): bump golang from 1.15.6 to 1.15.7
2021-01-25 14:22:48 +01:00
Manfred Touron
e40f5307a3 Merge pull request #232 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.15.7
2021-01-25 14:22:04 +01:00
Renovate Bot
6e6045306b chore(deps): update all docker tags to v1.15.7 2021-01-21 00:14:55 +00:00
dependabot[bot]
874467b1e6 chore(deps): bump golang from 1.15.6 to 1.15.7
Bumps golang from 1.15.6 to 1.15.7.

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-20 04:03:40 +00:00
Jonathan Lestrelin
5c1c559a9a Merge remote-tracking branch 'upstream/master' into ecdsa 2021-01-12 08:18:28 +01:00
Manfred Touron
6872c727ef Merge pull request #231 from moul/dev/moul/maintenance
chore: repo maintenance 🤖
2021-01-02 10:51:40 +01:00
moul-bot
cae996d041 chore: repo maintenance 🤖
more details: https://github.com/moul/repoman

Signed-off-by: moul-bot <bot@moul.io>
2021-01-01 15:24:08 +01:00
Manfred Touron
a23b77282c Merge pull request #229 from moul/renovate/docker-all
chore(deps): update circleci/golang docker tag to v1.15.6
2020-12-27 12:12:16 +01:00
Manfred Touron
24814c4152 Merge pull request #230 from moul/dependabot/docker/golang-1.15.6
chore(deps): bump golang from 1.15.5 to 1.15.6
2020-12-26 19:01:22 +01:00
Renovate Bot
07359988d0 chore(deps): update all docker tags to v1.15.6 2020-12-05 00:51:28 +00:00
dependabot[bot]
db6eb63297 chore(deps): bump golang from 1.15.5 to 1.15.6
Bumps golang from 1.15.5 to 1.15.6.

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-04 04:22:41 +00:00
Manfred Touron
5fdb31b97d Merge pull request #221 from moul/dependabot/github_actions/golangci/golangci-lint-action-v2.3.0
chore(deps): bump golangci/golangci-lint-action from v0.1.7 to v2.3.0
2020-11-15 21:48:47 +01:00
Manfred Touron
bce6b1998b Merge pull request #220 from moul/dependabot/github_actions/actions/cache-v2.1.3
chore(deps): bump actions/cache from v1 to v2.1.3
2020-11-15 21:48:34 +01:00
dependabot[bot]
f7fa60da97 chore(deps): bump golangci/golangci-lint-action from v0.1.7 to v2.3.0
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from v0.1.7 to v2.3.0.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v0.1.7...e868220d9fd3b523f1a8fcfb69749e8c7521ba14)

Signed-off-by: dependabot[bot] <support@github.com>
2020-11-15 20:40:22 +00:00
Manfred Touron
d2cd6b64a3 Merge pull request #215 from moul/renovate/all
chore(deps): update all
2020-11-15 21:39:39 +01:00
Manfred Touron
1ef0cc8725 Merge pull request #225 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.15.5
2020-11-15 21:38:37 +01:00
Renovate Bot
d894005c3f chore(deps): update all docker tags to v1.15.5 2020-11-13 01:41:26 +00:00
Renovate Bot
af7206d114 chore(deps): update all 2020-11-12 16:47:33 +00:00
dependabot[bot]
1f9d962cd6 chore(deps): bump actions/cache from v1 to v2.1.3
Bumps [actions/cache](https://github.com/actions/cache) from v1 to v2.1.3.
- [Release notes](https://github.com/actions/cache/releases)
- [Commits](https://github.com/actions/cache/compare/v1...0781355a23dac32fd3bac414512f4b903437991a)

Signed-off-by: dependabot[bot] <support@github.com>
2020-11-11 01:04:31 +00:00
Manfred Touron
460041c6e3 Merge pull request #219 from moul/dev/moul/maintenance
chore: repo maintenance 🤖
2020-11-11 02:04:08 +01:00
moul-bot
7068565ab1 chore: repo maintenance 🤖
more details: https://github.com/moul/repoman

Signed-off-by: moul-bot <bot@moul.io>
2020-11-09 22:53:21 +01:00
Manfred Touron
74bd885c1d Merge pull request #218 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.15.4
2020-11-07 16:54:04 +01:00
Renovate Bot
9317f206d1 chore(deps): update all docker tags to v1.15.4 2020-11-07 00:30:51 +00:00
Jonathan Lestrelin
6c3f803dc6 Add generation of ecdsa and ed25519 keys.
Make RSA keys use value from --length parameter.
Set default length when --length is unspecified based on key type.
Change default key format to ed25519 both in shell and for keys created
at initialization.
2020-10-10 04:21:11 +02:00
Renovate Bot
9c3d29eb83 chore(deps): update module gliderlabs/ssh to v0.3.1 2020-10-07 19:19:15 +00:00
Manfred Touron
e339a73931 Merge pull request #214 from moul/dev/moul/bump-deps4
chore: bump deps
2020-10-04 10:51:32 +02:00
Manfred Touron
0dcab1b380 chore: bump deps 2020-10-04 10:18:07 +02:00
Sergey Yashchuk
c697c9aaeb dev: ACLs external command hook 2020-09-18 01:24:51 +07:00
Manfred Touron
032f802348 Merge pull request #208 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.15.2
2020-09-14 20:57:45 +02:00
Renovate Bot
7fd9be9058 chore(deps): update all docker tags to v1.15.2 2020-09-10 00:31:00 +00:00
Manfred Touron
83b54aeeff Merge pull request #205 from moul/dev/moul/go115
chore: go1.15
2020-08-19 20:28:55 +02:00
Manfred Touron
2323d6fd1e chore: go1.15 2020-08-19 19:33:18 +02:00
Manfred Touron
4c947ce391 Merge pull request #204 from GreyOBox/increase-size-of-name-fields
fix: increase size of name fields
2020-08-19 18:27:33 +02:00
Sergey Yashchuk
44559f0547 fix: increase size of name fields 2020-08-19 18:23:36 +02:00
Manfred Touron
8234119cd4 Merge pull request #197 from moul/renovate/all
chore(deps): update golang.org/x/crypto commit hash to 123391f
2020-08-10 18:22:45 +02:00
Manfred Touron
7a75c13ac4 Merge pull request #200 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.14.7
2020-08-10 18:22:15 +02:00
Manfred Touron
4b10131790 Merge pull request #201 from moul/imgbot
[ImgBot] Optimize images
2020-08-09 17:14:22 +02:00
Manfred Touron
a29c6e8338 chore: add intro image 2020-08-09 00:30:36 +02:00
ImgBotApp
198e0717b5 [ImgBot] Optimize images
*Total -- 887.71kb -> 587.48kb (33.82%)

/.assets/bastion.jpg -- 503.44kb -> 249.40kb (50.46%)
/.assets/flow-diagram.png -- 104.11kb -> 79.45kb (23.69%)
/.assets/overview.png -- 32.65kb -> 26.50kb (18.82%)
/.assets/cluster-mysql.svg -- 8.50kb -> 7.08kb (16.74%)
/.assets/overview.svg -- 9.23kb -> 8.03kb (13.03%)
/.assets/flow-diagram.svg -- 13.85kb -> 12.39kb (10.51%)
/.assets/sql-schema.svg -- 36.89kb -> 33.99kb (7.85%)
/.assets/demo.gif -- 179.03kb -> 170.63kb (4.69%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2020-08-08 22:28:50 +00:00
Manfred Touron
d8fa2f6925 Add files via upload 2020-08-09 00:28:34 +02:00
Renovate Bot
16c8c0092e chore(deps): update all docker tags to v1.14.7 2020-08-08 00:28:27 +00:00
Renovate Bot
b0dfff2d90 chore(deps): update golang.org/x/crypto commit hash to 123391f 2020-07-28 20:38:36 +00:00
Manfred Touron
9d2badf253 Merge pull request #196 from moul/dev/moul/pr-194 2020-07-23 18:55:42 +02:00
Konstantin Bakaras
428344da17 feat: MySQL, Postgres support
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-07-23 18:50:42 +02:00
Konstantin Bakaras
0c07ac790a feat: ACL Check with inception and expiration
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-07-23 18:50:35 +02:00
Konstantin Bakaras
365a37959a chore: Model and edit
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-07-23 18:50:35 +02:00
Manfred Touron
90fd6057cf Merge pull request #193 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.14.6
2020-07-23 18:24:07 +02:00
Manfred Touron
4220f3fb89 Merge pull request #190 from moul/renovate/all
chore(deps): update all
2020-07-23 17:22:27 +02:00
Renovate Bot
3e2acfc992 chore(deps): update all 2020-07-20 06:19:00 +00:00
Renovate Bot
9c464b2610 chore(deps): update all docker tags to v1.14.6 2020-07-18 00:55:40 +00:00
Manfred Touron
5760aece65 Merge pull request #192 from moul/dev/moul/maintenance
chore: repo maintenance 🤖
2020-07-12 14:08:51 +02:00
moul-bot
a24e20252a chore: repo maintenance 🤖
more details: https://github.com/moul/repoman

Signed-off-by: moul-bot <bot@moul.io>
2020-07-12 14:04:09 +02:00
Manfred Touron
37a7fa1917 Merge pull request #189 from moul/renovate/all
chore(deps): update all
2020-07-09 17:47:46 +02:00
Renovate Bot
f1b28b0363 chore(deps): update all 2020-07-08 08:14:45 +00:00
Manfred Touron
e43bb55e70 Merge pull request #188 from moul/dev/moul/fix-166 2020-07-08 10:12:52 +02:00
Manfred Touron
763ced7524 feat: host logging modes (disabled, commands, everything)
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-07-04 22:16:46 +02:00
Manfred Touron
54128beb12 chore: point CHANGELOG.md to releases page
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-07-04 21:53:26 +02:00
Manfred Touron
64ba179cc7 chore: add .gitattributes
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-07-04 21:53:26 +02:00
Manfred Touron
bbdb4851a5 Merge pull request #187 from moul/dev/moul/maintenance
chore: repo maintenance 🤖
2020-07-04 01:59:48 +02:00
moul-bot
63719ec00e chore: repo maintenance 🤖
more details: https://github.com/moul/repoman

Signed-off-by: moul-bot <bot@moul.io>
2020-07-02 00:41:50 +02:00
Manfred Touron
0722497336 Update README.md 2020-07-01 14:51:57 +02:00
Manfred Touron
e74f7221b5 Merge pull request #168 from moul/dev/moul/linters
fix: add more linters
2020-07-01 14:51:03 +02:00
Manfred Touron
f4fc3a90bc fix: add more linters
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-07-01 14:46:23 +02:00
Manfred Touron
df3aa6e165 Merge pull request #181 from moul/renovate/all
chore(deps): update all
2020-07-01 14:46:01 +02:00
Manfred Touron
986bcd7971 Merge pull request #185 from moul/dev/moul/windows 2020-07-01 14:17:50 +02:00
Manfred Touron
7f3ea431a1 Merge pull request #183 from jrrdev/exit_fix 2020-07-01 14:16:14 +02:00
Manfred Touron
dae0252857 chore: small fix for build on windows 2020-07-01 14:10:50 +02:00
Manfred Touron
33b8e5272c Merge pull request #184 from moul/dev/moul/renovate-tidy
chore: tell renovate to run go mod tidy
2020-07-01 14:10:15 +02:00
Manfred Touron
21e73757ac chore: tell renovate to run go mod tidy 2020-07-01 14:01:14 +02:00
jerard@alfa-safety.fr
bcb5d3b7ef fixup! Fix early closure of data stream. 2020-06-30 18:28:47 +02:00
jerard@alfa-safety.fr
d2f3f460b2 Fix early closure of data stream.
Closes moul/sshportal#55 and closes moul/sshportal#127
2020-06-30 10:28:39 +02:00
Renovate Bot
e06fe6f5a3 chore(deps): update all 2020-06-22 22:25:28 +00:00
Manfred Touron
fb9dabfe6b chore: bump deps 2020-06-09 12:11:41 +02:00
Manfred Touron
0e0cd8fed5 Merge pull request #180 from moul/dev/moul/update-project-layout
fix: update project layout
2020-06-09 12:10:17 +02:00
Manfred Touron
8959e1782f fix: update project layout
Signed-off-by: Manfred Touron <94029+moul@users.noreply.github.com>
2020-06-09 11:45:45 +02:00
Manfred Touron
33151105e0 Merge pull request #173 from moul/renovate/all
chore(deps): update all
2020-06-09 11:07:15 +02:00
Manfred Touron
77b40eb9ed Merge pull request #177 from NocFlame/master
Update README.md; Info about missing host keys
2020-06-09 11:07:01 +02:00
Manfred Touron
075dfd0aa7 Merge pull request #176 from Zatte/update_readme_shell_conditions
Update readme; special host names
2020-06-09 11:06:38 +02:00
Manfred Touron
5cf6b1c218 Merge pull request #174 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.14.4
2020-06-06 18:21:26 +02:00
Renovate Bot
6527746a91 chore(deps): update all 2020-06-04 21:46:38 +00:00
Renovate Bot
020ca9c6b3 chore(deps): update all docker tags to v1.14.4 2020-06-03 00:16:26 +00:00
NocFlame
8c7831480b Update README.md
*What this PR does / why we need it:*
Current readme does not inform user of how to fix the behaviour of password promt when linking admin account

*Which issue this PR fixes: fixes #128*
Updated the readme to inform the user of a quick fix (ssh-keygen -t rsa) when association fails and user is asked for password
2020-05-28 11:12:28 +02:00
Mikael Rapp
e399dfd8e4 Update readme; special host names 2020-05-27 23:30:28 +02:00
Manfred Touron
be83c7148d Merge pull request #172 from moul/dev/moul/bump-deps4 2020-05-12 03:35:22 +02:00
Manfred Touron
ce187e8675 fix: bump deps 2020-05-12 03:30:27 +02:00
Manfred Touron
f13ede4ba7 Update README.md 2020-05-12 03:27:15 +02:00
Manfred Touron
fb061ed419 Merge pull request #138 from moul/renovate/all
Update all
2020-05-10 15:45:44 +02:00
Renovate Bot
b4a377f269 Update all 2020-05-02 08:14:28 +00:00
Manfred Touron
de6f37aa64 Merge pull request #170 from moul/renovate/docker-all
Update all Docker tags to v1.14.2
2020-04-10 10:11:05 +02:00
Renovate Bot
32219577b8 Update all Docker tags to v1.14.2 2020-04-10 00:21:11 +00:00
Manfred Touron
abc7329a71 Merge pull request #167 from moul/renovate/docker-all
Update all Docker tags to v1.14.1
2020-03-22 14:58:30 +01:00
Renovate Bot
675942e967 Update all Docker tags to v1.14.1 2020-03-21 00:28:54 +00:00
Manfred Touron
5b20cd501e Update README.md
See https://github.com/moul/sshportal/issues/164
2020-03-10 11:01:26 +01:00
Manfred Touron
b6aaf4d7cf Update README.md 2020-03-05 15:33:52 +01:00
Manfred Touron
972e232559 Merge pull request #143 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.14.0
2020-03-01 17:17:37 +01:00
Manfred Touron
851a91b1a0 fix: fix deps 2020-02-28 22:12:46 +01:00
Renovate Bot
6a068dc430 chore(deps): update all docker tags to v1.14.0 2020-02-28 22:05:37 +01:00
Manfred Touron
2cdfcf60fe Merge pull request #163 from MitaliBo/master
Security fix for github.com/moby/moby
2020-02-28 22:04:24 +01:00
Manfred Touron
5d9e0c367a feat: use secure rand seed 2020-02-25 11:49:38 +01:00
MitaliBo
cbf8263033 Update go.mod 2020-02-24 14:27:29 -08:00
Manfred Touron
846c73d9bc Merge pull request #160 from moul/dev/moul/bump-deps
chore: bump-deps
2020-02-22 20:51:29 +01:00
Manfred Touron
e0b43b1976 Merge pull request #162 from moul/dev/moul/semantic-release
feat: set up semantic release
2020-02-22 20:48:51 +01:00
Manfred Touron
6a6e788968 feat: set up semantic release 2020-02-22 20:43:54 +01:00
Manfred Touron
4754cad42a chore: bump-deps 2020-02-18 00:12:56 +01:00
Manfred Touron
db58e53f3b Merge pull request #156 from jeanlouisferey/master
Little correction
2020-02-18 00:06:27 +01:00
Manfred Touron
b31acb4348 Merge pull request #148 from jle64/fix_shell_connection_log
Fix db user id type in shell connection log.
2020-02-18 00:06:20 +01:00
Manfred Touron
c794c2c076 chore: add funding + update go.sum 2020-02-18 00:05:35 +01:00
Jonathan Lestrelin
42d6cd44bb Fix db user id type in shell connection log. 2020-02-17 23:55:35 +01:00
Manfred Touron
f9057ca56a Merge pull request #157 from bozzo/master
Add Helm chart to deploy SSHPortal on Kubernetes
2020-02-17 23:54:39 +01:00
bozzo
c2f1999037 Set theme jekyll-theme-slate 2020-02-17 09:08:51 +01:00
bozzo
44b386f7a7 Add Helm chart to deploy SSHPortal on Kubernetes
Support HA with Mysql database
Support autoscaling
2020-02-08 23:06:19 +01:00
Jean-Louis Férey
89b296db4e Little correction 2020-01-29 16:35:39 +01:00
Manfred Touron
c16403fb3f Merge pull request #146 from opencollective/opencollective
Activating Open Collective
2019-09-18 09:11:08 +02:00
Jess
5e21fb72e6 Added financial contributors to the README 2019-09-17 18:02:58 -07:00
Manfred Touron
c5681bf880 chore: post-release version bump 2019-06-24 11:43:32 +02:00
Manfred Touron
db85d6545d v1.10.0 2019-06-24 11:31:32 +02:00
Manfred Touron
9912c3deba chore: setup goreleaser 2019-06-24 10:57:47 +02:00
Manfred Touron
fc5c342e40 chore: bump github.com/gliderlabs/ssh to v0.2.2 (#141)
chore: bump github.com/gliderlabs/ssh to v0.2.2
2019-06-24 10:28:51 +02:00
Manfred Touron
60707b3faa chore: update CHANGELOG 2019-06-24 10:25:04 +02:00
Manfred Touron
f36845ac6b chore: bump github.com/gliderlabs/ssh to v0.2.2 2019-06-24 10:25:04 +02:00
Manfred Touron
9f76bd6cad Merge pull request #131 from moul/renovate/all
Update all
2019-06-14 11:26:07 +02:00
Manfred Touron
c53d5d9964 Merge pull request #137 from moul/renovate/docker-all
Update all Docker tags to v1.12.6
2019-06-14 11:25:21 +02:00
Renovate Bot
171d461ea5 Update all 2019-06-14 08:29:13 +00:00
Renovate Bot
b2b04a1155 Update all Docker tags to v1.12.6 2019-06-13 00:27:25 +00:00
Manfred Touron
671ba03b78 Update README.md 2019-06-10 09:57:20 +02:00
Manfred Touron
9095725778 Update README.md 2019-06-10 09:57:05 +02:00
Manfred Touron
8b2e5daba3 Set log files mode to 440 instead of 640. (#134)
Set log files mode to 440 instead of 640.
2019-06-10 09:54:53 +02:00
Jonathan Lestrelin
75b7a5f571 Set log files mode to 440 instead of 640. 2019-06-10 09:40:25 +02:00
Manfred Touron
4b9e881ad0 Merge pull request #135 from jle64/accept_ip_as_hostname
Allow to create a host using an ip as name
2019-06-09 15:39:41 +02:00
Manfred Touron
59f8f52cca Merge pull request #133 from jle64/better_logfile_name
Add username and session ID to session log filename.
2019-06-09 15:38:08 +02:00
Jonathan Lestrelin
4adaf83fd3 Accept to create a host using an ip as name by relaxing unix_user constraint on name and not splitting on . if an ip is detected. 2019-06-07 18:15:25 +02:00
Jonathan Lestrelin
84464a4ea6 Add username and session ID to session log filename. 2019-06-07 16:58:41 +02:00
Manfred Touron
cafac0b8b5 chore: fix CI (#132)
chore: fix CI
2019-06-06 16:35:45 +02:00
Manfred Touron
5346300a64 chore: fix CI 2019-06-06 16:32:49 +02:00
Manfred Touron
1d4554eabc chore: fixup 2019-06-06 16:32:22 +02:00
Manfred Touron
50bdba8b70 Update all (#110)
Update all
2019-05-30 20:40:22 +02:00
Manfred Touron
8c785f6dea Update all Docker tags to v1.12.5 (#130)
Update all Docker tags to v1.12.5
2019-05-30 20:40:01 +02:00
Renovate Bot
93e6abc9ba Update all 2019-05-28 19:27:01 +00:00
Renovate Bot
60d7c85c11 Update all Docker tags to v1.12.5 2019-05-21 20:28:40 +00:00
Manfred Touron
883bad2ee5 Merge pull request #124 from welderpb/master
[fix] unable to use encrypted ssh private keys
2019-03-29 07:11:08 +01:00
Manfred Touron
7d68e144f8 Merge pull request #116 from moul/renovate/docker-all
chore(deps): update all docker tags to v1.12.1
2019-03-29 07:08:36 +01:00
Manfred Touron
7f32e38cf8 Merge pull request #123 from vdaviot/master
[FIX] Format of id in new session | Closing channel if host unreachable
2019-03-29 07:07:36 +01:00
Renovate Bot
43a96d1636 chore(deps): update all docker tags to v1.12.1 2019-03-16 00:20:45 +00:00
welderpb
00e7d2e45d fix for encrypted ssh private keys 2019-03-05 17:04:05 +01:00
Valentin Daviot
2e711c3591 fixed format of id for new bastion session | applied fix from issue #56 to permit the closing of channel in case of unreachable host
Signed-off-by: Valentin Daviot <valentin.daviot@alterway.fr>
2019-03-01 16:04:27 +01:00
Manfred Touron
5d147fc03b Merge pull request #118 from nellyasher/master
Readme updates
2019-02-01 13:38:07 +01:00
Manfred Touron
3dccefbbcb chore: use png + add toc 2019-02-01 13:36:36 +01:00
Nelly Asher
7c4995fa4a Update README.md 2019-02-01 13:34:18 +01:00
Nelly Asher
2b8f051414 TOC added, chapters rearranged, "Use cases" added. 2019-02-01 13:34:18 +01:00
Manfred Touron
4e17c81d63 chore: switch to png for images in the readme 2019-02-01 12:57:18 +01:00
Manfred Touron
8b4b677d6a chore: update flow diagram + add png outputs 2019-02-01 12:56:07 +01:00
Manfred Touron
47229bf473 chore: update flow diagram style 2019-02-01 11:23:02 +01:00
Manfred Touron
ec5b567da9 Update README.md 2019-01-04 01:11:10 +01:00
Manfred Touron
03b59fae1c Merge pull request #113 from ahhx/master
refactor: split package main
2019-01-04 00:58:24 +01:00
Manfred Touron
ede8b3ecf2 chore: fix lint issues 2019-01-04 00:56:06 +01:00
Manfred Touron
7ae90b9199 build: fix deps checksums 2019-01-04 00:51:54 +01:00
ahh
a651da451e refactor: split package main
sshportal refactor. Focused on splitting up package main into packages
main, dbmodels, crypto, and bastion.
2019-01-03 21:11:43 +01:00
Manfred Touron
f220af5c54 Merge pull request #111 from shawn111/missing_authorized_key
Fix userkey create.
2019-01-03 19:57:19 +01:00
Shawn Wang
eebf987900 Fix UserKey.Key for govalidator.ValidateStruct.
UserKey.Key is a []byte field.
The valid: "required" would cause wrong Validation.
2018-12-13 10:46:05 +00:00
Shawn Wang
3d5101011f Fix userkey create, missing AuthorizedKey 2018-12-12 23:39:25 +08:00
Manfred Touron
2cdc19dfdd Merge pull request #109 from moul/moul-patch-1
Update renovate.json
2018-12-07 14:53:50 +01:00
Manfred Touron
d8a7b1e16c Update renovate.json 2018-12-07 14:49:50 +01:00
Manfred Touron
d7490d089c Merge pull request #102 from moul/dev/mou/bump-deps2
chore: bump deps
2018-11-25 09:46:57 +01:00
Manfred Touron
774c6c0f64 chore: bump deps 2018-11-25 09:43:28 +01:00
Manfred Touron
92d11c53de Merge pull request #98 from Raerten/env-variables
use Environment Variables for settings
2018-11-25 09:41:09 +01:00
Manfred Touron
c509f65a27 Merge pull request #101 from moul/dev/moul/windows-support
fix a build constraint
2018-11-25 09:40:00 +01:00
Дмитрий Шульгачик
20b9e839d3 fix a build constraint 2018-11-25 09:34:41 +01:00
Дмитрий Шульгачик
6f4fb24cd0 update mysql example using ENV 2018-11-19 14:56:24 +03:00
Дмитрий Шульгачик
23a89fe1de Add ability to set DB driver and DB credentials from environment variables 2018-11-19 14:49:31 +03:00
Manfred Touron
559df1f523 Merge pull request #96 from moul/dev/moul/bump-deps
chore: bump deps
2018-11-18 15:58:07 +01:00
Manfred Touron
ad2b8ebc38 chore: bump deps 2018-11-18 15:54:11 +01:00
Manfred Touron
3824629d4d Post-release version bump 2018-11-18 15:48:42 +01:00
Manfred Touron
38224714e1 v1.9.0 2018-11-18 15:46:55 +01:00
Manfred Touron
a9f4227bba Merge pull request #90 from alenn-m/master
fix #69
2018-11-18 15:32:37 +01:00
Alen Masic
06bde77f51 fixed the issue when Author didn't update 2018-11-17 10:39:34 +01:00
Alen Masic
2a2554e7a3 fix #69 2018-11-16 17:31:59 +01:00
Manfred Touron
5d835011e6 Merge pull request #87 from moul/dev/moul/golangci-lint
build: switch to golangci-lint
2018-11-16 17:26:26 +01:00
Manfred Touron
0f294cd62d build: switch to golangci-lint 2018-11-16 17:20:39 +01:00
Manfred Touron
8e62d21c25 Merge pull request #86 from moul/dev/moul/canonical
Switching to moul.io/sshportal (canonical url)
2018-11-16 15:42:38 +01:00
Manfred Touron
2a5dd63e87 chore: use moul.io/sshportal canonical url 2018-11-16 15:36:14 +01:00
Manfred Touron
06cb424b8f chore: bump deps 2018-11-16 15:29:51 +01:00
Manfred Touron
7c5864a9c3 Merge pull request #83 from moul/dev/moul/gomod
build: switch to go modules
2018-11-16 15:17:22 +01:00
Manfred Touron
668e34ccab build: switch to go modules 2018-11-16 15:02:47 +01:00
Manfred Touron
95477715fc Merge pull request #75 from vdaviot/closed_session
[FIX] session status and duration displaying properly
2018-11-16 10:19:38 +01:00
Manfred Touron
ecc004a485 Merge pull request #74 from vdaviot/unset_hop
[FIX] unsetting hop on host
2018-11-16 10:18:10 +01:00
valentin.daviot
9f0657374b unsetting work properly 2018-11-16 10:12:49 +01:00
Manfred Touron
61b7f72e94 Merge pull request #76 from vdaviot/active_session_list
[FIX] Session ls on big databases
2018-11-16 10:10:30 +01:00
Manfred Touron
db000baaa5 Merge pull request #67 from moul/renovate/configure
Configure Renovate
2018-11-16 10:05:42 +01:00
valentin.daviot
a1a3a29d00 session status and duration displaying properly 2018-11-16 10:03:37 +01:00
valentin.daviot
2ea73a941f listing active session 2018-11-16 10:01:23 +01:00
valentin.daviot
e860b60d20 session status and duration displaying properly 2018-11-16 10:01:23 +01:00
Manfred Touron
d6be01b9b7 build: temporarily disable gometalinter before changing to another solution 2018-11-16 09:59:18 +01:00
Manfred Touron
64c8e01c33 Merge pull request #80 from ahhx/idle-timeout
add idle connection timeout and idle-timeout flag
2018-11-16 09:53:02 +01:00
ahh
acce797e55 add logging 2018-11-15 13:56:10 -05:00
ahh
175fc8d68b add timeout and flag 2018-11-15 13:38:18 -05:00
Renovate Bot
b9d1cf69c7 Add renovate.json 2018-08-19 01:32:26 +00:00
Manfred Touron
41eeb364f8 Ignore some circle CI tests 2018-08-18 23:43:48 +02:00
Manfred Touron
a22f8f0b7b Merge pull request #58 from adyxax/master
Added `hostgroup update` and `usergroup update` features
2018-04-06 09:34:17 +02:00
Julien Dessaux
bd1c3609a7 Added hostgroup update and usergroup update features 2018-04-05 16:25:43 +02:00
Manfred Touron
c5e75df64f Post-release version bump 2018-04-02 22:36:07 +02:00
Manfred Touron
6b181dd291 v1.8.0 2018-04-02 22:36:06 +02:00
Manfred Touron
4ab88cad10 fix merge 2018-04-02 22:36:06 +02:00
Manfred Touron
b902953df4 Update changelog 2018-04-02 22:36:06 +02:00
Manuel Sabban
e141368734 Add log for exec request. 2018-04-02 22:36:06 +02:00
Manfred Touron
980da40988 Update Readme and Changelog 2018-04-02 22:28:10 +02:00
Manfred Touron
22d25f1e70 Merge pull request #44 from sabban/tunnel
Logtunnel
2018-03-24 00:02:31 +01:00
Manfred Touron
84d77d0a9f Merge pull request #52 from adyxax/master
Added ssh key import feature in "key import"
2018-03-23 23:29:37 +01:00
Julien Dessaux
b0afdf933a Added ssh key import feature in "key import" 2018-03-21 17:48:11 +01:00
Manuel
e9eef9a49e add an acceptable error management. 2018-03-19 18:06:03 +01:00
Manfred Touron
6f2b58cbdc chore: esthetics + update changelog 2018-03-14 18:17:40 +01:00
Manfred Touron
09ac2c35f3 Merge pull request #54 from jle64/dont_crash_on_missing_user
Show 'n/a' in case of missing information to avoid crashing.
2018-03-14 18:13:08 +01:00
Jonathan Lestrelin
47a6fc9906 Show 'n/a' in case of missing information to avoid crashing. 2018-03-14 17:40:48 +01:00
Manuel
c3d49fde95 Merge branch 'master' of https://github.com/moul/sshportal into tunnel 2018-03-12 12:31:17 +01:00
Manfred Touron
ec1e4d5c8a Update README and CHANGELOG 2018-02-28 17:22:59 +01:00
Manfred Touron
e65ef7ccc1 Merge pull request #47 from mathieui/multi-hops
Implement proxied connections
2018-02-28 17:20:18 +01:00
Manfred Touron
68e7fd2090 Merge pull request #49 from moul/dev/moul/fix-mysql-delete
Fix `--assign` commands when using MySQL driver
2018-02-28 16:56:29 +01:00
Manfred Touron
b958f8461f Fix commands when using MySQL driver ([#45](https://github.com/moul/sshportal/issues/45)) 2018-02-28 16:54:32 +01:00
Manfred Touron
a08d84e7ed Merge pull request #48 from moul/dev/moul/fix-make-dev-cmd
Small fixes
2018-02-28 16:29:36 +01:00
Manfred Touron
2b66d8d56a Ingore /log directory 2018-02-28 14:35:38 +01:00
Manfred Touron
a40789e1f2 Fix 'make dev' rule 2018-02-28 14:35:06 +01:00
Mathieu Pasquet
63571af252 Add hops management in "host update"
- allow changing the hop set for this host
- allow removing hops altogether
2018-02-27 17:54:57 +01:00
Mathieu Pasquet
75c6840ecd Implement proxied connections
The feature is implemented as follows:
- when creating a host, there is a possiblity to add a "hop"
- hops are referend them with the name of the host in sshportal
- the hop ID is then saved in the DB in the hosts table
- when connecting to a host, sshportal will recurse through all the
  possible hops of a host (allowing chained proxies)
2018-02-22 18:07:41 +01:00
Manfred Touron
e6a02a85f0 Fix typo in template 2018-02-03 00:45:05 +01:00
Manuel Sabban
2c3de75f3d Logtunnel (#1)
* * When a new channel is opened we got stuck in the select loop in
bastionsession.go, and we couldn't open a new channel. The fix is
easy it calls the bastionsession.ChannelHandler in a goroutine,
at the cost of some error management. I think this is ok because
we can allow a channel to fail on his own. This seems to be
* This add the tunnel feature, which use a new concurrent channel.
* This add some pcap logging for tunnel.
For now it is logged only one way, and the logged ip packet seems
buggy.

* Add logtunnuel as a package.
The logfile format is a tweaked version of ttyrec format file as it will be easy to review the use of human readable tunnel...

To get the ChannelHandler work as a go routine I had to deactivate lint errcheck for logcahnnel. I think this could be a problem. What is your thoughts about this ?
2018-01-18 11:20:37 +01:00
Manfred Touron
7c4aab34ed Merge pull request #39 from moul/moul/alt/gh-tmpl
Add GitHub templates
2018-01-11 13:15:37 +01:00
Manfred Touron
a8480f82e0 Merge pull request #38 from QuentinPerez/split-main
cleanup main
2018-01-11 13:15:09 +01:00
Manfred Touron
a5dacca9a1 Create ISSUE_TEMPLATE.md 2018-01-08 10:04:09 +01:00
Manfred Touron
31ba233b34 Create PULL_REQUEST_TEMPLATE.md 2018-01-08 09:41:10 +01:00
Quentin Perez
5720123576 main: remove globalContext, and move some functions outside of the main 2018-01-07 14:09:43 +01:00
Manfred Touron
9cc09b320d Merge pull request #36 from moul/sabban
Add audit feature
2018-01-05 11:09:26 +01:00
Manfred Touron
cb3c1056e5 Small fixes 2018-01-05 11:05:42 +01:00
Manfred Touron
82f96e457c Merge branch 'master' into sabban 2018-01-05 10:39:04 +01:00
Manfred Touron
062e2b4b8f Merge pull request #35 from moul/dev/moul/homebrew
Add homebrew config
2018-01-05 10:28:11 +01:00
Manfred Touron
9de51acbcc Add homebrew config 2018-01-05 10:24:43 +01:00
Manfred Touron
6d3a97cdbc Merge pull request #34 from moul/dev/moul/telnet
Add telnet support
2018-01-05 10:18:25 +01:00
Manfred Touron
3ebcdd9c3d Add telnet support 2018-01-05 10:14:02 +01:00
Manfred Touron
a9f86d1d01 Remove gliderlabs/ssh from vendor.json to avoid updating it 2018-01-05 10:13:45 +01:00
Manfred Touron
2a68fc3114 Support having different host.Scheme 2018-01-05 10:13:45 +01:00
Manfred Touron
2352a53e6e Add telnet dependencies 2018-01-05 10:13:45 +01:00
Manuel
fcc94c58d9 get rid of this package as we use it from its home location. 2018-01-04 14:15:05 +01:00
Manuel
da9c4920ab add log directory creation if it does not exist. 2018-01-04 13:41:14 +01:00
Manuel
0295eedb6e fix log location 2018-01-04 11:49:24 +01:00
Manuel
7f26cc1dbb Fix the default log path to ./log 2018-01-04 11:45:05 +01:00
Manuel
9e1c395810 add fatal error when record file cannot be opened. 2018-01-04 11:43:44 +01:00
Manuel
9db4b92d4e Use of govendor and add "github.com/arkan/bastion/pkg/logchannel" pkg. 2018-01-04 11:32:51 +01:00
Manuel
ff46ee89d9 logs_location -> logsLocation 2018-01-04 11:31:51 +01:00
Manfred Touron
b9af077ef4 Merge pull request #33 from moul/dev/moul/default-username
Dynamic username for the first created account
2018-01-03 19:54:21 +01:00
Manfred Touron
b23ee4144d The default created user now has the same username as the user starting sshportal (was hardcoded admin) 2018-01-03 19:00:52 +01:00
Manuel
57f894bfca Merge branch 'master' of https://github.com/moul/sshportal into sabban
pull from master.
2018-01-03 14:22:28 +01:00
Manuel
58e2abca8c Fix when error on session file creation. 2018-01-03 14:06:05 +01:00
Manuel
ed676b0d7e add the pkg 2018-01-03 10:56:49 +01:00
Manfred Touron
ed42f343d2 Post-release version bump 2018-01-03 00:27:07 +01:00
Manuel
26c0bb8b1a typo 2018-01-02 17:43:53 +01:00
Manuel
12b0db07da add audit feature. 2018-01-02 16:31:34 +01:00
581 changed files with 5122 additions and 413343 deletions

BIN
.assets/bastion.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

BIN
.assets/cluster-mysql.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 171 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
.assets/overview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,21 +1,21 @@
defaults: &defaults
working_directory: /go/src/github.com/moul/sshportal
working_directory: /go/src/moul.io/sshportal
docker:
- image: circleci/golang:1.8
- image: circleci/golang:1.17.3
environment:
GO111MODULE: "on"
install_retry: &install_retry
run:
name: install retry
command: |
command -v wget &>/dev/null && wget -O /tmp/retry "https://github.com/moul/retry/releases/download/v0.5.0/retry_$(uname -s)_$(uname -m)" || true
if [ ! -f /tmp/retry ]; then command -v curl &>/dev/null && curl -L -o /tmp/retry "https://github.com/moul/retry/releases/download/v0.5.0/retry_$(uname -s)_$(uname -m)"; fi
chmod +x /tmp/retry
/tmp/retry --version
version: 2
jobs:
go.build:
<<: *defaults
steps:
- checkout
- run: make install
- run: go get -v -t .
- run: make test
# - run: make integration
- run: go get -u github.com/alecthomas/gometalinter
- run: gometalinter --install
- run: make lint
docker.integration:
<<: *defaults
steps:
@@ -24,17 +24,17 @@ 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
- run: docker build -t moul/sshportal .
- run: make integration
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
workflows:
version: 2
build_and_integration:
jobs:
- go.build
- docker.integration
# requires: docker.build?

View File

@@ -1,3 +1,5 @@
# .git/ # should be kept for git-based versionning
examples/
.circleci/
.assets/

17
.gitattributes vendored Normal file
View 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
View File

@@ -0,0 +1,6 @@
github: ["moul"]
patreon: moul
open_collective: sshportal
custom:
- "https://www.buymeacoffee.com/moul"
- "https://manfred.life/donate"

15
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,15 @@
### Actual Result / Problem
When I do Foo, Bar happens...
### Expected Result / Suggestion
I expect that Foobar happens...
### Some context
Any screenshot to share?
`sshportal --version`?
`ssh sshportal info`?
OS/Go version?
...

1
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1 @@
<!-- thank you for your contribution! ❤️ -->

20
.github/dependabot.yml vendored Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"extends": [
"config:base"
],
"groupName": "all",
"gomodTidy": true
}

87
.github/workflows/ci.yml vendored Normal file
View 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.2
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.6
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.6
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
View 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.9.0
if: github.ref == 'refs/heads/master'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

11
.gitignore vendored
View File

@@ -1,3 +1,12 @@
coverage.txt
dist/
*~
*#
.*#
.DS_Store
/log/
/sshportal
*.db
/data
/data
sshportal.history
.idea

55
.golangci.yml Normal file
View File

@@ -0,0 +1,55 @@
run:
deadline: 1m
tests: false
skip-files:
- "testing.go"
- ".*\\.pb\\.go"
- ".*\\.gen\\.go"
linters-settings:
golint:
min-confidence: 0
maligned:
suggest-new: true
goconst:
min-len: 5
min-occurrences: 4
misspell:
locale: US
linters:
disable-all: true
enable:
- bodyclose
- deadcode
- depguard
- dogsled
#- dupl
- errcheck
#- 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
View 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
View File

@@ -0,0 +1,8 @@
module.exports = {
branch: 'master',
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/github',
],
};

39
AUTHORS generated Normal file
View File

@@ -0,0 +1,39 @@
# 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 <11705746+GreyOBox@users.noreply.github.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>

View File

@@ -1,78 +1,3 @@
# Changelog
## 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

View File

@@ -1,7 +1,10 @@
# build
FROM golang:1.9 as builder
COPY . /go/src/github.com/moul/sshportal
WORKDIR /go/src/github.com/moul/sshportal
FROM golang:1.17.3 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

View File

@@ -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.

View File

@@ -1,46 +1,47 @@
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
.PHONY: install
install:
go install -v -ldflags '$(LDFLAGS)' .
.PHONY: docker.build
docker.build:
docker build -t moul/sshportal .
.PHONY: integration
integration:
cd ./examples/integration && make
.PHONY: _docker_install
_docker_install:
CGO_ENABLED=1 go build -ldflags '-extldflags "-static" $(LDFLAGS)' -tags netgo -v -o /go/bin/sshportal
CGO_ENABLED=1 $(GO) build -ldflags '-extldflags "-static" $(LDFLAGS)' -tags netgo -v -o /go/bin/sshportal
.PHONY: dev
dev:
-go get github.com/githubnemo/CompileDaemon
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" .
.PHONY: test
test:
go test -i .
go test -v .
.PHONY: lint
lint:
gometalinter --disable-all --enable=errcheck --enable=vet --enable=vetshadow --enable=golint --enable=gas --enable=ineffassign --enable=goconst --enable=goimports --enable=gofmt --exclude="should have comment" --enable=staticcheck --enable=gosimple --enable=misspell --deadline=20s .
-$(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: 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

414
README.md
View File

@@ -1,59 +1,46 @@
# sshportal
[![CircleCI](https://circleci.com/gh/moul/sshportal.svg?style=svg)](https://circleci.com/gh/moul/sshportal)
[![Docker Build Status](https://img.shields.io/docker/build/moul/sshportal.svg)](https://hub.docker.com/r/moul/sshportal/)
[![Go Report Card](https://goreportcard.com/badge/github.com/moul/sshportal)](https://goreportcard.com/report/github.com/moul/sshportal)
[![GoDoc](https://godoc.org/github.com/moul/sshportal?status.svg)](https://godoc.org/github.com/moul/sshportal)
[![License](https://img.shields.io/github/license/moul/sshportal.svg)](https://github.com/moul/sshportal/blob/master/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/moul.io/sshportal)](https://goreportcard.com/report/moul.io/sshportal)
[![GoDoc](https://godoc.org/moul.io/sshportal?status.svg)](https://godoc.org/moul.io/sshportal)
[![Financial Contributors on Open Collective](https://opencollective.com/sshportal/all/badge.svg?label=financial+contributors)](https://opencollective.com/sshportal) [![License](https://img.shields.io/github/license/moul/sshportal.svg)](https://github.com/moul/sshportal/blob/master/LICENSE)
[![GitHub release](https://img.shields.io/github/release/moul/sshportal.svg)](https://github.com/moul/sshportal/releases)
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmoul%2Fsshportal.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmoul%2Fsshportal?ref=badge_shield)
<!-- temporarily broken? [![Docker Build Status](https://img.shields.io/docker/build/moul/sshportal.svg)](https://hub.docker.com/r/moul/sshportal/) -->
Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
![sshportal demo](https://github.com/moul/sshportal/raw/master/.assets/demo.gif)
<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.
![Flow Diagram](https://raw.githubusercontent.com/moul/sshportal/master/.assets/flow-diagram.png)
---
## Overview
## Contents
![sshportal overview](https://raw.github.com/moul/sshportal/master/.assets/overview.svg?sanitize=true)
<!-- 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)
- [Testing](#testing)
* 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 (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)
* Host Keys verifications shared across users
* Healthcheck user (replying OK to any user)
* 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
<!-- tocstop -->
## (Known) limitations
---
* Does not work (yet?) with [`mosh`](https://mosh.org/)
## Usage
## Installation and usage
Start the server
@@ -74,6 +61,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
@@ -133,95 +122,71 @@ To associate this account with a key, use the following SSH user: 'invite:NfHK5a
config>
```
## Flow Diagram
Demo gif:
![sshportal demo](https://github.com/moul/sshportal/raw/master/.assets/demo.gif)
![Flow Diagram](https://raw.github.com/moul/sshportal/master/.assets/flow-diagram.svg?sanitize=true)
---
## built-in shell
## Use cases
`sshportal` embeds a configuration CLI.
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.
By default, the configuration user is `admin`, (can be changed using `--config-user=<value>` when starting the server.
*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.
Each commands can be run directly by using this syntax: `ssh admin@portal.example.org <command> [args]`:
There are companies who use a jump host to monitor connections at a single point.
```
ssh admin@portal.example.org host inspect toto
```
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).
You can enter in interactive mode using this syntax: `ssh admin@portal.example.org`
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.
### Synopsis
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.
```sh
# acl management
acl help
acl create [-h] [--hostgroup=HOSTGROUP...] [--usergroup=USERGROUP...] [--pattern=<value>] [--comment=<value>] [--action=<value>] [--weight=value]
acl inspect [-h] ACL...
acl ls [-h] [--latest] [--quiet]
acl rm [-h] ACL...
acl update [-h] [--comment=<value>] [--action=<value>] [--weight=<value>] [--assign-hostgroup=HOSTGROUP...] [--unassign-hostgroup=HOSTGROUP...] [--assign-usergroup=USERGROUP...] [--unassign-usergroup=USERGROUP...] ACL...
---
# config management
config help
config backup [-h] [--indent] [--decrypt]
config restore [-h] [--confirm] [--decrypt]
## Features and limitations
# event management
event help
event ls [-h] [--latest] [--quiet]
event inspect [-h] EVENT...
* 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
# host management
host help
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] <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...] HOST...
**(Known) limitations**
# hostgroup management
hostgroup help
hostgroup create [-h] [--name=<value>] [--comment=<value>]
hostgroup inspect [-h] HOSTGROUP...
hostgroup ls [-h] [--latest] [--quiet]
hostgroup rm [-h] HOSTGROUP...
* 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.
# key management
key help
key create [-h] [--name=<value>] [--type=<value>] [--length=<value>] [--comment=<value>]
key inspect [-h] [--decrypt] KEY...
key ls [-h] [--latest] [--quiet]
key rm [-h] KEY...
key setup [-h] KEY
key show [-h] KEY
# session management
session help
session ls [-h] [--latest] [--quiet]
session inspect [-h] SESSION...
# user management
user help
user invite [-h] [--name=<value>] [--comment=<value>] [--group=USERGROUP...] <email>
user inspect [-h] USER...
user ls [-h] [--latest] [--quiet]
user rm [-h] USER...
user update [-h] [--name=<value>] [--email=<value>] [--set-admin] [--unset-admin] [--assign-group=USERGROUP...] [--unassign-group=USERGROUP...] USER...
# usergroup management
usergroup help
hostgroup create [-h] [--name=<value>] [--comment=<value>]
usergroup inspect [-h] USERGROUP...
usergroup ls [-h] [--latest] [--quiet]
usergroup rm [-h] USERGROUP...
# other
exit [-h]
help, h
info [-h]
version [-h]
```
---
## Docker
@@ -232,7 +197,7 @@ An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/ss
```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.7.1
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
@@ -241,7 +206,7 @@ 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.7.1
# 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
@@ -249,7 +214,7 @@ 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.7.1
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
```
@@ -267,36 +232,17 @@ docker start sshportal
docker logs -f sshportal
```
---
## Manual Install
Get the latest version using GO.
```sh
go get -u github.com/moul/sshportal
GO111MODULE=on 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
@@ -322,6 +268,26 @@ sqlite3 sshportal.db .dump > sshportal.sql.bkp
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. 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]`:
```
ssh admin@portal.example.org host inspect toto
```
You can enter in interactive mode using this syntax: `ssh admin@portal.example.org`
![sshportal overview](https://raw.github.com/moul/sshportal/master/.assets/overview.png)
---
## Demo data
The following servers are freely available, without external registration,
@@ -338,6 +304,83 @@ ssh portal host create test@chat.shazow.net
ssh chat@portal
```
---
## Shell commands
```sh
# acl management
acl help
acl create [-h] [--hostgroup=HOSTGROUP...] [--usergroup=USERGROUP...] [--pattern=<value>] [--comment=<value>] [--action=<value>] [--weight=value]
acl inspect [-h] ACL...
acl ls [-h] [--latest] [--quiet]
acl rm [-h] ACL...
acl update [-h] [--comment=<value>] [--action=<value>] [--weight=<value>] [--assign-hostgroup=HOSTGROUP...] [--unassign-hostgroup=HOSTGROUP...] [--assign-usergroup=USERGROUP...] [--unassign-usergroup=USERGROUP...] ACL...
# config management
config help
config backup [-h] [--indent] [--decrypt]
config restore [-h] [--confirm] [--decrypt]
# event management
event help
event ls [-h] [--latest] [--quiet]
event inspect [-h] EVENT...
# host management
host help
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...] [--logging-MODE] [--set-hop=HOST] [--unset-hop] HOST...
# hostgroup management
hostgroup help
hostgroup create [-h] [--name=<value>] [--comment=<value>]
hostgroup inspect [-h] HOSTGROUP...
hostgroup ls [-h] [--latest] [--quiet]
hostgroup rm [-h] HOSTGROUP...
# key management
key help
key create [-h] [--name=<value>] [--type=<value>] [--length=<value>] [--comment=<value>]
key import [-h] [--name=<value>] [--comment=<value>]
key inspect [-h] [--decrypt] KEY...
key ls [-h] [--latest] [--quiet]
key rm [-h] KEY...
key setup [-h] KEY
key show [-h] KEY
# session management
session help
session ls [-h] [--latest] [--quiet]
session inspect [-h] SESSION...
# user management
user help
user invite [-h] [--name=<value>] [--comment=<value>] [--group=USERGROUP...] <email>
user inspect [-h] USER...
user ls [-h] [--latest] [--quiet]
user rm [-h] USER...
user update [-h] [--name=<value>] [--email=<value>] [--set-admin] [--unset-admin] [--assign-group=USERGROUP...] [--unassign-group=USERGROUP...] USER...
# usergroup management
usergroup help
usergroup create [-h] [--name=<value>] [--comment=<value>]
usergroup inspect [-h] USERGROUP...
usergroup ls [-h] [--latest] [--quiet]
usergroup rm [-h] USERGROUP...
# other
exit [-h]
help, h
info [-h]
version [-h]
```
---
## Healthcheck
By default, `sshportal` will return `OK` to anyone sshing using the `healthcheck` user without checking for authentication.
@@ -370,6 +413,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.
@@ -378,10 +448,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`.
![sshportal cluster with MySQL backend](https://raw.github.com/moul/sshportal/master/.assets/cluster-mysql.svg?sanitize=true)
![sshportal cluster with MySQL backend](https://raw.github.com/moul/sshportal/master/.assets/cluster-mysql.png)
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)
@@ -399,12 +471,58 @@ 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
![sshportal data model](https://raw.github.com/moul/sshportal/master/.assets/sql-schema.svg?sanitize=true)
![sshportal data model](https://raw.github.com/moul/sshportal/master/.assets/sql-schema.png)
## Note
---
This is totally experimental for now, so please file issues to let me know what you think about it!
## Testing
[Install golangci-lint](https://golangci-lint.run/usage/install/#local-installation) and run this in project root:
```
golangci-lint run
```
---
Perform integration tests
```
make integration
```
---
Perform unit tests
```
make unittest
```
---
## License
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmoul%2Fsshportal.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmoul%2Fsshportal?ref=badge_large)
## Contributors
### 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>
### 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
[![Stargazers over time](https://starchart.cc/moul/sshportal.svg)](https://starchart.cc/moul/sshportal)

1
_config.yml Normal file
View File

@@ -0,0 +1 @@
theme: jekyll-theme-slate

40
acl.go
View File

@@ -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 ACLActionDeny, nil // default action
}
// transform map to slice and sort it
acls := []*ACL{}
for _, acl := range aclMap {
acls = append(acls, acl)
}
sort.Sort(ByWeight(acls))
return acls[0].Action, nil
}

View File

@@ -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)
})
}

132
crypto.go
View File

@@ -1,132 +0,0 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"strings"
gossh "golang.org/x/crypto/ssh"
)
func NewSSHKey(keyType string, length uint) (*SSHKey, error) {
key := SSHKey{
Type: keyType,
Length: length,
}
// generate the private key
if keyType != "rsa" {
return nil, fmt.Errorf("key type not supported: %q", 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)))
return &key, nil
}
func encrypt(key []byte, text string) (string, error) {
plaintext := []byte(text)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
ciphertext := make([]byte, aes.BlockSize+len(plaintext))
iv := ciphertext[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)
return base64.URLEncoding.EncodeToString(ciphertext), nil
}
func decrypt(key []byte, cryptoText string) (string, error) {
ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
if len(ciphertext) < aes.BlockSize {
return "", fmt.Errorf("ciphertext too short")
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)
return fmt.Sprintf("%s", ciphertext), nil
}
func safeDecrypt(key []byte, cryptoText string) string {
if len(key) == 0 {
return cryptoText
}
out, err := decrypt(key, cryptoText)
if err != nil {
return cryptoText
}
return out
}
func HostEncrypt(aesKey string, host *Host) error {
if aesKey == "" {
return nil
}
var err error
if host.Password != "" {
if host.Password, err = encrypt([]byte(aesKey), host.Password); err != nil {
return err
}
}
return nil
}
func HostDecrypt(aesKey string, host *Host) {
if aesKey == "" {
return
}
if host.Password != "" {
host.Password = safeDecrypt([]byte(aesKey), host.Password)
}
}
func SSHKeyEncrypt(aesKey string, key *SSHKey) error {
if aesKey == "" {
return nil
}
var err error
key.PrivKey, err = encrypt([]byte(aesKey), key.PrivKey)
return err
}
func SSHKeyDecrypt(aesKey string, key *SSHKey) {
if aesKey == "" {
return
}
key.PrivKey = safeDecrypt([]byte(aesKey), key.PrivKey)
}

588
dbinit.go
View File

@@ -1,588 +0,0 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"time"
"github.com/go-gormigrate/gormigrate"
"github.com/jinzhu/gorm"
gossh "golang.org/x/crypto/ssh"
)
func dbInit(db *gorm.DB) error {
log.SetOutput(ioutil.Discard)
db.Callback().Delete().Replace("gorm:delete", hardDeleteCallback)
log.SetOutput(os.Stderr)
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
{
ID: "1",
Migrate: func(tx *gorm.DB) error {
type Setting struct {
gorm.Model
Name string
Value string
}
return tx.AutoMigrate(&Setting{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("settings").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"`
Comment string
}
return tx.AutoMigrate(&SSHKey{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("ssh_keys").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;"`
Fingerprint string
Comment string
}
return tx.AutoMigrate(&Host{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("hosts").Error
},
}, {
ID: "4",
Migrate: func(tx *gorm.DB) error {
type UserKey struct {
gorm.Model
Key []byte `sql:"size:10000"`
UserID uint ``
User *User `gorm:"ForeignKey:UserID"`
Comment string
}
return tx.AutoMigrate(&UserKey{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("user_keys").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;"`
Comment string
InviteToken string
}
return tx.AutoMigrate(&User{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("users").Error
},
}, {
ID: "6",
Migrate: func(tx *gorm.DB) error {
type UserGroup struct {
gorm.Model
Name string
Users []*User `gorm:"many2many:user_user_groups;"`
ACLs []*ACL `gorm:"many2many:user_group_acls;"`
Comment string
}
return tx.AutoMigrate(&UserGroup{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("user_groups").Error
},
}, {
ID: "7",
Migrate: func(tx *gorm.DB) error {
type HostGroup struct {
gorm.Model
Name string
Hosts []*Host `gorm:"many2many:host_host_groups;"`
ACLs []*ACL `gorm:"many2many:host_group_acls;"`
Comment string
}
return tx.AutoMigrate(&HostGroup{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("host_groups").Error
},
}, {
ID: "8",
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;"`
HostPattern string
Action string
Weight uint
Comment string
}
return tx.AutoMigrate(&ACL{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("acls").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
},
Rollback: func(tx *gorm.DB) error {
return db.Model(&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
},
Rollback: func(tx *gorm.DB) error {
return db.Model(&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
},
Rollback: func(tx *gorm.DB) error {
return db.Model(&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
},
Rollback: func(tx *gorm.DB) error {
return db.Model(&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
},
Rollback: func(tx *gorm.DB) error {
return db.Model(&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
},
Rollback: func(tx *gorm.DB) error {
return db.Model(&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"`
}
return tx.AutoMigrate(&UserRole{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("user_roles").Error
},
}, {
ID: "16",
Migrate: func(tx *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)"`
}
return tx.AutoMigrate(&User{}).Error
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "17",
Migrate: func(tx *gorm.DB) error {
return tx.Create(&UserRole{Name: "admin"}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Where("name = ?", "admin").Delete(&UserRole{}).Error
},
}, {
ID: "18",
Migrate: func(tx *gorm.DB) error {
var adminRole UserRole
if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
return err
}
var users []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 {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "19",
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)"`
}
return tx.AutoMigrate(&User{}).Error
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "20",
Migrate: func(tx *gorm.DB) error {
return tx.Create(&UserRole{Name: "listhosts"}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Where("name = ?", "listhosts").Delete(&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"`
}
return tx.AutoMigrate(&Session{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("sessions").Error
},
}, {
ID: "22",
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)"`
}
return tx.AutoMigrate(&Event{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("events").Error
},
}, {
ID: "23",
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"`
}
return tx.AutoMigrate(&UserKey{}).Error
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "24",
Migrate: func(tx *gorm.DB) error {
var userKeys []UserKey
if err := db.Find(&userKeys).Error; err != nil {
return err
}
for _, userKey := range userKeys {
key, err := gossh.ParsePublicKey(userKey.Key)
if err != nil {
return err
}
userKey.AuthorizedKey = string(gossh.MarshalAuthorizedKey(key))
if err := db.Model(&userKey).Updates(&userKey).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
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"`
}
return tx.AutoMigrate(&Host{}).Error
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "26",
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"`
}
return tx.AutoMigrate(&Session{}).Error
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "27",
Migrate: func(tx *gorm.DB) error {
var sessions []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 {
return err
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
},
})
if err := m.Migrate(); err != nil {
return err
}
NewEvent("system", "migrated").Log(db)
// create default ssh key
var count uint
if err := db.Table("ssh_keys").Where("name = ?", "default").Count(&count).Error; err != nil {
return err
}
if count == 0 {
key, err := NewSSHKey("rsa", 2048)
if err != nil {
return err
}
key.Name = "default"
key.Comment = "created by sshportal"
if err := db.Create(&key).Error; err != nil {
return err
}
}
// create default host group
if err := db.Table("host_groups").Where("name = ?", "default").Count(&count).Error; err != nil {
return err
}
if count == 0 {
hostGroup := HostGroup{
Name: "default",
Comment: "created by sshportal",
}
if err := db.Create(&hostGroup).Error; err != nil {
return err
}
}
// create default user group
if err := db.Table("user_groups").Where("name = ?", "default").Count(&count).Error; err != nil {
return err
}
if count == 0 {
userGroup := UserGroup{
Name: "default",
Comment: "created by sshportal",
}
if err := db.Create(&userGroup).Error; err != nil {
return err
}
}
// create default acl
if err := db.Table("acls").Count(&count).Error; err != nil {
return err
}
if count == 0 {
var defaultUserGroup UserGroup
db.Where("name = ?", "default").First(&defaultUserGroup)
var defaultHostGroup HostGroup
db.Where("name = ?", "default").First(&defaultHostGroup)
acl := ACL{
UserGroups: []*UserGroup{&defaultUserGroup},
HostGroups: []*HostGroup{&defaultHostGroup},
Action: "allow",
//HostPattern: "",
//Weight: 0,
Comment: "created by sshportal",
}
if err := db.Create(&acl).Error; err != nil {
return err
}
}
// create admin user
var defaultUserGroup UserGroup
db.Where("name = ?", "default").First(&defaultUserGroup)
db.Table("users").Count(&count)
if count == 0 {
// if no admin, create an account for the first connection
inviteToken := randStringBytes(16)
if os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN") != "" {
inviteToken = os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN")
}
var adminRole UserRole
if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
return err
}
user := User{
Name: "admin",
Email: "admin@sshportal",
Comment: "created by sshportal",
Roles: []*UserRole{&adminRole},
InviteToken: inviteToken,
Groups: []*UserGroup{&defaultUserGroup},
}
if err := db.Create(&user).Error; err != nil {
return err
}
log.Printf("info 'admin' user created, use the user 'invite:%s' to associate a public key with this account", user.InviteToken)
}
// create host ssh key
if err := db.Table("ssh_keys").Where("name = ?", "host").Count(&count).Error; err != nil {
return err
}
if count == 0 {
key, err := NewSSHKey("rsa", 2048)
if err != nil {
return err
}
key.Name = "host"
key.Comment = "created by sshportal"
if err := db.Create(&key).Error; err != nil {
return err
}
}
// close unclosed connections
if err := db.Table("sessions").Where("status = ?", "active").Updates(&Session{
Status: SessionStatusClosed,
ErrMsg: "sshportal was halted while the connection was still active",
}).Error; err != nil {
return err
}
return nil
}
func hardDeleteCallback(scope *gorm.Scope) {
if !scope.HasError() {
var extraOption string
if str, ok := scope.Get("gorm:delete_option"); ok {
extraOption = fmt.Sprint(str)
}
/* #nosec */
scope.Raw(fmt.Sprintf(
"DELETE FROM %v%v%v",
scope.QuotedTableName(),
addExtraSpaceIfExist(scope.CombinedConditionSql()),
addExtraSpaceIfExist(extraOption),
)).Exec()
}
}
func addExtraSpaceIfExist(str string) string {
if str != "" {
return " " + str
}
return ""
}

128
depaware.txt Normal file
View 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+

View File

@@ -0,0 +1,24 @@
require "language/go"
class Sshportal < Formula
desc "sshportal: simple, fun and transparent SSH bastion"
homepage "https://github.com/moul/sshportal"
url "https://github.com/moul/sshportal/archive/v1.7.1.tar.gz"
sha256 "4611ae2f30cc595b2fb789bd0c92550533db6d4b63c638dd78cf85517b6aeaf0"
head "https://github.com/moul/sshportal.git"
depends_on "go" => :build
def install
ENV["GOPATH"] = buildpath
ENV["GOBIN"] = buildpath
(buildpath/"src/github.com/moul/sshportal").install Dir["*"]
system "go", "build", "-o", "#{bin}/sshportal", "-v", "github.com/moul/sshportal"
end
test do
output = shell_output(bin/"sshportal --version")
assert output.include? "sshportal version "
end
end

View File

@@ -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

View File

@@ -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
@@ -47,6 +47,10 @@ ssh sshportal -l admin host create test42
ssh sshportal -l admin host create --name=testtest --comment=test --password=test test@test.test
ssh sshportal -l admin host create --group=hg1 --group=hg2 hostwithgroups.org
ssh sshportal -l admin host inspect example test42 testtest hostwithgroups
ssh sshportal -l admin host update --assign-group=hg1 test42
ssh sshportal -l admin host update --unassign-group=hg1 test42
ssh sshportal -l admin host update --assign-group=hg1 test42
ssh sshportal -l admin host update --assign-group=hg2 --unassign-group=hg2 test42
ssh sshportal -l admin host ls
# backup/restore
@@ -60,12 +64,16 @@ ssh sshportal -l admin config backup --indent --ignore-events > backup-2
diff backup-1.clean backup-2.clean
)
# bastion
ssh sshportal -l admin host create --name=testserver toto@testserver:2222
out="$(ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":null,"Command":["echo","hello"]}'
if [ "$CIRCLECI" = "true" ]; then
echo "Strage behavior with cross-container communication on CircleCI, skipping some tests..."
else
# bastion
ssh sshportal -l admin host create --name=testserver toto@testserver:2222
out="$(ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":null,"Command":["echo","hello"]}'
out="$(TEST_A=1 TEST_B=2 TEST_C=3 TEST_D=4 TEST_E=5 TEST_F=6 TEST_G=7 TEST_H=8 TEST_I=9 ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":["TEST_A=1","TEST_B=2","TEST_C=3","TEST_D=4","TEST_E=5","TEST_F=6","TEST_G=7","TEST_H=8","TEST_I=9"],"Command":["echo","hello"]}'
out="$(TEST_A=1 TEST_B=2 TEST_C=3 TEST_D=4 TEST_E=5 TEST_F=6 TEST_G=7 TEST_H=8 TEST_I=9 ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":["TEST_A=1","TEST_B=2","TEST_C=3","TEST_D=4","TEST_E=5","TEST_F=6","TEST_G=7","TEST_H=8","TEST_I=9"],"Command":["echo","hello"]}'
fi
# TODO: test more cases (forwards, scp, sftp, interactive, pty, stdin, exit code, ...)

View File

@@ -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

36
go.mod generated Normal file
View File

@@ -0,0 +1,36 @@
module moul.io/sshportal
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/creack/pty v1.1.11 // indirect
github.com/docker/docker v20.10.9+incompatible
github.com/dustin/go-humanize v1.0.0
github.com/gliderlabs/ssh v0.3.3
github.com/go-gormigrate/gormigrate/v2 v2.0.0
github.com/kr/pty v1.1.8
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-runewidth v0.0.12 // 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/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sabban/bastion v0.0.0-20180110125408-b9d3c9b1f4d3
github.com/smartystreets/goconvey v1.7.2
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502
github.com/urfave/cli v1.22.5
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect
golang.org/x/tools v0.1.7
gorm.io/driver/mysql v1.2.0
gorm.io/driver/postgres v1.2.2
gorm.io/driver/sqlite v1.2.4
gorm.io/gorm v1.22.3
moul.io/srand v1.6.1
)
go 1.14

333
go.sum generated Normal file
View File

@@ -0,0 +1,333 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
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-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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-20200428022330-06a60b6afbbc h1:VRRKCwnzqk8QCaRC4os14xoKDdbHqqlJtJA0oc1ZAjg=
github.com/denisenkom/go-mssqldb v0.0.0-20200428022330-06a60b6afbbc/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/docker/docker v20.10.9+incompatible h1:JlsVnETOjM2RLQa0Cc1XCIspUdXW3Zenq9P54uXBm6k=
github.com/docker/docker v20.10.9+incompatible/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/gliderlabs/ssh v0.3.3 h1:mBQ8NiOgDkINJrZtoizkC3nDNYgSaWtxyem6S2XHBtA=
github.com/gliderlabs/ssh v0.3.3/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
github.com/go-gormigrate/gormigrate/v2 v2.0.0 h1:e2A3Uznk4viUC4UuemuVgsNnvYZyOA8B3awlYk3UioU=
github.com/go-gormigrate/gormigrate/v2 v2.0.0/go.mod h1:YuVJ+D/dNt4HWrThTBnjgZuRbt7AuwINeg4q52ZE3Jw=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.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/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.6.4/go.mod h1:w2pne1C2tZgP+TvjqLpOigGzNqjBgQW9dUw/4Chex78=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.10.0 h1:4EYhlDVEMsJ30nNj0mmgwIUXoq7e9sMJrVC2ED6QlCU=
github.com/jackc/pgconn v1.10.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1 h1:7PQ/4gLoqnl87ZxL7xjO0DR5gYuviDCZxQJsUlFW1eI=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
github.com/jackc/pgtype v1.4.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.8.1 h1:9k0IXtdJXHJbyAWQgbWr1lU+MEhPXZz6RIXxfR5oxXs=
github.com/jackc/pgtype v1.8.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.8.1/go.mod h1:4HOLxrl8wToZJReD04/yB20GDwf4KBYETvlHciCnwW0=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.13.0 h1:JCjhT5vmhMAf/YwBHLvrBn4OGdIQBiFG6ym8Zmdx570=
github.com/jackc/pgx/v4 v4.13.0/go.mod h1:9P4X524sErlaxj0XSGZk7s+LD0eOyu1ZDUrrpznYDF0=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
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 v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI=
github.com/jinzhu/now v1.1.2/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.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
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/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
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.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
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/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
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.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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 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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/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/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
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/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 h1:34icjjmqJ2HPjrSuJYEkdZ+0ItmGQAQ75cRHIiftIyE=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/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=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
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-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/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-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-20190813141303-74dc4d7220e7/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/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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-20191204190536-9bdfabe68543/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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.1/go.mod h1:KtqSthtg55lFp3S5kUXqlGaelnWpKitn4k1xZTnoiPw=
gorm.io/driver/mysql v1.2.0 h1:l8+9VwjjyzEkw0PNPBOr2JHhLOGVk7XEnl5hk42bcvs=
gorm.io/driver/mysql v1.2.0/go.mod h1:4RQmTg4okPghdt+kbe6e1bTXIQp7Ny1NnBn/3Z6ghjk=
gorm.io/driver/postgres v1.0.0/go.mod h1:wtMFcOzmuA5QigNsgEIb7O5lhvH1tHAF1RbWmLWV4to=
gorm.io/driver/postgres v1.2.2 h1:Ka9W6feOU+rPM9m007eYLMD4QoZuYGBnQ3Jp0faGSwg=
gorm.io/driver/postgres v1.2.2/go.mod h1:Ik3tK+a3FMp8ORZl29v4b3M0RsgXsaeMXh9s9eVMXco=
gorm.io/driver/sqlite v1.1.1/go.mod h1:hm2olEcl8Tmsc6eZyxYSeznnsDaMqamBvEXLNtBg4cI=
gorm.io/driver/sqlite v1.2.4 h1:jx16ESo1WzNjgBJNSbhEDoMKJnlhkU8BuBR2C0GC7D8=
gorm.io/driver/sqlite v1.2.4/go.mod h1:n8/CTEIEmo7lKrehQI4pd+rz6O514tMkBeCAR5UTXLs=
gorm.io/driver/sqlserver v1.0.2 h1:FzxAlw0/7hntMzSiNfotpYCo9Lz8dqWQGdmCGqIiFGo=
gorm.io/driver/sqlserver v1.0.2/go.mod h1:gb0Y9QePGgqjzrVyTQUZeh9zkd5v0iz71cM1B4ZycEY=
gorm.io/gorm v1.9.19/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.0/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.22.2/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.22.3 h1:/JS6z+GStEQvJNW3t1FTwJwG/gZ+A7crFdRqtvG5ehA=
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
moul.io/srand v1.6.1 h1:SJ335F+54ivLdlH7wH52Rtyv0Ffos6DpsF5wu3ZVMXU=
moul.io/srand v1.6.1/go.mod h1:P2uaZB+GFstFNo8sEj6/U8FRV1n25kD0LLckFpJ+qvc=

73
healthcheck.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"bytes"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/urfave/cli"
gossh "golang.org/x/crypto/ssh"
)
// perform a healthcheck test without requiring an ssh client or an ssh key (used for Docker's HEALTHCHECK)
func healthcheck(addr string, wait, quiet bool) error {
cfg := gossh.ClientConfig{
User: "healthcheck",
HostKeyCallback: func(hostname string, remote net.Addr, key gossh.PublicKey) error { return nil },
Auth: []gossh.AuthMethod{gossh.Password("healthcheck")},
}
if wait {
for {
if err := healthcheckOnce(addr, cfg, quiet); err != nil {
if !quiet {
log.Printf("error: %v", err)
}
time.Sleep(time.Second)
continue
}
return nil
}
}
if err := healthcheckOnce(addr, cfg, quiet); err != nil {
if quiet {
return cli.NewExitError("", 1)
}
return err
}
return nil
}
func healthcheckOnce(addr string, config gossh.ClientConfig, quiet bool) error {
client, err := gossh.Dial("tcp", addr, &config)
if err != nil {
return err
}
session, err := client.NewSession()
if err != nil {
return err
}
defer func() {
if err := session.Close(); err != nil {
if !quiet {
log.Printf("failed to close session: %v", err)
}
}
}()
var b bytes.Buffer
session.Stdout = &b
if err := session.Run(""); err != nil {
return err
}
stdout := strings.TrimSpace(b.String())
if stdout != "OK" {
return fmt.Errorf("invalid stdout: %q expected 'OK'", stdout)
}
return nil
}

View 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
View 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

View 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 }}

View 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 -}}

View 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 }}

View 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 }}

View 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 }}

View 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
View 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
View 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"
)

286
main.go
View File

@@ -1,54 +1,44 @@
package main
package main // import "moul.io/sshportal"
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net"
"os"
"os/exec"
"path"
"strings"
"syscall"
"time"
"unsafe"
"github.com/gliderlabs/ssh"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/kr/pty"
"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.7.1"
// 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.Email = "https://github.com/moul/sshportal"
app.Version = GitTag + " (" + GitSha + ")"
app.Email = "https://moul.io/sshportal"
app.Commands = []cli.Command{
{
Name: "server",
Usage: "Start sshportal server",
Action: server,
Name: "server",
Usage: "Start sshportal server",
Action: func(c *cli.Context) error {
if err := ensureLogDirectory(c.String("logs-location")); err != nil {
return err
}
cfg, err := parseServerConfig(c)
if err != nil {
return err
}
return server(cfg)
},
Flags: []cli.Flag{
cli.StringFlag{
Name: "bind-address, b",
@@ -57,27 +47,47 @@ 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",
EnvVar: "SSHPORTAL_LOGS_LOCATION",
Value: "./log",
Usage: "Store user session files",
},
cli.DurationFlag{
Name: "idle-timeout",
Value: 0,
Usage: "Duration before an inactive connection is timed out (0 to disable)",
},
cli.StringFlag{
Name: "acl-check-cmd",
EnvVar: "SSHPORTAL_ACL_CHECK_CMD",
Usage: "Execute external command to check ACL",
},
},
}, {
Name: "healthcheck",
Action: healthcheck,
Action: func(c *cli.Context) error { return healthcheck(c.String("addr"), c.Bool("wait"), c.Bool("quiet")) },
Flags: []cli.Flag{
cli.StringFlag{
Name: "addr, a",
@@ -103,199 +113,3 @@ func main() {
log.Fatalf("error: %v", err)
}
}
func server(c *cli.Context) error {
switch len(c.String("aes-key")) {
case 0, 16, 24, 32:
default:
return fmt.Errorf("invalid aes key size, should be 16 or 24, 32")
}
// db
db, err := gorm.Open(c.String("db-driver"), c.String("db-conn"))
if err != nil {
return err
}
defer func() {
if err2 := db.Close(); err2 != nil {
panic(err2)
}
}()
if err = db.DB().Ping(); err != nil {
return err
}
if c.Bool("debug") {
db.LogMode(true)
}
if err = dbInit(db); err != nil {
return err
}
opts := []ssh.Option{}
// custom PublicKeyAuth handler
opts = append(opts, ssh.PublicKeyAuth(publicKeyAuthHandler(db, c)))
opts = append(opts, ssh.PasswordAuth(passwordAuthHandler(db, c)))
// retrieve sshportal SSH private key from databse
opts = append(opts, func(srv *ssh.Server) error {
var key SSHKey
if err = SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
return err
}
SSHKeyDecrypt(c.String("aes-key"), &key)
var signer gossh.Signer
signer, err = gossh.ParsePrivateKey([]byte(key.PrivKey))
if err != nil {
return err
}
srv.AddHostKey(signer)
return nil
})
// create TCP listening socket
ln, err := net.Listen("tcp", c.String("bind-address"))
if err != nil {
return err
}
// configure server
srv := &ssh.Server{
Addr: c.String("bind-address"),
Handler: shellHandler, // ssh.Server.Handler is the handler for the DefaultSessionHandler
Version: fmt.Sprintf("sshportal-%s", Version),
ChannelHandler: channelHandler,
}
for _, opt := range opts {
if err := srv.SetOption(opt); err != nil {
return err
}
}
log.Printf("info: SSH Server accepting connections on %s", c.String("bind-address"))
return srv.Serve(ln)
}
// perform a healthcheck test without requiring an ssh client or an ssh key (used for Docker's HEALTHCHECK)
func healthcheck(c *cli.Context) error {
config := gossh.ClientConfig{
User: "healthcheck",
HostKeyCallback: func(hostname string, remote net.Addr, key gossh.PublicKey) error { return nil },
Auth: []gossh.AuthMethod{gossh.Password("healthcheck")},
}
if c.Bool("wait") {
for {
if err := healthcheckOnce(c.String("addr"), config, c.Bool("quiet")); err != nil {
if !c.Bool("quiet") {
log.Printf("error: %v", err)
}
time.Sleep(time.Second)
continue
}
return nil
}
}
if err := healthcheckOnce(c.String("addr"), config, c.Bool("quiet")); err != nil {
if c.Bool("quiet") {
return cli.NewExitError("", 1)
}
return err
}
return nil
}
func healthcheckOnce(addr string, config gossh.ClientConfig, quiet bool) error {
client, err := gossh.Dial("tcp", addr, &config)
if err != nil {
return err
}
session, err := client.NewSession()
if err != nil {
return err
}
defer func() {
if err := session.Close(); err != nil {
if !quiet {
log.Printf("failed to close session: %v", err)
}
}
}()
var b bytes.Buffer
session.Stdout = &b
if err := session.Run(""); err != nil {
return err
}
stdout := strings.TrimSpace(b.String())
if stdout != "OK" {
return fmt.Errorf("invalid stdout: %q expected 'OK'", stdout)
}
return nil
}
// testServer is an hidden handler used for integration tests
func testServer(c *cli.Context) error {
ssh.Handle(func(s ssh.Session) {
helloMsg := struct {
User string
Environ []string
Command []string
}{
User: s.User(),
Environ: s.Environ(),
Command: s.Command(),
}
enc := json.NewEncoder(s)
if err := enc.Encode(&helloMsg); err != nil {
log.Fatalf("failed to write helloMsg: %v", err)
}
var cmd *exec.Cmd
if s.Command() == nil {
cmd = exec.Command("/bin/sh") // #nosec
} else {
cmd = exec.Command(s.Command()[0], s.Command()[1:]...) // #nosec
}
ptyReq, winCh, isPty := s.Pty()
var cmdErr error
if isPty {
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
f, err := pty.Start(cmd)
if err != nil {
fmt.Fprintf(s, "failed to run command: %v\n", err)
_ = s.Exit(1)
return
}
go func() {
for win := range winCh {
_, _, _ = syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(win.Height), uint16(win.Width), 0, 0}))) // #nosec
}
}()
go func() {
_, _ = io.Copy(f, s) // stdin
}()
_, _ = io.Copy(s, f) // stdout
cmdErr = cmd.Wait()
} else {
//cmd.Stdin = s
cmd.Stdout = s
cmd.Stderr = s
cmdErr = cmd.Run()
}
if cmdErr != nil {
if exitError, ok := cmdErr.(*exec.ExitError); ok {
waitStatus := exitError.Sys().(syscall.WaitStatus)
_ = s.Exit(waitStatus.ExitStatus())
return
}
}
waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus)
_ = s.Exit(waitStatus.ExitStatus())
})
log.Println("starting ssh server on port 2222...")
return ssh.ListenAndServe(":2222", nil)
}

120
pkg/bastion/acl.go Normal file
View File

@@ -0,0 +1,120 @@
package bastion
import (
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"sort"
"strings"
"time"
"moul.io/sshportal/pkg/dbmodels"
)
// ACLHookTimeout is timeout for external ACL hook execution
const ACLHookTimeout = 2 * time.Second
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, aclCheckCmd string) 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
// if no shared ACL then execute ACLs hook if it exists and return its result
if len(aclMap) == 0 {
action, err := checkACLsHook(aclCheckCmd, string(dbmodels.ACLActionDeny), user, host)
if err != nil {
log.Println(err)
}
return 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))
action, err := checkACLsHook(aclCheckCmd, acls[0].Action, user, host)
if err != nil {
log.Println(err)
}
return action
}
// checkACLsHook executes external command to check ACL and passes following parameters:
// $1 - SSH Portal `action` (`allow` or `deny`)
// $2 - User as JSON string
// $3 - Host as JSON string
// External program has to return `allow` or `deny` in stdout.
// In case of any error function returns `action`.
func checkACLsHook(aclCheckCmd string, action string, user dbmodels.User, host dbmodels.Host) (string, error) {
if aclCheckCmd == "" {
return action, nil
}
ctx, cancel := context.WithTimeout(context.Background(), ACLHookTimeout)
defer cancel()
jsonUser, err := json.Marshal(user)
if err != nil {
return action, err
}
jsonHost, err := json.Marshal(host)
if err != nil {
return action, err
}
args := []string{
action,
string(jsonUser),
string(jsonHost),
}
cmd := exec.CommandContext(ctx, aclCheckCmd, args...)
out, err := cmd.Output()
if err != nil {
return action, err
}
if ctx.Err() == context.DeadlineExceeded {
return action, fmt.Errorf("external ACL hook command timed out")
}
outStr := strings.TrimSuffix(string(out), "\n")
switch outStr {
case string(dbmodels.ACLActionAllow):
return string(dbmodels.ACLActionAllow), nil
case string(dbmodels.ACLActionDeny):
return string(dbmodels.ACLActionDeny), nil
default:
return action, fmt.Errorf("acl-check-cmd wrong output '%s'", outStr)
}
}

47
pkg/bastion/acl_test.go Normal file
View File

@@ -0,0 +1,47 @@
package bastion // import "moul.io/sshportal/pkg/bastion"
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
. "github.com/smartystreets/goconvey/convey"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"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(sqlite.Open(filepath.Join(tempDir, "sshportal.db")), &gorm.Config{})
c.So(err, ShouldBeNil)
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)
})
}

684
pkg/bastion/dbinit.go Normal file
View File

@@ -0,0 +1,684 @@
package bastion // import "moul.io/sshportal/pkg/bastion"
import (
"fmt"
"io/ioutil"
"log"
"math/rand"
"os"
"os/user"
"strings"
"time"
gormigrate "github.com/go-gormigrate/gormigrate/v2"
gossh "golang.org/x/crypto/ssh"
"gorm.io/gorm"
"moul.io/sshportal/pkg/crypto"
"moul.io/sshportal/pkg/dbmodels"
)
func DBInit(db *gorm.DB) error {
log.SetOutput(ioutil.Discard)
log.SetOutput(os.Stderr)
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
{
ID: "1",
Migrate: func(tx *gorm.DB) error {
type Setting struct {
gorm.Model
Name string `gorm:"index:uix_settings_name,unique"`
Value string
}
return tx.AutoMigrate(&Setting{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("settings")
},
}, {
ID: "2",
Migrate: func(tx *gorm.DB) error {
type SSHKey struct {
gorm.Model
Name string
Type string
Length uint
Fingerprint string
PrivKey string `sql:"size:5000"`
PubKey string `sql:"size:1000"`
Hosts []*dbmodels.Host `gorm:"ForeignKey:SSHKeyID"`
Comment string
}
return tx.AutoMigrate(&SSHKey{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("ssh_keys")
},
}, {
ID: "3",
Migrate: func(tx *gorm.DB) error {
type Host struct {
gorm.Model
Name string `gorm:"size:32"`
Addr string
User string
Password string
SSHKey *dbmodels.SSHKey `gorm:"ForeignKey:SSHKeyID"`
SSHKeyID uint `gorm:"index"`
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
Fingerprint string
Comment string
}
return tx.AutoMigrate(&Host{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("hosts")
},
}, {
ID: "4",
Migrate: func(tx *gorm.DB) error {
type UserKey struct {
gorm.Model
Key []byte `sql:"size:1000"`
UserID uint ``
User *dbmodels.User `gorm:"ForeignKey:UserID"`
Comment string
}
return tx.AutoMigrate(&UserKey{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("user_keys")
},
}, {
ID: "5",
Migrate: func(tx *gorm.DB) error {
type User struct {
gorm.Model
IsAdmin bool
Email string
Name string
Keys []*dbmodels.UserKey `gorm:"ForeignKey:UserID"`
Groups []*dbmodels.UserGroup `gorm:"many2many:user_user_groups;"`
Comment string
InviteToken string
}
return tx.AutoMigrate(&User{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("users")
},
}, {
ID: "6",
Migrate: func(tx *gorm.DB) error {
type UserGroup struct {
gorm.Model
Name string
Users []*dbmodels.User `gorm:"many2many:user_user_groups;"`
ACLs []*dbmodels.ACL `gorm:"many2many:user_group_acls;"`
Comment string
}
return tx.AutoMigrate(&UserGroup{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("user_groups")
},
}, {
ID: "7",
Migrate: func(tx *gorm.DB) error {
type HostGroup struct {
gorm.Model
Name string
Hosts []*dbmodels.Host `gorm:"many2many:host_host_groups;"`
ACLs []*dbmodels.ACL `gorm:"many2many:host_group_acls;"`
Comment string
}
return tx.AutoMigrate(&HostGroup{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("host_groups")
},
}, {
ID: "8",
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
Action string
Weight uint
Comment string
}
return tx.AutoMigrate(&ACL{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("acls")
},
}, {
ID: "9",
Migrate: func(tx *gorm.DB) error {
if err := tx.Migrator().DropIndex(&dbmodels.Setting{}, "uix_settings_name"); err != nil {
return err
}
return tx.Migrator().CreateIndex(&dbmodels.Setting{}, "uix_settings_name")
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropIndex(&dbmodels.Setting{}, "uix_settings_name")
},
}, {
ID: "10",
Migrate: func(tx *gorm.DB) error {
if err := tx.Migrator().DropIndex(&dbmodels.SSHKey{}, "uix_keys_name"); err != nil {
return err
}
return tx.Migrator().CreateIndex(&dbmodels.SSHKey{}, "uix_keys_name")
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropIndex(&dbmodels.SSHKey{}, "uix_keys_name")
},
}, {
ID: "11",
Migrate: func(tx *gorm.DB) error {
if err := tx.Migrator().DropIndex(&dbmodels.Host{}, "uix_hosts_name"); err != nil {
return err
}
return tx.Migrator().CreateIndex(&dbmodels.Host{}, "uix_hosts_name")
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropIndex(&dbmodels.Host{}, "uix_hosts_name")
},
}, {
ID: "12",
Migrate: func(tx *gorm.DB) error {
if err := tx.Migrator().DropIndex(&dbmodels.User{}, "uix_users_name"); err != nil {
return err
}
return tx.Migrator().CreateIndex(&dbmodels.User{}, "uix_users_name")
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropIndex(&dbmodels.User{}, "uix_users_name")
},
}, {
ID: "13",
Migrate: func(tx *gorm.DB) error {
if err := tx.Migrator().DropIndex(&dbmodels.UserGroup{}, "uix_usergroups_name"); err != nil {
return err
}
return tx.Migrator().CreateIndex(&dbmodels.UserGroup{}, "uix_usergroups_name")
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropIndex(&dbmodels.UserGroup{}, "uix_usergroups_name")
},
}, {
ID: "14",
Migrate: func(tx *gorm.DB) error {
if err := tx.Migrator().DropIndex(&dbmodels.HostGroup{}, "uix_hostgroups_name"); err != nil {
return err
}
return tx.Migrator().CreateIndex(&dbmodels.HostGroup{}, "uix_hostgroups_name")
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropIndex(&dbmodels.HostGroup{}, "uix_hostgroups_name")
},
}, {
ID: "15",
Migrate: func(tx *gorm.DB) error {
type UserRole struct {
gorm.Model
Name string `valid:"required,length(1|32),unix_user"`
Users []*dbmodels.User `gorm:"many2many:user_user_roles"`
}
return tx.AutoMigrate(&UserRole{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("user_roles")
},
}, {
ID: "16",
Migrate: func(tx *gorm.DB) error {
type User struct {
gorm.Model
IsAdmin bool
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{})
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "17",
Migrate: func(tx *gorm.DB) error {
return tx.Create(&dbmodels.UserRole{Name: "admin"}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Where("name = ?", "admin").Unscoped().Delete(&dbmodels.UserRole{}).Error
},
}, {
ID: "18",
Migrate: func(tx *gorm.DB) error {
var adminRole dbmodels.UserRole
if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
return err
}
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 {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "19",
Migrate: func(tx *gorm.DB) error {
type User struct {
gorm.Model
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{})
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "20",
Migrate: func(tx *gorm.DB) error {
return tx.Create(&dbmodels.UserRole{Name: "listhosts"}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Where("name = ?", "listhosts").Unscoped().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 *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{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("sessions")
},
}, {
ID: "22",
Migrate: func(tx *gorm.DB) error {
type Event struct {
gorm.Model
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{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("events")
},
}, {
ID: "23",
Migrate: func(tx *gorm.DB) error {
type UserKey struct {
gorm.Model
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{})
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "24",
Migrate: func(tx *gorm.DB) error {
var userKeys []*dbmodels.UserKey
if err := db.Find(&userKeys).Error; err != nil {
return err
}
for _, userKey := range userKeys {
key, err := gossh.ParsePublicKey(userKey.Key)
if err != nil {
return err
}
userKey.AuthorizedKey = string(gossh.MarshalAuthorizedKey(key))
if err := db.Model(userKey).Updates(userKey).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "25",
Migrate: func(tx *gorm.DB) error {
type Host struct {
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 *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{})
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "26",
Migrate: func(tx *gorm.DB) error {
type Session struct {
gorm.Model
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{})
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "27",
Migrate: func(tx *gorm.DB) error {
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 {
return err
}
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "28",
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:1000"`
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
Comment string
}
return tx.AutoMigrate(&Host{})
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "29",
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:1000"`
Groups []*dbmodels.HostGroup `gorm:"many2many:host_host_groups;"`
Comment string
Hop *dbmodels.Host
HopID uint
}
return tx.AutoMigrate(&Host{})
},
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{})
},
Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") },
}, {
ID: "31",
Migrate: func(tx *gorm.DB) error {
return tx.Session(&gorm.Session{AllowGlobalUpdate: true}).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{})
},
Rollback: func(tx *gorm.DB) error { return fmt.Errorf("not implemented") },
},
})
if err := m.Migrate(); err != nil {
return err
}
dbmodels.NewEvent("system", "migrated").Log(db)
// create default ssh key
var count int64
if err := db.Table("ssh_keys").Where("name = ?", "default").Count(&count).Error; err != nil {
return err
}
if count == 0 {
key, err := crypto.NewSSHKey("ed25519", 1)
if err != nil {
return err
}
key.Name = "default"
key.Comment = "created by sshportal"
if err := db.Create(&key).Error; err != nil {
return err
}
}
// create default host group
if err := db.Table("host_groups").Where("name = ?", "default").Count(&count).Error; err != nil {
return err
}
if count == 0 {
hostGroup := dbmodels.HostGroup{
Name: "default",
Comment: "created by sshportal",
}
if err := db.Create(&hostGroup).Error; err != nil {
return err
}
}
// create default user group
if err := db.Table("user_groups").Where("name = ?", "default").Count(&count).Error; err != nil {
return err
}
if count == 0 {
userGroup := dbmodels.UserGroup{
Name: "default",
Comment: "created by sshportal",
}
if err := db.Create(&userGroup).Error; err != nil {
return err
}
}
// create default acl
if err := db.Table("acls").Count(&count).Error; err != nil {
return err
}
if count == 0 {
var defaultUserGroup dbmodels.UserGroup
db.Where("name = ?", "default").First(&defaultUserGroup)
var defaultHostGroup dbmodels.HostGroup
db.Where("name = ?", "default").First(&defaultHostGroup)
acl := dbmodels.ACL{
UserGroups: []*dbmodels.UserGroup{&defaultUserGroup},
HostGroups: []*dbmodels.HostGroup{&defaultHostGroup},
Action: "allow",
//HostPattern: "",
//Weight: 0,
Comment: "created by sshportal",
}
if err := db.Create(&acl).Error; err != nil {
return err
}
}
// create admin user
var defaultUserGroup dbmodels.UserGroup
db.Where("name = ?", "default").First(&defaultUserGroup)
if err := db.Table("users").Count(&count).Error; err != nil {
return err
}
if count == 0 {
// if no admin, create an account for the first connection
inviteToken := randStringBytes(16)
if os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN") != "" {
inviteToken = os.Getenv("SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN")
}
var adminRole dbmodels.UserRole
if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
return err
}
var username string
if currentUser, err := user.Current(); err == nil {
username = currentUser.Username
}
if username == "" {
username = os.Getenv("USER")
}
username = strings.ToLower(username)
if username == "" {
username = "admin" // fallback username
}
user := dbmodels.User{
Name: username,
Email: fmt.Sprintf("%s@localhost", username),
Comment: "created by sshportal",
Roles: []*dbmodels.UserRole{&adminRole},
InviteToken: inviteToken,
Groups: []*dbmodels.UserGroup{&defaultUserGroup},
}
if err := db.Create(&user).Error; err != nil {
return err
}
log.Printf("info 'admin' user created, use the user 'invite:%s' to associate a public key with this account", user.InviteToken)
}
// create host ssh key
if err := db.Table("ssh_keys").Where("name = ?", "host").Count(&count).Error; err != nil {
return err
}
if count == 0 {
key, err := crypto.NewSSHKey("ed25519", 1)
if err != nil {
return err
}
key.Name = "host"
key.Comment = "created by sshportal"
if err := db.Create(&key).Error; err != nil {
return err
}
}
// close unclosed connections
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
}
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)
}

71
pkg/bastion/logtunnel.go Normal file
View File

@@ -0,0 +1,71 @@
package bastion // import "moul.io/sshportal/pkg/bastion"
import (
"encoding/binary"
"errors"
"io"
"log"
"syscall"
"time"
"golang.org/x/crypto/ssh"
)
type logTunnel struct {
host string
channel ssh.Channel
writer io.WriteCloser
}
type logTunnelForwardData struct {
DestinationHost string
DestinationPort uint32
SourceHost string
SourcePort uint32
}
func writeHeader(fd io.Writer, length int) {
t := time.Now()
tv := syscall.NsecToTimeval(t.UnixNano())
if err := binary.Write(fd, binary.LittleEndian, int32(tv.Sec)); err != nil {
log.Printf("failed to write log header: %v", err)
}
if err := binary.Write(fd, binary.LittleEndian, tv.Usec); err != nil {
log.Printf("failed to write log header: %v", err)
}
if err := binary.Write(fd, binary.LittleEndian, int32(length)); err != nil {
log.Printf("failed to write log header: %v", err)
}
}
func newLogTunnel(channel ssh.Channel, writer io.WriteCloser, host string) io.ReadWriteCloser {
return &logTunnel{
host: host,
channel: channel,
writer: writer,
}
}
func (l *logTunnel) Read(data []byte) (int, error) {
return 0, errors.New("logTunnel.Read is not implemented")
}
func (l *logTunnel) Write(data []byte) (int, error) {
writeHeader(l.writer, len(data)+len(l.host+": "))
if _, err := l.writer.Write([]byte(l.host + ": ")); err != nil {
log.Printf("failed to write log: %v", err)
}
if _, err := l.writer.Write(data); err != nil {
log.Printf("failed to write log: %v", err)
}
return l.channel.Write(data)
}
func (l *logTunnel) Close() error {
l.writer.Close()
return l.channel.Close()
}

264
pkg/bastion/session.go Normal file
View File

@@ -0,0 +1,264 @@
package bastion // import "moul.io/sshportal/pkg/bastion"
import (
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"time"
"github.com/gliderlabs/ssh"
"github.com/pkg/errors"
"github.com/sabban/bastion/pkg/logchannel"
gossh "golang.org/x/crypto/ssh"
)
type sessionConfig struct {
Addr string
LogsLocation string
ClientConfig *gossh.ClientConfig
LoggingMode string
}
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":
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
// go through all the hops
for _, config := range configs {
var client *gossh.Client
if lastClient == nil {
client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig)
} else {
rconn, err := lastClient.Dial("tcp", config.Addr)
if err != nil {
return err
}
ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig)
if err != nil {
return err
}
client = gossh.NewClient(ncc, chans, reqs)
}
if err != nil {
lch.Close() // fix #56
return err
}
defer func() { _ = client.Close() }()
lastClient = client
}
rch, rreqs, err := lastClient.OpenChannel("session", []byte{})
if err != nil {
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], user, username, sessionID, newChan)
case "direct-tcpip":
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
// go through all the hops
for _, config := range configs {
var client *gossh.Client
if lastClient == nil {
client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig)
} else {
rconn, err := lastClient.Dial("tcp", config.Addr)
if err != nil {
return err
}
ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig)
if err != nil {
return err
}
client = gossh.NewClient(ncc, chans, reqs)
}
if err != nil {
lch.Close()
return err
}
defer func() { _ = client.Close() }()
lastClient = client
}
d := logTunnelForwardData{}
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
return err
}
rch, rreqs, err := lastClient.OpenChannel("direct-tcpip", newChan.ExtraData())
if err != nil {
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], user, username, sessionID, newChan)
default:
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
log.Printf("failed to reject chan: %v", err)
}
return nil
}
}
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()
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
}
if channeltype == "session" {
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 := logTunnelForwardData{}
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
return err
}
wrappedlch := newLogTunnel(lch, logWriter, d.SourceHost)
wrappedrch := newLogTunnel(rch, logWriter, d.DestinationHost)
go func(quit chan string) {
_, _ = io.Copy(wrappedlch, rch)
quit <- "rch"
}(quit)
go func(quit chan string) {
_, _ = io.Copy(wrappedrch, lch)
quit <- "lch"
}(quit)
}
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, logWriter)
req.Payload = append(req.Payload, []byte("\n")...)
if _, err := wrappedlch.LogWrite(req.Payload); err != nil {
log.Printf("failed to write log: %v", err)
}
}
if err != nil {
errch <- err
}
if err2 := req.Reply(b, nil); err2 != 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 {
errch <- err
}
if err2 := req.Reply(b, nil); err2 != nil {
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
}

File diff suppressed because it is too large Load Diff

354
pkg/bastion/ssh.go Normal file
View File

@@ -0,0 +1,354 @@
package bastion // import "moul.io/sshportal/pkg/bastion"
import (
"bytes"
"errors"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
"gorm.io/gorm"
"moul.io/sshportal/pkg/crypto"
"moul.io/sshportal/pkg/dbmodels"
)
type sshportalContextKey string
var authContextKey = sshportalContextKey("auth")
type authContext struct {
message string
err error
user dbmodels.User
inputUsername string
db *gorm.DB
userKey dbmodels.UserKey
logsLocation string
aclCheckCmd string
aesKey string
dbDriver, dbURL string
bindAddr string
demo, debug bool
authMethod string
authSuccess bool
}
type userType string
const (
userTypeHealthcheck userType = "healthcheck"
userTypeBastion userType = "bastion"
userTypeInvite userType = "invite"
userTypeShell userType = "shell"
)
func (c authContext) userType() userType {
switch {
case c.inputUsername == "healthcheck":
return userTypeHealthcheck
case c.inputUsername == c.user.Name || c.inputUsername == c.user.Email || c.inputUsername == "admin":
return userTypeShell
case strings.HasPrefix(c.inputUsername, "invite:"):
return userTypeInvite
default:
return userTypeBastion
}
}
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...")
return db.Model(host).Update("HostKey", key.Marshal()).Error
}
if !bytes.Equal(host.HostKey, key.Marshal()) {
return fmt.Errorf("ssh: host key mismatch")
}
return nil
}
}
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":
default:
// TODO: handle direct-tcp (only for ssh scheme)
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
log.Printf("error: failed to reject channel: %v", err)
}
return
}
actx := ctx.Value(authContextKey).(*authContext)
switch actx.userType() {
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 {
return
}
fmt.Fprintf(ch, "error: %v\n", err)
// FIXME: force close all channels
_ = ch.Close()
return
}
switch host.Scheme() {
case dbmodels.BastionSchemeSSH:
sessionConfigs := make([]sessionConfig, 0)
currentHost := host
for currentHost != nil {
clientConfig, err2 := bastionClientConfig(ctx, currentHost)
if err2 != nil {
ch, _, err3 := newChan.Accept()
if err3 != nil {
return
}
fmt.Fprintf(ch, "error: %v\n", err2)
// FIXME: force close all channels
_ = ch.Close()
return
}
sessionConfigs = append([]sessionConfig{{
Addr: currentHost.DialAddr(),
ClientConfig: clientConfig,
LogsLocation: actx.logsLocation,
LoggingMode: currentHost.Logging,
}}, sessionConfigs...)
if currentHost.HopID != 0 {
var newHost dbmodels.Host
if err := actx.db.Model(currentHost).Association("HopID").Find(&newHost); err != nil {
log.Printf("Error: %v", err)
return
}
hostname := newHost.Name
currentHost, _ = dbmodels.HostByName(actx.db, hostname)
} else {
currentHost = nil
}
}
sess := dbmodels.Session{
UserID: actx.user.ID,
HostID: host.ID,
Status: string(dbmodels.SessionStatusActive),
}
if err = actx.db.Create(&sess).Error; err != nil {
ch, _, err2 := newChan.Accept()
if err2 != nil {
return
}
fmt.Fprintf(ch, "error: %v\n", err)
_ = ch.Close()
return
}
go func() {
err = multiChannelHandler(conn, newChan, ctx, sessionConfigs, sess.ID)
if err != nil {
log.Printf("Error: %v", err)
}
now := time.Now()
sessUpdate := dbmodels.Session{
Status: string(dbmodels.SessionStatusClosed),
ErrMsg: fmt.Sprintf("%v", err),
StoppedAt: &now,
}
if err == nil {
sessUpdate.ErrMsg = ""
}
actx.db.Model(&sess).Updates(&sessUpdate)
}()
case dbmodels.BastionSchemeTelnet:
tmpSrv := ssh.Server{
// PtyCallback: srv.PtyCallback,
Handler: telnetHandler(host),
}
DefaultChannelHandler(&tmpSrv, conn, newChan, ctx)
default:
ch, _, err2 := newChan.Accept()
if err2 != nil {
return
}
fmt.Fprintf(ch, "error: unknown bastion scheme: %q\n", host.Scheme())
// FIXME: force close all channels
_ = ch.Close()
}
default: // shell
DefaultChannelHandler(srv, conn, newChan, ctx)
}
}
func bastionClientConfig(ctx ssh.Context, host *dbmodels.Host) (*gossh.ClientConfig, error) {
actx := ctx.Value(authContextKey).(*authContext)
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 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 dbmodels.Host
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err != nil {
return nil, err
}
action := checkACLs(tmpUser, tmpHost, actx.aclCheckCmd)
switch action {
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)
}
return clientConfig, nil
}
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:%d,email:%s", s.User(), s.RemoteAddr(), s.LocalAddr(), s.Command(), actx.user.ID, actx.user.Email)
}
if actx.err != nil {
fmt.Fprintf(s, "error: %v\n", actx.err)
_ = s.Exit(1)
return
}
if actx.message != "" {
fmt.Fprint(s, actx.message)
}
switch actx.userType() {
case userTypeHealthcheck:
fmt.Fprintln(s, "OK")
return
case userTypeShell:
if err := shell(s, version, gitSha, gitTag); err != nil {
fmt.Fprintf(s, "error: %v\n", err)
_ = s.Exit(1)
}
return
case userTypeInvite:
// do nothing (message was printed at the beginning of the function)
return
}
panic("should not happen")
}
func PasswordAuthHandler(db *gorm.DB, logsLocation, aclCheckCmd, aesKey, dbDriver, dbURL, bindAddr string, demo bool) ssh.PasswordHandler {
return func(ctx ssh.Context, pass string) bool {
actx := &authContext{
db: db,
inputUsername: ctx.User(),
logsLocation: logsLocation,
aclCheckCmd: aclCheckCmd,
aesKey: aesKey,
dbDriver: dbDriver,
dbURL: dbURL,
bindAddr: bindAddr,
demo: demo,
authMethod: "password",
}
actx.authSuccess = actx.userType() == userTypeHealthcheck
ctx.SetValue(authContextKey, actx)
return actx.authSuccess
}
}
func PrivateKeyFromDB(db *gorm.DB, aesKey string) func(*ssh.Server) error {
return func(srv *ssh.Server) error {
var key dbmodels.SSHKey
if err := dbmodels.SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
return err
}
crypto.SSHKeyDecrypt(aesKey, &key)
signer, err := gossh.ParsePrivateKey([]byte(key.PrivKey))
if err != nil {
return err
}
srv.AddHostKey(signer)
return nil
}
}
func PublicKeyAuthHandler(db *gorm.DB, logsLocation, aclCheckCmd, 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(),
logsLocation: logsLocation,
aclCheckCmd: aclCheckCmd,
aesKey: aesKey,
dbDriver: dbDriver,
dbURL: dbURL,
bindAddr: bindAddr,
demo: demo,
authMethod: "pubkey",
authSuccess: true,
}
ctx.SetValue(authContextKey, actx)
// lookup user by key
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 {
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 {
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 = dbmodels.UserKey{
UserID: actx.user.ID,
Key: key.Marshal(),
Comment: "created by sshportal",
AuthorizedKey: string(gossh.MarshalAuthorizedKey(key)),
}
db.Create(&actx.userKey)
// token is only usable once
actx.user.InviteToken = ""
db.Model(&actx.user).Updates(&actx.user)
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 = dbmodels.User{Name: "Anonymous"}
actx.err = errors.New("your token is invalid or expired")
}
return true
}
// fallback
actx.err = errors.New("unknown ssh key")
actx.user = dbmodels.User{Name: "Anonymous"}
return true
}
}

88
pkg/bastion/telnet.go Normal file
View File

@@ -0,0 +1,88 @@
package bastion // import "moul.io/sshportal/pkg/bastion"
import (
"bufio"
"bytes"
"fmt"
"io"
"log"
"time"
"github.com/gliderlabs/ssh"
oi "github.com/reiver/go-oi"
telnet "github.com/reiver/go-telnet"
"moul.io/sshportal/pkg/dbmodels"
)
type bastionTelnetCaller struct {
ssh ssh.Session
}
func (caller bastionTelnetCaller) CallTELNET(ctx telnet.Context, w telnet.Writer, r telnet.Reader) {
go func(writer io.Writer, reader io.Reader) {
var buffer [1]byte // Seems like the length of the buffer needs to be small, otherwise will have to wait for buffer to fill up.
p := buffer[:]
for {
// Read 1 byte.
n, err := reader.Read(p)
if n <= 0 && err == nil {
continue
} else if n <= 0 && err != nil {
break
}
if _, err = oi.LongWrite(writer, p); err != nil {
log.Printf("telnet longwrite failed: %v", err)
}
}
}(caller.ssh, r)
var buffer bytes.Buffer
var p []byte
var crlfBuffer = [2]byte{'\r', '\n'}
crlf := crlfBuffer[:]
scanner := bufio.NewScanner(caller.ssh)
scanner.Split(scannerSplitFunc)
for scanner.Scan() {
buffer.Write(scanner.Bytes())
buffer.Write(crlf)
p = buffer.Bytes()
n, err := oi.LongWrite(w, p)
if nil != err {
break
}
if expected, actual := int64(len(p)), n; expected != actual {
err := fmt.Errorf("transmission problem: tried sending %d bytes, but actually only sent %d bytes", expected, actual)
fmt.Fprint(caller.ssh, err.Error())
return
}
buffer.Reset()
}
// Wait a bit to receive data from the server (that we would send to io.Stdout).
time.Sleep(3 * time.Millisecond)
}
func scannerSplitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF {
return 0, nil, nil
}
return bufio.ScanLines(data, atEOF)
}
func telnetHandler(host *dbmodels.Host) ssh.Handler {
return func(s ssh.Session) {
// FIXME: log session in db
// 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)
}
}
}

View File

@@ -1,89 +0,0 @@
package bastionsession
import (
"errors"
"io"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
)
type Config struct {
Addr string
ClientConfig *gossh.ClientConfig
}
func ChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, config Config) error {
if newChan.ChannelType() != "session" {
newChan.Reject(gossh.UnknownChannelType, "unsupported channel type")
return nil
}
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
// open client channel
rconn, err := gossh.Dial("tcp", config.Addr, config.ClientConfig)
if err != nil {
return err
}
defer func() { _ = rconn.Close() }()
rch, rreqs, err := rconn.OpenChannel("session", []byte{})
if err != nil {
return err
}
// pipe everything
return pipe(lreqs, rreqs, lch, rch)
}
func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel) error {
defer func() {
_ = lch.Close()
_ = rch.Close()
}()
errch := make(chan error, 1)
go func() {
_, _ = io.Copy(lch, rch)
errch <- errors.New("lch closed the connection")
}()
go func() {
_, _ = io.Copy(rch, lch)
errch <- errors.New("rch closed the connection")
}()
for {
select {
case req := <-lreqs: // forward ssh requests from local to remote
if req == nil {
return nil
}
b, err := rch.SendRequest(req.Type, req.WantReply, req.Payload)
if err != nil {
return 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
}
b, err := lch.SendRequest(req.Type, req.WantReply, req.Payload)
if err != nil {
return err
}
if err2 := req.Reply(b, nil); err2 != nil {
return err2
}
case err := <-errch:
return err
}
}
}

242
pkg/crypto/crypto.go Normal file
View File

@@ -0,0 +1,242 @@
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"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"strings"
gossh "golang.org/x/crypto/ssh"
"moul.io/sshportal/pkg/dbmodels"
)
func NewSSHKey(keyType string, length uint) (*dbmodels.SSHKey, error) {
key := dbmodels.SSHKey{
Type: keyType,
Length: length,
}
// generate the private key
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)
}
if err != nil {
return nil, err
}
buf := bytes.NewBufferString("")
if err = pem.Encode(buf, pemKey); err != nil {
return nil, err
}
key.PrivKey = buf.String()
// generate authorized-key formatted pubkey output
key.PubKey = strings.TrimSpace(string(gossh.MarshalAuthorizedKey(publicKey)))
return &key, nil
}
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",
}
parsedKey, err := gossh.ParseRawPrivateKey([]byte(keyValue))
if err != nil {
return nil, err
}
var privateKey *rsa.PrivateKey
var ok bool
if privateKey, ok = parsedKey.(*rsa.PrivateKey); !ok {
return nil, errors.New("key type not supported")
}
key.Length = uint(privateKey.PublicKey.N.BitLen())
// 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)))
return &key, nil
}
func encrypt(key []byte, text string) (string, error) {
plaintext := []byte(text)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
ciphertext := make([]byte, aes.BlockSize+len(plaintext))
iv := ciphertext[:aes.BlockSize]
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)
return base64.URLEncoding.EncodeToString(ciphertext), nil
}
func decrypt(key []byte, cryptoText string) (string, error) {
ciphertext, _ := base64.URLEncoding.DecodeString(cryptoText)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
if len(ciphertext) < aes.BlockSize {
return "", fmt.Errorf("ciphertext too short")
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(ciphertext, ciphertext)
return string(ciphertext), nil
}
func safeDecrypt(key []byte, cryptoText string) string {
if len(key) == 0 {
return cryptoText
}
out, err := decrypt(key, cryptoText)
if err != nil {
return cryptoText
}
return out
}
func HostEncrypt(aesKey string, host *dbmodels.Host) (err error) {
if aesKey == "" {
return nil
}
if host.Password != "" {
host.Password, err = encrypt([]byte(aesKey), host.Password)
}
return
}
func HostDecrypt(aesKey string, host *dbmodels.Host) {
if aesKey == "" {
return
}
if host.Password != "" {
host.Password = safeDecrypt([]byte(aesKey), host.Password)
}
}
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 *dbmodels.SSHKey) {
if aesKey == "" {
return
}
key.PrivKey = safeDecrypt([]byte(aesKey), key.PrivKey)
}

View File

@@ -1,17 +1,16 @@
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"
"gorm.io/gorm"
)
type Config struct {
@@ -31,7 +30,7 @@ type Config struct {
type Setting struct {
gorm.Model
Name string `valid:"required"`
Name string `valid:"required" gorm:"index:uix_settings_name,unique"`
Value string `valid:"required"`
}
@@ -39,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" gorm:"index:uix_keys_name,unique"`
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"`
}
@@ -52,22 +51,26 @@ 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"`
Addr string `valid:"required"`
User string `valid:"optional"`
Password string `valid:"optional"`
Name string `gorm:"index:uix_hosts_name,unique;type:varchar(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
}
// 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"`
@@ -75,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"`
}
@@ -84,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" gorm:"index:uix_users_name,unique"`
Keys []*UserKey `gorm:"ForeignKey:UserID"`
Groups []*UserGroup `gorm:"many2many:user_user_groups;"`
Comment string `valid:"optional"`
@@ -93,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" gorm:"index:uix_usergroups_name,unique"`
Users []*User `gorm:"many2many:user_user_groups;"`
ACLs []*ACL `gorm:"many2many:user_group_acls;"`
Comment string `valid:"optional"`
@@ -101,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" gorm:"index:uix_hostgroups_name,unique"`
Hosts []*Host `gorm:"many2many:host_host_groups;"`
ACLs []*ACL `gorm:"many2many:host_group_acls;"`
Comment string `valid:"optional"`
@@ -115,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 {
@@ -144,80 +149,159 @@ type SessionStatus string
const (
SessionStatusUnknown SessionStatus = "unknown"
SessionStatusActive = "active"
SessionStatusClosed = "closed"
SessionStatusActive SessionStatus = "active"
SessionStatusClosed SessionStatus = "closed"
)
type ACLAction string
const (
ACLActionUnknown ACLAction = "unknown"
ACLActionAllow = "allow"
ACLActionDeny = "deny"
ACLActionAllow ACLAction = "allow"
ACLActionDeny ACLAction = "deny"
)
func init() {
unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*")
type BastionScheme string
govalidator.CustomTypeTagMap.Set("unix_user", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool {
name, ok := i.(string)
if !ok {
return false
const (
BastionSchemeSSH BastionScheme = "ssh"
BastionSchemeTelnet BastionScheme = "telnet"
)
// 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)
}))
}
func (host *Host) URL() string {
return fmt.Sprintf("%s@%s", host.User, host.Addr)
}
func NewHostFromURL(rawurl string) (*Host, error) {
if !strings.Contains(rawurl, "://") {
rawurl = "ssh://" + rawurl
}
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
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)
}
host := Host{Addr: u.Host}
if !strings.Contains(host.Addr, ":") {
host.Addr += ":22" // add port if not present
}
host.User = "root" // default username
if u.User != nil {
password, _ := u.User.Password()
host.Password = password
host.User = u.User.Username()
}
return &host, nil
}
func (host *Host) Hostname() string {
return strings.Split(host.Addr, ":")[0]
return db.Where("name IN (?)", names)
}
// Host helpers
func (host *Host) DialAddr() string {
return fmt.Sprintf("%s:%d", host.Hostname(), host.Port())
}
func (host *Host) String() string {
if host.URL != "" {
return host.URL
} else if host.Addr != "" { // to be removed in a future version in favor of URL
if host.Password != "" {
return fmt.Sprintf("ssh://%s:%s@%s", host.User, strings.Repeat("*", 4), host.Addr)
}
return fmt.Sprintf("ssh://%s@%s", host.User, host.Addr)
}
return ""
}
func (host *Host) Scheme() BastionScheme {
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return BastionSchemeSSH
}
return BastionScheme(u.Scheme)
} else if host.Addr != "" {
return BastionSchemeSSH
}
return ""
}
func (host *Host) Hostname() string {
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return ""
}
return u.Hostname()
} else if host.Addr != "" { // to be removed in a future version in favor of URL
return strings.Split(host.Addr, ":")[0]
}
return ""
}
func (host *Host) Username() string {
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return "root"
}
if u.User != nil {
return u.User.Username()
}
} else if host.User != "" { // to be removed in a future version in favor of URL
return host.User
}
return "root"
}
func (host *Host) Passwd() string {
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return ""
}
if u.User != nil {
password, _ := u.User.Password()
return password
}
} else if host.Password != "" { // to be removed in a future version in favor of URL
return host.Password
}
return ""
}
func (host *Host) Port() uint64 {
var portString string
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
goto defaultPort
}
portString = u.Port()
} else if host.Addr != "" { // to be removed in a future version in favor of URL
portString = strings.Split(host.Addr, ":")[1]
}
if portString != "" {
port, err := strconv.ParseUint(portString, 10, 64)
if err != nil {
goto defaultPort
}
return port
}
defaultPort:
switch host.Scheme() {
case BastionSchemeSSH:
return 22
case BastionSchemeTelnet:
return 23
default:
return 0
}
}
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
db.Preload("SSHKey").Where("name = ?", name).Find(&host)
if host.Name == "" {
// FIXME: add available hosts
return nil, fmt.Errorf("No such target: %q", name)
return nil, fmt.Errorf("no such target: %q", name)
}
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.User,
User: host.Username(),
HostKeyCallback: hk,
Auth: []gossh.AuthMethod{},
}
@@ -228,8 +312,8 @@ func (host *Host) clientConfig(hk gossh.HostKeyCallback) (*gossh.ClientConfig, e
}
config.Auth = append(config.Auth, gossh.PublicKeys(signer))
}
if host.Password != "" {
config.Auth = append(config.Auth, gossh.Password(host.Password))
if host.Passwd() != "" {
config.Auth = append(config.Auth, gossh.Password(host.Passwd()))
}
if len(config.Auth) == 0 {
return nil, fmt.Errorf("no valid authentication method for host %q", host.Name)
@@ -243,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
@@ -252,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
@@ -261,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
@@ -270,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 {
@@ -281,16 +379,11 @@ func (u *User) HasRole(name string) bool {
return false
}
func (u *User) CheckRoles(names []string) error {
ok := false
for _, name := range names {
if u.HasRole(name) {
ok = true
break
return nil
}
}
if ok {
return nil
}
return fmt.Errorf("you don't have permission to access this feature (requires any of these roles: '%s')", strings.Join(names, "', '"))
}
@@ -311,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
@@ -365,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
View 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"
}

View 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)
}

View 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)
}
})
}
}

361
rules.mk vendored Normal file
View 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.fmt
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

158
server.go Normal file
View File

@@ -0,0 +1,158 @@
package main
import (
"fmt"
"log"
"math"
"net"
"os"
"time"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gliderlabs/ssh"
"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
aclCheckCmd string
}
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"),
aclCheckCmd: c.String("acl-check-cmd"),
}
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 dbConnect(c *serverConfig, config gorm.Option) (*gorm.DB, error) {
var dbOpen func(string) gorm.Dialector
if c.dbDriver == "sqlite3" {
dbOpen = sqlite.Open
}
if c.dbDriver == "postgres" {
dbOpen = postgres.Open
}
if c.dbDriver == "mysql" {
dbOpen = mysql.Open
}
return gorm.Open(dbOpen(c.dbURL), config)
}
func server(c *serverConfig) (err error) {
// configure db logging
db, err := dbConnect(c, &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
sqlDB, err := db.DB()
defer func() {
origErr := err
err = sqlDB.Close()
if origErr != nil {
err = origErr
}
}()
if err = sqlDB.Ping(); err != nil {
return
}
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.aclCheckCmd, c.aesKey, c.dbDriver, c.dbURL, c.bindAddr, c.demo)),
ssh.PasswordAuth(bastion.PasswordAuthHandler(db, c.logsLocation, c.aclCheckCmd, 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)
}

290
ssh.go
View File

@@ -1,290 +0,0 @@
package main
import (
"bytes"
"errors"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/gliderlabs/ssh"
"github.com/jinzhu/gorm"
"github.com/moul/sshportal/pkg/bastionsession"
"github.com/urfave/cli"
gossh "golang.org/x/crypto/ssh"
)
type sshportalContextKey string
var authContextKey = sshportalContextKey("auth")
type authContext struct {
message string
err error
user User
inputUsername string
db *gorm.DB
userKey UserKey
globalContext *cli.Context
authMethod string
authSuccess bool
}
type UserType string
const (
UserTypeHealthcheck UserType = "healthcheck"
UserTypeBastion = "bastion"
UserTypeInvite = "invite"
UserTypeShell = "shell"
)
type SessionType string
const (
SessionTypeBastion SessionType = "bastion"
SessionTypeShell = "shell"
)
func (c authContext) userType() UserType {
switch {
case c.inputUsername == "healthcheck":
return UserTypeHealthcheck
case c.inputUsername == c.user.Name || c.inputUsername == c.user.Email || c.inputUsername == "admin":
return UserTypeShell
case strings.HasPrefix(c.inputUsername, "invite:"):
return UserTypeInvite
default:
return UserTypeBastion
}
}
func (c authContext) sessionType() SessionType {
switch c.userType() {
case "bastion":
return SessionTypeBastion
default:
return SessionTypeShell
}
}
func dynamicHostKey(db *gorm.DB, host *Host) gossh.HostKeyCallback {
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
if len(host.HostKey) == 0 {
log.Println("Discovering host fingerprint...")
return db.Model(host).Update("HostKey", key.Marshal()).Error
}
if !bytes.Equal(host.HostKey, key.Marshal()) {
return fmt.Errorf("ssh: host key mismatch")
}
return nil
}
}
func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
switch newChan.ChannelType() {
case "session":
default:
// TODO: handle direct-tcp
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
log.Printf("error: failed to reject channel: %v", err)
}
return
}
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, clientConfig, err := bastionConfig(ctx)
if err != nil {
ch, _, err2 := newChan.Accept()
if err2 != nil {
return
}
fmt.Fprintf(ch, "error: %v\n", err)
// FIXME: force close all channels
_ = ch.Close()
return
}
sess := Session{
UserID: actx.user.ID,
HostID: host.ID,
Status: SessionStatusActive,
}
if err = actx.db.Create(&sess).Error; err != nil {
ch, _, err2 := newChan.Accept()
if err2 != nil {
return
}
fmt.Fprintf(ch, "error: %v\n", err)
_ = ch.Close()
return
}
err = bastionsession.ChannelHandler(srv, conn, newChan, ctx, bastionsession.Config{
Addr: host.Addr,
ClientConfig: clientConfig,
})
now := time.Now()
sessUpdate := Session{
Status: SessionStatusClosed,
ErrMsg: fmt.Sprintf("%v", err),
StoppedAt: &now,
}
switch sessUpdate.ErrMsg {
case "lch closed the connection", "rch closed the connection":
sessUpdate.ErrMsg = ""
}
actx.db.Model(&sess).Updates(&sessUpdate)
default: // shell
ssh.DefaultChannelHandler(srv, conn, newChan, ctx)
}
}
func bastionConfig(ctx ssh.Context) (*Host, *gossh.ClientConfig, error) {
actx := ctx.Value(authContextKey).(*authContext)
host, err := HostByName(actx.db, actx.inputUsername)
if err != nil {
return nil, nil, err
}
clientConfig, err := host.clientConfig(dynamicHostKey(actx.db, host))
if err != nil {
return nil, nil, err
}
var tmpUser User
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", actx.user.ID).First(&tmpUser).Error; err != nil {
return nil, nil, err
}
var tmpHost Host
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err != nil {
return nil, nil, err
}
action, err2 := CheckACLs(tmpUser, tmpHost)
if err2 != nil {
return nil, nil, err2
}
HostDecrypt(actx.globalContext.String("aes-key"), host)
SSHKeyDecrypt(actx.globalContext.String("aes-key"), host.SSHKey)
switch action {
case ACLActionAllow:
case ACLActionDeny:
return nil, nil, fmt.Errorf("you don't have permission to that host")
default:
return nil, nil, fmt.Errorf("invalid ACL action: %q", action)
}
return host, clientConfig, nil
}
func shellHandler(s ssh.Session) {
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.err != nil {
fmt.Fprintf(s, "error: %v\n", actx.err)
_ = s.Exit(1)
return
}
if actx.message != "" {
fmt.Fprint(s, actx.message)
}
switch actx.userType() {
case UserTypeHealthcheck:
fmt.Fprintln(s, "OK")
return
case UserTypeShell:
if err := shell(s); err != nil {
fmt.Fprintf(s, "error: %v\n", err)
_ = s.Exit(1)
}
return
case UserTypeInvite:
// do nothing (message was printed at the beginning of the function)
return
}
panic("should not happen")
}
func passwordAuthHandler(db *gorm.DB, globalContext *cli.Context) ssh.PasswordHandler {
return func(ctx ssh.Context, pass string) bool {
actx := &authContext{
db: db,
inputUsername: ctx.User(),
globalContext: globalContext,
authMethod: "password",
}
actx.authSuccess = actx.userType() == UserTypeHealthcheck
ctx.SetValue(authContextKey, actx)
return actx.authSuccess
}
}
func publicKeyAuthHandler(db *gorm.DB, globalContext *cli.Context) ssh.PublicKeyHandler {
return func(ctx ssh.Context, key ssh.PublicKey) bool {
actx := &authContext{
db: db,
inputUsername: ctx.User(),
globalContext: globalContext,
authMethod: "pubkey",
authSuccess: true,
}
ctx.SetValue(authContextKey, actx)
// lookup user by key
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 {
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 {
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{
UserID: actx.user.ID,
Key: key.Marshal(),
Comment: "created by sshportal",
AuthorizedKey: string(gossh.MarshalAuthorizedKey(key)),
}
db.Create(&actx.userKey)
// token is only usable once
actx.user.InviteToken = ""
db.Model(&actx.user).Updates(&actx.user)
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.err = errors.New("your token is invalid or expired")
}
return true
}
// fallback
actx.err = errors.New("unknown ssh key")
actx.user = User{Name: "Anonymous"}
return true
}
}

81
testserver.go Normal file
View File

@@ -0,0 +1,81 @@
//go:build !windows
// +build !windows
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"os/exec"
"syscall"
"unsafe"
"github.com/gliderlabs/ssh"
"github.com/kr/pty"
"github.com/urfave/cli"
)
// testServer is an hidden handler used for integration tests
func testServer(c *cli.Context) error {
ssh.Handle(func(s ssh.Session) {
helloMsg := struct {
User string
Environ []string
Command []string
}{
User: s.User(),
Environ: s.Environ(),
Command: s.Command(),
}
if err := json.NewEncoder(s).Encode(&helloMsg); err != nil {
log.Fatalf("failed to write helloMsg: %v", err)
}
cmd := exec.Command(s.Command()[0], s.Command()[1:]...) // #nosec
if s.Command() == nil {
cmd = exec.Command("/bin/sh") // #nosec
}
ptyReq, winCh, isPty := s.Pty()
var cmdErr error
if isPty {
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
f, err := pty.Start(cmd)
if err != nil {
fmt.Fprintf(s, "failed to run command: %v\n", err) // #nosec
_ = s.Exit(1) // #nosec
return
}
go func() {
for win := range winCh {
_, _, _ = syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(win.Height), uint16(win.Width), 0, 0}))) // #nosec
}
}()
go func() {
// stdin
_, _ = io.Copy(f, s) // #nosec
}()
// stdout
_, _ = io.Copy(s, f) // #nosec
cmdErr = cmd.Wait()
} else {
// cmd.Stdin = s
cmd.Stdout = s
cmd.Stderr = s
cmdErr = cmd.Run()
}
if cmdErr != nil {
if exitError, ok := cmdErr.(*exec.ExitError); ok {
_ = s.Exit(exitError.Sys().(syscall.WaitStatus).ExitStatus()) // #nosec
return
}
}
_ = s.Exit(cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus()) // #nosec
})
log.Println("starting ssh server on port 2222...")
return ssh.ListenAndServe(":2222", nil)
}

14
testserver_unsupported.go Normal file
View 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
View File

@@ -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] + "..."
}

View File

@@ -1,20 +0,0 @@
Copyright (c) anmitsu <anmitsu.s@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,38 +0,0 @@
# go-shlex
go-shlex is a library to make a lexical analyzer like Unix shell for
Go.
## Install
go get -u "github.com/anmitsu/go-shlex"
## Usage
```go
package main
import (
"fmt"
"log"
"github.com/anmitsu/go-shlex"
)
func main() {
cmd := `cp -Rdp "file name" 'file name2' dir\ name`
words, err := shlex.Split(cmd, true)
if err != nil {
log.Fatal(err)
}
for _, w := range words {
fmt.Println(w)
}
}
```
## Documentation
http://godoc.org/github.com/anmitsu/go-shlex

View File

@@ -1,193 +0,0 @@
// Package shlex provides a simple lexical analysis like Unix shell.
package shlex
import (
"bufio"
"errors"
"io"
"strings"
"unicode"
)
var (
ErrNoClosing = errors.New("No closing quotation")
ErrNoEscaped = errors.New("No escaped character")
)
// Tokenizer is the interface that classifies a token according to
// words, whitespaces, quotations, escapes and escaped quotations.
type Tokenizer interface {
IsWord(rune) bool
IsWhitespace(rune) bool
IsQuote(rune) bool
IsEscape(rune) bool
IsEscapedQuote(rune) bool
}
// DefaultTokenizer implements a simple tokenizer like Unix shell.
type DefaultTokenizer struct{}
func (t *DefaultTokenizer) IsWord(r rune) bool {
return r == '_' || unicode.IsLetter(r) || unicode.IsNumber(r)
}
func (t *DefaultTokenizer) IsQuote(r rune) bool {
switch r {
case '\'', '"':
return true
default:
return false
}
}
func (t *DefaultTokenizer) IsWhitespace(r rune) bool {
return unicode.IsSpace(r)
}
func (t *DefaultTokenizer) IsEscape(r rune) bool {
return r == '\\'
}
func (t *DefaultTokenizer) IsEscapedQuote(r rune) bool {
return r == '"'
}
// Lexer represents a lexical analyzer.
type Lexer struct {
reader *bufio.Reader
tokenizer Tokenizer
posix bool
whitespacesplit bool
}
// NewLexer creates a new Lexer reading from io.Reader. This Lexer
// has a DefaultTokenizer according to posix and whitespacesplit
// rules.
func NewLexer(r io.Reader, posix, whitespacesplit bool) *Lexer {
return &Lexer{
reader: bufio.NewReader(r),
tokenizer: &DefaultTokenizer{},
posix: posix,
whitespacesplit: whitespacesplit,
}
}
// NewLexerString creates a new Lexer reading from a string. This
// Lexer has a DefaultTokenizer according to posix and whitespacesplit
// rules.
func NewLexerString(s string, posix, whitespacesplit bool) *Lexer {
return NewLexer(strings.NewReader(s), posix, whitespacesplit)
}
// Split splits a string according to posix or non-posix rules.
func Split(s string, posix bool) ([]string, error) {
return NewLexerString(s, posix, true).Split()
}
// SetTokenizer sets a Tokenizer.
func (l *Lexer) SetTokenizer(t Tokenizer) {
l.tokenizer = t
}
func (l *Lexer) Split() ([]string, error) {
result := make([]string, 0)
for {
token, err := l.readToken()
if token != "" {
result = append(result, token)
}
if err == io.EOF {
break
} else if err != nil {
return result, err
}
}
return result, nil
}
func (l *Lexer) readToken() (string, error) {
t := l.tokenizer
token := ""
quoted := false
state := ' '
escapedstate := ' '
scanning:
for {
next, _, err := l.reader.ReadRune()
if err != nil {
if t.IsQuote(state) {
return token, ErrNoClosing
} else if t.IsEscape(state) {
return token, ErrNoEscaped
}
return token, err
}
switch {
case t.IsWhitespace(state):
switch {
case t.IsWhitespace(next):
break scanning
case l.posix && t.IsEscape(next):
escapedstate = 'a'
state = next
case t.IsWord(next):
token += string(next)
state = 'a'
case t.IsQuote(next):
if !l.posix {
token += string(next)
}
state = next
default:
token = string(next)
if l.whitespacesplit {
state = 'a'
} else if token != "" || (l.posix && quoted) {
break scanning
}
}
case t.IsQuote(state):
quoted = true
switch {
case next == state:
if !l.posix {
token += string(next)
break scanning
} else {
state = 'a'
}
case l.posix && t.IsEscape(next) && t.IsEscapedQuote(state):
escapedstate = state
state = next
default:
token += string(next)
}
case t.IsEscape(state):
if t.IsQuote(escapedstate) && next != state && next != escapedstate {
token += string(state)
}
token += string(next)
state = escapedstate
case t.IsWord(state):
switch {
case t.IsWhitespace(next):
if token != "" || (l.posix && quoted) {
break scanning
}
case l.posix && t.IsQuote(next):
state = next
case l.posix && t.IsEscape(next):
escapedstate = 'a'
state = next
case t.IsWord(next) || t.IsQuote(next):
token += string(next)
default:
if l.whitespacesplit {
token += string(next)
} else if token != "" {
l.reader.UnreadRune()
break scanning
}
}
}
}
return token, nil
}

View File

@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2014 Alex Saskevich
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,410 +0,0 @@
govalidator
===========
[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/asaskevich/govalidator?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![GoDoc](https://godoc.org/github.com/asaskevich/govalidator?status.png)](https://godoc.org/github.com/asaskevich/govalidator) [![Coverage Status](https://img.shields.io/coveralls/asaskevich/govalidator.svg)](https://coveralls.io/r/asaskevich/govalidator?branch=master) [![wercker status](https://app.wercker.com/status/1ec990b09ea86c910d5f08b0e02c6043/s "wercker status")](https://app.wercker.com/project/bykey/1ec990b09ea86c910d5f08b0e02c6043)
[![Build Status](https://travis-ci.org/asaskevich/govalidator.svg?branch=master)](https://travis-ci.org/asaskevich/govalidator)
A package of validators and sanitizers for strings, structs and collections. Based on [validator.js](https://github.com/chriso/validator.js).
#### Installation
Make sure that Go is installed on your computer.
Type the following command in your terminal:
go get github.com/asaskevich/govalidator
or you can get specified release of the package with `gopkg.in`:
go get gopkg.in/asaskevich/govalidator.v4
After it the package is ready to use.
#### Import package in your project
Add following line in your `*.go` file:
```go
import "github.com/asaskevich/govalidator"
```
If you are unhappy to use long `govalidator`, you can do something like this:
```go
import (
valid "github.com/asaskevich/govalidator"
)
```
#### Activate behavior to require all fields have a validation tag by default
`SetFieldsRequiredByDefault` causes validation to fail when struct fields do not include validations or are not explicitly marked as exempt (using `valid:"-"` or `valid:"email,optional"`). A good place to activate this is a package init function or the main() function.
```go
import "github.com/asaskevich/govalidator"
func init() {
govalidator.SetFieldsRequiredByDefault(true)
}
```
Here's some code to explain it:
```go
// this struct definition will fail govalidator.ValidateStruct() (and the field values do not matter):
type exampleStruct struct {
Name string ``
Email string `valid:"email"`
}
// this, however, will only fail when Email is empty or an invalid email address:
type exampleStruct2 struct {
Name string `valid:"-"`
Email string `valid:"email"`
}
// lastly, this will only fail when Email is an invalid email address but not when it's empty:
type exampleStruct2 struct {
Name string `valid:"-"`
Email string `valid:"email,optional"`
}
```
#### Recent breaking changes (see [#123](https://github.com/asaskevich/govalidator/pull/123))
##### Custom validator function signature
A context was added as the second parameter, for structs this is the object being validated this makes dependent validation possible.
```go
import "github.com/asaskevich/govalidator"
// old signature
func(i interface{}) bool
// new signature
func(i interface{}, o interface{}) bool
```
##### Adding a custom validator
This was changed to prevent data races when accessing custom validators.
```go
import "github.com/asaskevich/govalidator"
// before
govalidator.CustomTypeTagMap["customByteArrayValidator"] = CustomTypeValidator(func(i interface{}, o interface{}) bool {
// ...
})
// after
govalidator.CustomTypeTagMap.Set("customByteArrayValidator", CustomTypeValidator(func(i interface{}, o interface{}) bool {
// ...
}))
```
#### List of functions:
```go
func Abs(value float64) float64
func BlackList(str, chars string) string
func ByteLength(str string, params ...string) bool
func StringLength(str string, params ...string) bool
func StringMatches(s string, params ...string) bool
func CamelCaseToUnderscore(str string) string
func Contains(str, substring string) bool
func Count(array []interface{}, iterator ConditionIterator) int
func Each(array []interface{}, iterator Iterator)
func ErrorByField(e error, field string) string
func Filter(array []interface{}, iterator ConditionIterator) []interface{}
func Find(array []interface{}, iterator ConditionIterator) interface{}
func GetLine(s string, index int) (string, error)
func GetLines(s string) []string
func IsHost(s string) bool
func InRange(value, left, right float64) bool
func IsASCII(str string) bool
func IsAlpha(str string) bool
func IsAlphanumeric(str string) bool
func IsBase64(str string) bool
func IsByteLength(str string, min, max int) bool
func IsCreditCard(str string) bool
func IsDataURI(str string) bool
func IsDialString(str string) bool
func IsDNSName(str string) bool
func IsDivisibleBy(str, num string) bool
func IsEmail(str string) bool
func IsFilePath(str string) (bool, int)
func IsFloat(str string) bool
func IsFullWidth(str string) bool
func IsHalfWidth(str string) bool
func IsHexadecimal(str string) bool
func IsHexcolor(str string) bool
func IsIP(str string) bool
func IsIPv4(str string) bool
func IsIPv6(str string) bool
func IsISBN(str string, version int) bool
func IsISBN10(str string) bool
func IsISBN13(str string) bool
func IsISO3166Alpha2(str string) bool
func IsISO3166Alpha3(str string) bool
func IsInt(str string) bool
func IsIn(str string, params ...string) bool
func IsJSON(str string) bool
func IsLatitude(str string) bool
func IsLongitude(str string) bool
func IsLowerCase(str string) bool
func IsMAC(str string) bool
func IsMongoID(str string) bool
func IsMultibyte(str string) bool
func IsNatural(value float64) bool
func IsNegative(value float64) bool
func IsNonNegative(value float64) bool
func IsNonPositive(value float64) bool
func IsNull(str string) bool
func IsNumeric(str string) bool
func IsPort(str string) bool
func IsPositive(value float64) bool
func IsPrintableASCII(str string) bool
func IsRGBcolor(str string) bool
func IsRequestURI(rawurl string) bool
func IsRequestURL(rawurl string) bool
func IsSSN(str string) bool
func IsSemver(str string) bool
func IsURL(str string) bool
func IsUTFDigit(str string) bool
func IsUTFLetter(str string) bool
func IsUTFLetterNumeric(str string) bool
func IsUTFNumeric(str string) bool
func IsUUID(str string) bool
func IsUUIDv3(str string) bool
func IsUUIDv4(str string) bool
func IsUUIDv5(str string) bool
func IsUpperCase(str string) bool
func IsVariableWidth(str string) bool
func IsWhole(value float64) bool
func LeftTrim(str, chars string) string
func Map(array []interface{}, iterator ResultIterator) []interface{}
func Matches(str, pattern string) bool
func NormalizeEmail(str string) (string, error)
func PadBoth(str string, padStr string, padLen int) string
func PadLeft(str string, padStr string, padLen int) string
func PadRight(str string, padStr string, padLen int) string
func RemoveTags(s string) string
func ReplacePattern(str, pattern, replace string) string
func Reverse(s string) string
func RightTrim(str, chars string) string
func SafeFileName(str string) string
func Sign(value float64) float64
func StripLow(str string, keepNewLines bool) string
func ToBoolean(str string) (bool, error)
func ToFloat(str string) (float64, error)
func ToInt(str string) (int64, error)
func ToJSON(obj interface{}) (string, error)
func ToString(obj interface{}) string
func Trim(str, chars string) string
func Truncate(str string, length int, ending string) string
func UnderscoreToCamelCase(s string) string
func ValidateStruct(s interface{}) (bool, error)
func WhiteList(str, chars string) string
type ConditionIterator
type Error
func (e Error) Error() string
type Errors
func (es Errors) Error() string
type ISO3166Entry
type Iterator
type ParamValidator
type ResultIterator
type UnsupportedTypeError
func (e *UnsupportedTypeError) Error() string
type Validator
```
#### Examples
###### IsURL
```go
println(govalidator.IsURL(`http://user@pass:domain.com/path/page`))
```
###### ToString
```go
type User struct {
FirstName string
LastName string
}
str := govalidator.ToString(&User{"John", "Juan"})
println(str)
```
###### Each, Map, Filter, Count for slices
Each iterates over the slice/array and calls Iterator for every item
```go
data := []interface{}{1, 2, 3, 4, 5}
var fn govalidator.Iterator = func(value interface{}, index int) {
println(value.(int))
}
govalidator.Each(data, fn)
```
```go
data := []interface{}{1, 2, 3, 4, 5}
var fn govalidator.ResultIterator = func(value interface{}, index int) interface{} {
return value.(int) * 3
}
_ = govalidator.Map(data, fn) // result = []interface{}{1, 6, 9, 12, 15}
```
```go
data := []interface{}{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
var fn govalidator.ConditionIterator = func(value interface{}, index int) bool {
return value.(int)%2 == 0
}
_ = govalidator.Filter(data, fn) // result = []interface{}{2, 4, 6, 8, 10}
_ = govalidator.Count(data, fn) // result = 5
```
###### ValidateStruct [#2](https://github.com/asaskevich/govalidator/pull/2)
If you want to validate structs, you can use tag `valid` for any field in your structure. All validators used with this field in one tag are separated by comma. If you want to skip validation, place `-` in your tag. If you need a validator that is not on the list below, you can add it like this:
```go
govalidator.TagMap["duck"] = govalidator.Validator(func(str string) bool {
return str == "duck"
})
```
For completely custom validators (interface-based), see below.
Here is a list of available validators for struct fields (validator - used function):
```go
"email": IsEmail,
"url": IsURL,
"dialstring": IsDialString,
"requrl": IsRequestURL,
"requri": IsRequestURI,
"alpha": IsAlpha,
"utfletter": IsUTFLetter,
"alphanum": IsAlphanumeric,
"utfletternum": IsUTFLetterNumeric,
"numeric": IsNumeric,
"utfnumeric": IsUTFNumeric,
"utfdigit": IsUTFDigit,
"hexadecimal": IsHexadecimal,
"hexcolor": IsHexcolor,
"rgbcolor": IsRGBcolor,
"lowercase": IsLowerCase,
"uppercase": IsUpperCase,
"int": IsInt,
"float": IsFloat,
"null": IsNull,
"uuid": IsUUID,
"uuidv3": IsUUIDv3,
"uuidv4": IsUUIDv4,
"uuidv5": IsUUIDv5,
"creditcard": IsCreditCard,
"isbn10": IsISBN10,
"isbn13": IsISBN13,
"json": IsJSON,
"multibyte": IsMultibyte,
"ascii": IsASCII,
"printableascii": IsPrintableASCII,
"fullwidth": IsFullWidth,
"halfwidth": IsHalfWidth,
"variablewidth": IsVariableWidth,
"base64": IsBase64,
"datauri": IsDataURI,
"ip": IsIP,
"port": IsPort,
"ipv4": IsIPv4,
"ipv6": IsIPv6,
"dns": IsDNSName,
"host": IsHost,
"mac": IsMAC,
"latitude": IsLatitude,
"longitude": IsLongitude,
"ssn": IsSSN,
"semver": IsSemver,
"rfc3339": IsRFC3339,
"ISO3166Alpha2": IsISO3166Alpha2,
"ISO3166Alpha3": IsISO3166Alpha3,
```
Validators with parameters
```go
"length(min|max)": ByteLength,
"runelength(min|max)": RuneLegth,
"matches(pattern)": StringMatches,
"in(string1|string2|...|stringN)": IsIn,
```
And here is small example of usage:
```go
type Post struct {
Title string `valid:"alphanum,required"`
Message string `valid:"duck,ascii"`
AuthorIP string `valid:"ipv4"`
Date string `valid:"-"`
}
post := &Post{
Title: "My Example Post",
Message: "duck",
AuthorIP: "123.234.54.3",
}
// Add your own struct validation tags
govalidator.TagMap["duck"] = govalidator.Validator(func(str string) bool {
return str == "duck"
})
result, err := govalidator.ValidateStruct(post)
if err != nil {
println("error: " + err.Error())
}
println(result)
```
###### WhiteList
```go
// Remove all characters from string ignoring characters between "a" and "z"
println(govalidator.WhiteList("a3a43a5a4a3a2a23a4a5a4a3a4", "a-z") == "aaaaaaaaaaaa")
```
###### Custom validation functions
Custom validation using your own domain specific validators is also available - here's an example of how to use it:
```go
import "github.com/asaskevich/govalidator"
type CustomByteArray [6]byte // custom types are supported and can be validated
type StructWithCustomByteArray struct {
ID CustomByteArray `valid:"customByteArrayValidator,customMinLengthValidator"` // multiple custom validators are possible as well and will be evaluated in sequence
Email string `valid:"email"`
CustomMinLength int `valid:"-"`
}
govalidator.CustomTypeTagMap.Set("customByteArrayValidator", CustomTypeValidator(func(i interface{}, context interface{}) bool {
switch v := context.(type) { // you can type switch on the context interface being validated
case StructWithCustomByteArray:
// you can check and validate against some other field in the context,
// return early or not validate against the context at all your choice
case SomeOtherType:
// ...
default:
// expecting some other type? Throw/panic here or continue
}
switch v := i.(type) { // type switch on the struct field being validated
case CustomByteArray:
for _, e := range v { // this validator checks that the byte array is not empty, i.e. not all zeroes
if e != 0 {
return true
}
}
}
return false
}))
govalidator.CustomTypeTagMap.Set("customMinLengthValidator", CustomTypeValidator(func(i interface{}, context interface{}) bool {
switch v := context.(type) { // this validates a field against the value in another field, i.e. dependent validation
case StructWithCustomByteArray:
return len(v.ID) >= v.CustomMinLength
}
return false
}))
```
#### Notes
Documentation is available here: [godoc.org](https://godoc.org/github.com/asaskevich/govalidator).
Full information about code coverage is also available here: [govalidator on gocover.io](http://gocover.io/github.com/asaskevich/govalidator).
#### Support
If you do have a contribution for the package feel free to put up a Pull Request or open Issue.
#### Special thanks to [contributors](https://github.com/asaskevich/govalidator/graphs/contributors)
* [Daniel Lohse](https://github.com/annismckenzie)
* [Attila Oláh](https://github.com/attilaolah)
* [Daniel Korner](https://github.com/Dadie)
* [Steven Wilkin](https://github.com/stevenwilkin)
* [Deiwin Sarjas](https://github.com/deiwin)
* [Noah Shibley](https://github.com/slugmobile)
* [Nathan Davies](https://github.com/nathj07)
* [Matt Sanford](https://github.com/mzsanford)
* [Simon ccl1115](https://github.com/ccl1115)

View File

@@ -1,58 +0,0 @@
package govalidator
// Iterator is the function that accepts element of slice/array and its index
type Iterator func(interface{}, int)
// ResultIterator is the function that accepts element of slice/array and its index and returns any result
type ResultIterator func(interface{}, int) interface{}
// ConditionIterator is the function that accepts element of slice/array and its index and returns boolean
type ConditionIterator func(interface{}, int) bool
// Each iterates over the slice and apply Iterator to every item
func Each(array []interface{}, iterator Iterator) {
for index, data := range array {
iterator(data, index)
}
}
// Map iterates over the slice and apply ResultIterator to every item. Returns new slice as a result.
func Map(array []interface{}, iterator ResultIterator) []interface{} {
var result = make([]interface{}, len(array))
for index, data := range array {
result[index] = iterator(data, index)
}
return result
}
// Find iterates over the slice and apply ConditionIterator to every item. Returns first item that meet ConditionIterator or nil otherwise.
func Find(array []interface{}, iterator ConditionIterator) interface{} {
for index, data := range array {
if iterator(data, index) {
return data
}
}
return nil
}
// Filter iterates over the slice and apply ConditionIterator to every item. Returns new slice.
func Filter(array []interface{}, iterator ConditionIterator) []interface{} {
var result = make([]interface{}, 0)
for index, data := range array {
if iterator(data, index) {
result = append(result, data)
}
}
return result
}
// Count iterates over the slice and apply ConditionIterator to every item. Returns count of items that meets ConditionIterator.
func Count(array []interface{}, iterator ConditionIterator) int {
count := 0
for index, data := range array {
if iterator(data, index) {
count = count + 1
}
}
return count
}

View File

@@ -1,49 +0,0 @@
package govalidator
import (
"encoding/json"
"fmt"
"strconv"
)
// ToString convert the input to a string.
func ToString(obj interface{}) string {
res := fmt.Sprintf("%v", obj)
return string(res)
}
// ToJSON convert the input to a valid JSON string
func ToJSON(obj interface{}) (string, error) {
res, err := json.Marshal(obj)
if err != nil {
res = []byte("")
}
return string(res), err
}
// ToFloat convert the input string to a float, or 0.0 if the input is not a float.
func ToFloat(str string) (float64, error) {
res, err := strconv.ParseFloat(str, 64)
if err != nil {
res = 0.0
}
return res, err
}
// ToInt convert the input string to an integer, or 0 if the input is not an integer.
func ToInt(str string) (int64, error) {
res, err := strconv.ParseInt(str, 0, 64)
if err != nil {
res = 0
}
return res, err
}
// ToBoolean convert the input string to a boolean.
func ToBoolean(str string) (bool, error) {
res, err := strconv.ParseBool(str)
if err != nil {
res = false
}
return res, err
}

View File

@@ -1,31 +0,0 @@
package govalidator
// Errors is an array of multiple errors and conforms to the error interface.
type Errors []error
// Errors returns itself.
func (es Errors) Errors() []error {
return es
}
func (es Errors) Error() string {
var err string
for _, e := range es {
err += e.Error() + ";"
}
return err
}
// Error encapsulates a name, an error and whether there's a custom error message or not.
type Error struct {
Name string
Err error
CustomErrorMessageExists bool
}
func (e Error) Error() string {
if e.CustomErrorMessageExists {
return e.Err.Error()
}
return e.Name + ": " + e.Err.Error()
}

View File

@@ -1,57 +0,0 @@
package govalidator
import "math"
// Abs returns absolute value of number
func Abs(value float64) float64 {
return value * Sign(value)
}
// Sign returns signum of number: 1 in case of value > 0, -1 in case of value < 0, 0 otherwise
func Sign(value float64) float64 {
if value > 0 {
return 1
} else if value < 0 {
return -1
} else {
return 0
}
}
// IsNegative returns true if value < 0
func IsNegative(value float64) bool {
return value < 0
}
// IsPositive returns true if value > 0
func IsPositive(value float64) bool {
return value > 0
}
// IsNonNegative returns true if value >= 0
func IsNonNegative(value float64) bool {
return value >= 0
}
// IsNonPositive returns true if value <= 0
func IsNonPositive(value float64) bool {
return value <= 0
}
// InRange returns true if value lies between left and right border
func InRange(value, left, right float64) bool {
if left > right {
left, right = right, left
}
return value >= left && value <= right
}
// IsWhole returns true if value is whole number
func IsWhole(value float64) bool {
return Abs(math.Remainder(value, 1)) == 0
}
// IsNatural returns true if value is natural number (positive and whole)
func IsNatural(value float64) bool {
return IsWhole(value) && IsPositive(value)
}

View File

@@ -1,91 +0,0 @@
package govalidator
import "regexp"
// Basic regular expressions for validating strings
const (
Email string = "^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$"
CreditCard string = "^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$"
ISBN10 string = "^(?:[0-9]{9}X|[0-9]{10})$"
ISBN13 string = "^(?:[0-9]{13})$"
UUID3 string = "^[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$"
UUID4 string = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
UUID5 string = "^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
UUID string = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
Alpha string = "^[a-zA-Z]+$"
Alphanumeric string = "^[a-zA-Z0-9]+$"
Numeric string = "^[-+]?[0-9]+$"
Int string = "^(?:[-+]?(?:0|[1-9][0-9]*))$"
Float string = "^(?:[-+]?(?:[0-9]+))?(?:\\.[0-9]*)?(?:[eE][\\+\\-]?(?:[0-9]+))?$"
Hexadecimal string = "^[0-9a-fA-F]+$"
Hexcolor string = "^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
RGBcolor string = "^rgb\\(\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*\\)$"
ASCII string = "^[\x00-\x7F]+$"
Multibyte string = "[^\x00-\x7F]"
FullWidth string = "[^\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]"
HalfWidth string = "[\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]"
Base64 string = "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=|[A-Za-z0-9+\\/]{4})$"
PrintableASCII string = "^[\x20-\x7E]+$"
DataURI string = "^data:.+\\/(.+);base64$"
Latitude string = "^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?)$"
Longitude string = "^[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)$"
DNSName string = `^([a-zA-Z0-9]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9]{1}[a-zA-Z0-9_-]{1,62})*$`
IP string = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`
URLSchema string = `((ftp|tcp|udp|wss?|https?):\/\/)`
URLUsername string = `(\S+(:\S*)?@)`
Hostname string = ``
URLPath string = `((\/|\?|#)[^\s]*)`
URLPort string = `(:(\d{1,5}))`
URLIP string = `([1-9]\d?|1\d\d|2[01]\d|22[0-3])(\.(1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))`
URLSubdomain string = `((www\.)|([a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*))`
URL string = `^` + URLSchema + `?` + URLUsername + `?` + `((` + URLIP + `|(\[` + IP + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + URLSubdomain + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))` + URLPort + `?` + URLPath + `?$`
SSN string = `^\d{3}[- ]?\d{2}[- ]?\d{4}$`
WinPath string = `^[a-zA-Z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*$`
UnixPath string = `^(/[^/\x00]*)+/?$`
Semver string = "^v?(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(-(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\\+[0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*)?$"
tagName string = "valid"
)
// Used by IsFilePath func
const (
// Unknown is unresolved OS type
Unknown = iota
// Win is Windows type
Win
// Unix is *nix OS types
Unix
)
var (
rxEmail = regexp.MustCompile(Email)
rxCreditCard = regexp.MustCompile(CreditCard)
rxISBN10 = regexp.MustCompile(ISBN10)
rxISBN13 = regexp.MustCompile(ISBN13)
rxUUID3 = regexp.MustCompile(UUID3)
rxUUID4 = regexp.MustCompile(UUID4)
rxUUID5 = regexp.MustCompile(UUID5)
rxUUID = regexp.MustCompile(UUID)
rxAlpha = regexp.MustCompile(Alpha)
rxAlphanumeric = regexp.MustCompile(Alphanumeric)
rxNumeric = regexp.MustCompile(Numeric)
rxInt = regexp.MustCompile(Int)
rxFloat = regexp.MustCompile(Float)
rxHexadecimal = regexp.MustCompile(Hexadecimal)
rxHexcolor = regexp.MustCompile(Hexcolor)
rxRGBcolor = regexp.MustCompile(RGBcolor)
rxASCII = regexp.MustCompile(ASCII)
rxPrintableASCII = regexp.MustCompile(PrintableASCII)
rxMultibyte = regexp.MustCompile(Multibyte)
rxFullWidth = regexp.MustCompile(FullWidth)
rxHalfWidth = regexp.MustCompile(HalfWidth)
rxBase64 = regexp.MustCompile(Base64)
rxDataURI = regexp.MustCompile(DataURI)
rxLatitude = regexp.MustCompile(Latitude)
rxLongitude = regexp.MustCompile(Longitude)
rxDNSName = regexp.MustCompile(DNSName)
rxURL = regexp.MustCompile(URL)
rxSSN = regexp.MustCompile(SSN)
rxWinPath = regexp.MustCompile(WinPath)
rxUnixPath = regexp.MustCompile(UnixPath)
rxSemver = regexp.MustCompile(Semver)
)

View File

@@ -1,385 +0,0 @@
package govalidator
import (
"reflect"
"regexp"
"sync"
)
// Validator is a wrapper for a validator function that returns bool and accepts string.
type Validator func(str string) bool
// CustomTypeValidator is a wrapper for validator functions that returns bool and accepts any type.
// The second parameter should be the context (in the case of validating a struct: the whole object being validated).
type CustomTypeValidator func(i interface{}, o interface{}) bool
// ParamValidator is a wrapper for validator functions that accepts additional parameters.
type ParamValidator func(str string, params ...string) bool
type tagOptionsMap map[string]string
// UnsupportedTypeError is a wrapper for reflect.Type
type UnsupportedTypeError struct {
Type reflect.Type
}
// stringValues is a slice of reflect.Value holding *reflect.StringValue.
// It implements the methods to sort by string.
type stringValues []reflect.Value
// ParamTagMap is a map of functions accept variants parameters
var ParamTagMap = map[string]ParamValidator{
"length": ByteLength,
"runelength": RuneLength,
"stringlength": StringLength,
"matches": StringMatches,
"in": isInRaw,
}
// ParamTagRegexMap maps param tags to their respective regexes.
var ParamTagRegexMap = map[string]*regexp.Regexp{
"length": regexp.MustCompile("^length\\((\\d+)\\|(\\d+)\\)$"),
"runelength": regexp.MustCompile("^runelength\\((\\d+)\\|(\\d+)\\)$"),
"stringlength": regexp.MustCompile("^stringlength\\((\\d+)\\|(\\d+)\\)$"),
"in": regexp.MustCompile(`^in\((.*)\)`),
"matches": regexp.MustCompile(`^matches\((.+)\)$`),
}
type customTypeTagMap struct {
validators map[string]CustomTypeValidator
sync.RWMutex
}
func (tm *customTypeTagMap) Get(name string) (CustomTypeValidator, bool) {
tm.RLock()
defer tm.RUnlock()
v, ok := tm.validators[name]
return v, ok
}
func (tm *customTypeTagMap) Set(name string, ctv CustomTypeValidator) {
tm.Lock()
defer tm.Unlock()
tm.validators[name] = ctv
}
// CustomTypeTagMap is a map of functions that can be used as tags for ValidateStruct function.
// Use this to validate compound or custom types that need to be handled as a whole, e.g.
// `type UUID [16]byte` (this would be handled as an array of bytes).
var CustomTypeTagMap = &customTypeTagMap{validators: make(map[string]CustomTypeValidator)}
// TagMap is a map of functions, that can be used as tags for ValidateStruct function.
var TagMap = map[string]Validator{
"email": IsEmail,
"url": IsURL,
"dialstring": IsDialString,
"requrl": IsRequestURL,
"requri": IsRequestURI,
"alpha": IsAlpha,
"utfletter": IsUTFLetter,
"alphanum": IsAlphanumeric,
"utfletternum": IsUTFLetterNumeric,
"numeric": IsNumeric,
"utfnumeric": IsUTFNumeric,
"utfdigit": IsUTFDigit,
"hexadecimal": IsHexadecimal,
"hexcolor": IsHexcolor,
"rgbcolor": IsRGBcolor,
"lowercase": IsLowerCase,
"uppercase": IsUpperCase,
"int": IsInt,
"float": IsFloat,
"null": IsNull,
"uuid": IsUUID,
"uuidv3": IsUUIDv3,
"uuidv4": IsUUIDv4,
"uuidv5": IsUUIDv5,
"creditcard": IsCreditCard,
"isbn10": IsISBN10,
"isbn13": IsISBN13,
"json": IsJSON,
"multibyte": IsMultibyte,
"ascii": IsASCII,
"printableascii": IsPrintableASCII,
"fullwidth": IsFullWidth,
"halfwidth": IsHalfWidth,
"variablewidth": IsVariableWidth,
"base64": IsBase64,
"datauri": IsDataURI,
"ip": IsIP,
"port": IsPort,
"ipv4": IsIPv4,
"ipv6": IsIPv6,
"dns": IsDNSName,
"host": IsHost,
"mac": IsMAC,
"latitude": IsLatitude,
"longitude": IsLongitude,
"ssn": IsSSN,
"semver": IsSemver,
"rfc3339": IsRFC3339,
"ISO3166Alpha2": IsISO3166Alpha2,
"ISO3166Alpha3": IsISO3166Alpha3,
}
// ISO3166Entry stores country codes
type ISO3166Entry struct {
EnglishShortName string
FrenchShortName string
Alpha2Code string
Alpha3Code string
Numeric string
}
//ISO3166List based on https://www.iso.org/obp/ui/#search/code/ Code Type "Officially Assigned Codes"
var ISO3166List = []ISO3166Entry{
{"Afghanistan", "Afghanistan (l')", "AF", "AFG", "004"},
{"Albania", "Albanie (l')", "AL", "ALB", "008"},
{"Antarctica", "Antarctique (l')", "AQ", "ATA", "010"},
{"Algeria", "Algérie (l')", "DZ", "DZA", "012"},
{"American Samoa", "Samoa américaines (les)", "AS", "ASM", "016"},
{"Andorra", "Andorre (l')", "AD", "AND", "020"},
{"Angola", "Angola (l')", "AO", "AGO", "024"},
{"Antigua and Barbuda", "Antigua-et-Barbuda", "AG", "ATG", "028"},
{"Azerbaijan", "Azerbaïdjan (l')", "AZ", "AZE", "031"},
{"Argentina", "Argentine (l')", "AR", "ARG", "032"},
{"Australia", "Australie (l')", "AU", "AUS", "036"},
{"Austria", "Autriche (l')", "AT", "AUT", "040"},
{"Bahamas (the)", "Bahamas (les)", "BS", "BHS", "044"},
{"Bahrain", "Bahreïn", "BH", "BHR", "048"},
{"Bangladesh", "Bangladesh (le)", "BD", "BGD", "050"},
{"Armenia", "Arménie (l')", "AM", "ARM", "051"},
{"Barbados", "Barbade (la)", "BB", "BRB", "052"},
{"Belgium", "Belgique (la)", "BE", "BEL", "056"},
{"Bermuda", "Bermudes (les)", "BM", "BMU", "060"},
{"Bhutan", "Bhoutan (le)", "BT", "BTN", "064"},
{"Bolivia (Plurinational State of)", "Bolivie (État plurinational de)", "BO", "BOL", "068"},
{"Bosnia and Herzegovina", "Bosnie-Herzégovine (la)", "BA", "BIH", "070"},
{"Botswana", "Botswana (le)", "BW", "BWA", "072"},
{"Bouvet Island", "Bouvet (l'Île)", "BV", "BVT", "074"},
{"Brazil", "Brésil (le)", "BR", "BRA", "076"},
{"Belize", "Belize (le)", "BZ", "BLZ", "084"},
{"British Indian Ocean Territory (the)", "Indien (le Territoire britannique de l'océan)", "IO", "IOT", "086"},
{"Solomon Islands", "Salomon (Îles)", "SB", "SLB", "090"},
{"Virgin Islands (British)", "Vierges britanniques (les Îles)", "VG", "VGB", "092"},
{"Brunei Darussalam", "Brunéi Darussalam (le)", "BN", "BRN", "096"},
{"Bulgaria", "Bulgarie (la)", "BG", "BGR", "100"},
{"Myanmar", "Myanmar (le)", "MM", "MMR", "104"},
{"Burundi", "Burundi (le)", "BI", "BDI", "108"},
{"Belarus", "Bélarus (le)", "BY", "BLR", "112"},
{"Cambodia", "Cambodge (le)", "KH", "KHM", "116"},
{"Cameroon", "Cameroun (le)", "CM", "CMR", "120"},
{"Canada", "Canada (le)", "CA", "CAN", "124"},
{"Cabo Verde", "Cabo Verde", "CV", "CPV", "132"},
{"Cayman Islands (the)", "Caïmans (les Îles)", "KY", "CYM", "136"},
{"Central African Republic (the)", "République centrafricaine (la)", "CF", "CAF", "140"},
{"Sri Lanka", "Sri Lanka", "LK", "LKA", "144"},
{"Chad", "Tchad (le)", "TD", "TCD", "148"},
{"Chile", "Chili (le)", "CL", "CHL", "152"},
{"China", "Chine (la)", "CN", "CHN", "156"},
{"Taiwan (Province of China)", "Taïwan (Province de Chine)", "TW", "TWN", "158"},
{"Christmas Island", "Christmas (l'Île)", "CX", "CXR", "162"},
{"Cocos (Keeling) Islands (the)", "Cocos (les Îles)/ Keeling (les Îles)", "CC", "CCK", "166"},
{"Colombia", "Colombie (la)", "CO", "COL", "170"},
{"Comoros (the)", "Comores (les)", "KM", "COM", "174"},
{"Mayotte", "Mayotte", "YT", "MYT", "175"},
{"Congo (the)", "Congo (le)", "CG", "COG", "178"},
{"Congo (the Democratic Republic of the)", "Congo (la République démocratique du)", "CD", "COD", "180"},
{"Cook Islands (the)", "Cook (les Îles)", "CK", "COK", "184"},
{"Costa Rica", "Costa Rica (le)", "CR", "CRI", "188"},
{"Croatia", "Croatie (la)", "HR", "HRV", "191"},
{"Cuba", "Cuba", "CU", "CUB", "192"},
{"Cyprus", "Chypre", "CY", "CYP", "196"},
{"Czech Republic (the)", "tchèque (la République)", "CZ", "CZE", "203"},
{"Benin", "Bénin (le)", "BJ", "BEN", "204"},
{"Denmark", "Danemark (le)", "DK", "DNK", "208"},
{"Dominica", "Dominique (la)", "DM", "DMA", "212"},
{"Dominican Republic (the)", "dominicaine (la République)", "DO", "DOM", "214"},
{"Ecuador", "Équateur (l')", "EC", "ECU", "218"},
{"El Salvador", "El Salvador", "SV", "SLV", "222"},
{"Equatorial Guinea", "Guinée équatoriale (la)", "GQ", "GNQ", "226"},
{"Ethiopia", "Éthiopie (l')", "ET", "ETH", "231"},
{"Eritrea", "Érythrée (l')", "ER", "ERI", "232"},
{"Estonia", "Estonie (l')", "EE", "EST", "233"},
{"Faroe Islands (the)", "Féroé (les Îles)", "FO", "FRO", "234"},
{"Falkland Islands (the) [Malvinas]", "Falkland (les Îles)/Malouines (les Îles)", "FK", "FLK", "238"},
{"South Georgia and the South Sandwich Islands", "Géorgie du Sud-et-les Îles Sandwich du Sud (la)", "GS", "SGS", "239"},
{"Fiji", "Fidji (les)", "FJ", "FJI", "242"},
{"Finland", "Finlande (la)", "FI", "FIN", "246"},
{"Åland Islands", "Åland(les Îles)", "AX", "ALA", "248"},
{"France", "France (la)", "FR", "FRA", "250"},
{"French Guiana", "Guyane française (la )", "GF", "GUF", "254"},
{"French Polynesia", "Polynésie française (la)", "PF", "PYF", "258"},
{"French Southern Territories (the)", "Terres australes françaises (les)", "TF", "ATF", "260"},
{"Djibouti", "Djibouti", "DJ", "DJI", "262"},
{"Gabon", "Gabon (le)", "GA", "GAB", "266"},
{"Georgia", "Géorgie (la)", "GE", "GEO", "268"},
{"Gambia (the)", "Gambie (la)", "GM", "GMB", "270"},
{"Palestine, State of", "Palestine, État de", "PS", "PSE", "275"},
{"Germany", "Allemagne (l')", "DE", "DEU", "276"},
{"Ghana", "Ghana (le)", "GH", "GHA", "288"},
{"Gibraltar", "Gibraltar", "GI", "GIB", "292"},
{"Kiribati", "Kiribati", "KI", "KIR", "296"},
{"Greece", "Grèce (la)", "GR", "GRC", "300"},
{"Greenland", "Groenland (le)", "GL", "GRL", "304"},
{"Grenada", "Grenade (la)", "GD", "GRD", "308"},
{"Guadeloupe", "Guadeloupe (la)", "GP", "GLP", "312"},
{"Guam", "Guam", "GU", "GUM", "316"},
{"Guatemala", "Guatemala (le)", "GT", "GTM", "320"},
{"Guinea", "Guinée (la)", "GN", "GIN", "324"},
{"Guyana", "Guyana (le)", "GY", "GUY", "328"},
{"Haiti", "Haïti", "HT", "HTI", "332"},
{"Heard Island and McDonald Islands", "Heard-et-Îles MacDonald (l'Île)", "HM", "HMD", "334"},
{"Holy See (the)", "Saint-Siège (le)", "VA", "VAT", "336"},
{"Honduras", "Honduras (le)", "HN", "HND", "340"},
{"Hong Kong", "Hong Kong", "HK", "HKG", "344"},
{"Hungary", "Hongrie (la)", "HU", "HUN", "348"},
{"Iceland", "Islande (l')", "IS", "ISL", "352"},
{"India", "Inde (l')", "IN", "IND", "356"},
{"Indonesia", "Indonésie (l')", "ID", "IDN", "360"},
{"Iran (Islamic Republic of)", "Iran (République Islamique d')", "IR", "IRN", "364"},
{"Iraq", "Iraq (l')", "IQ", "IRQ", "368"},
{"Ireland", "Irlande (l')", "IE", "IRL", "372"},
{"Israel", "Israël", "IL", "ISR", "376"},
{"Italy", "Italie (l')", "IT", "ITA", "380"},
{"Côte d'Ivoire", "Côte d'Ivoire (la)", "CI", "CIV", "384"},
{"Jamaica", "Jamaïque (la)", "JM", "JAM", "388"},
{"Japan", "Japon (le)", "JP", "JPN", "392"},
{"Kazakhstan", "Kazakhstan (le)", "KZ", "KAZ", "398"},
{"Jordan", "Jordanie (la)", "JO", "JOR", "400"},
{"Kenya", "Kenya (le)", "KE", "KEN", "404"},
{"Korea (the Democratic People's Republic of)", "Corée (la République populaire démocratique de)", "KP", "PRK", "408"},
{"Korea (the Republic of)", "Corée (la République de)", "KR", "KOR", "410"},
{"Kuwait", "Koweït (le)", "KW", "KWT", "414"},
{"Kyrgyzstan", "Kirghizistan (le)", "KG", "KGZ", "417"},
{"Lao People's Democratic Republic (the)", "Lao, République démocratique populaire", "LA", "LAO", "418"},
{"Lebanon", "Liban (le)", "LB", "LBN", "422"},
{"Lesotho", "Lesotho (le)", "LS", "LSO", "426"},
{"Latvia", "Lettonie (la)", "LV", "LVA", "428"},
{"Liberia", "Libéria (le)", "LR", "LBR", "430"},
{"Libya", "Libye (la)", "LY", "LBY", "434"},
{"Liechtenstein", "Liechtenstein (le)", "LI", "LIE", "438"},
{"Lithuania", "Lituanie (la)", "LT", "LTU", "440"},
{"Luxembourg", "Luxembourg (le)", "LU", "LUX", "442"},
{"Macao", "Macao", "MO", "MAC", "446"},
{"Madagascar", "Madagascar", "MG", "MDG", "450"},
{"Malawi", "Malawi (le)", "MW", "MWI", "454"},
{"Malaysia", "Malaisie (la)", "MY", "MYS", "458"},
{"Maldives", "Maldives (les)", "MV", "MDV", "462"},
{"Mali", "Mali (le)", "ML", "MLI", "466"},
{"Malta", "Malte", "MT", "MLT", "470"},
{"Martinique", "Martinique (la)", "MQ", "MTQ", "474"},
{"Mauritania", "Mauritanie (la)", "MR", "MRT", "478"},
{"Mauritius", "Maurice", "MU", "MUS", "480"},
{"Mexico", "Mexique (le)", "MX", "MEX", "484"},
{"Monaco", "Monaco", "MC", "MCO", "492"},
{"Mongolia", "Mongolie (la)", "MN", "MNG", "496"},
{"Moldova (the Republic of)", "Moldova , République de", "MD", "MDA", "498"},
{"Montenegro", "Monténégro (le)", "ME", "MNE", "499"},
{"Montserrat", "Montserrat", "MS", "MSR", "500"},
{"Morocco", "Maroc (le)", "MA", "MAR", "504"},
{"Mozambique", "Mozambique (le)", "MZ", "MOZ", "508"},
{"Oman", "Oman", "OM", "OMN", "512"},
{"Namibia", "Namibie (la)", "NA", "NAM", "516"},
{"Nauru", "Nauru", "NR", "NRU", "520"},
{"Nepal", "Népal (le)", "NP", "NPL", "524"},
{"Netherlands (the)", "Pays-Bas (les)", "NL", "NLD", "528"},
{"Curaçao", "Curaçao", "CW", "CUW", "531"},
{"Aruba", "Aruba", "AW", "ABW", "533"},
{"Sint Maarten (Dutch part)", "Saint-Martin (partie néerlandaise)", "SX", "SXM", "534"},
{"Bonaire, Sint Eustatius and Saba", "Bonaire, Saint-Eustache et Saba", "BQ", "BES", "535"},
{"New Caledonia", "Nouvelle-Calédonie (la)", "NC", "NCL", "540"},
{"Vanuatu", "Vanuatu (le)", "VU", "VUT", "548"},
{"New Zealand", "Nouvelle-Zélande (la)", "NZ", "NZL", "554"},
{"Nicaragua", "Nicaragua (le)", "NI", "NIC", "558"},
{"Niger (the)", "Niger (le)", "NE", "NER", "562"},
{"Nigeria", "Nigéria (le)", "NG", "NGA", "566"},
{"Niue", "Niue", "NU", "NIU", "570"},
{"Norfolk Island", "Norfolk (l'Île)", "NF", "NFK", "574"},
{"Norway", "Norvège (la)", "NO", "NOR", "578"},
{"Northern Mariana Islands (the)", "Mariannes du Nord (les Îles)", "MP", "MNP", "580"},
{"United States Minor Outlying Islands (the)", "Îles mineures éloignées des États-Unis (les)", "UM", "UMI", "581"},
{"Micronesia (Federated States of)", "Micronésie (États fédérés de)", "FM", "FSM", "583"},
{"Marshall Islands (the)", "Marshall (Îles)", "MH", "MHL", "584"},
{"Palau", "Palaos (les)", "PW", "PLW", "585"},
{"Pakistan", "Pakistan (le)", "PK", "PAK", "586"},
{"Panama", "Panama (le)", "PA", "PAN", "591"},
{"Papua New Guinea", "Papouasie-Nouvelle-Guinée (la)", "PG", "PNG", "598"},
{"Paraguay", "Paraguay (le)", "PY", "PRY", "600"},
{"Peru", "Pérou (le)", "PE", "PER", "604"},
{"Philippines (the)", "Philippines (les)", "PH", "PHL", "608"},
{"Pitcairn", "Pitcairn", "PN", "PCN", "612"},
{"Poland", "Pologne (la)", "PL", "POL", "616"},
{"Portugal", "Portugal (le)", "PT", "PRT", "620"},
{"Guinea-Bissau", "Guinée-Bissau (la)", "GW", "GNB", "624"},
{"Timor-Leste", "Timor-Leste (le)", "TL", "TLS", "626"},
{"Puerto Rico", "Porto Rico", "PR", "PRI", "630"},
{"Qatar", "Qatar (le)", "QA", "QAT", "634"},
{"Réunion", "Réunion (La)", "RE", "REU", "638"},
{"Romania", "Roumanie (la)", "RO", "ROU", "642"},
{"Russian Federation (the)", "Russie (la Fédération de)", "RU", "RUS", "643"},
{"Rwanda", "Rwanda (le)", "RW", "RWA", "646"},
{"Saint Barthélemy", "Saint-Barthélemy", "BL", "BLM", "652"},
{"Saint Helena, Ascension and Tristan da Cunha", "Sainte-Hélène, Ascension et Tristan da Cunha", "SH", "SHN", "654"},
{"Saint Kitts and Nevis", "Saint-Kitts-et-Nevis", "KN", "KNA", "659"},
{"Anguilla", "Anguilla", "AI", "AIA", "660"},
{"Saint Lucia", "Sainte-Lucie", "LC", "LCA", "662"},
{"Saint Martin (French part)", "Saint-Martin (partie française)", "MF", "MAF", "663"},
{"Saint Pierre and Miquelon", "Saint-Pierre-et-Miquelon", "PM", "SPM", "666"},
{"Saint Vincent and the Grenadines", "Saint-Vincent-et-les Grenadines", "VC", "VCT", "670"},
{"San Marino", "Saint-Marin", "SM", "SMR", "674"},
{"Sao Tome and Principe", "Sao Tomé-et-Principe", "ST", "STP", "678"},
{"Saudi Arabia", "Arabie saoudite (l')", "SA", "SAU", "682"},
{"Senegal", "Sénégal (le)", "SN", "SEN", "686"},
{"Serbia", "Serbie (la)", "RS", "SRB", "688"},
{"Seychelles", "Seychelles (les)", "SC", "SYC", "690"},
{"Sierra Leone", "Sierra Leone (la)", "SL", "SLE", "694"},
{"Singapore", "Singapour", "SG", "SGP", "702"},
{"Slovakia", "Slovaquie (la)", "SK", "SVK", "703"},
{"Viet Nam", "Viet Nam (le)", "VN", "VNM", "704"},
{"Slovenia", "Slovénie (la)", "SI", "SVN", "705"},
{"Somalia", "Somalie (la)", "SO", "SOM", "706"},
{"South Africa", "Afrique du Sud (l')", "ZA", "ZAF", "710"},
{"Zimbabwe", "Zimbabwe (le)", "ZW", "ZWE", "716"},
{"Spain", "Espagne (l')", "ES", "ESP", "724"},
{"South Sudan", "Soudan du Sud (le)", "SS", "SSD", "728"},
{"Sudan (the)", "Soudan (le)", "SD", "SDN", "729"},
{"Western Sahara*", "Sahara occidental (le)*", "EH", "ESH", "732"},
{"Suriname", "Suriname (le)", "SR", "SUR", "740"},
{"Svalbard and Jan Mayen", "Svalbard et l'Île Jan Mayen (le)", "SJ", "SJM", "744"},
{"Swaziland", "Swaziland (le)", "SZ", "SWZ", "748"},
{"Sweden", "Suède (la)", "SE", "SWE", "752"},
{"Switzerland", "Suisse (la)", "CH", "CHE", "756"},
{"Syrian Arab Republic", "République arabe syrienne (la)", "SY", "SYR", "760"},
{"Tajikistan", "Tadjikistan (le)", "TJ", "TJK", "762"},
{"Thailand", "Thaïlande (la)", "TH", "THA", "764"},
{"Togo", "Togo (le)", "TG", "TGO", "768"},
{"Tokelau", "Tokelau (les)", "TK", "TKL", "772"},
{"Tonga", "Tonga (les)", "TO", "TON", "776"},
{"Trinidad and Tobago", "Trinité-et-Tobago (la)", "TT", "TTO", "780"},
{"United Arab Emirates (the)", "Émirats arabes unis (les)", "AE", "ARE", "784"},
{"Tunisia", "Tunisie (la)", "TN", "TUN", "788"},
{"Turkey", "Turquie (la)", "TR", "TUR", "792"},
{"Turkmenistan", "Turkménistan (le)", "TM", "TKM", "795"},
{"Turks and Caicos Islands (the)", "Turks-et-Caïcos (les Îles)", "TC", "TCA", "796"},
{"Tuvalu", "Tuvalu (les)", "TV", "TUV", "798"},
{"Uganda", "Ouganda (l')", "UG", "UGA", "800"},
{"Ukraine", "Ukraine (l')", "UA", "UKR", "804"},
{"Macedonia (the former Yugoslav Republic of)", "Macédoine (l'exRépublique yougoslave de)", "MK", "MKD", "807"},
{"Egypt", "Égypte (l')", "EG", "EGY", "818"},
{"United Kingdom of Great Britain and Northern Ireland (the)", "Royaume-Uni de Grande-Bretagne et d'Irlande du Nord (le)", "GB", "GBR", "826"},
{"Guernsey", "Guernesey", "GG", "GGY", "831"},
{"Jersey", "Jersey", "JE", "JEY", "832"},
{"Isle of Man", "Île de Man", "IM", "IMN", "833"},
{"Tanzania, United Republic of", "Tanzanie, République-Unie de", "TZ", "TZA", "834"},
{"United States of America (the)", "États-Unis d'Amérique (les)", "US", "USA", "840"},
{"Virgin Islands (U.S.)", "Vierges des États-Unis (les Îles)", "VI", "VIR", "850"},
{"Burkina Faso", "Burkina Faso (le)", "BF", "BFA", "854"},
{"Uruguay", "Uruguay (l')", "UY", "URY", "858"},
{"Uzbekistan", "Ouzbékistan (l')", "UZ", "UZB", "860"},
{"Venezuela (Bolivarian Republic of)", "Venezuela (République bolivarienne du)", "VE", "VEN", "862"},
{"Wallis and Futuna", "Wallis-et-Futuna", "WF", "WLF", "876"},
{"Samoa", "Samoa (le)", "WS", "WSM", "882"},
{"Yemen", "Yémen (le)", "YE", "YEM", "887"},
{"Zambia", "Zambie (la)", "ZM", "ZMB", "894"},
}

View File

@@ -1,268 +0,0 @@
package govalidator
import (
"errors"
"fmt"
"html"
"math"
"path"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// Contains check if the string contains the substring.
func Contains(str, substring string) bool {
return strings.Contains(str, substring)
}
// Matches check if string matches the pattern (pattern is regular expression)
// In case of error return false
func Matches(str, pattern string) bool {
match, _ := regexp.MatchString(pattern, str)
return match
}
// LeftTrim trim characters from the left-side of the input.
// If second argument is empty, it's will be remove leading spaces.
func LeftTrim(str, chars string) string {
pattern := ""
if chars == "" {
pattern = "^\\s+"
} else {
pattern = "^[" + chars + "]+"
}
r, _ := regexp.Compile(pattern)
return string(r.ReplaceAll([]byte(str), []byte("")))
}
// RightTrim trim characters from the right-side of the input.
// If second argument is empty, it's will be remove spaces.
func RightTrim(str, chars string) string {
pattern := ""
if chars == "" {
pattern = "\\s+$"
} else {
pattern = "[" + chars + "]+$"
}
r, _ := regexp.Compile(pattern)
return string(r.ReplaceAll([]byte(str), []byte("")))
}
// Trim trim characters from both sides of the input.
// If second argument is empty, it's will be remove spaces.
func Trim(str, chars string) string {
return LeftTrim(RightTrim(str, chars), chars)
}
// WhiteList remove characters that do not appear in the whitelist.
func WhiteList(str, chars string) string {
pattern := "[^" + chars + "]+"
r, _ := regexp.Compile(pattern)
return string(r.ReplaceAll([]byte(str), []byte("")))
}
// BlackList remove characters that appear in the blacklist.
func BlackList(str, chars string) string {
pattern := "[" + chars + "]+"
r, _ := regexp.Compile(pattern)
return string(r.ReplaceAll([]byte(str), []byte("")))
}
// StripLow remove characters with a numerical value < 32 and 127, mostly control characters.
// If keep_new_lines is true, newline characters are preserved (\n and \r, hex 0xA and 0xD).
func StripLow(str string, keepNewLines bool) string {
chars := ""
if keepNewLines {
chars = "\x00-\x09\x0B\x0C\x0E-\x1F\x7F"
} else {
chars = "\x00-\x1F\x7F"
}
return BlackList(str, chars)
}
// ReplacePattern replace regular expression pattern in string
func ReplacePattern(str, pattern, replace string) string {
r, _ := regexp.Compile(pattern)
return string(r.ReplaceAll([]byte(str), []byte(replace)))
}
// Escape replace <, >, & and " with HTML entities.
var Escape = html.EscapeString
func addSegment(inrune, segment []rune) []rune {
if len(segment) == 0 {
return inrune
}
if len(inrune) != 0 {
inrune = append(inrune, '_')
}
inrune = append(inrune, segment...)
return inrune
}
// UnderscoreToCamelCase converts from underscore separated form to camel case form.
// Ex.: my_func => MyFunc
func UnderscoreToCamelCase(s string) string {
return strings.Replace(strings.Title(strings.Replace(strings.ToLower(s), "_", " ", -1)), " ", "", -1)
}
// CamelCaseToUnderscore converts from camel case form to underscore separated form.
// Ex.: MyFunc => my_func
func CamelCaseToUnderscore(str string) string {
var output []rune
var segment []rune
for _, r := range str {
if !unicode.IsLower(r) {
output = addSegment(output, segment)
segment = nil
}
segment = append(segment, unicode.ToLower(r))
}
output = addSegment(output, segment)
return string(output)
}
// Reverse return reversed string
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
// GetLines split string by "\n" and return array of lines
func GetLines(s string) []string {
return strings.Split(s, "\n")
}
// GetLine return specified line of multiline string
func GetLine(s string, index int) (string, error) {
lines := GetLines(s)
if index < 0 || index >= len(lines) {
return "", errors.New("line index out of bounds")
}
return lines[index], nil
}
// RemoveTags remove all tags from HTML string
func RemoveTags(s string) string {
return ReplacePattern(s, "<[^>]*>", "")
}
// SafeFileName return safe string that can be used in file names
func SafeFileName(str string) string {
name := strings.ToLower(str)
name = path.Clean(path.Base(name))
name = strings.Trim(name, " ")
separators, err := regexp.Compile(`[ &_=+:]`)
if err == nil {
name = separators.ReplaceAllString(name, "-")
}
legal, err := regexp.Compile(`[^[:alnum:]-.]`)
if err == nil {
name = legal.ReplaceAllString(name, "")
}
for strings.Contains(name, "--") {
name = strings.Replace(name, "--", "-", -1)
}
return name
}
// NormalizeEmail canonicalize an email address.
// The local part of the email address is lowercased for all domains; the hostname is always lowercased and
// the local part of the email address is always lowercased for hosts that are known to be case-insensitive (currently only GMail).
// Normalization follows special rules for known providers: currently, GMail addresses have dots removed in the local part and
// are stripped of tags (e.g. some.one+tag@gmail.com becomes someone@gmail.com) and all @googlemail.com addresses are
// normalized to @gmail.com.
func NormalizeEmail(str string) (string, error) {
if !IsEmail(str) {
return "", fmt.Errorf("%s is not an email", str)
}
parts := strings.Split(str, "@")
parts[0] = strings.ToLower(parts[0])
parts[1] = strings.ToLower(parts[1])
if parts[1] == "gmail.com" || parts[1] == "googlemail.com" {
parts[1] = "gmail.com"
parts[0] = strings.Split(ReplacePattern(parts[0], `\.`, ""), "+")[0]
}
return strings.Join(parts, "@"), nil
}
// Truncate a string to the closest length without breaking words.
func Truncate(str string, length int, ending string) string {
var aftstr, befstr string
if len(str) > length {
words := strings.Fields(str)
before, present := 0, 0
for i := range words {
befstr = aftstr
before = present
aftstr = aftstr + words[i] + " "
present = len(aftstr)
if present > length && i != 0 {
if (length - before) < (present - length) {
return Trim(befstr, " /\\.,\"'#!?&@+-") + ending
}
return Trim(aftstr, " /\\.,\"'#!?&@+-") + ending
}
}
}
return str
}
// Pad left side of string if size of string is less then indicated pad length
func PadLeft(str string, padStr string, padLen int) string {
return buildPadStr(str, padStr, padLen, true, false)
}
// Pad right side of string if size of string is less then indicated pad length
func PadRight(str string, padStr string, padLen int) string {
return buildPadStr(str, padStr, padLen, false, true)
}
// Pad both sides of string if size of string is less then indicated pad length
func PadBoth(str string, padStr string, padLen int) string {
return buildPadStr(str, padStr, padLen, true, true)
}
// Pad string either left, right or both sides, not the padding string can be unicode and more then one
// character
func buildPadStr(str string, padStr string, padLen int, padLeft bool, padRight bool) string {
// When padded length is less then the current string size
if padLen < utf8.RuneCountInString(str) {
return str
}
padLen -= utf8.RuneCountInString(str)
targetLen := padLen
targetLenLeft := targetLen
targetLenRight := targetLen
if padLeft && padRight {
targetLenLeft = padLen / 2
targetLenRight = padLen - targetLenLeft
}
strToRepeatLen := utf8.RuneCountInString(padStr)
repeatTimes := int(math.Ceil(float64(targetLen) / float64(strToRepeatLen)))
repeatedString := strings.Repeat(padStr, repeatTimes)
leftSide := ""
if padLeft {
leftSide = repeatedString[0:targetLenLeft]
}
rightSide := ""
if padRight {
rightSide = repeatedString[0:targetLenRight]
}
return leftSide + str + rightSide
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
box: golang
build:
steps:
- setup-go-workspace
- script:
name: go get
code: |
go version
go get -t ./...
- script:
name: go test
code: |
go test -race ./...

View File

@@ -1,21 +0,0 @@
Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<http://www.opensource.org/licenses/mit-license.php>

View File

@@ -1,92 +0,0 @@
# Humane Units [![Build Status](https://travis-ci.org/dustin/go-humanize.svg?branch=master)](https://travis-ci.org/dustin/go-humanize) [![GoDoc](https://godoc.org/github.com/dustin/go-humanize?status.svg)](https://godoc.org/github.com/dustin/go-humanize)
Just a few functions for helping humanize times and sizes.
`go get` it as `github.com/dustin/go-humanize`, import it as
`"github.com/dustin/go-humanize"`, use it as `humanize`
See [godoc](https://godoc.org/github.com/dustin/go-humanize) for
complete documentation.
## Sizes
This lets you take numbers like `82854982` and convert them to useful
strings like, `83MB` or `79MiB` (whichever you prefer).
Example:
```go
fmt.Printf("That file is %s.", humanize.Bytes(82854982))
```
## Times
This lets you take a `time.Time` and spit it out in relative terms.
For example, `12 seconds ago` or `3 days from now`.
Example:
```go
fmt.Printf("This was touched %s", humanize.Time(someTimeInstance))
```
Thanks to Kyle Lemons for the time implementation from an IRC
conversation one day. It's pretty neat.
## Ordinals
From a [mailing list discussion][odisc] where a user wanted to be able
to label ordinals.
0 -> 0th
1 -> 1st
2 -> 2nd
3 -> 3rd
4 -> 4th
[...]
Example:
```go
fmt.Printf("You're my %s best friend.", humanize.Ordinal(193))
```
## Commas
Want to shove commas into numbers? Be my guest.
0 -> 0
100 -> 100
1000 -> 1,000
1000000000 -> 1,000,000,000
-100000 -> -100,000
Example:
```go
fmt.Printf("You owe $%s.\n", humanize.Comma(6582491))
```
## Ftoa
Nicer float64 formatter that removes trailing zeros.
```go
fmt.Printf("%f", 2.24) // 2.240000
fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24
fmt.Printf("%f", 2.0) // 2.000000
fmt.Printf("%s", humanize.Ftoa(2.0)) // 2
```
## SI notation
Format numbers with [SI notation][sinotation].
Example:
```go
humanize.SI(0.00000000223, "M") // 2.23nM
```
[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion
[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix

View File

@@ -1,31 +0,0 @@
package humanize
import (
"math/big"
)
// order of magnitude (to a max order)
func oomm(n, b *big.Int, maxmag int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
if mag == maxmag && maxmag >= 0 {
break
}
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}
// total order of magnitude
// (same as above, but with no upper limit)
func oom(n, b *big.Int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}

View File

@@ -1,173 +0,0 @@
package humanize
import (
"fmt"
"math/big"
"strings"
"unicode"
)
var (
bigIECExp = big.NewInt(1024)
// BigByte is one byte in bit.Ints
BigByte = big.NewInt(1)
// BigKiByte is 1,024 bytes in bit.Ints
BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp)
// BigMiByte is 1,024 k bytes in bit.Ints
BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp)
// BigGiByte is 1,024 m bytes in bit.Ints
BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp)
// BigTiByte is 1,024 g bytes in bit.Ints
BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp)
// BigPiByte is 1,024 t bytes in bit.Ints
BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp)
// BigEiByte is 1,024 p bytes in bit.Ints
BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp)
// BigZiByte is 1,024 e bytes in bit.Ints
BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp)
// BigYiByte is 1,024 z bytes in bit.Ints
BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp)
)
var (
bigSIExp = big.NewInt(1000)
// BigSIByte is one SI byte in big.Ints
BigSIByte = big.NewInt(1)
// BigKByte is 1,000 SI bytes in big.Ints
BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp)
// BigMByte is 1,000 SI k bytes in big.Ints
BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp)
// BigGByte is 1,000 SI m bytes in big.Ints
BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp)
// BigTByte is 1,000 SI g bytes in big.Ints
BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp)
// BigPByte is 1,000 SI t bytes in big.Ints
BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp)
// BigEByte is 1,000 SI p bytes in big.Ints
BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp)
// BigZByte is 1,000 SI e bytes in big.Ints
BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp)
// BigYByte is 1,000 SI z bytes in big.Ints
BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp)
)
var bigBytesSizeTable = map[string]*big.Int{
"b": BigByte,
"kib": BigKiByte,
"kb": BigKByte,
"mib": BigMiByte,
"mb": BigMByte,
"gib": BigGiByte,
"gb": BigGByte,
"tib": BigTiByte,
"tb": BigTByte,
"pib": BigPiByte,
"pb": BigPByte,
"eib": BigEiByte,
"eb": BigEByte,
"zib": BigZiByte,
"zb": BigZByte,
"yib": BigYiByte,
"yb": BigYByte,
// Without suffix
"": BigByte,
"ki": BigKiByte,
"k": BigKByte,
"mi": BigMiByte,
"m": BigMByte,
"gi": BigGiByte,
"g": BigGByte,
"ti": BigTiByte,
"t": BigTByte,
"pi": BigPiByte,
"p": BigPByte,
"ei": BigEiByte,
"e": BigEByte,
"z": BigZByte,
"zi": BigZiByte,
"y": BigYByte,
"yi": BigYiByte,
}
var ten = big.NewInt(10)
func humanateBigBytes(s, base *big.Int, sizes []string) string {
if s.Cmp(ten) < 0 {
return fmt.Sprintf("%d B", s)
}
c := (&big.Int{}).Set(s)
val, mag := oomm(c, base, len(sizes)-1)
suffix := sizes[mag]
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// BigBytes produces a human readable representation of an SI size.
//
// See also: ParseBigBytes.
//
// BigBytes(82854982) -> 83MB
func BigBytes(s *big.Int) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
return humanateBigBytes(s, bigSIExp, sizes)
}
// BigIBytes produces a human readable representation of an IEC size.
//
// See also: ParseBigBytes.
//
// BigIBytes(82854982) -> 79MiB
func BigIBytes(s *big.Int) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
return humanateBigBytes(s, bigIECExp, sizes)
}
// ParseBigBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See also: BigBytes, BigIBytes.
//
// ParseBigBytes("42MB") -> 42000000, nil
// ParseBigBytes("42mib") -> 44040192, nil
func ParseBigBytes(s string) (*big.Int, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
val := &big.Rat{}
_, err := fmt.Sscanf(num, "%f", val)
if err != nil {
return nil, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bigBytesSizeTable[extra]; ok {
mv := (&big.Rat{}).SetInt(m)
val.Mul(val, mv)
rv := &big.Int{}
rv.Div(val.Num(), val.Denom())
return rv, nil
}
return nil, fmt.Errorf("unhandled size name: %v", extra)
}

View File

@@ -1,143 +0,0 @@
package humanize
import (
"fmt"
"math"
"strconv"
"strings"
"unicode"
)
// IEC Sizes.
// kibis of bits
const (
Byte = 1 << (iota * 10)
KiByte
MiByte
GiByte
TiByte
PiByte
EiByte
)
// SI Sizes.
const (
IByte = 1
KByte = IByte * 1000
MByte = KByte * 1000
GByte = MByte * 1000
TByte = GByte * 1000
PByte = TByte * 1000
EByte = PByte * 1000
)
var bytesSizeTable = map[string]uint64{
"b": Byte,
"kib": KiByte,
"kb": KByte,
"mib": MiByte,
"mb": MByte,
"gib": GiByte,
"gb": GByte,
"tib": TiByte,
"tb": TByte,
"pib": PiByte,
"pb": PByte,
"eib": EiByte,
"eb": EByte,
// Without suffix
"": Byte,
"ki": KiByte,
"k": KByte,
"mi": MiByte,
"m": MByte,
"gi": GiByte,
"g": GByte,
"ti": TiByte,
"t": TByte,
"pi": PiByte,
"p": PByte,
"ei": EiByte,
"e": EByte,
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}
func humanateBytes(s uint64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// Bytes produces a human readable representation of an SI size.
//
// See also: ParseBytes.
//
// Bytes(82854982) -> 83MB
func Bytes(s uint64) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
return humanateBytes(s, 1000, sizes)
}
// IBytes produces a human readable representation of an IEC size.
//
// See also: ParseBytes.
//
// IBytes(82854982) -> 79MiB
func IBytes(s uint64) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanateBytes(s, 1024, sizes)
}
// ParseBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See Also: Bytes, IBytes.
//
// ParseBytes("42MB") -> 42000000, nil
// ParseBytes("42mib") -> 44040192, nil
func ParseBytes(s string) (uint64, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
f, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bytesSizeTable[extra]; ok {
f *= float64(m)
if f >= math.MaxUint64 {
return 0, fmt.Errorf("too large: %v", s)
}
return uint64(f), nil
}
return 0, fmt.Errorf("unhandled size name: %v", extra)
}

View File

@@ -1,108 +0,0 @@
package humanize
import (
"bytes"
"math"
"math/big"
"strconv"
"strings"
)
// Comma produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Comma(834142) -> 834,142
func Comma(v int64) string {
sign := ""
// minin64 can't be negated to a usable value, so it has to be special cased.
if v == math.MinInt64 {
return "-9,223,372,036,854,775,808"
}
if v < 0 {
sign = "-"
v = 0 - v
}
parts := []string{"", "", "", "", "", "", ""}
j := len(parts) - 1
for v > 999 {
parts[j] = strconv.FormatInt(v%1000, 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
v = v / 1000
j--
}
parts[j] = strconv.Itoa(int(v))
return sign + strings.Join(parts[j:], ",")
}
// Commaf produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Commaf(834142.32) -> 834,142.32
func Commaf(v float64) string {
buf := &bytes.Buffer{}
if v < 0 {
buf.Write([]byte{'-'})
v = 0 - v
}
comma := []byte{','}
parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}
// BigComma produces a string form of the given big.Int in base 10
// with commas after every three orders of magnitude.
func BigComma(b *big.Int) string {
sign := ""
if b.Sign() < 0 {
sign = "-"
b.Abs(b)
}
athousand := big.NewInt(1000)
c := (&big.Int{}).Set(b)
_, m := oom(c, athousand)
parts := make([]string, m+1)
j := len(parts) - 1
mod := &big.Int{}
for b.Cmp(athousand) >= 0 {
b.DivMod(b, athousand, mod)
parts[j] = strconv.FormatInt(mod.Int64(), 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
j--
}
parts[j] = strconv.Itoa(int(b.Int64()))
return sign + strings.Join(parts[j:], ",")
}

View File

@@ -1,40 +0,0 @@
// +build go1.6
package humanize
import (
"bytes"
"math/big"
"strings"
)
// BigCommaf produces a string form of the given big.Float in base 10
// with commas after every three orders of magnitude.
func BigCommaf(v *big.Float) string {
buf := &bytes.Buffer{}
if v.Sign() < 0 {
buf.Write([]byte{'-'})
v.Abs(v)
}
comma := []byte{','}
parts := strings.Split(v.Text('f', -1), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}

View File

@@ -1,23 +0,0 @@
package humanize
import "strconv"
func stripTrailingZeros(s string) string {
offset := len(s) - 1
for offset > 0 {
if s[offset] == '.' {
offset--
break
}
if s[offset] != '0' {
break
}
offset--
}
return s[:offset+1]
}
// Ftoa converts a float to a string with no trailing zeros.
func Ftoa(num float64) string {
return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64))
}

View File

@@ -1,8 +0,0 @@
/*
Package humanize converts boring ugly numbers to human-friendly strings and back.
Durations can be turned into strings such as "3 days ago", numbers
representing sizes like 82854982 into useful strings like, "83MB" or
"79MiB" (whichever you prefer).
*/
package humanize

View File

@@ -1,192 +0,0 @@
package humanize
/*
Slightly adapted from the source to fit go-humanize.
Author: https://github.com/gorhill
Source: https://gist.github.com/gorhill/5285193
*/
import (
"math"
"strconv"
)
var (
renderFloatPrecisionMultipliers = [...]float64{
1,
10,
100,
1000,
10000,
100000,
1000000,
10000000,
100000000,
1000000000,
}
renderFloatPrecisionRounders = [...]float64{
0.5,
0.05,
0.005,
0.0005,
0.00005,
0.000005,
0.0000005,
0.00000005,
0.000000005,
0.0000000005,
}
)
// FormatFloat produces a formatted number as string based on the following user-specified criteria:
// * thousands separator
// * decimal separator
// * decimal precision
//
// Usage: s := RenderFloat(format, n)
// The format parameter tells how to render the number n.
//
// See examples: http://play.golang.org/p/LXc1Ddm1lJ
//
// Examples of format strings, given n = 12345.6789:
// "#,###.##" => "12,345.67"
// "#,###." => "12,345"
// "#,###" => "12345,678"
// "#\u202F###,##" => "12345,68"
// "#.###,###### => 12.345,678900
// "" (aka default format) => 12,345.67
//
// The highest precision allowed is 9 digits after the decimal symbol.
// There is also a version for integer number, FormatInteger(),
// which is convenient for calls within template.
func FormatFloat(format string, n float64) string {
// Special cases:
// NaN = "NaN"
// +Inf = "+Infinity"
// -Inf = "-Infinity"
if math.IsNaN(n) {
return "NaN"
}
if n > math.MaxFloat64 {
return "Infinity"
}
if n < -math.MaxFloat64 {
return "-Infinity"
}
// default format
precision := 2
decimalStr := "."
thousandStr := ","
positiveStr := ""
negativeStr := "-"
if len(format) > 0 {
format := []rune(format)
// If there is an explicit format directive,
// then default values are these:
precision = 9
thousandStr = ""
// collect indices of meaningful formatting directives
formatIndx := []int{}
for i, char := range format {
if char != '#' && char != '0' {
formatIndx = append(formatIndx, i)
}
}
if len(formatIndx) > 0 {
// Directive at index 0:
// Must be a '+'
// Raise an error if not the case
// index: 0123456789
// +0.000,000
// +000,000.0
// +0000.00
// +0000
if formatIndx[0] == 0 {
if format[formatIndx[0]] != '+' {
panic("RenderFloat(): invalid positive sign directive")
}
positiveStr = "+"
formatIndx = formatIndx[1:]
}
// Two directives:
// First is thousands separator
// Raise an error if not followed by 3-digit
// 0123456789
// 0.000,000
// 000,000.00
if len(formatIndx) == 2 {
if (formatIndx[1] - formatIndx[0]) != 4 {
panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers")
}
thousandStr = string(format[formatIndx[0]])
formatIndx = formatIndx[1:]
}
// One directive:
// Directive is decimal separator
// The number of digit-specifier following the separator indicates wanted precision
// 0123456789
// 0.00
// 000,0000
if len(formatIndx) == 1 {
decimalStr = string(format[formatIndx[0]])
precision = len(format) - formatIndx[0] - 1
}
}
}
// generate sign part
var signStr string
if n >= 0.000000001 {
signStr = positiveStr
} else if n <= -0.000000001 {
signStr = negativeStr
n = -n
} else {
signStr = ""
n = 0.0
}
// split number into integer and fractional parts
intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision])
// generate integer part string
intStr := strconv.FormatInt(int64(intf), 10)
// add thousand separator if required
if len(thousandStr) > 0 {
for i := len(intStr); i > 3; {
i -= 3
intStr = intStr[:i] + thousandStr + intStr[i:]
}
}
// no fractional part, we can leave now
if precision == 0 {
return signStr + intStr
}
// generate fractional part
fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision]))
// may need padding
if len(fracStr) < precision {
fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr
}
return signStr + intStr + decimalStr + fracStr
}
// FormatInteger produces a formatted number as string.
// See FormatFloat.
func FormatInteger(format string, n int) string {
return FormatFloat(format, float64(n))
}

View File

@@ -1,25 +0,0 @@
package humanize
import "strconv"
// Ordinal gives you the input number in a rank/ordinal format.
//
// Ordinal(3) -> 3rd
func Ordinal(x int) string {
suffix := "th"
switch x % 10 {
case 1:
if x%100 != 11 {
suffix = "st"
}
case 2:
if x%100 != 12 {
suffix = "nd"
}
case 3:
if x%100 != 13 {
suffix = "rd"
}
}
return strconv.Itoa(x) + suffix
}

View File

@@ -1,113 +0,0 @@
package humanize
import (
"errors"
"math"
"regexp"
"strconv"
)
var siPrefixTable = map[float64]string{
-24: "y", // yocto
-21: "z", // zepto
-18: "a", // atto
-15: "f", // femto
-12: "p", // pico
-9: "n", // nano
-6: "µ", // micro
-3: "m", // milli
0: "",
3: "k", // kilo
6: "M", // mega
9: "G", // giga
12: "T", // tera
15: "P", // peta
18: "E", // exa
21: "Z", // zetta
24: "Y", // yotta
}
var revSIPrefixTable = revfmap(siPrefixTable)
// revfmap reverses the map and precomputes the power multiplier
func revfmap(in map[float64]string) map[string]float64 {
rv := map[string]float64{}
for k, v := range in {
rv[v] = math.Pow(10, k)
}
return rv
}
var riParseRegex *regexp.Regexp
func init() {
ri := `^([\-0-9.]+)\s?([`
for _, v := range siPrefixTable {
ri += v
}
ri += `]?)(.*)`
riParseRegex = regexp.MustCompile(ri)
}
// ComputeSI finds the most appropriate SI prefix for the given number
// and returns the prefix along with the value adjusted to be within
// that prefix.
//
// See also: SI, ParseSI.
//
// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p")
func ComputeSI(input float64) (float64, string) {
if input == 0 {
return 0, ""
}
mag := math.Abs(input)
exponent := math.Floor(logn(mag, 10))
exponent = math.Floor(exponent/3) * 3
value := mag / math.Pow(10, exponent)
// Handle special case where value is exactly 1000.0
// Should return 1M instead of 1000k
if value == 1000.0 {
exponent += 3
value = mag / math.Pow(10, exponent)
}
value = math.Copysign(value, input)
prefix := siPrefixTable[exponent]
return value, prefix
}
// SI returns a string with default formatting.
//
// SI uses Ftoa to format float value, removing trailing zeros.
//
// See also: ComputeSI, ParseSI.
//
// e.g. SI(1000000, B) -> 1MB
// e.g. SI(2.2345e-12, "F") -> 2.2345pF
func SI(input float64, unit string) string {
value, prefix := ComputeSI(input)
return Ftoa(value) + " " + prefix + unit
}
var errInvalid = errors.New("invalid input")
// ParseSI parses an SI string back into the number and unit.
//
// See also: SI, ComputeSI.
//
// e.g. ParseSI(2.2345pF) -> (2.2345e-12, "F", nil)
func ParseSI(input string) (float64, string, error) {
found := riParseRegex.FindStringSubmatch(input)
if len(found) != 4 {
return 0, "", errInvalid
}
mag := revSIPrefixTable[found[2]]
unit := found[3]
base, err := strconv.ParseFloat(found[1], 64)
return base * mag, unit, err
}

Some files were not shown because too many files have changed in this diff Show More