Compare commits

...

122 Commits

Author SHA1 Message Date
Manfred Touron
2555c478b4 v1.7.1 2018-01-03 00:26:38 +01:00
Manfred Touron
6152e55e7d Merge pull request #30 from moul/dev/moul/more-integration-tests
More integration tests
2018-01-03 00:25:45 +01:00
Manfred Touron
023cdd1bb3 Test bastion in integration 2018-01-03 00:23:46 +01:00
Manfred Touron
5efe250466 hotfix: repair invite system (broken in v1.7.0) 2018-01-03 00:23:46 +01:00
Manfred Touron
695ddc91dd Return non-null exit-code on authentication error 2018-01-03 00:23:46 +01:00
Manfred Touron
7b30017a14 Complete list of features 2018-01-03 00:23:45 +01:00
Manfred Touron
e5542ae266 Update graphs 2018-01-03 00:23:45 +01:00
Manfred Touron
d19b8a53f2 Add dependencies 2018-01-03 00:23:45 +01:00
Manfred Touron
2e39f70cd5 Add '_test_server' hidden handler 2018-01-03 00:23:45 +01:00
Manfred Touron
7aace9109a Update Changelog 2018-01-02 05:58:54 +01:00
Manfred Touron
6c4caea26f Post-release version bump 2018-01-02 05:57:13 +01:00
Manfred Touron
13c0726849 v1.7.0 2018-01-02 05:56:24 +01:00
Manfred Touron
1b52673603 Add missing ch.Close() on premature error 2018-01-02 05:55:01 +01:00
Manfred Touron
7ea7237d19 Merge pull request #25 from moul/dev/moul/fix-24
Support putty
2018-01-02 05:46:10 +01:00
Manfred Touron
d6bb5e44a1 Refactor bastion handler to forward every requests properly 2018-01-02 05:37:24 +01:00
Manfred Touron
072464928b Refactor sshportal: create a custom bastion session handler 2018-01-01 22:15:28 +01:00
Manfred Touron
4125bc2768 Refactor gliderlabs/ssh to support custom handlers 2018-01-01 22:14:44 +01:00
Manfred Touron
ee29310ed3 Update gliderlabs/ssh dependency to latest upstream commit hash 2018-01-01 22:14:44 +01:00
Manfred Touron
0e6917ae2a Merge pull request #29 from moul/dev/moul/docker-healthcheck
Add Docker healthcheck helper
2018-01-01 11:08:46 +01:00
Manfred Touron
57dd2c6c01 Add healthcheck --wait and --quiet options 2018-01-01 11:05:05 +01:00
Manfred Touron
6494e69632 Add Docker healthcheck helper 2018-01-01 10:58:43 +01:00
Manfred Touron
d6ea80dab1 Merge pull request #28 from moul/dev/moul/ci-integration
Run integration tests on CI
2018-01-01 09:30:35 +01:00
Manfred Touron
fbb3e7134f Run integration tests in parallel in CI 2018-01-01 09:28:34 +01:00
Manfred Touron
9fdb36c6ca Refactor 'make integration' to be runnable with docker-compose 2018-01-01 09:16:09 +01:00
Manfred Touron
9bc545b4bb Add config backup --ignore-events option 2018-01-01 09:07:41 +01:00
Manfred Touron
457f60f815 Use sshportal server instead of sshportal to start a new server 2018-01-01 09:07:41 +01:00
Manfred Touron
78db26a532 Merge pull request #26 from moul/dev/moul/fix-22
Fix connection failure when sending too many environment variables
2017-12-28 23:29:41 +01:00
Manfred Touron
fb15225c35 Fix connection failure when sending too many environment variables (fix #22)
*Temporary fix*

A better solution requires a refactor of the ssh session handler (related with #24)

Related with this comment: https://github.com/gliderlabs/ssh/issues/47#issuecomment-340550104
2017-12-28 23:25:11 +01:00
Manfred Touron
c8fb103762 Merge pull request #21 from sabban/master
typo fix.
2017-12-21 15:37:55 +01:00
Manuel
585fd3a3ff typo fix. 2017-12-21 15:35:26 +01:00
Manfred Touron
0aefd4d093 Merge pull request #20 from sabban/master
fix typo.
2017-12-21 13:08:34 +01:00
Manuel
5f0c5b3375 fix typo. 2017-12-21 12:32:18 +01:00
Manfred Touron
6b0b22cb7b Merge pull request #19 from QuentinPerez/typo
fix typo 's/aes-keuy/aes-key'
2017-12-19 09:34:15 +01:00
Quentin Perez
0f84be8fa0 fix typo 's/aes-keuy/aes-key' 2017-12-19 09:10:37 +01:00
Manfred Touron
849a485621 Merge pull request #18 from alexanderturner/fix/vendor+shell
Small fixes
2017-12-19 07:31:21 +01:00
Alexander Turner
a721d4ff01 -Updated typo in shell /s/invite-/invite:
-Removed gliderlabs/ssh from vendor.json to force /vendor version until committed upstream
2017-12-19 12:24:51 +11:00
Manfred Touron
62c8fe2dbf Update .dockerignore 2017-12-17 10:25:55 +01:00
Manfred Touron
756c8f02e8 Add diagram flow 2017-12-17 10:11:34 +01:00
Manfred Touron
62db91b7be Merge pull request #16 from fossabot/master
Add license scan report and status
2017-12-16 10:10:36 +01:00
Manfred Touron
8543a1f01a Add goreport card + udpate features and limitations list 2017-12-16 10:06:46 +01:00
fossabot
db20c81a39 Add license scan report and status 2017-12-16 00:56:09 -08:00
Manfred Touron
395827afeb Merge pull request #15 from moul/dev/moul/doc
Improve doc
2017-12-16 08:56:48 +01:00
Manfred Touron
8329fd3ab7 Improve doc 2017-12-15 20:27:07 +01:00
Manfred Touron
e32f4200d1 Merge pull request #14 from moul/dev/moul/fix-13
Fix panic when entering empty command
2017-12-15 13:57:41 +01:00
Manfred Touron
7ed60f6908 Fix panic when entering empty command (fix #13) 2017-12-15 13:55:53 +01:00
Manfred Touron
a413aa86c2 Add sql-schema.svg 2017-12-13 17:23:34 +01:00
Manfred Touron
b51c90a0e9 Update README 2017-12-13 11:52:19 +01:00
Manfred Touron
7245508c76 Add experimental warning 2017-12-12 14:55:50 +01:00
Manfred Touron
905159f044 Update demo gif 2017-12-12 12:26:49 +01:00
Manfred Touron
ac8181474c Post-release version bump 2017-12-12 10:40:14 +01:00
Manfred Touron
554937dd7a v1.6.0 2017-12-12 10:39:36 +01:00
Manfred Touron
4aa9a227e8 Switch to a lonely tmux demo 2017-12-12 10:15:09 +01:00
Manfred Touron
6e4cbf5dd8 Add server/client gif example in README 2017-12-12 09:05:49 +01:00
Manfred Touron
44d1ac7f11 Add example gifs 2017-12-12 09:01:33 +01:00
Manfred Touron
1c32da7751 Merge pull request #12 from moul/dev/moul/key-show
Add 'key show KEY' command
2017-12-06 00:37:37 +01:00
Manfred Touron
999b740df6 Add 'key show KEY' command (#11) 2017-12-06 00:33:40 +01:00
Manfred Touron
6864b7ca10 govendor add github.com/mgutz/ansi + its deps 2017-12-06 00:33:40 +01:00
Manfred Touron
546b350a6c Create LICENSE 2017-12-04 16:45:42 +01:00
Manfred Touron
d70296cd95 Update README.md 2017-12-04 16:33:41 +01:00
Manfred Touron
10f4ad49d9 Improve logging 2017-12-04 11:14:20 +01:00
Manfred Touron
edb230b278 Update README.md 2017-12-04 10:41:25 +01:00
Manfred Touron
efbf66a0a4 Update README.md 2017-12-04 10:39:40 +01:00
Manfred Touron
0746458762 Merge pull request #10 from moul/dev/moul/circle
Dev/moul/circle
2017-12-04 10:37:24 +01:00
Manfred Touron
f2738e2bd1 Fix lint warns 2017-12-04 10:32:29 +01:00
Manfred Touron
b0d8180809 Add CircleCI config 2017-12-04 10:21:36 +01:00
Manfred Touron
f9d450ffaf Add healthcheck user 2017-12-04 09:34:52 +01:00
Manfred Touron
391a39d82c Add --latest and --quiet options to ls commands 2017-12-04 09:27:10 +01:00
Manfred Touron
7eb76c861f Lint code + fix tests 2017-12-03 18:18:17 +01:00
Manfred Touron
cd437a3a7b Post-release version bump 2017-12-02 01:12:29 +01:00
Manfred Touron
2accc7abd4 v1.5.0 2017-12-02 01:11:40 +01:00
Manfred Touron
3c10578584 Fix some backup/restore bugs + improve MySQL support 2017-12-02 00:01:31 +01:00
Manfred Touron
511470087b Host key checking shared across users 2017-12-01 22:19:22 +01:00
Manfred Touron
017ee2ab39 Add MySQL support 2017-11-29 14:07:59 +01:00
Manfred Touron
b093f61fb5 Switch to hard delete 2017-11-29 10:27:04 +01:00
Manfred Touron
bd158819d3 Add 'make dev EXTRA_RUN_OPTS' flag
make dev EXTRA_RUN_OPTS="--db-conn=root@/db?parseTime=true --db-driver=mysql"
2017-11-29 10:25:52 +01:00
Manfred Touron
86f6e87efe Add audit log 2017-11-29 09:17:19 +01:00
Manfred Touron
e377cac8e6 Ignore some errors when logging closed connections 2017-11-28 20:08:31 +01:00
Manfred Touron
0fbcc0dd41 Session management 2017-11-27 08:52:33 +01:00
Manfred Touron
1fdf37dc07 Create Session objects on each connections (history) 2017-11-27 08:22:13 +01:00
Manfred Touron
4cf73e3410 Moved demo code in the README as example 2017-11-27 08:09:22 +01:00
Manfred Touron
328bb0153b Add session model 2017-11-27 07:43:52 +01:00
Manfred Touron
1ddd6867b6 Post-release version bump 2017-11-24 15:22:50 +01:00
Manfred Touron
2becd5eec2 v1.4.0 2017-11-24 15:22:22 +01:00
Manfred Touron
571b37da6b Add option to encrypt sensitive data 2017-11-24 15:15:24 +01:00
Manfred Touron
01d464f4c5 Sort items by created_at in 'ls' commands 2017-11-24 07:27:38 +01:00
Manfred Touron
bf184c621d Merge branch 'dev/moul/timeago'
* dev/moul/timeago:
  Add Updated and Created fields in 'ls' commands
  govendor add github.com/dustin/go-humanize
2017-11-24 06:48:07 +01:00
Manfred Touron
f4309f843b Add Updated and Created fields in 'ls' commands 2017-11-24 06:47:39 +01:00
Manfred Touron
cbdc231cbf govendor add github.com/dustin/go-humanize 2017-11-24 06:46:54 +01:00
Manfred Touron
0f0a8dd9bb Add 'key setup' command (easy SSH key installation) 2017-11-24 05:04:22 +01:00
Manfred Touron
4189eb8154 Update README.md 2017-11-23 19:06:30 +01:00
Manfred Touron
1d6349767d Post-commit version bump 2017-11-23 19:04:57 +01:00
Manfred Touron
f6ba06298d v1.3.0 2017-11-23 19:04:00 +01:00
Manfred Touron
31a8cef59f Merge pull request #9 from moul/dev/moul/roles
Support multiple roles
2017-11-23 19:00:12 +01:00
Manfred Touron
beeba0551b Add 'listhosts' role (fix #5) 2017-11-23 18:59:59 +01:00
Manfred Touron
a36bb68957 Allow connecting to the shell mode with the registered username or email 2017-11-23 17:45:16 +01:00
Manfred Touron
9cd9152a91 Switch from IsAdmin boolean to Roles 2017-11-23 17:45:16 +01:00
Manfred Touron
09c1e0504e Add 'acl update' command (fix #4) 2017-11-23 12:01:17 +01:00
Manfred Touron
37d7c839dd Add 'user update' command (fix #3) 2017-11-23 11:36:24 +01:00
Manfred Touron
8ba418308e Merge pull request #8 from moul/dev/moul/wip-update
Add 'host update'
2017-11-23 10:36:34 +01:00
Manfred Touron
cfcf124d83 Improve CLI help messages 2017-11-23 10:35:51 +01:00
Manfred Touron
ccb0071d12 Add Add 'host update' command (fix #2) 2017-11-23 10:15:28 +01:00
Manfred Touron
681f59c1e6 More details in 'ls' commands 2017-11-23 10:05:13 +01:00
Manfred Touron
1bdee1a107 Refactor database helpers 2017-11-23 09:58:32 +01:00
Manfred Touron
a2f3a51fe5 Post-release version bump 2017-11-22 15:16:24 +01:00
Manfred Touron
98d4360a76 v1.2.0 2017-11-22 15:15:30 +01:00
Manfred Touron
15c58c99b2 Small typo 2017-11-20 11:12:58 +01:00
Manfred Touron
e6198e16e5 Testing backup/restore/backup/diff in integration 2017-11-20 10:24:30 +01:00
Manfred Touron
97d166ad8f Enable debug in integration 2017-11-19 01:30:37 +01:00
Manfred Touron
fb4ca3d219 Use a database migration system 2017-11-19 01:30:35 +01:00
Manfred Touron
62aea661cc Backup/restore settings 2017-11-19 01:30:16 +01:00
Manfred Touron
f33326db4d govendor add github.com/go-gormigrate/gormigrate 2017-11-19 01:30:15 +01:00
Manfred Touron
c5d00728d0 $> govendor add github.com/asaskevich/govalidator 2017-11-19 01:30:15 +01:00
Manfred Touron
1591cbc208 Use govalidator to perform more consistent input validation 2017-11-19 01:30:13 +01:00
Manfred Touron
58d9aef616 Test ACLs/hostgroups/usergroups 2017-11-15 19:17:14 +01:00
Manfred Touron
a087cdad09 Store and compare version in database 2017-11-15 19:17:14 +01:00
Manfred Touron
7f754e2ab9 Add basic integration tests 2017-11-15 19:17:14 +01:00
Manfred Touron
2261d27c94 Allow to set the first invite token (for testing) 2017-11-15 19:17:14 +01:00
Manfred Touron
f97c9f2878 Support assign multiple groups to hosts and users (#2) 2017-11-15 19:16:55 +01:00
Manfred Touron
d6a7a6702f Post-release version bump 2017-11-14 11:16:24 +01:00
Manfred Touron
abba8dc990 Show how to expose port with Docker 2017-11-14 10:15:40 +01:00
Manfred Touron
602514f517 Add Docker upgrade instructions 2017-11-14 10:12:25 +01:00
Manfred Touron
8db6afe8b9 Add 'make backup' dev helper 2017-11-14 09:57:59 +01:00
132 changed files with 11224 additions and 1059 deletions

BIN
.assets/client.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

30
.assets/cluster-mysql.dot Normal file
View File

@@ -0,0 +1,30 @@
graph {
rankdir=LR;
subgraph cluster_sshportal {
label="sshportal cluster";
edge[style=dashed,color=grey,constraint=false];
sshportal1; sshportal2; sshportal3; sshportalN;
sshportal1 -- MySQL;
sshportal2 -- MySQL;
sshportal3 -- MySQL;
sshportalN -- MySQL;
}
subgraph cluster_hosts {
label="hosts";
host1; host2; host3; hostN;
}
subgraph cluster_users {
label="users";
user1; user2; user3; userN;
}
{
user1 -- sshportal1 -- host1[color=red,penwidth=3.0];
user2 -- sshportal2 -- host2[color=green,penwidth=3.0];
user3 -- sshportal3 -- host3[color=blue,penwidth=3.0];
user3 -- sshportal2 -- host1[color=purple,penwidth=3.0];
userN -- sshportalN -- hostN[style=dotted];
}
}

176
.assets/cluster-mysql.svg Normal file
View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.40.1 (20161225.0304)
-->
<!-- Title: %3 Pages: 1 -->
<svg width="334pt" height="314pt"
viewBox="0.00 0.00 333.78 314.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 310)">
<title>%3</title>
<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-310 329.7751,-310 329.7751,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_sshportal</title>
<polygon fill="none" stroke="#000000" points="106.4533,-8 106.4533,-298 219.3045,-298 219.3045,-8 106.4533,-8"/>
<text text-anchor="middle" x="162.8789" y="-282.8" font-family="Times,serif" font-size="14.00" fill="#000000">sshportal cluster</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_hosts</title>
<polygon fill="none" stroke="#000000" points="239.3045,-62 239.3045,-298 317.7751,-298 317.7751,-62 239.3045,-62"/>
<text text-anchor="middle" x="278.5398" y="-282.8" font-family="Times,serif" font-size="14.00" fill="#000000">hosts</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_users</title>
<polygon fill="none" stroke="#000000" points="8,-62 8,-298 86.4533,-298 86.4533,-62 8,-62"/>
<text text-anchor="middle" x="47.2266" y="-282.8" font-family="Times,serif" font-size="14.00" fill="#000000">users</text>
</g>
<!-- sshportal1 -->
<g id="node1" class="node">
<title>sshportal1</title>
<ellipse fill="none" stroke="#000000" cx="162.8789" cy="-250" rx="46.4218" ry="18"/>
<text text-anchor="middle" x="162.8789" y="-245.8" font-family="Times,serif" font-size="14.00" fill="#000000">sshportal1</text>
</g>
<!-- MySQL -->
<g id="node5" class="node">
<title>MySQL</title>
<ellipse fill="none" stroke="#000000" cx="162.8789" cy="-34" rx="39.1973" ry="18"/>
<text text-anchor="middle" x="162.8789" y="-29.8" font-family="Times,serif" font-size="14.00" fill="#000000">MySQL</text>
</g>
<!-- sshportal1&#45;&#45;MySQL -->
<g id="edge1" class="edge">
<title>sshportal1&#45;&#45;MySQL</title>
<path fill="none" stroke="#c0c0c0" stroke-dasharray="5,2" d="M124.8869,-239.5365C113.0929,-234.0967 101.7898,-225.9823 96.4533,-214 83.4344,-184.768 83.4344,-99.232 96.4533,-70 102.3234,-56.8194 115.4132,-48.3192 128.4343,-42.9106"/>
</g>
<!-- host1 -->
<g id="node6" class="node">
<title>host1</title>
<ellipse fill="none" stroke="#000000" cx="278.5398" cy="-250" rx="29.0429" ry="18"/>
<text text-anchor="middle" x="278.5398" y="-245.8" font-family="Times,serif" font-size="14.00" fill="#000000">host1</text>
</g>
<!-- sshportal1&#45;&#45;host1 -->
<g id="edge6" class="edge">
<title>sshportal1&#45;&#45;host1</title>
<path fill="none" stroke="#ff0000" stroke-width="3" d="M209.5892,-250C222.8807,-250 236.9866,-250 248.9537,-250"/>
</g>
<!-- sshportal2 -->
<g id="node2" class="node">
<title>sshportal2</title>
<ellipse fill="none" stroke="#000000" cx="162.8789" cy="-196" rx="46.4218" ry="18"/>
<text text-anchor="middle" x="162.8789" y="-191.8" font-family="Times,serif" font-size="14.00" fill="#000000">sshportal2</text>
</g>
<!-- sshportal2&#45;&#45;MySQL -->
<g id="edge2" class="edge">
<title>sshportal2&#45;&#45;MySQL</title>
<path fill="none" stroke="#c0c0c0" stroke-dasharray="5,2" d="M124.8869,-185.5365C113.0929,-180.0967 101.7898,-171.9823 96.4533,-160 88.3165,-141.73 88.3165,-88.27 96.4533,-70 102.3234,-56.8194 115.4132,-48.3192 128.4343,-42.9106"/>
</g>
<!-- sshportal2&#45;&#45;host1 -->
<g id="edge12" class="edge">
<title>sshportal2&#45;&#45;host1</title>
<path fill="none" stroke="#a020f0" stroke-width="3" d="M192.6645,-209.9063C212.0749,-218.9687 237.0472,-230.6278 255.0535,-239.0347"/>
</g>
<!-- host2 -->
<g id="node7" class="node">
<title>host2</title>
<ellipse fill="none" stroke="#000000" cx="278.5398" cy="-196" rx="29.0429" ry="18"/>
<text text-anchor="middle" x="278.5398" y="-191.8" font-family="Times,serif" font-size="14.00" fill="#000000">host2</text>
</g>
<!-- sshportal2&#45;&#45;host2 -->
<g id="edge8" class="edge">
<title>sshportal2&#45;&#45;host2</title>
<path fill="none" stroke="#00ff00" stroke-width="3" d="M209.5892,-196C222.8807,-196 236.9866,-196 248.9537,-196"/>
</g>
<!-- sshportal3 -->
<g id="node3" class="node">
<title>sshportal3</title>
<ellipse fill="none" stroke="#000000" cx="162.8789" cy="-142" rx="46.4218" ry="18"/>
<text text-anchor="middle" x="162.8789" y="-137.8" font-family="Times,serif" font-size="14.00" fill="#000000">sshportal3</text>
</g>
<!-- sshportal3&#45;&#45;MySQL -->
<g id="edge3" class="edge">
<title>sshportal3&#45;&#45;MySQL</title>
<path fill="none" stroke="#c0c0c0" stroke-dasharray="5,2" d="M124.8869,-131.5365C113.0929,-126.0967 101.7898,-117.9823 96.4533,-106 89.9439,-91.384 89.9439,-84.616 96.4533,-70 102.3234,-56.8194 115.4132,-48.3192 128.4343,-42.9106"/>
</g>
<!-- host3 -->
<g id="node8" class="node">
<title>host3</title>
<ellipse fill="none" stroke="#000000" cx="278.5398" cy="-142" rx="29.0429" ry="18"/>
<text text-anchor="middle" x="278.5398" y="-137.8" font-family="Times,serif" font-size="14.00" fill="#000000">host3</text>
</g>
<!-- sshportal3&#45;&#45;host3 -->
<g id="edge10" class="edge">
<title>sshportal3&#45;&#45;host3</title>
<path fill="none" stroke="#0000ff" stroke-width="3" d="M209.5892,-142C222.8807,-142 236.9866,-142 248.9537,-142"/>
</g>
<!-- sshportalN -->
<g id="node4" class="node">
<title>sshportalN</title>
<ellipse fill="none" stroke="#000000" cx="162.8789" cy="-88" rx="48.3514" ry="18"/>
<text text-anchor="middle" x="162.8789" y="-83.8" font-family="Times,serif" font-size="14.00" fill="#000000">sshportalN</text>
</g>
<!-- sshportalN&#45;&#45;MySQL -->
<g id="edge4" class="edge">
<title>sshportalN&#45;&#45;MySQL</title>
<path fill="none" stroke="#c0c0c0" stroke-dasharray="5,2" d="M162.8789,-69.7902C162.8789,-63.907 162.8789,-58.0238 162.8789,-52.1406"/>
</g>
<!-- hostN -->
<g id="node9" class="node">
<title>hostN</title>
<ellipse fill="none" stroke="#000000" cx="278.5398" cy="-88" rx="31.4723" ry="18"/>
<text text-anchor="middle" x="278.5398" y="-83.8" font-family="Times,serif" font-size="14.00" fill="#000000">hostN</text>
</g>
<!-- sshportalN&#45;&#45;hostN -->
<g id="edge14" class="edge">
<title>sshportalN&#45;&#45;hostN</title>
<path fill="none" stroke="#000000" stroke-dasharray="1,5" d="M211.5943,-88C223.5713,-88 236.0833,-88 247.0054,-88"/>
</g>
<!-- user1 -->
<g id="node10" class="node">
<title>user1</title>
<ellipse fill="none" stroke="#000000" cx="47.2266" cy="-250" rx="29.0257" ry="18"/>
<text text-anchor="middle" x="47.2266" y="-245.8" font-family="Times,serif" font-size="14.00" fill="#000000">user1</text>
</g>
<!-- user1&#45;&#45;sshportal1 -->
<g id="edge5" class="edge">
<title>user1&#45;&#45;sshportal1</title>
<path fill="none" stroke="#ff0000" stroke-width="3" d="M76.7098,-250C88.7561,-250 102.9825,-250 116.3672,-250"/>
</g>
<!-- user2 -->
<g id="node11" class="node">
<title>user2</title>
<ellipse fill="none" stroke="#000000" cx="47.2266" cy="-196" rx="29.0257" ry="18"/>
<text text-anchor="middle" x="47.2266" y="-191.8" font-family="Times,serif" font-size="14.00" fill="#000000">user2</text>
</g>
<!-- user2&#45;&#45;sshportal2 -->
<g id="edge7" class="edge">
<title>user2&#45;&#45;sshportal2</title>
<path fill="none" stroke="#00ff00" stroke-width="3" d="M76.7098,-196C88.7561,-196 102.9825,-196 116.3672,-196"/>
</g>
<!-- user3 -->
<g id="node12" class="node">
<title>user3</title>
<ellipse fill="none" stroke="#000000" cx="47.2266" cy="-142" rx="29.0257" ry="18"/>
<text text-anchor="middle" x="47.2266" y="-137.8" font-family="Times,serif" font-size="14.00" fill="#000000">user3</text>
</g>
<!-- user3&#45;&#45;sshportal2 -->
<g id="edge11" class="edge">
<title>user3&#45;&#45;sshportal2</title>
<path fill="none" stroke="#a020f0" stroke-width="3" d="M70.6306,-152.9277C88.5836,-161.3103 113.4965,-172.9425 132.9075,-182.0059"/>
</g>
<!-- user3&#45;&#45;sshportal3 -->
<g id="edge9" class="edge">
<title>user3&#45;&#45;sshportal3</title>
<path fill="none" stroke="#0000ff" stroke-width="3" d="M76.7098,-142C88.7561,-142 102.9825,-142 116.3672,-142"/>
</g>
<!-- userN -->
<g id="node13" class="node">
<title>userN</title>
<ellipse fill="none" stroke="#000000" cx="47.2266" cy="-88" rx="31.4549" ry="18"/>
<text text-anchor="middle" x="47.2266" y="-83.8" font-family="Times,serif" font-size="14.00" fill="#000000">userN</text>
</g>
<!-- userN&#45;&#45;sshportalN -->
<g id="edge13" class="edge">
<title>userN&#45;&#45;sshportalN</title>
<path fill="none" stroke="#000000" stroke-dasharray="1,5" d="M78.5238,-88C89.4948,-88 102.0923,-88 114.1488,-88"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
.assets/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

65
.assets/flow-diagram.dot Normal file
View File

@@ -0,0 +1,65 @@
digraph {
rankdir=LR;
layout=dot;
node[shape=record];
start[label="ssh sshportal";color=blue;fontcolor=blue;fontsize=20];
subgraph cluster_sshportal {
graph[fontsize=20;style=dashed;color=purple;fontcolor=purple];
label="sshportal";
{
node[color=darkorange;fontcolor=darkorange];
known_user_key[label="known user key"];
unknown_user_key[label="unknown user key"];
invite_manager[label="invite manager"];
acl_manager[label="ACL manager"];
}
{
node[color=darkgreen;fontcolor=darkgreen];
builtin_shell[label="built-in shell"];
ssh_proxy[label="SSH proxy"];
learn_key[label="learn key"];
}
err_and_exit[label="error and exit";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];
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;
start -> unknown_user_key;
ssh_proxy -> host_1;
ssh_proxy -> host_2;
ssh_proxy -> host_3;
}
{
edge[color=darkgreen;fontcolor=darkgreen];
known_user_key -> builtin_shell[label="user=admin"];
acl_manager -> ssh_proxy[label="authorized"];
invite_manager -> learn_key[label="valid token"];
}
{
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>"];
}
{
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"];
}
}

188
.assets/flow-diagram.svg Normal file
View File

@@ -0,0 +1,188 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.40.1 (20161225.0304)
-->
<!-- Title: %3 Pages: 1 -->
<svg width="1026pt" height="312pt"
viewBox="0.00 0.00 1026.42 312.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 308)">
<title>%3</title>
<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-308 1022.4219,-308 1022.4219,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_sshportal</title>
<polygon fill="none" stroke="#a020f0" stroke-dasharray="5,2" points="147.7832,-8 147.7832,-296 858.9775,-296 858.9775,-8 147.7832,-8"/>
<text text-anchor="middle" x="503.3804" y="-276" font-family="Times,serif" font-size="20.00" fill="#a020f0">sshportal</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_hosts</title>
<polygon fill="none" stroke="#a020f0" stroke-dasharray="5,2" points="879.9775,-104 879.9775,-296 1010.4219,-296 1010.4219,-104 879.9775,-104"/>
<text text-anchor="middle" x="945.1997" y="-276" font-family="Times,serif" font-size="20.00" fill="#a020f0">your hosts</text>
</g>
<!-- start -->
<g id="node1" class="node">
<title>start</title>
<polygon fill="none" stroke="#0000ff" points="0,-118 0,-154 118.7832,-154 118.7832,-118 0,-118"/>
<text text-anchor="middle" x="59.3916" y="-130" font-family="Times,serif" font-size="20.00" fill="#0000ff">ssh sshportal</text>
</g>
<!-- known_user_key -->
<g id="node2" class="node">
<title>known_user_key</title>
<polygon fill="none" stroke="#ff8c00" points="162.7832,-157 162.7832,-193 267.4316,-193 267.4316,-157 162.7832,-157"/>
<text text-anchor="middle" x="215.1074" y="-170.8" font-family="Times,serif" font-size="14.00" fill="#ff8c00">known user key</text>
</g>
<!-- start&#45;&gt;known_user_key -->
<g id="edge1" class="edge">
<title>start&#45;&gt;known_user_key</title>
<path fill="none" stroke="#0000ff" d="M119.1501,-150.9669C130.1162,-153.7134 141.5894,-156.587 152.6326,-159.3528"/>
<polygon fill="#0000ff" stroke="#0000ff" points="152.0758,-162.8214 162.6266,-161.8558 153.7765,-156.0311 152.0758,-162.8214"/>
</g>
<!-- unknown_user_key -->
<g id="node3" class="node">
<title>unknown_user_key</title>
<polygon fill="none" stroke="#ff8c00" points="155.7832,-72 155.7832,-108 274.4316,-108 274.4316,-72 155.7832,-72"/>
<text text-anchor="middle" x="215.1074" y="-85.8" font-family="Times,serif" font-size="14.00" fill="#ff8c00">unknown user key</text>
</g>
<!-- start&#45;&gt;unknown_user_key -->
<g id="edge2" class="edge">
<title>start&#45;&gt;unknown_user_key</title>
<path fill="none" stroke="#0000ff" d="M119.1501,-118.3468C127.968,-115.7419 137.1138,-113.0401 146.1003,-110.3854"/>
<polygon fill="#0000ff" stroke="#0000ff" points="147.1673,-113.7198 155.766,-107.5301 145.1841,-107.0066 147.1673,-113.7198"/>
</g>
<!-- acl_manager -->
<g id="node5" class="node">
<title>acl_manager</title>
<polygon fill="none" stroke="#ff8c00" points="514.7056,-173 514.7056,-209 609.8862,-209 609.8862,-173 514.7056,-173"/>
<text text-anchor="middle" x="562.2959" y="-186.8" font-family="Times,serif" font-size="14.00" fill="#ff8c00">ACL manager</text>
</g>
<!-- known_user_key&#45;&gt;acl_manager -->
<g id="edge9" class="edge">
<title>known_user_key&#45;&gt;acl_manager</title>
<path fill="none" stroke="#ff8c00" d="M267.461,-177.4127C331.1153,-180.3462 438.21,-185.2816 504.3082,-188.3277"/>
<polygon fill="#ff8c00" stroke="#ff8c00" points="504.401,-191.8356 514.5516,-188.7997 504.7233,-184.843 504.401,-191.8356"/>
<text text-anchor="middle" x="393.4697" y="-188.8" font-family="Times,serif" font-size="14.00" fill="#ff8c00">user matches an existing host</text>
</g>
<!-- builtin_shell -->
<g id="node6" class="node">
<title>builtin_shell</title>
<polygon fill="none" stroke="#006400" points="761.6929,-223 761.6929,-259 848.855,-259 848.855,-223 761.6929,-223"/>
<text text-anchor="middle" x="805.2739" y="-236.8" font-family="Times,serif" font-size="14.00" fill="#006400">built&#45;in shell</text>
</g>
<!-- known_user_key&#45;&gt;builtin_shell -->
<g id="edge6" class="edge">
<title>known_user_key&#45;&gt;builtin_shell</title>
<path fill="none" stroke="#006400" d="M267.592,-193.0548C281.6792,-197.2785 297.0081,-201.3215 311.4316,-204 469.5409,-233.361 660.2348,-239.5693 751.4965,-240.7835"/>
<polygon fill="#006400" stroke="#006400" points="751.5568,-244.2844 761.5974,-240.9027 751.6394,-237.2848 751.5568,-244.2844"/>
<text text-anchor="middle" x="562.2959" y="-238.8" font-family="Times,serif" font-size="14.00" fill="#006400">user=admin</text>
</g>
<!-- err_and_exit -->
<g id="node9" class="node">
<title>err_and_exit</title>
<polygon fill="none" stroke="#ff0000" points="759.5703,-106 759.5703,-142 850.9775,-142 850.9775,-106 759.5703,-106"/>
<text text-anchor="middle" x="805.2739" y="-119.8" font-family="Times,serif" font-size="14.00" fill="#ff0000">error and exit</text>
</g>
<!-- known_user_key&#45;&gt;err_and_exit -->
<g id="edge11" class="edge">
<title>known_user_key&#45;&gt;err_and_exit</title>
<path fill="none" stroke="#ff0000" d="M267.4808,-170.4741C378.1362,-160.9117 634.8943,-138.7236 748.9418,-128.868"/>
<polygon fill="#ff0000" stroke="#ff0000" points="749.5354,-132.3298 759.1969,-127.9818 748.9327,-125.3558 749.5354,-132.3298"/>
<text text-anchor="middle" x="562.2959" y="-151.8" font-family="Times,serif" font-size="14.00" fill="#ff0000">invalid user</text>
</g>
<!-- invite_manager -->
<g id="node4" class="node">
<title>invite_manager</title>
<polygon fill="none" stroke="#ff8c00" points="512.5078,-17 512.5078,-53 612.084,-53 612.084,-17 512.5078,-17"/>
<text text-anchor="middle" x="562.2959" y="-30.8" font-family="Times,serif" font-size="14.00" fill="#ff8c00">invite manager</text>
</g>
<!-- unknown_user_key&#45;&gt;invite_manager -->
<g id="edge10" class="edge">
<title>unknown_user_key&#45;&gt;invite_manager</title>
<path fill="none" stroke="#ff8c00" d="M274.7912,-80.5452C338.467,-70.4579 438.7527,-54.5711 502.4793,-44.4759"/>
<polygon fill="#ff8c00" stroke="#ff8c00" points="503.0528,-47.9288 512.382,-42.9071 501.9575,-41.015 503.0528,-47.9288"/>
<text text-anchor="middle" x="455.4386" y="-31.7071" font-family="Times,serif" font-size="14.00" fill="#ff8c00">user=invite:&lt;token&gt;</text>
</g>
<!-- unknown_user_key&#45;&gt;err_and_exit -->
<g id="edge13" class="edge">
<title>unknown_user_key&#45;&gt;err_and_exit</title>
<path fill="none" stroke="#ff0000" d="M274.4978,-89.2935C352.2933,-89.0083 492.8294,-90.6942 612.084,-104 628.7169,-105.8558 632.5001,-108.7473 649.084,-111 682.1267,-115.4884 719.327,-118.6586 749.132,-120.7442"/>
<polygon fill="#ff0000" stroke="#ff0000" points="749.133,-124.2522 759.347,-121.437 749.6068,-117.2683 749.133,-124.2522"/>
<text text-anchor="middle" x="562.2959" y="-106.8" font-family="Times,serif" font-size="14.00" fill="#ff0000">any other user</text>
</g>
<!-- learn_key -->
<g id="node8" class="node">
<title>learn_key</title>
<polygon fill="none" stroke="#006400" points="771.4272,-17 771.4272,-53 839.1206,-53 839.1206,-17 771.4272,-17"/>
<text text-anchor="middle" x="805.2739" y="-30.8" font-family="Times,serif" font-size="14.00" fill="#006400">learn key</text>
</g>
<!-- invite_manager&#45;&gt;learn_key -->
<g id="edge8" class="edge">
<title>invite_manager&#45;&gt;learn_key</title>
<path fill="none" stroke="#006400" d="M612.3465,-35C656.1463,-35 719.1598,-35 761.1155,-35"/>
<polygon fill="#006400" stroke="#006400" points="761.3041,-38.5001 771.3041,-35 761.304,-31.5001 761.3041,-38.5001"/>
<text text-anchor="middle" x="685.8271" y="-37.8" font-family="Times,serif" font-size="14.00" fill="#006400">valid token</text>
</g>
<!-- invite_manager&#45;&gt;err_and_exit -->
<g id="edge14" class="edge">
<title>invite_manager&#45;&gt;err_and_exit</title>
<path fill="none" stroke="#ff0000" d="M611.4661,-53.0105C651.6045,-67.7127 708.3017,-88.4802 750.0066,-103.7562"/>
<polygon fill="#ff0000" stroke="#ff0000" points="748.8708,-107.0676 759.4646,-107.2206 751.2785,-100.4946 748.8708,-107.0676"/>
<text text-anchor="middle" x="685.8271" y="-95.8" font-family="Times,serif" font-size="14.00" fill="#ff0000">invalid token</text>
</g>
<!-- ssh_proxy -->
<g id="node7" class="node">
<title>ssh_proxy</title>
<polygon fill="none" stroke="#006400" points="766.3516,-168 766.3516,-204 844.1963,-204 844.1963,-168 766.3516,-168"/>
<text text-anchor="middle" x="805.2739" y="-181.8" font-family="Times,serif" font-size="14.00" fill="#006400">SSH proxy</text>
</g>
<!-- acl_manager&#45;&gt;ssh_proxy -->
<g id="edge7" class="edge">
<title>acl_manager&#45;&gt;ssh_proxy</title>
<path fill="none" stroke="#006400" d="M610.0008,-192.3563C641.8818,-193.0022 684.7518,-193.37 722.5703,-192 733.3636,-191.609 744.9337,-190.9319 755.8983,-190.1699"/>
<polygon fill="#006400" stroke="#006400" points="756.4612,-193.6382 766.18,-189.4199 755.9519,-186.6568 756.4612,-193.6382"/>
<text text-anchor="middle" x="685.8271" y="-194.8" font-family="Times,serif" font-size="14.00" fill="#006400">authorized</text>
</g>
<!-- acl_manager&#45;&gt;err_and_exit -->
<g id="edge12" class="edge">
<title>acl_manager&#45;&gt;err_and_exit</title>
<path fill="none" stroke="#ff0000" d="M610.264,-178.009C646.3866,-168.197 697.1155,-154.3556 741.5703,-142 744.1794,-141.2748 746.8478,-140.5307 749.5426,-139.7772"/>
<polygon fill="#ff0000" stroke="#ff0000" points="750.6733,-143.0952 759.3567,-137.025 748.7831,-136.3552 750.6733,-143.0952"/>
<text text-anchor="middle" x="685.8271" y="-169.8" font-family="Times,serif" font-size="14.00" fill="#ff0000">unauthorized</text>
</g>
<!-- host_1 -->
<g id="node10" class="node">
<title>host_1</title>
<polygon fill="none" stroke="#0000ff" points="904.3086,-223 904.3086,-259 986.0908,-259 986.0908,-223 904.3086,-223"/>
<text text-anchor="middle" x="945.1997" y="-236.8" font-family="Times,serif" font-size="14.00" fill="#0000ff">root@host1</text>
</g>
<!-- ssh_proxy&#45;&gt;host_1 -->
<g id="edge3" class="edge">
<title>ssh_proxy&#45;&gt;host_1</title>
<path fill="none" stroke="#0000ff" d="M844.2511,-201.3206C859.7986,-207.4318 877.9046,-214.5486 894.4551,-221.054"/>
<polygon fill="#0000ff" stroke="#0000ff" points="893.4017,-224.4006 903.9889,-224.8015 895.9624,-217.8858 893.4017,-224.4006"/>
</g>
<!-- host_2 -->
<g id="node11" class="node">
<title>host_2</title>
<polygon fill="none" stroke="#0000ff" points="887.9775,-168 887.9775,-204 1002.4219,-204 1002.4219,-168 887.9775,-168"/>
<text text-anchor="middle" x="945.1997" y="-181.8" font-family="Times,serif" font-size="14.00" fill="#0000ff">user@host2:2222</text>
</g>
<!-- ssh_proxy&#45;&gt;host_2 -->
<g id="edge4" class="edge">
<title>ssh_proxy&#45;&gt;host_2</title>
<path fill="none" stroke="#0000ff" d="M844.2511,-186C854.6959,-186 866.2954,-186 877.8023,-186"/>
<polygon fill="#0000ff" stroke="#0000ff" points="877.8592,-189.5001 887.8591,-186 877.8591,-182.5001 877.8592,-189.5001"/>
</g>
<!-- host_3 -->
<g id="node12" class="node">
<title>host_3</title>
<polygon fill="none" stroke="#0000ff" points="888.3638,-113 888.3638,-149 1002.0356,-149 1002.0356,-113 888.3638,-113"/>
<text text-anchor="middle" x="945.1997" y="-126.8" font-family="Times,serif" font-size="14.00" fill="#0000ff">root@host3:1234</text>
</g>
<!-- ssh_proxy&#45;&gt;host_3 -->
<g id="edge5" class="edge">
<title>ssh_proxy&#45;&gt;host_3</title>
<path fill="none" stroke="#0000ff" d="M844.2511,-170.6794C858.381,-165.1255 874.624,-158.7409 889.8921,-152.7395"/>
<polygon fill="#0000ff" stroke="#0000ff" points="891.2185,-155.9789 899.245,-149.0632 888.6578,-149.4641 891.2185,-155.9789"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

36
.assets/overview.dot Normal file
View File

@@ -0,0 +1,36 @@
graph {
rankdir=LR;
node[shape=box,style=rounded,style=rounded,fillcolor=gray];
subgraph cluster_sshportal {
sshportal[penwidth=3.0,color=brown,fontcolor=brown,fontsize=20];
shell[label="built-in\nadmin shell",color=orange,fontcolor=orange];
db[color=gray,fontcolor=gray,shape=circle];
{ rank=same; db; sshportal; shell }
}
{
node[color="green"];
host1; host2; host3; hostN;
}
{
node[color="blue"];
user1; user2; user3; userN;
}
{
edge[penwidth=3.0];
user1 -- sshportal -- host1[color=red];
user2 -- sshportal -- host2[color=blue];
user3 -- sshportal -- host1[color=purple];
user2 -- sshportal -- host3[color=green];
user2 -- sshportal -- shell[color=orange,constraint=false];
}
userN -- sshportal[style=dotted];
sshportal -- hostN[style=dotted];
sshportal -- db[style=dotted,color=grey];
}

149
.assets/overview.svg Normal file
View File

@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.40.1 (20161225.0304)
-->
<!-- Title: %3 Pages: 1 -->
<svg width="276pt" height="224pt"
viewBox="0.00 0.00 276.22 224.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 220)">
<title>%3</title>
<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-220 272.2168,-220 272.2168,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_sshportal</title>
<polygon fill="none" stroke="#000000" points="82,-46 82,-208 186.2168,-208 186.2168,-46 82,-46"/>
</g>
<!-- sshportal -->
<g id="node1" class="node">
<title>sshportal</title>
<path fill="none" stroke="#a52a2a" stroke-width="3" d="M166.3255,-144C166.3255,-144 101.8913,-144 101.8913,-144 95.8913,-144 89.8913,-138 89.8913,-132 89.8913,-132 89.8913,-120 89.8913,-120 89.8913,-114 95.8913,-108 101.8913,-108 101.8913,-108 166.3255,-108 166.3255,-108 172.3255,-108 178.3255,-114 178.3255,-120 178.3255,-120 178.3255,-132 178.3255,-132 178.3255,-138 172.3255,-144 166.3255,-144"/>
<text text-anchor="middle" x="134.1084" y="-120" font-family="Times,serif" font-size="20.00" fill="#a52a2a">sshportal</text>
</g>
<!-- shell -->
<g id="node2" class="node">
<title>shell</title>
<path fill="none" stroke="#ffa500" d="M162.543,-90C162.543,-90 105.6738,-90 105.6738,-90 99.6738,-90 93.6738,-84 93.6738,-78 93.6738,-78 93.6738,-66 93.6738,-66 93.6738,-60 99.6738,-54 105.6738,-54 105.6738,-54 162.543,-54 162.543,-54 168.543,-54 174.543,-60 174.543,-66 174.543,-66 174.543,-78 174.543,-78 174.543,-84 168.543,-90 162.543,-90"/>
<text text-anchor="middle" x="134.1084" y="-74.8" font-family="Times,serif" font-size="14.00" fill="#ffa500">built&#45;in</text>
<text text-anchor="middle" x="134.1084" y="-60.8" font-family="Times,serif" font-size="14.00" fill="#ffa500">admin shell</text>
</g>
<!-- sshportal&#45;&#45;shell -->
<g id="edge10" class="edge">
<title>sshportal&#45;&#45;shell</title>
<path fill="none" stroke="#ffa500" stroke-width="3" d="M134.1084,-107.7902C134.1084,-101.907 134.1084,-96.0238 134.1084,-90.1406"/>
</g>
<!-- db -->
<g id="node3" class="node">
<title>db</title>
<ellipse fill="none" stroke="#c0c0c0" cx="134.1084" cy="-181" rx="18.9007" ry="18.9007"/>
<text text-anchor="middle" x="134.1084" y="-176.8" font-family="Times,serif" font-size="14.00" fill="#c0c0c0">db</text>
</g>
<!-- sshportal&#45;&#45;db -->
<g id="edge13" class="edge">
<title>sshportal&#45;&#45;db</title>
<path fill="none" stroke="#c0c0c0" stroke-dasharray="1,5" d="M134.1084,-144.0469C134.1084,-150.0133 134.1084,-155.9797 134.1084,-161.946"/>
</g>
<!-- host1 -->
<g id="node4" class="node">
<title>host1</title>
<path fill="none" stroke="#00ff00" d="M256.2168,-198C256.2168,-198 226.2168,-198 226.2168,-198 220.2168,-198 214.2168,-192 214.2168,-186 214.2168,-186 214.2168,-174 214.2168,-174 214.2168,-168 220.2168,-162 226.2168,-162 226.2168,-162 256.2168,-162 256.2168,-162 262.2168,-162 268.2168,-168 268.2168,-174 268.2168,-174 268.2168,-186 268.2168,-186 268.2168,-192 262.2168,-198 256.2168,-198"/>
<text text-anchor="middle" x="241.2168" y="-175.8" font-family="Times,serif" font-size="14.00" fill="#000000">host1</text>
</g>
<!-- sshportal&#45;&#45;host1 -->
<g id="edge2" class="edge">
<title>sshportal&#45;&#45;host1</title>
<path fill="none" stroke="#ff0000" stroke-width="3" d="M178.2919,-141.6183C191.4305,-147.98 205.3457,-155.29 216.7405,-161.8863"/>
</g>
<!-- sshportal&#45;&#45;host1 -->
<g id="edge6" class="edge">
<title>sshportal&#45;&#45;host1</title>
<path fill="none" stroke="#a020f0" stroke-width="3" d="M158.413,-144.0143C174.9543,-153.6007 196.8661,-164.7159 213.9941,-172.2404"/>
</g>
<!-- host2 -->
<g id="node5" class="node">
<title>host2</title>
<path fill="none" stroke="#00ff00" d="M256.2168,-144C256.2168,-144 226.2168,-144 226.2168,-144 220.2168,-144 214.2168,-138 214.2168,-132 214.2168,-132 214.2168,-120 214.2168,-120 214.2168,-114 220.2168,-108 226.2168,-108 226.2168,-108 256.2168,-108 256.2168,-108 262.2168,-108 268.2168,-114 268.2168,-120 268.2168,-120 268.2168,-132 268.2168,-132 268.2168,-138 262.2168,-144 256.2168,-144"/>
<text text-anchor="middle" x="241.2168" y="-121.8" font-family="Times,serif" font-size="14.00" fill="#000000">host2</text>
</g>
<!-- sshportal&#45;&#45;host2 -->
<g id="edge4" class="edge">
<title>sshportal&#45;&#45;host2</title>
<path fill="none" stroke="#0000ff" stroke-width="3" d="M178.2919,-126C190.3932,-126 203.1534,-126 213.9962,-126"/>
</g>
<!-- host3 -->
<g id="node6" class="node">
<title>host3</title>
<path fill="none" stroke="#00ff00" d="M256.2168,-90C256.2168,-90 226.2168,-90 226.2168,-90 220.2168,-90 214.2168,-84 214.2168,-78 214.2168,-78 214.2168,-66 214.2168,-66 214.2168,-60 220.2168,-54 226.2168,-54 226.2168,-54 256.2168,-54 256.2168,-54 262.2168,-54 268.2168,-60 268.2168,-66 268.2168,-66 268.2168,-78 268.2168,-78 268.2168,-84 262.2168,-90 256.2168,-90"/>
<text text-anchor="middle" x="241.2168" y="-67.8" font-family="Times,serif" font-size="14.00" fill="#000000">host3</text>
</g>
<!-- sshportal&#45;&#45;host3 -->
<g id="edge8" class="edge">
<title>sshportal&#45;&#45;host3</title>
<path fill="none" stroke="#00ff00" stroke-width="3" d="M170.0719,-107.8686C184.4145,-100.6376 200.6507,-92.4519 213.9876,-85.728"/>
</g>
<!-- hostN -->
<g id="node7" class="node">
<title>hostN</title>
<path fill="none" stroke="#00ff00" d="M256.2168,-36C256.2168,-36 226.2168,-36 226.2168,-36 220.2168,-36 214.2168,-30 214.2168,-24 214.2168,-24 214.2168,-12 214.2168,-12 214.2168,-6 220.2168,0 226.2168,0 226.2168,0 256.2168,0 256.2168,0 262.2168,0 268.2168,-6 268.2168,-12 268.2168,-12 268.2168,-24 268.2168,-24 268.2168,-30 262.2168,-36 256.2168,-36"/>
<text text-anchor="middle" x="241.2168" y="-13.8" font-family="Times,serif" font-size="14.00" fill="#000000">hostN</text>
</g>
<!-- sshportal&#45;&#45;hostN -->
<g id="edge12" class="edge">
<title>sshportal&#45;&#45;hostN</title>
<path fill="none" stroke="#000000" stroke-dasharray="1,5" d="M175.0827,-107.9914C179.0963,-105.3082 182.9022,-102.3137 186.2168,-99 205.3358,-79.8865 198.2486,-66.8147 214.2168,-45 216.4095,-42.0045 218.9349,-39.0275 221.5425,-36.2043"/>
</g>
<!-- user1 -->
<g id="node8" class="node">
<title>user1</title>
<path fill="none" stroke="#0000ff" d="M42,-198C42,-198 12,-198 12,-198 6,-198 0,-192 0,-186 0,-186 0,-174 0,-174 0,-168 6,-162 12,-162 12,-162 42,-162 42,-162 48,-162 54,-168 54,-174 54,-174 54,-186 54,-186 54,-192 48,-198 42,-198"/>
<text text-anchor="middle" x="27" y="-175.8" font-family="Times,serif" font-size="14.00" fill="#000000">user1</text>
</g>
<!-- user1&#45;&#45;sshportal -->
<g id="edge1" class="edge">
<title>user1&#45;&#45;sshportal</title>
<path fill="none" stroke="#ff0000" stroke-width="3" d="M54.028,-166.3735C67.4141,-159.6248 83.7582,-151.3846 98.1822,-144.1126"/>
</g>
<!-- user2 -->
<g id="node9" class="node">
<title>user2</title>
<path fill="none" stroke="#0000ff" d="M42,-144C42,-144 12,-144 12,-144 6,-144 0,-138 0,-132 0,-132 0,-120 0,-120 0,-114 6,-108 12,-108 12,-108 42,-108 42,-108 48,-108 54,-114 54,-120 54,-120 54,-132 54,-132 54,-138 48,-144 42,-144"/>
<text text-anchor="middle" x="27" y="-121.8" font-family="Times,serif" font-size="14.00" fill="#000000">user2</text>
</g>
<!-- user2&#45;&#45;sshportal -->
<g id="edge3" class="edge">
<title>user2&#45;&#45;sshportal</title>
<path fill="none" stroke="#0000ff" stroke-width="3" d="M54.028,-114.0952C64.8112,-112.6824 77.514,-112.208 89.5863,-112.6721"/>
</g>
<!-- user2&#45;&#45;sshportal -->
<g id="edge7" class="edge">
<title>user2&#45;&#45;sshportal</title>
<path fill="none" stroke="#00ff00" stroke-width="3" d="M54.028,-126C64.8112,-126 77.514,-126 89.5863,-126"/>
</g>
<!-- user2&#45;&#45;sshportal -->
<g id="edge9" class="edge">
<title>user2&#45;&#45;sshportal</title>
<path fill="none" stroke="#ffa500" stroke-width="3" d="M54.028,-137.9048C64.8112,-139.3176 77.514,-139.792 89.5863,-139.3279"/>
</g>
<!-- user3 -->
<g id="node10" class="node">
<title>user3</title>
<path fill="none" stroke="#0000ff" d="M42,-90C42,-90 12,-90 12,-90 6,-90 0,-84 0,-78 0,-78 0,-66 0,-66 0,-60 6,-54 12,-54 12,-54 42,-54 42,-54 48,-54 54,-60 54,-66 54,-66 54,-78 54,-78 54,-84 48,-90 42,-90"/>
<text text-anchor="middle" x="27" y="-67.8" font-family="Times,serif" font-size="14.00" fill="#000000">user3</text>
</g>
<!-- user3&#45;&#45;sshportal -->
<g id="edge5" class="edge">
<title>user3&#45;&#45;sshportal</title>
<path fill="none" stroke="#a020f0" stroke-width="3" d="M54.028,-85.6265C67.4141,-92.3752 83.7582,-100.6154 98.1822,-107.8874"/>
</g>
<!-- userN -->
<g id="node11" class="node">
<title>userN</title>
<path fill="none" stroke="#0000ff" d="M42,-36C42,-36 12,-36 12,-36 6,-36 0,-30 0,-24 0,-24 0,-12 0,-12 0,-6 6,0 12,0 12,0 42,0 42,0 48,0 54,-6 54,-12 54,-12 54,-24 54,-24 54,-30 48,-36 42,-36"/>
<text text-anchor="middle" x="27" y="-13.8" font-family="Times,serif" font-size="14.00" fill="#000000">userN</text>
</g>
<!-- userN&#45;&#45;sshportal -->
<g id="edge11" class="edge">
<title>userN&#45;&#45;sshportal</title>
<path fill="none" stroke="#000000" stroke-dasharray="1,5" d="M46.6743,-36.2043C49.2819,-39.0275 51.8073,-42.0045 54,-45 69.9682,-66.8147 62.881,-79.8865 82,-99 85.3146,-102.3137 89.1205,-105.3082 93.1341,-107.9914"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
.assets/server.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

390
.assets/sql-schema.svg Normal file
View File

@@ -0,0 +1,390 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.40.1 (20161225.0304)
-->
<!-- Title: Database Structure Pages: 1 -->
<svg width="1498pt" height="1073pt"
viewBox="0.00 0.00 1498.00 1073.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 1069)">
<title>Database Structure</title>
<polygon fill="#ffffff" stroke="transparent" points="-4,4 -4,-1069 1494,-1069 1494,4 -4,4"/>
<text text-anchor="middle" x="745" y="-1049.8" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#000000">ER Diagram: db</text>
<!-- acls -->
<g id="node1" class="node">
<title>acls</title>
<polygon fill="#dddddd" stroke="transparent" points="700,-556 700,-576 828,-576 828,-556 700,-556"/>
<polygon fill="none" stroke="#000000" points="700,-556 700,-576 828,-576 828,-556 700,-556"/>
<text text-anchor="start" x="753.1172" y="-561.8" font-family="Times,serif" font-size="14.00" fill="#000000">acls</text>
<polygon fill="none" stroke="#000000" points="700,-536 700,-556 828,-556 828,-536 700,-536"/>
<text text-anchor="start" x="748.5562" y="-542.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="763.3354" y="-542.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="700,-516 700,-536 828,-536 828,-516 700,-516"/>
<text text-anchor="start" x="704.501" y="-522.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="765.9014" y="-522.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-496 700,-516 828,-516 828,-496 700,-496"/>
<text text-anchor="start" x="702.5459" y="-502.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="767.8564" y="-502.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-476 700,-496 828,-496 828,-476 700,-476"/>
<text text-anchor="start" x="704.4941" y="-482.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="765.9082" y="-482.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-456 700,-476 828,-476 828,-456 700,-456"/>
<text text-anchor="start" x="703.3721" y="-462.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_pattern:</text>
<text text-anchor="start" x="776.4688" y="-462.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-436 700,-456 828,-456 828,-436 700,-436"/>
<text text-anchor="start" x="720.8721" y="-442.8" font-family="Times,serif" font-size="14.00" fill="#000000">action:</text>
<text text-anchor="start" x="758.9688" y="-442.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-416 700,-436 828,-436 828,-416 700,-416"/>
<text text-anchor="start" x="734.9492" y="-422.8" font-family="Times,serif" font-size="14.00" fill="#000000">weight:</text>
<text text-anchor="start" x="776.9424" y="-422.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="700,-396 700,-416 828,-416 828,-396 700,-396"/>
<text text-anchor="start" x="711.9272" y="-402.8" font-family="Times,serif" font-size="14.00" fill="#000000">comment:</text>
<text text-anchor="start" x="767.9136" y="-402.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- host_group_acls -->
<g id="node2" class="node">
<title>host_group_acls</title>
<polygon fill="#dddddd" stroke="transparent" points="137,-673 137,-693 243,-693 243,-673 137,-673"/>
<polygon fill="none" stroke="#000000" points="137,-673 137,-693 243,-693 243,-673 137,-673"/>
<text text-anchor="start" x="144.1172" y="-678.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_group_acls</text>
<polygon fill="none" stroke="#000000" points="137,-653 137,-673 243,-673 243,-653 137,-653"/>
<text text-anchor="start" x="139.5562" y="-659.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_group_id:</text>
<text text-anchor="start" x="224.3354" y="-659.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="137,-633 137,-653 243,-653 243,-633 137,-633"/>
<text text-anchor="start" x="162.8975" y="-639.8" font-family="Times,serif" font-size="14.00" fill="#000000">acl_id:</text>
<text text-anchor="start" x="200.9941" y="-639.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
</g>
<!-- host_group_acls&#45;&gt;acls -->
<g id="edge1" class="edge">
<title>host_group_acls:acl_id&#45;&gt;acls:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-643C447.1889,-643 490.9328,-549.194 689.7944,-546.0793"/>
<polygon fill="#444444" stroke="#444444" points="700.0272,-549.4999 690.0003,-546.0778 699.9728,-542.5001 700.0272,-549.4999"/>
</g>
<!-- host_groups -->
<g id="node3" class="node">
<title>host_groups</title>
<polygon fill="#dddddd" stroke="transparent" points="700,-722 700,-742 828,-742 828,-722 700,-722"/>
<polygon fill="none" stroke="#000000" points="700,-722 700,-742 828,-742 828,-722 700,-722"/>
<text text-anchor="start" x="729.7759" y="-727.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_groups</text>
<polygon fill="none" stroke="#000000" points="700,-702 700,-722 828,-722 828,-702 700,-702"/>
<text text-anchor="start" x="748.5562" y="-708.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="763.3354" y="-708.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="700,-682 700,-702 828,-702 828,-682 700,-682"/>
<text text-anchor="start" x="704.501" y="-688.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="765.9014" y="-688.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-662 700,-682 828,-682 828,-662 700,-662"/>
<text text-anchor="start" x="702.5459" y="-668.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="767.8564" y="-668.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-642 700,-662 828,-662 828,-642 700,-642"/>
<text text-anchor="start" x="704.4941" y="-648.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="765.9082" y="-648.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-622 700,-642 828,-642 828,-622 700,-622"/>
<text text-anchor="start" x="722.8169" y="-628.8" font-family="Times,serif" font-size="14.00" fill="#000000">name:</text>
<text text-anchor="start" x="757.0239" y="-628.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-602 700,-622 828,-622 828,-602 700,-602"/>
<text text-anchor="start" x="711.9272" y="-608.8" font-family="Times,serif" font-size="14.00" fill="#000000">comment:</text>
<text text-anchor="start" x="767.9136" y="-608.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- host_group_acls&#45;&gt;host_groups -->
<g id="edge2" class="edge">
<title>host_group_acls:host_group_id&#45;&gt;host_groups:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-663C443.884,-663 494.0731,-710.3866 689.9535,-711.9599"/>
<polygon fill="#444444" stroke="#444444" points="699.986,-715.5 690.0001,-711.96 700.014,-708.5 699.986,-715.5"/>
</g>
<!-- host_host_groups -->
<g id="node4" class="node">
<title>host_host_groups</title>
<polygon fill="#dddddd" stroke="transparent" points="137,-787 137,-807 243,-807 243,-787 137,-787"/>
<polygon fill="none" stroke="#000000" points="137,-787 137,-807 243,-807 243,-787 137,-787"/>
<text text-anchor="start" x="140.6069" y="-792.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_host_groups</text>
<polygon fill="none" stroke="#000000" points="137,-767 137,-787 243,-787 243,-767 137,-767"/>
<text text-anchor="start" x="159.3872" y="-773.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_id:</text>
<text text-anchor="start" x="204.5044" y="-773.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="137,-747 137,-767 243,-767 243,-747 137,-747"/>
<text text-anchor="start" x="139.5562" y="-753.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_group_id:</text>
<text text-anchor="start" x="224.3354" y="-753.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
</g>
<!-- host_host_groups&#45;&gt;host_groups -->
<g id="edge4" class="edge">
<title>host_host_groups:host_group_id&#45;&gt;host_groups:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-757C443.7051,-757 494.243,-713.4817 689.9622,-712.0368"/>
<polygon fill="#444444" stroke="#444444" points="700.0129,-715.5 690.0001,-712.0368 699.9871,-708.5 700.0129,-715.5"/>
</g>
<!-- hosts -->
<g id="node5" class="node">
<title>hosts</title>
<polygon fill="#dddddd" stroke="transparent" points="700,-1008 700,-1028 828,-1028 828,-1008 700,-1008"/>
<polygon fill="none" stroke="#000000" points="700,-1008 700,-1028 828,-1028 828,-1008 700,-1008"/>
<text text-anchor="start" x="749.6069" y="-1013.8" font-family="Times,serif" font-size="14.00" fill="#000000">hosts</text>
<polygon fill="none" stroke="#000000" points="700,-988 700,-1008 828,-1008 828,-988 700,-988"/>
<text text-anchor="start" x="748.5562" y="-994.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="763.3354" y="-994.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="700,-968 700,-988 828,-988 828,-968 700,-968"/>
<text text-anchor="start" x="704.501" y="-974.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="765.9014" y="-974.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-948 700,-968 828,-968 828,-948 700,-948"/>
<text text-anchor="start" x="702.5459" y="-954.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="767.8564" y="-954.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-928 700,-948 828,-948 828,-928 700,-928"/>
<text text-anchor="start" x="704.4941" y="-934.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="765.9082" y="-934.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-908 700,-928 828,-928 828,-908 700,-908"/>
<text text-anchor="start" x="722.8169" y="-914.8" font-family="Times,serif" font-size="14.00" fill="#000000">name:</text>
<text text-anchor="start" x="757.0239" y="-914.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-888 700,-908 828,-908 828,-888 700,-888"/>
<text text-anchor="start" x="725.5376" y="-894.8" font-family="Times,serif" font-size="14.00" fill="#000000">addr:</text>
<text text-anchor="start" x="754.3032" y="-894.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-868 700,-888 828,-888 828,-868 700,-868"/>
<text text-anchor="start" x="726.3135" y="-874.8" font-family="Times,serif" font-size="14.00" fill="#000000">user:</text>
<text text-anchor="start" x="753.5273" y="-874.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-848 700,-868 828,-868 828,-848 700,-848"/>
<text text-anchor="start" x="711.5342" y="-854.8" font-family="Times,serif" font-size="14.00" fill="#000000">password:</text>
<text text-anchor="start" x="768.3066" y="-854.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-828 700,-848 828,-848 828,-828 700,-828"/>
<text text-anchor="start" x="722.501" y="-834.8" font-family="Times,serif" font-size="14.00" fill="#000000">ssh_key_id:</text>
<text text-anchor="start" x="789.3906" y="-834.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="700,-808 700,-828 828,-828 828,-808 700,-808"/>
<text text-anchor="start" x="708.4238" y="-814.8" font-family="Times,serif" font-size="14.00" fill="#000000">fingerprint:</text>
<text text-anchor="start" x="771.417" y="-814.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-788 700,-808 828,-808 828,-788 700,-788"/>
<text text-anchor="start" x="711.9272" y="-794.8" font-family="Times,serif" font-size="14.00" fill="#000000">comment:</text>
<text text-anchor="start" x="767.9136" y="-794.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-768 700,-788 828,-788 828,-768 700,-768"/>
<text text-anchor="start" x="708.3394" y="-774.8" font-family="Times,serif" font-size="14.00" fill="#000000">host_key:</text>
<text text-anchor="start" x="762.7808" y="-774.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARBINARY</text>
</g>
<!-- host_host_groups&#45;&gt;hosts -->
<g id="edge3" class="edge">
<title>host_host_groups:host_id&#45;&gt;hosts:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-777C465.1991,-777 474.1608,-991.3602 689.9044,-997.8496"/>
<polygon fill="#444444" stroke="#444444" points="699.9478,-1001.4996 690.0011,-997.851 700.0522,-994.5004 699.9478,-1001.4996"/>
</g>
<!-- ssh_keys -->
<g id="node8" class="node">
<title>ssh_keys</title>
<polygon fill="#dddddd" stroke="transparent" points="1255,-848 1255,-868 1383,-868 1383,-848 1255,-848"/>
<polygon fill="none" stroke="#000000" points="1255,-848 1255,-868 1383,-868 1383,-848 1255,-848"/>
<text text-anchor="start" x="1293.7207" y="-853.8" font-family="Times,serif" font-size="14.00" fill="#000000">ssh_keys</text>
<polygon fill="none" stroke="#000000" points="1255,-828 1255,-848 1383,-848 1383,-828 1255,-828"/>
<text text-anchor="start" x="1303.5562" y="-834.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="1318.3354" y="-834.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="1255,-808 1255,-828 1383,-828 1383,-808 1255,-808"/>
<text text-anchor="start" x="1259.501" y="-814.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="1320.9014" y="-814.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="1255,-788 1255,-808 1383,-808 1383,-788 1255,-788"/>
<text text-anchor="start" x="1257.5459" y="-794.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="1322.8564" y="-794.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="1255,-768 1255,-788 1383,-788 1383,-768 1255,-768"/>
<text text-anchor="start" x="1259.4941" y="-774.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="1320.9082" y="-774.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="1255,-748 1255,-768 1383,-768 1383,-748 1255,-748"/>
<text text-anchor="start" x="1277.8169" y="-754.8" font-family="Times,serif" font-size="14.00" fill="#000000">name:</text>
<text text-anchor="start" x="1312.0239" y="-754.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="1255,-728 1255,-748 1383,-748 1383,-728 1255,-728"/>
<text text-anchor="start" x="1280.9238" y="-734.8" font-family="Times,serif" font-size="14.00" fill="#000000">type:</text>
<text text-anchor="start" x="1308.917" y="-734.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="1255,-708 1255,-728 1383,-728 1383,-708 1255,-708"/>
<text text-anchor="start" x="1291.5044" y="-714.8" font-family="Times,serif" font-size="14.00" fill="#000000">length:</text>
<text text-anchor="start" x="1330.3872" y="-714.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="1255,-688 1255,-708 1383,-708 1383,-688 1255,-688"/>
<text text-anchor="start" x="1263.4238" y="-694.8" font-family="Times,serif" font-size="14.00" fill="#000000">fingerprint:</text>
<text text-anchor="start" x="1326.417" y="-694.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="1255,-668 1255,-688 1383,-688 1383,-668 1255,-668"/>
<text text-anchor="start" x="1268.0928" y="-674.8" font-family="Times,serif" font-size="14.00" fill="#000000">priv_key:</text>
<text text-anchor="start" x="1321.748" y="-674.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="1255,-648 1255,-668 1383,-668 1383,-648 1255,-648"/>
<text text-anchor="start" x="1268.8687" y="-654.8" font-family="Times,serif" font-size="14.00" fill="#000000">pub_key:</text>
<text text-anchor="start" x="1320.9722" y="-654.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="1255,-628 1255,-648 1383,-648 1383,-628 1255,-628"/>
<text text-anchor="start" x="1266.9272" y="-634.8" font-family="Times,serif" font-size="14.00" fill="#000000">comment:</text>
<text text-anchor="start" x="1322.9136" y="-634.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- hosts&#45;&gt;ssh_keys -->
<g id="edge5" class="edge">
<title>hosts:ssh_key_id&#45;&gt;ssh_keys:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M828,-838C1014.3492,-838 1063.4615,-838 1244.8519,-838"/>
<polygon fill="#444444" stroke="#444444" points="1255,-841.5 1245,-838.0001 1255,-834.5 1255,-841.5"/>
</g>
<!-- migrations -->
<g id="node6" class="node">
<title>migrations</title>
<polygon fill="#dddddd" stroke="transparent" points="156,-853 156,-873 224,-873 224,-853 156,-853"/>
<polygon fill="none" stroke="#000000" points="156,-853 156,-873 224,-873 224,-853 156,-853"/>
<text text-anchor="start" x="160.0586" y="-858.8" font-family="Times,serif" font-size="14.00" fill="#000000">migrations</text>
<polygon fill="none" stroke="#000000" points="156,-833 156,-853 224,-853 224,-833 156,-833"/>
<text text-anchor="start" x="158.5308" y="-839.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="173.3101" y="-839.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- settings -->
<g id="node7" class="node">
<title>settings</title>
<polygon fill="#dddddd" stroke="transparent" points="126,-1019 126,-1039 254,-1039 254,-1019 126,-1019"/>
<polygon fill="none" stroke="#000000" points="126,-1019 126,-1039 254,-1039 254,-1019 126,-1019"/>
<text text-anchor="start" x="168.6104" y="-1024.8" font-family="Times,serif" font-size="14.00" fill="#000000">settings</text>
<polygon fill="none" stroke="#000000" points="126,-999 126,-1019 254,-1019 254,-999 126,-999"/>
<text text-anchor="start" x="174.5562" y="-1005.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="189.3354" y="-1005.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="126,-979 126,-999 254,-999 254,-979 126,-979"/>
<text text-anchor="start" x="130.501" y="-985.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="191.9014" y="-985.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="126,-959 126,-979 254,-979 254,-959 126,-959"/>
<text text-anchor="start" x="128.5459" y="-965.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="193.8564" y="-965.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="126,-939 126,-959 254,-959 254,-939 126,-939"/>
<text text-anchor="start" x="130.4941" y="-945.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="191.9082" y="-945.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="126,-919 126,-939 254,-939 254,-919 126,-919"/>
<text text-anchor="start" x="148.8169" y="-925.8" font-family="Times,serif" font-size="14.00" fill="#000000">name:</text>
<text text-anchor="start" x="183.0239" y="-925.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="126,-899 126,-919 254,-919 254,-899 126,-899"/>
<text text-anchor="start" x="148.8169" y="-905.8" font-family="Times,serif" font-size="14.00" fill="#000000">value:</text>
<text text-anchor="start" x="183.0239" y="-905.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- user_group_acls -->
<g id="node9" class="node">
<title>user_group_acls</title>
<polygon fill="#dddddd" stroke="transparent" points="137,-530 137,-550 243,-550 243,-530 137,-530"/>
<polygon fill="none" stroke="#000000" points="137,-530 137,-550 243,-550 243,-530 137,-530"/>
<text text-anchor="start" x="144.124" y="-535.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_group_acls</text>
<polygon fill="none" stroke="#000000" points="137,-510 137,-530 243,-530 243,-510 137,-510"/>
<text text-anchor="start" x="139.563" y="-516.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_group_id:</text>
<text text-anchor="start" x="224.3286" y="-516.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="137,-490 137,-510 243,-510 243,-490 137,-490"/>
<text text-anchor="start" x="162.8975" y="-496.8" font-family="Times,serif" font-size="14.00" fill="#000000">acl_id:</text>
<text text-anchor="start" x="200.9941" y="-496.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
</g>
<!-- user_group_acls&#45;&gt;acls -->
<g id="edge6" class="edge">
<title>user_group_acls:acl_id&#45;&gt;acls:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-500C443.7485,-500 494.2019,-544.4853 689.9601,-545.9624"/>
<polygon fill="#444444" stroke="#444444" points="699.9869,-549.5 690.0001,-545.9624 700.0131,-542.5 699.9869,-549.5"/>
</g>
<!-- user_groups -->
<g id="node10" class="node">
<title>user_groups</title>
<polygon fill="#dddddd" stroke="transparent" points="700,-350 700,-370 828,-370 828,-350 700,-350"/>
<polygon fill="none" stroke="#000000" points="700,-350 700,-370 828,-370 828,-350 700,-350"/>
<text text-anchor="start" x="729.7827" y="-355.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_groups</text>
<polygon fill="none" stroke="#000000" points="700,-330 700,-350 828,-350 828,-330 700,-330"/>
<text text-anchor="start" x="748.5562" y="-336.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="763.3354" y="-336.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="700,-310 700,-330 828,-330 828,-310 700,-310"/>
<text text-anchor="start" x="704.501" y="-316.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="765.9014" y="-316.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-290 700,-310 828,-310 828,-290 700,-290"/>
<text text-anchor="start" x="702.5459" y="-296.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="767.8564" y="-296.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-270 700,-290 828,-290 828,-270 700,-270"/>
<text text-anchor="start" x="704.4941" y="-276.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="765.9082" y="-276.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-250 700,-270 828,-270 828,-250 700,-250"/>
<text text-anchor="start" x="722.8169" y="-256.8" font-family="Times,serif" font-size="14.00" fill="#000000">name:</text>
<text text-anchor="start" x="757.0239" y="-256.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-230 700,-250 828,-250 828,-230 700,-230"/>
<text text-anchor="start" x="711.9272" y="-236.8" font-family="Times,serif" font-size="14.00" fill="#000000">comment:</text>
<text text-anchor="start" x="767.9136" y="-236.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- user_group_acls&#45;&gt;user_groups -->
<g id="edge7" class="edge">
<title>user_group_acls:user_group_id&#45;&gt;user_groups:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-520C457.8873,-520 481.0158,-345.5811 689.9107,-340.1305"/>
<polygon fill="#444444" stroke="#444444" points="700.0453,-343.4997 690.0008,-340.1294 699.9547,-336.5003 700.0453,-343.4997"/>
</g>
<!-- user_keys -->
<g id="node11" class="node">
<title>user_keys</title>
<polygon fill="#dddddd" stroke="transparent" points="118,-284 118,-304 262,-304 262,-284 118,-284"/>
<polygon fill="none" stroke="#000000" points="118,-284 118,-304 262,-304 262,-284 118,-284"/>
<text text-anchor="start" x="162.0068" y="-289.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_keys</text>
<polygon fill="none" stroke="#000000" points="118,-264 118,-284 262,-284 262,-264 118,-264"/>
<text text-anchor="start" x="174.5562" y="-270.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="189.3354" y="-270.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="118,-244 118,-264 262,-264 262,-244 118,-244"/>
<text text-anchor="start" x="130.501" y="-250.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="191.9014" y="-250.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="118,-224 118,-244 262,-244 262,-224 118,-224"/>
<text text-anchor="start" x="128.5459" y="-230.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="193.8564" y="-230.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="118,-204 118,-224 262,-224 262,-204 118,-204"/>
<text text-anchor="start" x="130.4941" y="-210.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="191.9082" y="-210.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="118,-184 118,-204 262,-204 262,-184 118,-184"/>
<text text-anchor="start" x="149.5083" y="-190.8" font-family="Times,serif" font-size="14.00" fill="#000000">key:</text>
<text text-anchor="start" x="173.6118" y="-190.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARBINARY</text>
<polygon fill="none" stroke="#000000" points="118,-164 118,-184 262,-184 262,-164 118,-164"/>
<text text-anchor="start" x="159.394" y="-170.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_id:</text>
<text text-anchor="start" x="204.4976" y="-170.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="118,-144 118,-164 262,-164 262,-144 118,-144"/>
<text text-anchor="start" x="137.9272" y="-150.8" font-family="Times,serif" font-size="14.00" fill="#000000">comment:</text>
<text text-anchor="start" x="193.9136" y="-150.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="118,-124 118,-144 262,-144 262,-124 118,-124"/>
<text text-anchor="start" x="120.8271" y="-130.8" font-family="Times,serif" font-size="14.00" fill="#000000">authorized_key:</text>
<text text-anchor="start" x="211.0137" y="-130.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- users -->
<g id="node13" class="node">
<title>users</title>
<polygon fill="#dddddd" stroke="transparent" points="700,-184 700,-204 828,-204 828,-184 700,-184"/>
<polygon fill="none" stroke="#000000" points="700,-184 700,-204 828,-204 828,-184 700,-184"/>
<text text-anchor="start" x="749.6138" y="-189.8" font-family="Times,serif" font-size="14.00" fill="#000000">users</text>
<polygon fill="none" stroke="#000000" points="700,-164 700,-184 828,-184 828,-164 700,-164"/>
<text text-anchor="start" x="748.5562" y="-170.8" font-family="Times,serif" font-size="14.00" fill="#000000">id:</text>
<text text-anchor="start" x="763.3354" y="-170.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="700,-144 700,-164 828,-164 828,-144 700,-144"/>
<text text-anchor="start" x="704.501" y="-150.8" font-family="Times,serif" font-size="14.00" fill="#000000">created_at:</text>
<text text-anchor="start" x="765.9014" y="-150.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-124 700,-144 828,-144 828,-124 700,-124"/>
<text text-anchor="start" x="702.5459" y="-130.8" font-family="Times,serif" font-size="14.00" fill="#000000">updated_at:</text>
<text text-anchor="start" x="767.8564" y="-130.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-104 700,-124 828,-124 828,-104 700,-104"/>
<text text-anchor="start" x="704.4941" y="-110.8" font-family="Times,serif" font-size="14.00" fill="#000000">deleted_at:</text>
<text text-anchor="start" x="765.9082" y="-110.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TIMESTAMP</text>
<polygon fill="none" stroke="#000000" points="700,-84 700,-104 828,-104 828,-84 700,-84"/>
<text text-anchor="start" x="716.9463" y="-90.8" font-family="Times,serif" font-size="14.00" fill="#000000">is_admin:</text>
<text text-anchor="start" x="772.167" y="-90.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">TINYINT</text>
<polygon fill="none" stroke="#000000" points="700,-64 700,-84 828,-84 828,-64 700,-64"/>
<text text-anchor="start" x="722.4272" y="-70.8" font-family="Times,serif" font-size="14.00" fill="#000000">email:</text>
<text text-anchor="start" x="757.4136" y="-70.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-44 700,-64 828,-64 828,-44 700,-44"/>
<text text-anchor="start" x="722.8169" y="-50.8" font-family="Times,serif" font-size="14.00" fill="#000000">name:</text>
<text text-anchor="start" x="757.0239" y="-50.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-24 700,-44 828,-44 828,-24 700,-24"/>
<text text-anchor="start" x="711.9272" y="-30.8" font-family="Times,serif" font-size="14.00" fill="#000000">comment:</text>
<text text-anchor="start" x="767.9136" y="-30.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
<polygon fill="none" stroke="#000000" points="700,-4 700,-24 828,-24 828,-4 700,-4"/>
<text text-anchor="start" x="702.9824" y="-10.8" font-family="Times,serif" font-size="14.00" fill="#000000">invite_token:</text>
<text text-anchor="start" x="776.8584" y="-10.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">VARCHAR</text>
</g>
<!-- user_keys&#45;&gt;users -->
<g id="edge8" class="edge">
<title>user_keys:user_id&#45;&gt;users:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M262,-174C453.2448,-174 503.5773,-174 689.8681,-174"/>
<polygon fill="#444444" stroke="#444444" points="700,-177.5 690,-174.0001 700,-170.5 700,-177.5"/>
</g>
<!-- user_user_groups -->
<g id="node12" class="node">
<title>user_user_groups</title>
<polygon fill="#dddddd" stroke="transparent" points="137,-370 137,-390 243,-390 243,-370 137,-370"/>
<polygon fill="none" stroke="#000000" points="137,-370 137,-390 243,-390 243,-370 137,-370"/>
<text text-anchor="start" x="140.6206" y="-375.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_user_groups</text>
<polygon fill="none" stroke="#000000" points="137,-350 137,-370 243,-370 243,-350 137,-350"/>
<text text-anchor="start" x="159.394" y="-356.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_id:</text>
<text text-anchor="start" x="204.4976" y="-356.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
<polygon fill="none" stroke="#000000" points="137,-330 137,-350 243,-350 243,-330 137,-330"/>
<text text-anchor="start" x="139.563" y="-336.8" font-family="Times,serif" font-size="14.00" fill="#000000">user_group_id:</text>
<text text-anchor="start" x="224.3286" y="-336.8" font-family="Helvetica,sans-Serif" font-style="oblique" font-size="10.00" fill="#000000">INT</text>
</g>
<!-- user_user_groups&#45;&gt;user_groups -->
<g id="edge10" class="edge">
<title>user_user_groups:user_group_id&#45;&gt;user_groups:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-340C442.64,-340 495.1088,-340 689.7185,-340"/>
<polygon fill="#444444" stroke="#444444" points="700,-343.5 690,-340.0001 700,-336.5 700,-343.5"/>
</g>
<!-- user_user_groups&#45;&gt;users -->
<g id="edge9" class="edge">
<title>user_user_groups:user_id&#45;&gt;users:id</title>
<path fill="none" stroke="#444444" stroke-dasharray="5,2" d="M243,-360C458.8631,-360 480.0858,-179.7671 689.8664,-174.1348"/>
<polygon fill="#444444" stroke="#444444" points="700.0466,-177.4997 690.0009,-174.1331 699.9534,-170.5003 700.0466,-177.4997"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 37 KiB

40
.circleci/config.yml Normal file
View File

@@ -0,0 +1,40 @@
defaults: &defaults
working_directory: /go/src/github.com/moul/sshportal
docker:
- image: circleci/golang:1.8
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:
- checkout
- run:
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
- setup_remote_docker:
docker_layer_caching: true
- run: docker build -t moul/sshportal .
- run: make integration
workflows:
version: 2
build_and_integration:
jobs:
- go.build
- docker.integration
# requires: docker.build?

View File

@@ -1 +1,4 @@
examples
examples/
.circleci/
.assets/
/sshportal

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/sshportal
*.db
*.db
/data

View File

@@ -1,6 +1,61 @@
# Changelog
## 1.1.0 (2017-11-15)
## 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)
@@ -8,7 +63,7 @@
* Disable mysql support (not fully working right now)
* Set random seed properly
## 1.0.0 (2017-11-14)
## v1.0.0 (2017-11-14)
Initial version

View File

@@ -5,6 +5,9 @@ WORKDIR /go/src/github.com/moul/sshportal
RUN make _docker_install
# minimal runtime
FROM scratch
FROM alpine
COPY --from=builder /go/bin/sshportal /bin/sshportal
ENTRYPOINT ["/bin/sshportal"]
CMD ["server"]
EXPOSE 2222
HEALTHCHECK CMD /bin/sshportal healthcheck --wait

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2017 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,11 +1,21 @@
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.GIT_SHA=$(GIT_SHA) -X main.GIT_TAG=$(GIT_TAG) -X main.GIT_BRANCH=$(GIT_BRANCH)
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)
AES_KEY ?= my-dummy-aes-key
.PHONY: install
install:
go install -ldflags '$(LDFLAGS)' .
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:
@@ -14,9 +24,23 @@ _docker_install:
.PHONY: dev
dev:
-go get github.com/githubnemo/CompileDaemon
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal --demo --debug" .
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 .
.PHONY: backup
backup:
mkdir -p data/backups
cp sshportal.db data/backups/$(shell date +%s)-$(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

320
README.md
View File

@@ -1,43 +1,64 @@
# 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)
[![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)
Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
DMZ │
┌────────┐ │ ┌────────┐
│ homer │───▶╔═════════════════╗───▶│ host1 │ │
└────────┘ ║ ║ └────────┘
┌────────┐ ║ ║ ┌────────┐ │
│ bart │───▶║ sshportal ║───▶│ host2 │
└────────┘ ║ ║ └────────┘ │
┌────────┐ ║ ║ ┌────────┐
│ lisa │───▶╚═════════════════╝───▶│ host3 │ │
└────────┘ │ └────────┘
┌────────┐ ┌────────┐ │
│ ... │ │ │ ... │
└────────┘ └────────┘ │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
```
![sshportal demo](https://github.com/moul/sshportal/raw/master/.assets/demo.gif)
---
## Overview
![sshportal overview](https://raw.github.com/moul/sshportal/master/.assets/overview.svg?sanitize=true)
## Features
* 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
* 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
## (Known) limitations
* Does not work (yet?) with [`mosh`](https://mosh.org/)
## Usage
Start the server
```console
$ sshportal
$ sshportal server
2017/11/13 10:58:35 Admin user created, use the user 'invite:BpLnfgDsc2WD8F2q' to associate a public key with this account
2017/11/13 10:58:35 SSH Server accepting connections on :2222
```
@@ -46,7 +67,7 @@ Link your SSH key with the admin account
```console
$ ssh localhost -p 2222 -l invite:BpLnfgDsc2WD8F2q
Welcome Administrator!
Welcome admin!
Your key is now associated with the user "admin@sshportal".
Shared connection to localhost closed.
@@ -80,28 +101,18 @@ List hosts
```console
config> host ls
ID | NAME | URL | KEY | PASS | GROUPS | COMMENT
+----+------+-------------------------+---------+------+--------+---------+
1 | foo | bart@foo.example.org:22 | default | | 1 |
ID | NAME | URL | KEY | PASS | GROUPS | COMMENT
+----+------+-------------------------+---------+------+---------+---------+
1 | foo | bart@foo.example.org:22 | default | | default |
Total: 1 hosts.
config>
```
Get the default key in authorized_keys format
Add the key to the server
```console
config> key inspect default
[...]
"PubKey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvUP/8FedyIe+a+RWU4KvJ1+iZwtWmY9czJubLwN4RcjKHQMzLqWC7pKZHAABCZjLJjVD/3Zb53jZwbh7mysAkocundMpvUL5+Yb4a8lDiflXkdXT9fZCx+ibJBk4jRnKLGIneSzVtFEerEwQKKnKQoCgPkZwCDaL/jHhDlOmAvxqAJrjiy42HXwppX2UuF8zujs6OKHRYJ/Q1vo0caa6/o1eoyXE9OrOwIk+IcAN3YIQi/B1BOlZOQBzHIZz83AFlD2TcPhyYcbxPyKGih84Zr3rQaaP1WiaiPqxzp3s5OhTLthc5XtCSLzmRSLvgC2eFdNhBDB5KLtO2khBkz5ID",
[...]
config>
```
Add this key to the server
```console
$ ssh bart@foo.example.org
> umask 077; mkdir -p .ssh; echo ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvUP/8FedyIe+a+RWU4KvJ1+iZwtWmY9czJubLwN4RcjKHQMzLqWC7pKZHAABCZjLJjVD/3Zb53jZwbh7mysAkocundMpvUL5+Yb4a8lDiflXkdXT9fZCx+ibJBk4jRnKLGIneSzVtFEerEwQKKnKQoCgPkZwCDaL/jHhDlOmAvxqAJrjiy42HXwppX2UuF8zujs6OKHRYJ/Q1vo0caa6/o1eoyXE9OrOwIk+IcAN3YIQi/B1BOlZOQBzHIZz83AFlD2TcPhyYcbxPyKGih84Zr3rQaaP1WiaiPqxzp3s5OhTLthc5XtCSLzmRSLvgC2eFdNhBDB5KLtO2khBkz5ID >> .ssh/authorized_keys
$ ssh bart@foo.example.org "$(ssh localhost -p 2222 -l admin key setup default)"
$
```
Profit
@@ -113,16 +124,22 @@ bart@foo>
Invite friends
*This command doesn't create a user on the remote server, it only creates an account in the sshportal database.*
```console
config> user invite bob@example.com
User 2 created.
To associate this account with a key, use the following SSH user: 'invite-NfHK5a84jjJkwzDk'.
To associate this account with a key, use the following SSH user: 'invite:NfHK5a84jjJkwzDk'.
config>
```
## CLI
## Flow Diagram
sshportal embeds a configuration CLI.
![Flow Diagram](https://raw.github.com/moul/sshportal/master/.assets/flow-diagram.svg?sanitize=true)
## 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.
@@ -139,50 +156,65 @@ You can enter in interactive mode using this syntax: `ssh admin@portal.example.o
```sh
# acl management
acl help
acl create [-h] [--hostgroup=<value>...] [--usergroup=<value>...] [--pattern=<value>] [--comment=<value>] [--action=<value>] [--weight=value]
acl inspect [-h] <id> [<id> [<id>...]]
acl ls [-h]
acl rm [-h] <id> [<id> [<id>...]]
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]
config restore [-h] [--confirm]
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>] [--fingerprint=<value>] [--comment=<value>] [--key=<value>] [--group=<value>] <user>[:<password>]@<host>[:<port>]
host inspect [-h] <id or name> [<id or name> [<id or name>...]]
host ls [-h]
host rm [-h] <id or name> [<id or name> [<id or name>...]]
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...
# hostgroup management
hostgroup help
hostgroup create [-h] [--name=<value>] [--comment=<value>]
hostgroup inspect [-h] <id or name> [<id or name> [<id or name>...]]
hostgroup ls [-h]
hostgroup rm [-h] <id or name> [<id or name> [<id or name>...]]
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 inspect [-h] <id or name> [<id or name> [<id or name>...]]
key ls [-h]
key rm [-h] <id or name> [<id or name> [<id or name>...]]
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=<value>] <email>
user inspect [-h] <id or email> [<id or email> [<id or email>...]]
user ls [-h]
user rm [-h] <id or email> [<id or email> [<id or email>...]]
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] <id or name> [<id or name> [<id or name>...]]
usergroup ls [-h]
usergroup rm [-h] <id or name> [<id or name> [<id or name>...]]
usergroup inspect [-h] USERGROUP...
usergroup ls [-h] [--latest] [--quiet]
usergroup rm [-h] USERGROUP...
# other
exit [-h]
@@ -193,12 +225,46 @@ version [-h]
## Docker
Docker is the recommended way to run sshportal.
An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/sshportal/tags/).
```console
# Start a server in background
# mount `pwd` to persist the sqlite database file
docker run -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.1.0
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.7.1
# check logs (mandatory on first run to get the administrator invite token)
docker logs -f sshportal
```
The easier way to upgrade sshportal is to do the following:
```sh
# we consider you were using an old version and you want to use the new version v1.7.1
# stop and rename the last working container + backup the database
docker stop sshportal
docker rename sshportal sshportal_old
cp sshportal.db sshportal.db.bkp
# run the new version
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.7.1
# check the logs for migration or cross-version incompabitility errors
docker logs -f sshportal
```
Now you can test ssh-ing to sshportal to check if everything looks OK.
In case of problem, you can rollback to the latest working version with the latest working backup, using:
```sh
docker stop sshportal
docker rm sshportal
cp sshportal.db.bkp sshportal.db
docker rename sshportal_old sshportal
docker start sshportal
docker logs -f sshportal
```
## Manual Install
@@ -209,16 +275,39 @@ Get the latest version using GO.
go get -u github.com/moul/sshportal
```
## portal alias (.ssh/config)
Edit your `~/.ssh/config` file (create it first if needed)
```ini
Host portal
User admin
Port 2222 # portal port
HostName 127.0.0.1 # portal hostname
```
```bash
# you can now run a shell using this:
ssh portal
# instead of this:
ssh localhost -p 2222 -l admin
# or connect to hosts using this:
ssh hostname@portal
# instead of this:
ssh localhost -p 2222 -l hostname
```
## Backup / Restore
sshportal embeds built-in backup/restore methods which basically import/export JSON objects:
```sh
# Backup
ssh admin@sshportal config backup > sshportal.bkp
ssh portal config backup > sshportal.bkp
# Restore
ssh admin@sshportal config restore < sshporta.bkp
ssh portal config restore < sshportal.bkp
```
This method is particularly useful as it should be resistant against future DB schema changes (expected during development phase).
@@ -232,3 +321,90 @@ sqlite3 sshportal.db .dump > sshportal.sql.bkp
# or just the immortal cp
cp sshportal.db sshportal.db.bkp
```
## Demo data
The following servers are freely available, without external registration,
it makes it easier to quickly test `sshportal` without configuring your own servers to accept sshportal connections.
```
ssh portal host create new@sdf.org
ssh sdf@portal
ssh portal host create test@whoami.filippo.io
ssh whoami@portal
ssh portal host create test@chat.shazow.net
ssh chat@portal
```
## Healthcheck
By default, `sshportal` will return `OK` to anyone sshing using the `healthcheck` user without checking for authentication.
```console
$ ssh healthcheck@sshportal
OK
$
```
the `healtcheck` user can be changed using the `healthcheck-user` option.
---
Alternatively, you can run the built-in healthcheck helper (requiring no ssh client nor ssh key):
Usage: `sshportal healthcheck [--addr=host:port] [--wait] [--quiet]
```console
$ sshportal healthcheck --addr=localhost:2222; echo $?
$ 0
```
---
Wait for sshportal to be healthy, then connect
```console
$ sshportal healthcheck --wait && ssh sshportal -l admin
config>
```
## Scaling
`sshportal` is stateless but relies on a database to store configuration and logs.
By default, `sshportal` uses a local [sqlite](https://www.sqlite.org/) database which isn't scalable by design.
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)
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)
* Backed by (see [dep graph](https://godoc.org/github.com/moul/sshportal?import-graph&hide=2)):
* SSH
* https://github.com/gliderlabs/ssh: SSH server made easy (well-designed golang library to build SSH servers)
* https://godoc.org/golang.org/x/crypto/ssh: both client and server SSH protocol and helpers
* Database
* https://github.com/jinzhu/gorm/: SQL orm
* https://github.com/go-gormigrate/gormigrate: Database migration system
* Built-in shell
* https://github.com/olekukonko/tablewriter: Ascii tables
* https://github.com/asaskevich/govalidator: Valide user inputs
* https://github.com/dustin/go-humanize: Human-friendly representation of technical data (time ago, bytes, ...)
* 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)
## Note
This is totally experimental for now, so please file issues to let me know what you think about it!
## 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)

10
acl.go
View File

@@ -2,7 +2,7 @@ package main
import "sort"
type ByWeight []ACL
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] }
@@ -10,7 +10,7 @@ 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{}
aclMap := map[uint]*ACL{}
for _, userGroup := range user.Groups {
for _, userGroupACL := range userGroup.ACLs {
for _, hostGroup := range host.Groups {
@@ -26,11 +26,11 @@ func CheckACLs(user User, host Host) (string, error) {
// deny by default if no shared ACL
if len(aclMap) == 0 {
return "deny", nil // default action
return ACLActionDeny, nil // default action
}
// transofrm map to slice and sort it
acls := []ACL{}
// transform map to slice and sort it
acls := []*ACL{}
for _, acl := range aclMap {
acls = append(acls, acl)
}

View File

@@ -15,17 +15,21 @@ func TestCheckACLs(t *testing.T) {
// create tmp dir
tempDir, err := ioutil.TempDir("", "sshportal")
So(err, ShouldBeNil)
defer os.RemoveAll(tempDir)
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
hostGroup, err := FindHostGroupByIdOrName(db, "default")
var hostGroup HostGroup
err = HostGroupsByIdentifiers(db, []string{"default"}).First(&hostGroup).Error
So(err, ShouldBeNil)
db.Create(&Host{Groups: []HostGroup{*hostGroup}})
db.Create(&Host{Groups: []*HostGroup{&hostGroup}})
//. load db
var (
@@ -38,6 +42,6 @@ func TestCheckACLs(t *testing.T) {
// test
action, err := CheckACLs(users[0], hosts[0])
So(err, ShouldBeNil)
So(action, ShouldEqual, "allow")
So(action, ShouldEqual, ACLActionAllow)
})
}

View File

@@ -2,11 +2,15 @@ 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"
@@ -47,3 +51,82 @@ func NewSSHKey(keyType string, length uint) (*SSHKey, error) {
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)
}

550
db.go
View File

@@ -1,250 +1,171 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/url"
"regexp"
"strings"
"time"
"github.com/gliderlabs/ssh"
"github.com/asaskevich/govalidator"
"github.com/jinzhu/gorm"
gossh "golang.org/x/crypto/ssh"
)
type Config struct {
SSHKeys []SSHKey `json:"keys"`
Hosts []Host `json:"hosts"`
UserKeys []UserKey `json:"user_keys"`
Users []User `json:"users"`
UserGroups []UserGroup `json:"user_groups"`
HostGroups []HostGroup `json:"host_groups"`
ACLs []ACL `json:"acls"`
Date time.Time `json:"date"`
SSHKeys []*SSHKey `json:"keys"`
Hosts []*Host `json:"hosts"`
UserKeys []*UserKey `json:"user_keys"`
Users []*User `json:"users"`
UserGroups []*UserGroup `json:"user_groups"`
HostGroups []*HostGroup `json:"host_groups"`
ACLs []*ACL `json:"acls"`
Settings []*Setting `json:"settings"`
Events []*Event `json:"events"`
Sessions []*Session `json:"sessions"`
// FIXME: add latest migration
Date time.Time `json:"date"`
}
type Setting struct {
gorm.Model
Name string `valid:"required"`
Value string `valid:"required"`
}
// SSHKey defines a ssh client key (used by sshportal to connect to remote hosts)
type SSHKey struct {
// FIXME: use uuid for ID
gorm.Model
Name string // FIXME: govalidator: min length 3, alphanum
Type string
Length uint
Fingerprint string
PrivKey string `sql:"size:10000;"`
PubKey string `sql:"size:10000;"`
Hosts []Host
Comment string
Name string `valid:"required,length(1|32),unix_user"`
Type string `valid:"required"`
Length uint `valid:"required"`
Fingerprint string `valid:"optional"`
PrivKey string `sql:"size:10000" valid:"required"`
PubKey string `sql:"size:10000" valid:"optional"`
Hosts []*Host `gorm:"ForeignKey:SSHKeyID"`
Comment string `valid:"optional"`
}
type Host struct {
// FIXME: use uuid for ID
gorm.Model
Name string `gorm:"size:63"` // FIXME: govalidator: min length 3, alphanum
Addr string
User string
Password string
SSHKey *SSHKey
SSHKeyID uint `gorm:"index"`
Groups []HostGroup `gorm:"many2many:host_host_groups;"`
Fingerprint string // FIXME: replace with hostkey ?
Comment string
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;"`
Comment string `valid:"optional"`
}
// UserKey defines a user public key used by sshportal to identify the user
type UserKey struct {
gorm.Model
Key []byte `sql:"size:10000;"`
UserID uint
User *User
Comment string
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"`
}
type UserRole struct {
gorm.Model
Name string `valid:"required,length(1|32),unix_user"`
Users []*User `gorm:"many2many:user_user_roles"`
}
type User struct {
// FIXME: use uuid for ID
gorm.Model
IsAdmin bool
Email string // FIXME: govalidator: email
Name string // FIXME: govalidator: min length 3, alphanum
Keys []UserKey
Groups []UserGroup `gorm:"many2many:user_user_groups;"`
Comment string
InviteToken string
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)"`
}
type UserGroup struct {
gorm.Model
Name string
Users []User `gorm:"many2many:user_user_groups;"`
ACLs []ACL `gorm:"many2many:user_group_acls;"`
Comment string
Name string `valid:"required,length(1|32),unix_user"`
Users []*User `gorm:"many2many:user_user_groups;"`
ACLs []*ACL `gorm:"many2many:user_group_acls;"`
Comment string `valid:"optional"`
}
type HostGroup struct {
gorm.Model
Name string
Hosts []Host `gorm:"many2many:host_host_groups;"`
ACLs []ACL `gorm:"many2many:host_group_acls;"`
Comment string
Name string `valid:"required,length(1|32),unix_user"`
Hosts []*Host `gorm:"many2many:host_host_groups;"`
ACLs []*ACL `gorm:"many2many:host_group_acls;"`
Comment string `valid:"optional"`
}
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
HostGroups []*HostGroup `gorm:"many2many:host_group_acls;"`
UserGroups []*UserGroup `gorm:"many2many:user_group_acls;"`
HostPattern string `valid:"optional"`
Action string `valid:"required"`
Weight uint ``
Comment string `valid:"optional"`
}
func dbInit(db *gorm.DB) error {
db.AutoMigrate(&User{})
db.AutoMigrate(&SSHKey{})
db.AutoMigrate(&Host{})
db.AutoMigrate(&UserKey{})
db.AutoMigrate(&UserGroup{})
db.AutoMigrate(&HostGroup{})
db.AutoMigrate(&ACL{})
// FIXME: check if indexes exist to avoid gorm warns
db.Exec(`CREATE UNIQUE INDEX uix_keys_name ON "ssh_keys"("name") WHERE ("deleted_at" IS NULL)`)
db.Exec(`CREATE UNIQUE INDEX uix_hosts_name ON "hosts"("name") WHERE ("deleted_at" IS NULL)`)
db.Exec(`CREATE UNIQUE INDEX uix_users_name ON "users"("email") WHERE ("deleted_at" IS NULL)`)
db.Exec(`CREATE UNIQUE INDEX uix_usergroups_name ON "user_groups"("name") WHERE ("deleted_at" IS NULL)`)
db.Exec(`CREATE UNIQUE INDEX uix_hostgroups_name ON "host_groups"("name") WHERE ("deleted_at" IS NULL)`)
// 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
user := User{
Name: "Administrator",
Email: "admin@sshportal",
Comment: "created by sshportal",
IsAdmin: true,
InviteToken: RandStringBytes(16),
Groups: []UserGroup{defaultUserGroup},
}
db.Create(&user)
log.Printf("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
}
}
return nil
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"`
}
func dbDemo(db *gorm.DB) error {
hostGroup, err := FindHostGroupByIdOrName(db, "default")
if err != nil {
return err
}
key, err := FindKeyByIdOrName(db, "default")
if err != nil {
return err
}
var (
host1 = Host{Name: "sdf", Addr: "sdf.org:22", User: "new", SSHKeyID: key.ID, Groups: []HostGroup{*hostGroup}}
host2 = Host{Name: "whoami", Addr: "whoami.filippo.io:22", User: "test", SSHKeyID: key.ID, Groups: []HostGroup{*hostGroup}}
host3 = Host{Name: "ssh-chat", Addr: "chat.shazow.net:22", User: "test", SSHKeyID: key.ID, Fingerprint: "MD5:e5:d5:d1:75:90:38:42:f6:c7:03:d7:d0:56:7d:6a:db", Groups: []HostGroup{*hostGroup}}
)
// FIXME: check if hosts exist to avoid `UNIQUE constraint` error
db.Create(&host1)
db.Create(&host2)
db.Create(&host3)
return nil
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)" json:"-"`
ArgsMap map[string]interface{} `gorm:"-" json:"Args"`
}
func RemoteHostFromSession(s ssh.Session, db *gorm.DB) (*Host, error) {
var host Host
db.Preload("SSHKey").Where("name = ?", s.User()).Find(&host)
if host.Name == "" {
// FIXME: add available hosts
return nil, fmt.Errorf("No such target: %q", s.User())
}
return &host, nil
type SessionStatus string
const (
SessionStatusUnknown SessionStatus = "unknown"
SessionStatusActive = "active"
SessionStatusClosed = "closed"
)
type ACLAction string
const (
ACLActionUnknown ACLAction = "unknown"
ACLActionAllow = "allow"
ACLActionDeny = "deny"
)
func init() {
unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*")
govalidator.CustomTypeTagMap.Set("unix_user", govalidator.CustomTypeValidator(func(i interface{}, context interface{}) bool {
name, ok := i.(string)
if !ok {
return false
}
return unixUserRegexp.MatchString(name)
}))
}
func (host *Host) URL() string {
@@ -278,147 +199,178 @@ func (host *Host) Hostname() string {
// Host helpers
func FindHostByIdOrName(db *gorm.DB, query string) (*Host, error) {
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)
}
func HostByName(db *gorm.DB, name string) (*Host, error) {
var host Host
if err := db.Preload("Groups").Preload("SSHKey").Where("id = ?", query).Or("name = ?", query).First(&host).Error; err != nil {
return nil, err
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 &host, nil
}
func FindHostsByIdOrName(db *gorm.DB, queries []string) ([]*Host, error) {
var hosts []*Host
for _, query := range queries {
host, err := FindHostByIdOrName(db, query)
func (host *Host) clientConfig(hk gossh.HostKeyCallback) (*gossh.ClientConfig, error) {
config := gossh.ClientConfig{
User: host.User,
HostKeyCallback: hk,
Auth: []gossh.AuthMethod{},
}
if host.SSHKey != nil {
signer, err := gossh.ParsePrivateKey([]byte(host.SSHKey.PrivKey))
if err != nil {
return nil, err
}
hosts = append(hosts, host)
config.Auth = append(config.Auth, gossh.PublicKeys(signer))
}
return hosts, nil
if host.Password != "" {
config.Auth = append(config.Auth, gossh.Password(host.Password))
}
if len(config.Auth) == 0 {
return nil, fmt.Errorf("no valid authentication method for host %q", host.Name)
}
return &config, nil
}
// SSHKey helpers
func FindKeyByIdOrName(db *gorm.DB, query string) (*SSHKey, error) {
var key SSHKey
if err := db.Preload("Hosts").Where("id = ?", query).Or("name = ?", query).First(&key).Error; err != nil {
return nil, err
}
return &key, nil
func SSHKeysPreload(db *gorm.DB) *gorm.DB {
return db.Preload("Hosts")
}
func FindKeysByIdOrName(db *gorm.DB, queries []string) ([]*SSHKey, error) {
var keys []*SSHKey
for _, query := range queries {
key, err := FindKeyByIdOrName(db, query)
if err != nil {
return nil, err
}
keys = append(keys, key)
}
return keys, nil
func SSHKeysByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
}
// HostGroup helpers
func FindHostGroupByIdOrName(db *gorm.DB, query string) (*HostGroup, error) {
var hostGroup HostGroup
if err := db.Preload("ACLs").Preload("Hosts").Where("id = ?", query).Or("name = ?", query).First(&hostGroup).Error; err != nil {
return nil, err
}
return &hostGroup, nil
func HostGroupsPreload(db *gorm.DB) *gorm.DB {
return db.Preload("ACLs").Preload("Hosts")
}
func FindHostGroupsByIdOrName(db *gorm.DB, queries []string) ([]*HostGroup, error) {
var hostGroups []*HostGroup
for _, query := range queries {
hostGroup, err := FindHostGroupByIdOrName(db, query)
if err != nil {
return nil, err
}
hostGroups = append(hostGroups, hostGroup)
}
return hostGroups, nil
func HostGroupsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
}
// UserGroup heleprs
// UserGroup helpers
func FindUserGroupByIdOrName(db *gorm.DB, query string) (*UserGroup, error) {
var userGroup UserGroup
if err := db.Preload("ACLs").Preload("Users").Where("id = ?", query).Or("name = ?", query).First(&userGroup).Error; err != nil {
return nil, err
}
return &userGroup, nil
func UserGroupsPreload(db *gorm.DB) *gorm.DB {
return db.Preload("ACLs").Preload("Users")
}
func FindUserGroupsByIdOrName(db *gorm.DB, queries []string) ([]*UserGroup, error) {
var userGroups []*UserGroup
for _, query := range queries {
userGroup, err := FindUserGroupByIdOrName(db, query)
if err != nil {
return nil, err
}
userGroups = append(userGroups, userGroup)
}
return userGroups, nil
func UserGroupsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers).Or("name IN (?)", identifiers)
}
// User helpers
func FindUserByIdOrEmail(db *gorm.DB, query string) (*User, error) {
var user User
if err := db.Preload("Groups").Preload("Keys").Where("id = ?", query).Or("email = ?", query).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
func UsersPreload(db *gorm.DB) *gorm.DB {
return db.Preload("Groups").Preload("Keys").Preload("Roles")
}
func FindUsersByIdOrEmail(db *gorm.DB, queries []string) ([]*User, error) {
var users []*User
for _, query := range queries {
user, err := FindUserByIdOrEmail(db, query)
if err != nil {
return nil, err
func UsersByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers).Or("email IN (?)", identifiers).Or("name IN (?)", identifiers)
}
func (u *User) HasRole(name string) bool {
for _, role := range u.Roles {
if role.Name == name {
return true
}
users = append(users, user)
}
return users, nil
return false
}
func (u *User) CheckRoles(names []string) error {
ok := false
for _, name := range names {
if u.HasRole(name) {
ok = true
break
}
}
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, "', '"))
}
// ACL helpers
func FindACLById(db *gorm.DB, query string) (*ACL, error) {
var acl ACL
if err := db.Preload("UserGroups").Preload("HostGroups").Where("id = ?", query).First(&acl).Error; err != nil {
return nil, err
}
return &acl, nil
func ACLsPreload(db *gorm.DB) *gorm.DB {
return db.Preload("UserGroups").Preload("HostGroups")
}
func FindACLsById(db *gorm.DB, queries []string) ([]*ACL, error) {
var acls []*ACL
for _, query := range queries {
acl, err := FindACLById(db, query)
if err != nil {
return nil, err
}
acls = append(acls, acl)
}
return acls, nil
func ACLsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers)
}
// UserKey helpers
func FindUserkeyById(db *gorm.DB, query string) (*UserKey, error) {
var userkey UserKey
if err := db.Preload("User").Where("id = ?", query).First(&userkey).Error; err != nil {
return nil, err
}
return &userkey, nil
func UserKeysPreload(db *gorm.DB) *gorm.DB {
return db.Preload("User")
}
func FindUserkeysById(db *gorm.DB, queries []string) ([]*UserKey, error) {
var userkeys []*UserKey
for _, query := range queries {
userkey, err := FindUserkeyById(db, query)
if err != nil {
return nil, err
func UserKeysByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("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)
}
// Session helpers
func SessionsPreload(db *gorm.DB) *gorm.DB {
return db.Preload("User").Preload("Host")
}
func SessionsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers)
}
// Events helpers
func EventsPreload(db *gorm.DB) *gorm.DB {
return db.Preload("Author")
}
func EventsByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers)
}
func NewEvent(domain, action string) *Event {
return &Event{
Domain: domain,
Action: action,
ArgsMap: map[string]interface{}{},
}
}
func (e *Event) String() string {
return fmt.Sprintf("%s %s %s %s", e.Domain, e.Action, e.Entity, string(e.Args))
}
func (e *Event) Log(db *gorm.DB) {
if len(e.ArgsMap) > 0 {
var err error
if e.Args, err = json.Marshal(e.ArgsMap); err != nil {
log.Printf("error: %v", err)
}
userkeys = append(userkeys, userkey)
}
return userkeys, nil
log.Printf("info: %s", e)
if err := db.Create(e).Error; err != nil {
log.Printf("warning: %v", err)
}
}
func (e *Event) SetAuthor(user *User) *Event {
e.Author = user
e.AuthorID = user.ID
return e
}
func (e *Event) SetArg(name string, value interface{}) *Event {
e.ArgsMap[name] = value
return e
}

588
dbinit.go Normal file
View File

@@ -0,0 +1,588 @@
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 ""
}

View File

@@ -0,0 +1,4 @@
FROM occitech/ssh-client
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["/integration/_client.sh"]
COPY . /integration

View File

@@ -0,0 +1,7 @@
run:
docker-compose down
docker-compose up -d sshportal
docker-compose build client
docker-compose exec sshportal /bin/sshportal healthcheck --wait --quiet
docker-compose run client /integration/_client.sh
docker-compose down

71
examples/integration/_client.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/sh -e
mkdir -p ~/.ssh
cp /integration/client_test_rsa ~/.ssh/id_rsa
chmod -R 700 ~/.ssh
cat >~/.ssh/config <<EOF
Host sshportal
Port 2222
HostName sshportal
Host testserver
Port 2222
HostName testserver
Host *
StrictHostKeyChecking no
ControlMaster auto
SendEnv TEST_*
EOF
set -x
# login
ssh sshportal -l invite:integration
# hostgroup/usergroup/acl
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 ls
ssh sshportal -l admin usergroup create
ssh sshportal -l admin usergroup create --name=ug1
ssh sshportal -l admin usergroup create --name=ug2 --comment=test
ssh sshportal -l admin usergroup inspect ug1 ug2
ssh sshportal -l admin usergroup ls
ssh sshportal -l admin acl create --ug=ug1 --ug=ug2 --hg=hg1 --hg=hg2 --comment=test --action=allow --weight=42
ssh sshportal -l admin acl inspect 2
ssh sshportal -l admin acl ls
# basic host create
ssh sshportal -l admin host create bob@example.org:1234
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 ls
# backup/restore
ssh sshportal -l admin config backup --indent --ignore-events > backup-1
ssh sshportal -l admin config restore --confirm < backup-1
ssh sshportal -l admin config backup --indent --ignore-events > backup-2
(
cat backup-1 | grep -v '"date":' | grep -v 'tedAt":' > backup-1.clean
cat backup-2 | grep -v '"date":' | grep -v 'tedAt":' > backup-2.clean
set -xe
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"]}'
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"]}'
# TODO: test more cases (forwards, scp, sftp, interactive, pty, stdin, exit code, ...)

View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAxV0ds/oMuOw9QVLFgxaM0Js2IdJKiYLnmKq96IuZ/wMqMea3
qi1UfNBPUQ2CojwbJGTea8cA9J1Et+a6v1mL66YG8zyxmhdlKHm2KOMnUXSfWPNg
ZArXH7Uj4Nx1k/O1ujfQFAsYTx63kMqwq1lM9JrExLSdp/8D/zQAyF68c82w8UZH
aIpLZJkM/fgh0VJWiw65NYAzuIkJNBgZR8rEBQU7V3lCqFGcSJ88MoqIdVGy0I4b
GGpO9VppDTf+uYGYDthhXlV0nHM45neWL5hzFK6oqbLFLpsaUOY7C3kKv+8+B3lX
p3OfGVoFy7u3evro+yRQEMQ+myS5UBIHaI3qOwIDAQABAoIBABM7/vASV3kSNOoP
2gXrha+y4LStHOyH4HBFe5qVOF3c/hi85ntkTY6YcpJwoaGUAAUs+2w/ib1NMmxF
xT9ux68gkB7WdGyTCR3HttQHR0at+fWeSm+Vit+hNKzub1sK7lQGqnW5mxXi5Xrr
9gnM+y3/g1u0SoUb2lTdyZG9gdo7LnLElzRinraEqTJUowXkqzAhGf1A+Kgp2fkb
/+QP1oiK8QeOFOsITD2UwIVCBRwRl5TjjwfLQ4El6oAWNjcL1ZfSmQLiXZ7U8Smk
Cd+BI+6ZDLA43fBUGDjbg4+2dt2JoKNkS0FfqhCW+Z2A0+ClJ8pwuMqRz8XXaOYr
ONCqOPECgYEA/qyWxSUjEWMvN3tC/mZPEbwHP3m7mbR1KGwhZylWVCmEF7kVC6il
/ICQZUI9ekyGJZ/SKZKwxDe7oeV+vFsus/9FWC5wrp45Xm4kEUwsBr4bWvuNpVOq
jrKecY8NgPZS1X6Uc5BbpiE9/VF2gCdYVVCDXP1NfO2MDhkniXJQUEMCgYEAxmQl
3s/vih9rXllPZcWHafjnFcGU1AIiJD1c+8lAqwCZzm0Bt0Ex4s1t3lp0ew6YBVXN
yGy+BORxOC9FQGTlKZNk/S705+8iAVNc9Sy7XbgN3GY3eat7XYbNpGbQrjiyZ+7I
pdEnoHWQD4NFXHaVsXaVHcBFUovXKoes2PODeqkCgYEAoN/3Ucv2zgoAjqSfmkKY
mhRT48YLOroi9AjyRM95CCs9lRrGb5n2WH4COOTSHwpuByBhSv+uCBVIwqlNGMDk
zLFpZZ3YcoXiqYMb541dlljKwPt8673hVMkCi6uZFSkFBHY0YpgDPPtsxDOMjsHL
7ACzKq+cHlmUimdbcViz4S8CgYEAr2+sVYaHixsRtVNA9PxiLQIgR4rx8zEXw/hH
m5hyiUV0vaiDlewfEzMab0CKNK/JGx6vZQdUWbsxq7+Re8o9JDDlY0b854T+CzIO
x/iQj+XMzBPQBtXvt9sXSsRo0Uft7B6qbIeyhSCxDibFVWjAIzh70N1P8BkdYsyr
uwZMRFECgYEA5QuutlFLI7hMPdBQvsEhjdVwKAj7LvpNemgxDpEoMiQWWm51XzcP
IZjlCwl1UvIE0MxowtvNr5lQuGRN8/88Dajpq+W6eeTSCKi67nn0VZh13cQLKvoX
DRZ6nfC3iLnEYKK+KN/I3NY7JcSjHmW6V8WtrCYAi2D5Ns05XJAG6t8=
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,27 @@
version: '3.0'
services:
sshportal:
image: moul/sshportal
environment:
- SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN=integration
command: server --debug
depends_on:
- testserver
ports:
- 2222
testserver:
image: moul/sshportal
command: _test_server
ports:
- 2222
client:
build: .
depends_on:
- sshportal
- testserver
#volumes:
# - .:/integration
tty: true

View File

@@ -9,7 +9,7 @@ services:
condition: service_healthy
links:
- mysql
command: --db-driver=mysql --debug --db-conn="root:root@tcp(mysql:3306)/db?charset=utf8&parseTime=true&loc=Local"
command: server --db-driver=mysql --debug --db-conn="root:root@tcp(mysql:3306)/db?charset=utf8&parseTime=true&loc=Local"
ports:
- 2222:2222

422
main.go
View File

@@ -1,40 +1,39 @@
package main
import (
"errors"
"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"
)
var (
// VERSION should be updated by hand at each release
VERSION = "1.1.0"
// GIT_TAG will be overwritten automatically by the build system
GIT_TAG string
// GIT_SHA will be overwritten automatically by the build system
GIT_SHA string
// GIT_BRANCH will be overwritten automatically by the build system
GIT_BRANCH string
)
type sshportalContextKey string
var (
userContextKey = sshportalContextKey("user")
messageContextKey = sshportalContextKey("message")
errorContextKey = sshportalContextKey("error")
// Version should be updated by hand at each release
Version = "1.7.1"
// GitTag will be overwritten automatically by the build system
GitTag string
// GitSha will be overwritten automatically by the build system
GitSha string
// GitBranch will be overwritten automatically by the build system
GitBranch string
)
func main() {
@@ -43,190 +42,109 @@ func main() {
app := cli.NewApp()
app.Name = path.Base(os.Args[0])
app.Author = "Manfred Touron"
app.Version = VERSION + " (" + GIT_SHA + ")"
app.Version = Version + " (" + GitSha + ")"
app.Email = "https://github.com/moul/sshportal"
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "bind-address, b",
EnvVar: "SSHPORTAL_BIND",
Value: ":2222",
Usage: "SSH server bind address",
},
cli.BoolFlag{
Name: "demo",
Usage: "*unsafe* - demo mode: accept all connections",
},
/*cli.StringFlag{
Name: "db-driver",
Value: "sqlite3",
Usage: "GORM driver (sqlite3)",
},*/
cli.StringFlag{
Name: "db-conn",
Value: "./sshportal.db",
Usage: "GORM connection string",
},
cli.BoolFlag{
Name: "debug, D",
Usage: "Display debug information",
},
cli.StringFlag{
Name: "config-user",
Usage: "SSH user that spawns a configuration shell",
Value: "admin",
app.Commands = []cli.Command{
{
Name: "server",
Usage: "Start sshportal server",
Action: server,
Flags: []cli.Flag{
cli.StringFlag{
Name: "bind-address, b",
EnvVar: "SSHPORTAL_BIND",
Value: ":2222",
Usage: "SSH server bind address",
},
cli.StringFlag{
Name: "db-driver",
Value: "sqlite3",
Usage: "GORM driver (sqlite3)",
},
cli.StringFlag{
Name: "db-conn",
Value: "./sshportal.db",
Usage: "GORM connection string",
},
cli.BoolFlag{
Name: "debug, D",
Usage: "Display debug information",
},
cli.StringFlag{
Name: "aes-key",
Usage: "Encrypt sensitive data in database (length: 16, 24 or 32)",
},
},
}, {
Name: "healthcheck",
Action: healthcheck,
Flags: []cli.Flag{
cli.StringFlag{
Name: "addr, a",
Value: "localhost:2222",
Usage: "sshportal server address",
},
cli.BoolFlag{
Name: "wait, w",
Usage: "Loop indefinitely until sshportal is ready",
},
cli.BoolFlag{
Name: "quiet, q",
Usage: "Do not print errors, if any",
},
},
}, {
Name: "_test_server",
Hidden: true,
Action: testServer,
},
}
app.Action = server
if err := app.Run(os.Args); err != nil {
log.Fatalf("error: %v", err)
}
}
func server(c *cli.Context) error {
db, err := gorm.Open("sqlite3", c.String("db-conn"))
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 db.Close()
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 {
if err = dbInit(db); err != nil {
return err
}
ssh.Handle(func(s ssh.Session) {
currentUser := s.Context().Value(userContextKey).(User)
log.Printf("New connection: sshUser=%q remote=%q local=%q command=%q dbUser=id:%q,email:%s", s.User(), s.RemoteAddr(), s.LocalAddr(), s.Command(), currentUser.ID, currentUser.Email)
if err := s.Context().Value(errorContextKey); err != nil {
fmt.Fprintf(s, "error: %v\n", err)
return
}
if msg := s.Context().Value(messageContextKey); msg != nil {
fmt.Fprint(s, msg.(string))
}
switch username := s.User(); {
case username == c.String("config-user"):
if !currentUser.IsAdmin {
fmt.Fprintf(s, "You are not an administrator, permission denied.\n")
return
}
if err := shell(c, s, s.Command(), db); err != nil {
fmt.Fprintf(s, "error: %v\n", err)
}
case strings.HasPrefix(username, "invite:"):
return
default:
host, err := RemoteHostFromSession(s, db)
if err != nil {
fmt.Fprintf(s, "error: %v\n", err)
// FIXME: print available hosts
return
}
// load up-to-date objects
// FIXME: cache them or try not to load them
var tmpUser User
if err := db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", currentUser.ID).First(&tmpUser).Error; err != nil {
fmt.Fprintf(s, "error: %v\n", err)
return
}
var tmpHost Host
if err := db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err != nil {
fmt.Fprintf(s, "error: %v\n", err)
return
}
action, err := CheckACLs(tmpUser, tmpHost)
if err != nil {
fmt.Fprintf(s, "error: %v\n", err)
return
}
switch action {
case "allow":
if err := proxy(s, host); err != nil {
fmt.Fprintf(s, "error: %v\n", err)
}
case "deny":
fmt.Fprintf(s, "You don't have permission to that host.\n")
default:
fmt.Fprintf(s, "error: %v\n", err)
}
}
})
opts := []ssh.Option{}
if c.Bool("demo") {
if c.Bool("demo") {
if err := dbDemo(db); err != nil {
return err
}
}
}
opts = append(opts, ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
var (
userKey UserKey
user User
username = ctx.User()
)
// lookup user by key
db.Where("key = ?", key.Marshal()).First(&userKey)
if userKey.UserID > 0 {
db.Where("id = ?", userKey.UserID).First(&user)
if strings.HasPrefix(username, "invite:") {
ctx.SetValue(errorContextKey, fmt.Errorf("invites are only supported for ney SSH keys; your ssh key is already associated with the user %q.", user.Email))
}
ctx.SetValue(userContextKey, user)
return true
}
// handle invite "links"
if strings.HasPrefix(username, "invite:") {
inputToken := strings.Split(username, ":")[1]
if len(inputToken) == 16 {
db.Where("invite_token = ?", inputToken).First(&user)
}
if user.ID > 0 {
userKey = UserKey{
UserID: user.ID,
Key: key.Marshal(),
Comment: "created by sshportal",
}
db.Create(&userKey)
// token is only usable once
user.InviteToken = ""
db.Update(&user)
ctx.SetValue(messageContextKey, fmt.Sprintf("Welcome %s!\n\nYour key is now associated with the user %q.\n", user.Name, user.Email))
ctx.SetValue(userContextKey, user)
} else {
ctx.SetValue(userContextKey, User{Name: "Anonymous"})
ctx.SetValue(errorContextKey, errors.New("your token is invalid or expired"))
}
return true
}
// fallback
ctx.SetValue(errorContextKey, errors.New("unknown ssh key"))
ctx.SetValue(userContextKey, User{Name: "Anonymous"})
return true
}))
// 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 {
key, err := FindKeyByIdOrName(db, "host")
if err != nil {
var key SSHKey
if err = SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
return err
}
signer, err := gossh.ParsePrivateKey([]byte(key.PrivKey))
SSHKeyDecrypt(c.String("aes-key"), &key)
var signer gossh.Signer
signer, err = gossh.ParsePrivateKey([]byte(key.PrivKey))
if err != nil {
return err
}
@@ -234,6 +152,150 @@ func server(c *cli.Context) error {
return nil
})
log.Printf("SSH Server accepting connections on %s", c.String("bind-address"))
return ssh.ListenAndServe(c.String("bind-address"), nil, opts...)
// 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)
}

View File

@@ -1,40 +1,49 @@
package main
package bastionsession
import (
"errors"
"fmt"
"io"
"log"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
)
func proxy(s ssh.Session, host *Host) error {
config, err := host.ClientConfig(s)
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
}
rconn, err := gossh.Dial("tcp", host.Addr, config)
if err != nil {
return err
}
defer rconn.Close()
defer func() { _ = rconn.Close() }()
rch, rreqs, err := rconn.OpenChannel("session", []byte{})
if err != nil {
return err
}
log.Println("SSH Connectin established")
return pipe(s.MaskedReqs(), rreqs, s, rch)
// 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()
_ = lch.Close()
_ = rch.Close()
}()
errch := make(chan error, 1)
@@ -59,7 +68,9 @@ func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel) error {
if err != nil {
return err
}
req.Reply(b, nil)
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
@@ -68,32 +79,11 @@ func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel) error {
if err != nil {
return err
}
req.Reply(b, nil)
if err2 := req.Reply(b, nil); err2 != nil {
return err2
}
case err := <-errch:
return err
}
}
return nil
}
func (host *Host) ClientConfig(_ ssh.Session) (*gossh.ClientConfig, error) {
config := gossh.ClientConfig{
User: host.User,
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Auth: []gossh.AuthMethod{},
}
if host.SSHKey != nil {
signer, err := gossh.ParsePrivateKey([]byte(host.SSHKey.PrivKey))
if err != nil {
return nil, err
}
config.Auth = append(config.Auth, gossh.PublicKeys(signer))
}
if host.Password != "" {
config.Auth = append(config.Auth, gossh.Password(host.Password))
}
if len(config.Auth) == 0 {
return nil, fmt.Errorf("no valid authentication method for host %q", host.Name)
}
return &config, nil
}

1346
shell.go

File diff suppressed because it is too large Load Diff

290
ssh.go Normal file
View File

@@ -0,0 +1,290 @@
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
}
}

View File

@@ -4,10 +4,17 @@ import "math/rand"
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func RandStringBytes(n int) string {
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] + "..."
}

21
vendor/github.com/asaskevich/govalidator/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
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.

410
vendor/github.com/asaskevich/govalidator/README.md generated vendored Normal file
View File

@@ -0,0 +1,410 @@
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)

58
vendor/github.com/asaskevich/govalidator/arrays.go generated vendored Normal file
View File

@@ -0,0 +1,58 @@
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
}

49
vendor/github.com/asaskevich/govalidator/converter.go generated vendored Normal file
View File

@@ -0,0 +1,49 @@
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
}

31
vendor/github.com/asaskevich/govalidator/error.go generated vendored Normal file
View File

@@ -0,0 +1,31 @@
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()
}

57
vendor/github.com/asaskevich/govalidator/numerics.go generated vendored Normal file
View File

@@ -0,0 +1,57 @@
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)
}

91
vendor/github.com/asaskevich/govalidator/patterns.go generated vendored Normal file
View File

@@ -0,0 +1,91 @@
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)
)

385
vendor/github.com/asaskevich/govalidator/types.go generated vendored Normal file
View File

@@ -0,0 +1,385 @@
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"},
}

268
vendor/github.com/asaskevich/govalidator/utils.go generated vendored Normal file
View File

@@ -0,0 +1,268 @@
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
}

1022
vendor/github.com/asaskevich/govalidator/validator.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

15
vendor/github.com/asaskevich/govalidator/wercker.yml generated vendored Normal file
View File

@@ -0,0 +1,15 @@
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 ./...

21
vendor/github.com/dustin/go-humanize/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
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>

92
vendor/github.com/dustin/go-humanize/README.markdown generated vendored Normal file
View File

@@ -0,0 +1,92 @@
# 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

31
vendor/github.com/dustin/go-humanize/big.go generated vendored Normal file
View File

@@ -0,0 +1,31 @@
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
}

173
vendor/github.com/dustin/go-humanize/bigbytes.go generated vendored Normal file
View File

@@ -0,0 +1,173 @@
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)
}

143
vendor/github.com/dustin/go-humanize/bytes.go generated vendored Normal file
View File

@@ -0,0 +1,143 @@
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)
}

108
vendor/github.com/dustin/go-humanize/comma.go generated vendored Normal file
View File

@@ -0,0 +1,108 @@
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:], ",")
}

40
vendor/github.com/dustin/go-humanize/commaf.go generated vendored Normal file
View File

@@ -0,0 +1,40 @@
// +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()
}

23
vendor/github.com/dustin/go-humanize/ftoa.go generated vendored Normal file
View File

@@ -0,0 +1,23 @@
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))
}

8
vendor/github.com/dustin/go-humanize/humanize.go generated vendored Normal file
View File

@@ -0,0 +1,8 @@
/*
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

192
vendor/github.com/dustin/go-humanize/number.go generated vendored Normal file
View File

@@ -0,0 +1,192 @@
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))
}

25
vendor/github.com/dustin/go-humanize/ordinals.go generated vendored Normal file
View File

@@ -0,0 +1,25 @@
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
}

113
vendor/github.com/dustin/go-humanize/si.go generated vendored Normal file
View File

@@ -0,0 +1,113 @@
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
}

117
vendor/github.com/dustin/go-humanize/times.go generated vendored Normal file
View File

@@ -0,0 +1,117 @@
package humanize
import (
"fmt"
"math"
"sort"
"time"
)
// Seconds-based time units
const (
Day = 24 * time.Hour
Week = 7 * Day
Month = 30 * Day
Year = 12 * Month
LongTime = 37 * Year
)
// Time formats a time into a relative string.
//
// Time(someT) -> "3 weeks ago"
func Time(then time.Time) string {
return RelTime(then, time.Now(), "ago", "from now")
}
// A RelTimeMagnitude struct contains a relative time point at which
// the relative format of time will switch to a new format string. A
// slice of these in ascending order by their "D" field is passed to
// CustomRelTime to format durations.
//
// The Format field is a string that may contain a "%s" which will be
// replaced with the appropriate signed label (e.g. "ago" or "from
// now") and a "%d" that will be replaced by the quantity.
//
// The DivBy field is the amount of time the time difference must be
// divided by in order to display correctly.
//
// e.g. if D is 2*time.Minute and you want to display "%d minutes %s"
// DivBy should be time.Minute so whatever the duration is will be
// expressed in minutes.
type RelTimeMagnitude struct {
D time.Duration
Format string
DivBy time.Duration
}
var defaultMagnitudes = []RelTimeMagnitude{
{time.Second, "now", time.Second},
{2 * time.Second, "1 second %s", 1},
{time.Minute, "%d seconds %s", time.Second},
{2 * time.Minute, "1 minute %s", 1},
{time.Hour, "%d minutes %s", time.Minute},
{2 * time.Hour, "1 hour %s", 1},
{Day, "%d hours %s", time.Hour},
{2 * Day, "1 day %s", 1},
{Week, "%d days %s", Day},
{2 * Week, "1 week %s", 1},
{Month, "%d weeks %s", Week},
{2 * Month, "1 month %s", 1},
{Year, "%d months %s", Month},
{18 * Month, "1 year %s", 1},
{2 * Year, "2 years %s", 1},
{LongTime, "%d years %s", Year},
{math.MaxInt64, "a long while %s", 1},
}
// RelTime formats a time into a relative string.
//
// It takes two times and two labels. In addition to the generic time
// delta string (e.g. 5 minutes), the labels are used applied so that
// the label corresponding to the smaller time is applied.
//
// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier"
func RelTime(a, b time.Time, albl, blbl string) string {
return CustomRelTime(a, b, albl, blbl, defaultMagnitudes)
}
// CustomRelTime formats a time into a relative string.
//
// It takes two times two labels and a table of relative time formats.
// In addition to the generic time delta string (e.g. 5 minutes), the
// labels are used applied so that the label corresponding to the
// smaller time is applied.
func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string {
lbl := albl
diff := b.Sub(a)
if a.After(b) {
lbl = blbl
diff = a.Sub(b)
}
n := sort.Search(len(magnitudes), func(i int) bool {
return magnitudes[i].D >= diff
})
if n >= len(magnitudes) {
n = len(magnitudes) - 1
}
mag := magnitudes[n]
args := []interface{}{}
escaped := false
for _, ch := range mag.Format {
if escaped {
switch ch {
case 's':
args = append(args, lbl)
case 'd':
args = append(args, diff/mag.DivBy)
}
escaped = false
} else {
escaped = ch == '%'
}
}
return fmt.Sprintf(mag.Format, args...)
}

View File

@@ -14,7 +14,7 @@ package](https://godoc.org/golang.org/x/crypto/ssh) with a higher-level API for
building SSH servers. The goal of the API was to make it as simple as using
[net/http](https://golang.org/pkg/net/http/), so the API is very similar:
```
```go
package main
import (

View File

@@ -22,8 +22,10 @@ const (
// client requested agent forwarding
var contextKeyAgentRequest = &contextKey{"auth-agent-req"}
func setAgentRequested(sess *session) {
sess.ctx.SetValue(contextKeyAgentRequest, true)
// SetAgentRequested sets up the session context so that AgentRequested
// returns true.
func SetAgentRequested(ctx Context) {
ctx.SetValue(contextKeyAgentRequest, true)
}
// AgentRequested returns true if the client requested agent forwarding.

View File

@@ -103,7 +103,7 @@ func newContext(srv *Server) (*sshContext, context.CancelFunc) {
// this is separate from newContext because we will get ConnMetadata
// at different points so it needs to be applied separately
func (ctx *sshContext) applyConnMetadata(conn gossh.ConnMetadata) {
func applyConnMetadata(ctx Context, conn gossh.ConnMetadata) {
if ctx.Value(ContextKeySessionID) != nil {
return
}

View File

@@ -26,6 +26,7 @@ type Server struct {
PasswordHandler PasswordHandler // password authentication handler
PublicKeyHandler PublicKeyHandler // public key authentication handler
ChannelHandler ChannelHandler // channel handler
PtyCallback PtyCallback // callback for allowing PTY sessions, allows all if nil
ConnCallback ConnCallback // optional callback for wrapping net.Conn before handling
LocalPortForwardingCallback LocalPortForwardingCallback // callback for allowing local port forwarding, denies all if nil
@@ -33,16 +34,13 @@ type Server struct {
IdleTimeout time.Duration // connection timeout when no activity, none if empty
MaxTimeout time.Duration // absolute connection timeout, none if empty
channelHandlers map[string]channelHandler
mu sync.Mutex
listeners map[net.Listener]struct{}
conns map[*gossh.ServerConn]struct{}
doneChan chan struct{}
}
// internal for now
type channelHandler func(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx *sshContext)
type ChannelHandler func(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx Context)
func (srv *Server) ensureHostSigner() error {
if len(srv.HostSigners) == 0 {
@@ -55,11 +53,7 @@ func (srv *Server) ensureHostSigner() error {
return nil
}
func (srv *Server) config(ctx *sshContext) *gossh.ServerConfig {
srv.channelHandlers = map[string]channelHandler{
"session": sessionHandler,
"direct-tcpip": directTcpipHandler,
}
func (srv *Server) config(ctx Context) *gossh.ServerConfig {
config := &gossh.ServerConfig{}
for _, signer := range srv.HostSigners {
config.AddHostKey(signer)
@@ -72,7 +66,7 @@ func (srv *Server) config(ctx *sshContext) *gossh.ServerConfig {
}
if srv.PasswordHandler != nil {
config.PasswordCallback = func(conn gossh.ConnMetadata, password []byte) (*gossh.Permissions, error) {
ctx.applyConnMetadata(conn)
applyConnMetadata(ctx, conn)
if ok := srv.PasswordHandler(ctx, string(password)); !ok {
return ctx.Permissions().Permissions, fmt.Errorf("permission denied")
}
@@ -81,7 +75,7 @@ func (srv *Server) config(ctx *sshContext) *gossh.ServerConfig {
}
if srv.PublicKeyHandler != nil {
config.PublicKeyCallback = func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
ctx.applyConnMetadata(conn)
applyConnMetadata(ctx, conn)
if ok := srv.PublicKeyHandler(ctx, key); !ok {
return ctx.Permissions().Permissions, fmt.Errorf("permission denied")
}
@@ -223,15 +217,25 @@ func (srv *Server) handleConn(newConn net.Conn) {
defer srv.trackConn(sshConn, false)
ctx.SetValue(ContextKeyConn, sshConn)
ctx.applyConnMetadata(sshConn)
applyConnMetadata(ctx, sshConn)
go gossh.DiscardRequests(reqs)
for ch := range chans {
handler, found := srv.channelHandlers[ch.ChannelType()]
if !found {
ch.Reject(gossh.UnknownChannelType, "unsupported channel type")
continue
if srv.ChannelHandler == nil {
DefaultChannelHandler(srv, sshConn, ch, ctx)
} else {
srv.ChannelHandler(srv, sshConn, ch, ctx)
}
go handler(srv, sshConn, ch, ctx)
}
}
func DefaultChannelHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx Context) {
switch newChan.ChannelType() {
case "session":
go sessionHandler(srv, conn, newChan, ctx)
case "direct-tcpip":
go directTcpipHandler(srv, conn, newChan, ctx)
default:
newChan.Reject(gossh.UnknownChannelType, "unsupported channel type")
}
}

View File

@@ -71,27 +71,24 @@ type Session interface {
// If there are buffered signals when a channel is registered, they will be
// sent in order on the channel immediately after registering.
Signals(c chan<- Signal)
MaskedReqs() chan *gossh.Request
}
// maxSigBufSize is how many signals will be buffered
// when there is no signal channel specified
const maxSigBufSize = 128
func sessionHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx *sshContext) {
func sessionHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx Context) {
ch, reqs, err := newChan.Accept()
if err != nil {
// TODO: trigger event callback
return
}
sess := &session{
Channel: ch,
conn: conn,
handler: srv.Handler,
ptyCb: srv.PtyCallback,
maskedReqs: make(chan *gossh.Request, 5),
ctx: ctx,
Channel: ch,
conn: conn,
handler: srv.Handler,
ptyCb: srv.PtyCallback,
ctx: ctx,
}
sess.handleRequests(reqs)
}
@@ -99,19 +96,18 @@ func sessionHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChanne
type session struct {
sync.Mutex
gossh.Channel
conn *gossh.ServerConn
handler Handler
handled bool
exited bool
pty *Pty
winch chan Window
env []string
ptyCb PtyCallback
cmd []string
ctx *sshContext
sigCh chan<- Signal
sigBuf []Signal
maskedReqs chan *gossh.Request
conn *gossh.ServerConn
handler Handler
handled bool
exited bool
pty *Pty
winch chan Window
env []string
ptyCb PtyCallback
cmd []string
ctx Context
sigCh chan<- Signal
sigBuf []Signal
}
func (sess *session) Write(p []byte) (n int, err error) {
@@ -146,13 +142,12 @@ func (sess *session) Permissions() Permissions {
}
func (sess *session) Context() context.Context {
return sess.ctx.Context
return sess.ctx
}
func (sess *session) Exit(code int) error {
sess.Lock()
defer sess.Unlock()
if sess.exited {
return errors.New("Session.Exit called multiple times")
}
@@ -163,9 +158,6 @@ func (sess *session) Exit(code int) error {
if err != nil {
return err
}
close(sess.maskedReqs)
return sess.Close()
}
@@ -209,10 +201,6 @@ func (sess *session) Signals(c chan<- Signal) {
}
}
func (sess *session) MaskedReqs() chan *gossh.Request {
return sess.maskedReqs
}
func (sess *session) handleRequests(reqs <-chan *gossh.Request) {
for req := range reqs {
switch req.Type {
@@ -290,12 +278,10 @@ func (sess *session) handleRequests(reqs <-chan *gossh.Request) {
req.Reply(ok, nil)
case agentRequestType:
// TODO: option/callback to allow agent forwarding
setAgentRequested(sess)
SetAgentRequested(sess.ctx)
req.Reply(true, nil)
default:
// TODO: debug log
}
sess.maskedReqs <- req
}
}

View File

@@ -17,7 +17,7 @@ type forwardData struct {
OriginatorPort uint32
}
func directTcpipHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx *sshContext) {
func directTcpipHandler(srv *Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx Context) {
d := forwardData{}
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
newChan.Reject(gossh.ConnectionFailed, "error parsing forward data: "+err.Error())

View File

@@ -9,7 +9,7 @@ import (
)
func generateSigner() (ssh.Signer, error) {
key, err := rsa.GenerateKey(rand.Reader, 768)
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}

8
vendor/github.com/go-gormigrate/gormigrate/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,8 @@
MIT License
Copyright (c) 2016 Andrey Nering
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.

170
vendor/github.com/go-gormigrate/gormigrate/README.md generated vendored Normal file
View File

@@ -0,0 +1,170 @@
[![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](https://github.com/go-gormigrate/gormigrate/blob/master/LICENSE)
[![GoDoc](https://godoc.org/gopkg.in/gormigrate.v1?status.svg)](https://godoc.org/gopkg.in/gormigrate.v1)
[![Go Report Card](https://goreportcard.com/badge/gopkg.in/gormigrate.v1)](https://goreportcard.com/report/gopkg.in/gormigrate.v1)
[![Build Status](https://travis-ci.org/go-gormigrate/gormigrate.svg?branch=master)](https://travis-ci.org/go-gormigrate/gormigrate)
# Gormigrate
[![Join the chat at https://gitter.im/go-gormigrate/gormigrate](https://badges.gitter.im/go-gormigrate/gormigrate.svg)](https://gitter.im/go-gormigrate/gormigrate?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Gormigrate is a migration helper for [Gorm][gorm].
Gorm already have useful [migrate functions][gormmigrate], just misses
proper schema versioning and rollback cababilities.
## Supported databases
It supports the databases [Gorm supports][gormdatabases]:
- PostgreSQL
- MySQL
- SQLite
- Microsoft SQL Server
## Installing
```bash
go get -u gopkg.in/gormigrate.v1
```
## Usage
```go
package main
import (
"log"
"gopkg.in/gormigrate.v1"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite"
)
func main() {
db, err := gorm.Open("sqlite3", "mydb.sqlite3")
if err != nil {
log.Fatal(err)
}
if err = db.DB().Ping(); err != nil {
log.Fatal(err)
}
db.LogMode(true)
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
// create persons table
{
ID: "201608301400",
Migrate: func(tx *gorm.DB) error {
// it's a good pratice to copy the struct inside the function,
// so side effects are prevented if the original struct changes during the time
type Person struct {
gorm.Model
Name string
}
return tx.AutoMigrate(&Person{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("people").Error
},
},
// add age column to persons
{
ID: "201608301415",
Migrate: func(tx *gorm.DB) error {
// when table already exists, it just adds fields as columns
type Person struct {
Age int
}
return tx.AutoMigrate(&Person{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Table("people").DropColumn("age").Error
},
},
// add pets table
{
ID: "201608301430",
Migrate: func(tx *gorm.DB) error {
type Pet struct {
gorm.Model
Name string
PersonID int
}
return tx.AutoMigrate(&Pet{}).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.DropTable("pets").Error
},
},
})
if err = m.Migrate(); err != nil {
log.Fatalf("Could not migrate: %v", err)
}
log.Printf("Migration did run successfully")
}
```
## Having a separated function for initializing the schema
If you have a lot of migrations, it can be a pain to run all them, as example,
when you are deploying a new instance of the app, in a clean database.
To prevent this, you can set a function that will run if no migration was run
before (in a new clean database). Remember to create everything here, all tables,
foreign keys and what more you need in your app.
```go
type Person struct {
gorm.Model
Name string
Age int
}
type Pet struct {
gorm.Model
Name string
PersonID int
}
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
// you migrations here
})
m.InitSchema(func(tx *gorm.DB) error {
err := tx.AutoMigrate(
&Person{},
&Pet{},
// all other tables of you app
)
if err != nil {
return err
}
if err := tx.Model(Pet{}).AddForeignKey("person_id", "people (id)", "RESTRICT", "RESTRICT").Error; err != nil {
return err
}
// all other foreign keys...
return nil
})
```
## Options
This is the options struct, in case you don't want the defaults:
```go
type Options struct {
// Migrations table name. Default to "migrations".
TableName string
// The of the column that stores the id of migrations. Defaults to "id".
IDColumnName string
// UseTransaction makes Gormigrate execute migrations inside a single transaction.
// Keep in mind that not all databases support DDL commands inside transactions.
// Defaults to false.
UseTransaction bool
}
```
[gorm]: http://jinzhu.me/gorm/
[gormmigrate]: http://jinzhu.me/gorm/database.html#migration
[gormdatabases]: http://jinzhu.me/gorm/database.html#connecting-to-a-database

View File

@@ -0,0 +1,16 @@
# https://github.com/go-task/task
dl-deps:
desc: Downloads cli dependencies
cmds:
- go get -u github.com/golang/lint/golint
lint:
desc: Runs golint on this project
cmds:
- golint .
test:
desc: Runs automated tests for this project
cmds:
- go test -v .

68
vendor/github.com/go-gormigrate/gormigrate/doc.go generated vendored Normal file
View File

@@ -0,0 +1,68 @@
// Package gormigrate is a migration helper for Gorm (http://jinzhu.me/gorm/).
// Gorm already have useful migrate functions
// (http://jinzhu.me/gorm/database.html#migration), just misses
// proper schema versioning and rollback cababilities.
//
// Example:
//
// package main
//
// import (
// "log"
//
// "github.com/go-gormigrate/gormigrate"
// "github.com/jinzhu/gorm"
// _ "github.com/jinzhu/gorm/dialects/sqlite"
// )
//
// type Person struct {
// gorm.Model
// Name string
// }
//
// type Pet struct {
// gorm.Model
// Name string
// PersonID int
// }
//
// func main() {
// db, err := gorm.Open("sqlite3", "mydb.sqlite3")
// if err != nil {
// log.Fatal(err)
// }
// if err = db.DB().Ping(); err != nil {
// log.Fatal(err)
// }
//
// db.LogMode(true)
//
// m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
// {
// ID: "201608301400",
// Migrate: func(tx *gorm.DB) error {
// return tx.AutoMigrate(&Person{}).Error
// },
// Rollback: func(tx *gorm.DB) error {
// return tx.DropTable("people").Error
// },
// },
// {
// ID: "201608301430",
// Migrate: func(tx *gorm.DB) error {
// return tx.AutoMigrate(&Pet{}).Error
// },
// Rollback: func(tx *gorm.DB) error {
// return tx.DropTable("pets").Error
// },
// },
// })
//
// err = m.Migrate()
// if err == nil {
// log.Printf("Migration did run successfully")
// } else {
// log.Printf("Could not migrate: %v", err)
// }
// }
package gormigrate

View File

@@ -0,0 +1,285 @@
package gormigrate
import (
"errors"
"fmt"
"github.com/jinzhu/gorm"
)
// MigrateFunc is the func signature for migrating.
type MigrateFunc func(*gorm.DB) error
// RollbackFunc is the func signature for rollbacking.
type RollbackFunc func(*gorm.DB) error
// InitSchemaFunc is the func signature for initializing the schema.
type InitSchemaFunc func(*gorm.DB) error
// Options define options for all migrations.
type Options struct {
// TableName is the migration table.
TableName string
// IDColumnName is the name of column where the migration id will be stored.
IDColumnName string
// IDColumnSize is the length of the migration id column
IDColumnSize int
// UseTransaction makes Gormigrate execute migrations inside a single transaction.
// Keep in mind that not all databases support DDL commands inside transactions.
UseTransaction bool
}
// Migration represents a database migration (a modification to be made on the database).
type Migration struct {
// ID is the migration identifier. Usually a timestamp like "201601021504".
ID string
// Migrate is a function that will br executed while running this migration.
Migrate MigrateFunc
// Rollback will be executed on rollback. Can be nil.
Rollback RollbackFunc
}
// Gormigrate represents a collection of all migrations of a database schema.
type Gormigrate struct {
db *gorm.DB
tx *gorm.DB
options *Options
migrations []*Migration
initSchema InitSchemaFunc
}
// DuplicatedIDError is returned when more than one migration have the same ID
type DuplicatedIDError struct {
ID string
}
func (e *DuplicatedIDError) Error() string {
return fmt.Sprintf(`Duplicated migration ID: "%s"`, e.ID)
}
var (
// DefaultOptions can be used if you don't want to think about options.
DefaultOptions = &Options{
TableName: "migrations",
IDColumnName: "id",
IDColumnSize: 255,
UseTransaction: false,
}
// ErrRollbackImpossible is returned when trying to rollback a migration
// that has no rollback function.
ErrRollbackImpossible = errors.New("It's impossible to rollback this migration")
// ErrNoMigrationDefined is returned when no migration is defined.
ErrNoMigrationDefined = errors.New("No migration defined")
// ErrMissingID is returned when the ID od migration is equal to ""
ErrMissingID = errors.New("Missing ID in migration")
// ErrNoRunnedMigration is returned when any runned migration was found while
// running RollbackLast
ErrNoRunnedMigration = errors.New("Could not find last runned migration")
)
// New returns a new Gormigrate.
func New(db *gorm.DB, options *Options, migrations []*Migration) *Gormigrate {
if options.TableName == "" {
options.TableName = DefaultOptions.TableName
}
if options.IDColumnName == "" {
options.IDColumnName = DefaultOptions.IDColumnName
}
if options.IDColumnSize == 0 {
options.IDColumnSize = DefaultOptions.IDColumnSize
}
return &Gormigrate{
db: db,
options: options,
migrations: migrations,
}
}
// InitSchema sets a function that is run if no migration is found.
// The idea is preventing to run all migrations when a new clean database
// is being migrating. In this function you should create all tables and
// foreign key necessary to your application.
func (g *Gormigrate) InitSchema(initSchema InitSchemaFunc) {
g.initSchema = initSchema
}
// Migrate executes all migrations that did not run yet.
func (g *Gormigrate) Migrate() error {
if err := g.checkDuplicatedID(); err != nil {
return err
}
if err := g.createMigrationTableIfNotExists(); err != nil {
return err
}
g.begin()
if g.initSchema != nil && g.isFirstRun() {
if err := g.runInitSchema(); err != nil {
g.rollback()
return err
}
return g.commit()
}
for _, migration := range g.migrations {
if err := g.runMigration(migration); err != nil {
g.rollback()
return err
}
}
return g.commit()
}
func (g *Gormigrate) checkDuplicatedID() error {
lookup := make(map[string]struct{}, len(g.migrations))
for _, m := range g.migrations {
if _, ok := lookup[m.ID]; ok {
return &DuplicatedIDError{ID: m.ID}
}
lookup[m.ID] = struct{}{}
}
return nil
}
// RollbackLast undo the last migration
func (g *Gormigrate) RollbackLast() error {
if len(g.migrations) == 0 {
return ErrNoMigrationDefined
}
lastRunnedMigration, err := g.getLastRunnedMigration()
if err != nil {
return err
}
if err := g.RollbackMigration(lastRunnedMigration); err != nil {
return err
}
return nil
}
func (g *Gormigrate) getLastRunnedMigration() (*Migration, error) {
for i := len(g.migrations) - 1; i >= 0; i-- {
migration := g.migrations[i]
if g.migrationDidRun(migration) {
return migration, nil
}
}
return nil, ErrNoRunnedMigration
}
// RollbackMigration undo a migration.
func (g *Gormigrate) RollbackMigration(m *Migration) error {
if m.Rollback == nil {
return ErrRollbackImpossible
}
g.begin()
if err := m.Rollback(g.tx); err != nil {
return err
}
sql := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", g.options.TableName, g.options.IDColumnName)
if err := g.db.Exec(sql, m.ID).Error; err != nil {
g.rollback()
return err
}
return g.commit()
}
func (g *Gormigrate) runInitSchema() error {
if err := g.initSchema(g.tx); err != nil {
return err
}
for _, migration := range g.migrations {
if err := g.insertMigration(migration.ID); err != nil {
return err
}
}
return nil
}
func (g *Gormigrate) runMigration(migration *Migration) error {
if len(migration.ID) == 0 {
return ErrMissingID
}
if !g.migrationDidRun(migration) {
if err := migration.Migrate(g.tx); err != nil {
return err
}
if err := g.insertMigration(migration.ID); err != nil {
return err
}
}
return nil
}
func (g *Gormigrate) createMigrationTableIfNotExists() error {
if g.db.HasTable(g.options.TableName) {
return nil
}
sql := fmt.Sprintf("CREATE TABLE %s (%s VARCHAR(%d) PRIMARY KEY)", g.options.TableName, g.options.IDColumnName, g.options.IDColumnSize)
if err := g.db.Exec(sql).Error; err != nil {
return err
}
return nil
}
func (g *Gormigrate) migrationDidRun(m *Migration) bool {
var count int
g.db.
Table(g.options.TableName).
Where(fmt.Sprintf("%s = ?", g.options.IDColumnName), m.ID).
Count(&count)
return count > 0
}
func (g *Gormigrate) isFirstRun() bool {
var count int
g.db.
Table(g.options.TableName).
Count(&count)
return count == 0
}
func (g *Gormigrate) insertMigration(id string) error {
sql := fmt.Sprintf("INSERT INTO %s (%s) VALUES (?)", g.options.TableName, g.options.IDColumnName)
return g.tx.Exec(sql, id).Error
}
func (g *Gormigrate) begin() {
if g.options.UseTransaction {
g.tx = g.db.Begin()
} else {
g.tx = g.db
}
}
func (g *Gormigrate) commit() error {
if g.options.UseTransaction {
if err := g.tx.Commit().Error; err != nil {
return err
}
}
return nil
}
func (g *Gormigrate) rollback() {
if g.options.UseTransaction {
g.tx.Rollback()
}
}

23
vendor/github.com/kr/pty/License generated vendored Normal file
View File

@@ -0,0 +1,23 @@
Copyright (c) 2011 Keith Rarick
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.

36
vendor/github.com/kr/pty/README.md generated vendored Normal file
View File

@@ -0,0 +1,36 @@
# pty
Pty is a Go package for using unix pseudo-terminals.
## Install
go get github.com/kr/pty
## Example
```go
package main
import (
"github.com/kr/pty"
"io"
"os"
"os/exec"
)
func main() {
c := exec.Command("grep", "--color=auto", "bar")
f, err := pty.Start(c)
if err != nil {
panic(err)
}
go func() {
f.Write([]byte("foo\n"))
f.Write([]byte("bar\n"))
f.Write([]byte("baz\n"))
f.Write([]byte{4}) // EOT
}()
io.Copy(os.Stdout, f)
}
```

16
vendor/github.com/kr/pty/doc.go generated vendored Normal file
View File

@@ -0,0 +1,16 @@
// Package pty provides functions for working with Unix terminals.
package pty
import (
"errors"
"os"
)
// ErrUnsupported is returned if a function is not
// available on the current platform.
var ErrUnsupported = errors.New("unsupported")
// Opens a pty and its corresponding tty.
func Open() (pty, tty *os.File, err error) {
return open()
}

13
vendor/github.com/kr/pty/ioctl.go generated vendored Normal file
View File

@@ -0,0 +1,13 @@
// +build !windows
package pty
import "syscall"
func ioctl(fd, cmd, ptr uintptr) error {
_, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, cmd, ptr)
if e != 0 {
return e
}
return nil
}

39
vendor/github.com/kr/pty/ioctl_bsd.go generated vendored Normal file
View File

@@ -0,0 +1,39 @@
// +build darwin dragonfly freebsd netbsd openbsd
package pty
// from <sys/ioccom.h>
const (
_IOC_VOID uintptr = 0x20000000
_IOC_OUT uintptr = 0x40000000
_IOC_IN uintptr = 0x80000000
_IOC_IN_OUT uintptr = _IOC_OUT | _IOC_IN
_IOC_DIRMASK = _IOC_VOID | _IOC_OUT | _IOC_IN
_IOC_PARAM_SHIFT = 13
_IOC_PARAM_MASK = (1 << _IOC_PARAM_SHIFT) - 1
)
func _IOC_PARM_LEN(ioctl uintptr) uintptr {
return (ioctl >> 16) & _IOC_PARAM_MASK
}
func _IOC(inout uintptr, group byte, ioctl_num uintptr, param_len uintptr) uintptr {
return inout | (param_len&_IOC_PARAM_MASK)<<16 | uintptr(group)<<8 | ioctl_num
}
func _IO(group byte, ioctl_num uintptr) uintptr {
return _IOC(_IOC_VOID, group, ioctl_num, 0)
}
func _IOR(group byte, ioctl_num uintptr, param_len uintptr) uintptr {
return _IOC(_IOC_OUT, group, ioctl_num, param_len)
}
func _IOW(group byte, ioctl_num uintptr, param_len uintptr) uintptr {
return _IOC(_IOC_IN, group, ioctl_num, param_len)
}
func _IOWR(group byte, ioctl_num uintptr, param_len uintptr) uintptr {
return _IOC(_IOC_IN_OUT, group, ioctl_num, param_len)
}

19
vendor/github.com/kr/pty/mktypes.bash generated vendored Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
GOOSARCH="${GOOS}_${GOARCH}"
case "$GOOSARCH" in
_* | *_ | _)
echo 'undefined $GOOS_$GOARCH:' "$GOOSARCH" 1>&2
exit 1
;;
esac
GODEFS="go tool cgo -godefs"
$GODEFS types.go |gofmt > ztypes_$GOARCH.go
case $GOOS in
freebsd|dragonfly)
$GODEFS types_$GOOS.go |gofmt > ztypes_$GOOSARCH.go
;;
esac

60
vendor/github.com/kr/pty/pty_darwin.go generated vendored Normal file
View File

@@ -0,0 +1,60 @@
package pty
import (
"errors"
"os"
"syscall"
"unsafe"
)
func open() (pty, tty *os.File, err error) {
p, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
if err != nil {
return nil, nil, err
}
sname, err := ptsname(p)
if err != nil {
return nil, nil, err
}
err = grantpt(p)
if err != nil {
return nil, nil, err
}
err = unlockpt(p)
if err != nil {
return nil, nil, err
}
t, err := os.OpenFile(sname, os.O_RDWR, 0)
if err != nil {
return nil, nil, err
}
return p, t, nil
}
func ptsname(f *os.File) (string, error) {
n := make([]byte, _IOC_PARM_LEN(syscall.TIOCPTYGNAME))
err := ioctl(f.Fd(), syscall.TIOCPTYGNAME, uintptr(unsafe.Pointer(&n[0])))
if err != nil {
return "", err
}
for i, c := range n {
if c == 0 {
return string(n[:i]), nil
}
}
return "", errors.New("TIOCPTYGNAME string not NUL-terminated")
}
func grantpt(f *os.File) error {
return ioctl(f.Fd(), syscall.TIOCPTYGRANT, 0)
}
func unlockpt(f *os.File) error {
return ioctl(f.Fd(), syscall.TIOCPTYUNLK, 0)
}

76
vendor/github.com/kr/pty/pty_dragonfly.go generated vendored Normal file
View File

@@ -0,0 +1,76 @@
package pty
import (
"errors"
"os"
"strings"
"syscall"
"unsafe"
)
// same code as pty_darwin.go
func open() (pty, tty *os.File, err error) {
p, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
if err != nil {
return nil, nil, err
}
sname, err := ptsname(p)
if err != nil {
return nil, nil, err
}
err = grantpt(p)
if err != nil {
return nil, nil, err
}
err = unlockpt(p)
if err != nil {
return nil, nil, err
}
t, err := os.OpenFile(sname, os.O_RDWR, 0)
if err != nil {
return nil, nil, err
}
return p, t, nil
}
func grantpt(f *os.File) error {
_, err := isptmaster(f.Fd())
return err
}
func unlockpt(f *os.File) error {
_, err := isptmaster(f.Fd())
return err
}
func isptmaster(fd uintptr) (bool, error) {
err := ioctl(fd, syscall.TIOCISPTMASTER, 0)
return err == nil, err
}
var (
emptyFiodgnameArg fiodgnameArg
ioctl_FIODNAME = _IOW('f', 120, unsafe.Sizeof(emptyFiodgnameArg))
)
func ptsname(f *os.File) (string, error) {
name := make([]byte, _C_SPECNAMELEN)
fa := fiodgnameArg{Name: (*byte)(unsafe.Pointer(&name[0])), Len: _C_SPECNAMELEN, Pad_cgo_0: [4]byte{0, 0, 0, 0}}
err := ioctl(f.Fd(), ioctl_FIODNAME, uintptr(unsafe.Pointer(&fa)))
if err != nil {
return "", err
}
for i, c := range name {
if c == 0 {
s := "/dev/" + string(name[:i])
return strings.Replace(s, "ptm", "pts", -1), nil
}
}
return "", errors.New("TIOCPTYGNAME string not NUL-terminated")
}

73
vendor/github.com/kr/pty/pty_freebsd.go generated vendored Normal file
View File

@@ -0,0 +1,73 @@
package pty
import (
"errors"
"os"
"syscall"
"unsafe"
)
func posix_openpt(oflag int) (fd int, err error) {
r0, _, e1 := syscall.Syscall(syscall.SYS_POSIX_OPENPT, uintptr(oflag), 0, 0)
fd = int(r0)
if e1 != 0 {
err = e1
}
return
}
func open() (pty, tty *os.File, err error) {
fd, err := posix_openpt(syscall.O_RDWR | syscall.O_CLOEXEC)
if err != nil {
return nil, nil, err
}
p := os.NewFile(uintptr(fd), "/dev/pts")
sname, err := ptsname(p)
if err != nil {
return nil, nil, err
}
t, err := os.OpenFile("/dev/"+sname, os.O_RDWR, 0)
if err != nil {
return nil, nil, err
}
return p, t, nil
}
func isptmaster(fd uintptr) (bool, error) {
err := ioctl(fd, syscall.TIOCPTMASTER, 0)
return err == nil, err
}
var (
emptyFiodgnameArg fiodgnameArg
ioctl_FIODGNAME = _IOW('f', 120, unsafe.Sizeof(emptyFiodgnameArg))
)
func ptsname(f *os.File) (string, error) {
master, err := isptmaster(f.Fd())
if err != nil {
return "", err
}
if !master {
return "", syscall.EINVAL
}
const n = _C_SPECNAMELEN + 1
var (
buf = make([]byte, n)
arg = fiodgnameArg{Len: n, Buf: (*byte)(unsafe.Pointer(&buf[0]))}
)
err = ioctl(f.Fd(), ioctl_FIODGNAME, uintptr(unsafe.Pointer(&arg)))
if err != nil {
return "", err
}
for i, c := range buf {
if c == 0 {
return string(buf[:i]), nil
}
}
return "", errors.New("FIODGNAME string not NUL-terminated")
}

46
vendor/github.com/kr/pty/pty_linux.go generated vendored Normal file
View File

@@ -0,0 +1,46 @@
package pty
import (
"os"
"strconv"
"syscall"
"unsafe"
)
func open() (pty, tty *os.File, err error) {
p, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
if err != nil {
return nil, nil, err
}
sname, err := ptsname(p)
if err != nil {
return nil, nil, err
}
err = unlockpt(p)
if err != nil {
return nil, nil, err
}
t, err := os.OpenFile(sname, os.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
return nil, nil, err
}
return p, t, nil
}
func ptsname(f *os.File) (string, error) {
var n _C_uint
err := ioctl(f.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&n)))
if err != nil {
return "", err
}
return "/dev/pts/" + strconv.Itoa(int(n)), nil
}
func unlockpt(f *os.File) error {
var u _C_int
// use TIOCSPTLCK with a zero valued arg to clear the slave pty lock
return ioctl(f.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&u)))
}

11
vendor/github.com/kr/pty/pty_unsupported.go generated vendored Normal file
View File

@@ -0,0 +1,11 @@
// +build !linux,!darwin,!freebsd,!dragonfly
package pty
import (
"os"
)
func open() (pty, tty *os.File, err error) {
return nil, nil, ErrUnsupported
}

34
vendor/github.com/kr/pty/run.go generated vendored Normal file
View File

@@ -0,0 +1,34 @@
// +build !windows
package pty
import (
"os"
"os/exec"
"syscall"
)
// Start assigns a pseudo-terminal tty os.File to c.Stdin, c.Stdout,
// and c.Stderr, calls c.Start, and returns the File of the tty's
// corresponding pty.
func Start(c *exec.Cmd) (pty *os.File, err error) {
pty, tty, err := Open()
if err != nil {
return nil, err
}
defer tty.Close()
c.Stdout = tty
c.Stdin = tty
c.Stderr = tty
if c.SysProcAttr == nil {
c.SysProcAttr = &syscall.SysProcAttr{}
}
c.SysProcAttr.Setctty = true
c.SysProcAttr.Setsid = true
err = c.Start()
if err != nil {
pty.Close()
return nil, err
}
return pty, err
}

10
vendor/github.com/kr/pty/types.go generated vendored Normal file
View File

@@ -0,0 +1,10 @@
// +build ignore
package pty
import "C"
type (
_C_int C.int
_C_uint C.uint
)

17
vendor/github.com/kr/pty/types_dragonfly.go generated vendored Normal file
View File

@@ -0,0 +1,17 @@
// +build ignore
package pty
/*
#define _KERNEL
#include <sys/conf.h>
#include <sys/param.h>
#include <sys/filio.h>
*/
import "C"
const (
_C_SPECNAMELEN = C.SPECNAMELEN /* max length of devicename */
)
type fiodgnameArg C.struct_fiodname_args

15
vendor/github.com/kr/pty/types_freebsd.go generated vendored Normal file
View File

@@ -0,0 +1,15 @@
// +build ignore
package pty
/*
#include <sys/param.h>
#include <sys/filio.h>
*/
import "C"
const (
_C_SPECNAMELEN = C.SPECNAMELEN /* max length of devicename */
)
type fiodgnameArg C.struct_fiodgname_arg

37
vendor/github.com/kr/pty/util.go generated vendored Normal file
View File

@@ -0,0 +1,37 @@
// +build !windows
package pty
import (
"os"
"syscall"
"unsafe"
)
// Getsize returns the number of rows (lines) and cols (positions
// in each line) in terminal t.
func Getsize(t *os.File) (rows, cols int, err error) {
var ws winsize
err = windowrect(&ws, t.Fd())
return int(ws.ws_row), int(ws.ws_col), err
}
type winsize struct {
ws_row uint16
ws_col uint16
ws_xpixel uint16
ws_ypixel uint16
}
func windowrect(ws *winsize, fd uintptr) error {
_, _, errno := syscall.Syscall(
syscall.SYS_IOCTL,
fd,
syscall.TIOCGWINSZ,
uintptr(unsafe.Pointer(ws)),
)
if errno != 0 {
return syscall.Errno(errno)
}
return nil
}

9
vendor/github.com/kr/pty/ztypes_386.go generated vendored Normal file
View File

@@ -0,0 +1,9 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
package pty
type (
_C_int int32
_C_uint uint32
)

9
vendor/github.com/kr/pty/ztypes_amd64.go generated vendored Normal file
View File

@@ -0,0 +1,9 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
package pty
type (
_C_int int32
_C_uint uint32
)

9
vendor/github.com/kr/pty/ztypes_arm.go generated vendored Normal file
View File

@@ -0,0 +1,9 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
package pty
type (
_C_int int32
_C_uint uint32
)

11
vendor/github.com/kr/pty/ztypes_arm64.go generated vendored Normal file
View File

@@ -0,0 +1,11 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
// +build arm64
package pty
type (
_C_int int32
_C_uint uint32
)

14
vendor/github.com/kr/pty/ztypes_dragonfly_amd64.go generated vendored Normal file
View File

@@ -0,0 +1,14 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types_dragonfly.go
package pty
const (
_C_SPECNAMELEN = 0x3f
)
type fiodgnameArg struct {
Name *byte
Len uint32
Pad_cgo_0 [4]byte
}

13
vendor/github.com/kr/pty/ztypes_freebsd_386.go generated vendored Normal file
View File

@@ -0,0 +1,13 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types_freebsd.go
package pty
const (
_C_SPECNAMELEN = 0x3f
)
type fiodgnameArg struct {
Len int32
Buf *byte
}

14
vendor/github.com/kr/pty/ztypes_freebsd_amd64.go generated vendored Normal file
View File

@@ -0,0 +1,14 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types_freebsd.go
package pty
const (
_C_SPECNAMELEN = 0x3f
)
type fiodgnameArg struct {
Len int32
Pad_cgo_0 [4]byte
Buf *byte
}

13
vendor/github.com/kr/pty/ztypes_freebsd_arm.go generated vendored Normal file
View File

@@ -0,0 +1,13 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types_freebsd.go
package pty
const (
_C_SPECNAMELEN = 0x3f
)
type fiodgnameArg struct {
Len int32
Buf *byte
}

12
vendor/github.com/kr/pty/ztypes_mipsx.go generated vendored Normal file
View File

@@ -0,0 +1,12 @@
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
// +build linux
// +build mips mipsle mips64 mips64le
package pty
type (
_C_int int32
_C_uint uint32
)

11
vendor/github.com/kr/pty/ztypes_ppc64.go generated vendored Normal file
View File

@@ -0,0 +1,11 @@
// +build ppc64
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
package pty
type (
_C_int int32
_C_uint uint32
)

11
vendor/github.com/kr/pty/ztypes_ppc64le.go generated vendored Normal file
View File

@@ -0,0 +1,11 @@
// +build ppc64le
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
package pty
type (
_C_int int32
_C_uint uint32
)

11
vendor/github.com/kr/pty/ztypes_s390x.go generated vendored Normal file
View File

@@ -0,0 +1,11 @@
// +build s390x
// Created by cgo -godefs - DO NOT EDIT
// cgo -godefs types.go
package pty
type (
_C_int int32
_C_uint uint32
)

21
vendor/github.com/mattn/go-colorable/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Yasuhiro Matsumoto
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.

48
vendor/github.com/mattn/go-colorable/README.md generated vendored Normal file
View File

@@ -0,0 +1,48 @@
# go-colorable
[![Godoc Reference](https://godoc.org/github.com/mattn/go-colorable?status.svg)](http://godoc.org/github.com/mattn/go-colorable)
[![Build Status](https://travis-ci.org/mattn/go-colorable.svg?branch=master)](https://travis-ci.org/mattn/go-colorable)
[![Coverage Status](https://coveralls.io/repos/github/mattn/go-colorable/badge.svg?branch=master)](https://coveralls.io/github/mattn/go-colorable?branch=master)
[![Go Report Card](https://goreportcard.com/badge/mattn/go-colorable)](https://goreportcard.com/report/mattn/go-colorable)
Colorable writer for windows.
For example, most of logger packages doesn't show colors on windows. (I know we can do it with ansicon. But I don't want.)
This package is possible to handle escape sequence for ansi color on windows.
## Too Bad!
![](https://raw.githubusercontent.com/mattn/go-colorable/gh-pages/bad.png)
## So Good!
![](https://raw.githubusercontent.com/mattn/go-colorable/gh-pages/good.png)
## Usage
```go
logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true})
logrus.SetOutput(colorable.NewColorableStdout())
logrus.Info("succeeded")
logrus.Warn("not correct")
logrus.Error("something error")
logrus.Fatal("panic")
```
You can compile above code on non-windows OSs.
## Installation
```
$ go get github.com/mattn/go-colorable
```
# License
MIT
# Author
Yasuhiro Matsumoto (a.k.a mattn)

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