Compare commits

...

104 Commits

Author SHA1 Message Date
Manfred Touron
6b181dd291 v1.8.0 2018-04-02 22:36:06 +02:00
Manfred Touron
4ab88cad10 fix merge 2018-04-02 22:36:06 +02:00
Manfred Touron
b902953df4 Update changelog 2018-04-02 22:36:06 +02:00
Manuel Sabban
e141368734 Add log for exec request. 2018-04-02 22:36:06 +02:00
Manfred Touron
980da40988 Update Readme and Changelog 2018-04-02 22:28:10 +02:00
Manfred Touron
22d25f1e70 Merge pull request #44 from sabban/tunnel
Logtunnel
2018-03-24 00:02:31 +01:00
Manfred Touron
84d77d0a9f Merge pull request #52 from adyxax/master
Added ssh key import feature in "key import"
2018-03-23 23:29:37 +01:00
Julien Dessaux
b0afdf933a Added ssh key import feature in "key import" 2018-03-21 17:48:11 +01:00
Manuel
e9eef9a49e add an acceptable error management. 2018-03-19 18:06:03 +01:00
Manfred Touron
6f2b58cbdc chore: esthetics + update changelog 2018-03-14 18:17:40 +01:00
Manfred Touron
09ac2c35f3 Merge pull request #54 from jle64/dont_crash_on_missing_user
Show 'n/a' in case of missing information to avoid crashing.
2018-03-14 18:13:08 +01:00
Jonathan Lestrelin
47a6fc9906 Show 'n/a' in case of missing information to avoid crashing. 2018-03-14 17:40:48 +01:00
Manuel
c3d49fde95 Merge branch 'master' of https://github.com/moul/sshportal into tunnel 2018-03-12 12:31:17 +01:00
Manfred Touron
ec1e4d5c8a Update README and CHANGELOG 2018-02-28 17:22:59 +01:00
Manfred Touron
e65ef7ccc1 Merge pull request #47 from mathieui/multi-hops
Implement proxied connections
2018-02-28 17:20:18 +01:00
Manfred Touron
68e7fd2090 Merge pull request #49 from moul/dev/moul/fix-mysql-delete
Fix `--assign` commands when using MySQL driver
2018-02-28 16:56:29 +01:00
Manfred Touron
b958f8461f Fix commands when using MySQL driver ([#45](https://github.com/moul/sshportal/issues/45)) 2018-02-28 16:54:32 +01:00
Manfred Touron
a08d84e7ed Merge pull request #48 from moul/dev/moul/fix-make-dev-cmd
Small fixes
2018-02-28 16:29:36 +01:00
Manfred Touron
2b66d8d56a Ingore /log directory 2018-02-28 14:35:38 +01:00
Manfred Touron
a40789e1f2 Fix 'make dev' rule 2018-02-28 14:35:06 +01:00
Mathieu Pasquet
63571af252 Add hops management in "host update"
- allow changing the hop set for this host
- allow removing hops altogether
2018-02-27 17:54:57 +01:00
Mathieu Pasquet
75c6840ecd Implement proxied connections
The feature is implemented as follows:
- when creating a host, there is a possiblity to add a "hop"
- hops are referend them with the name of the host in sshportal
- the hop ID is then saved in the DB in the hosts table
- when connecting to a host, sshportal will recurse through all the
  possible hops of a host (allowing chained proxies)
2018-02-22 18:07:41 +01:00
Manfred Touron
e6a02a85f0 Fix typo in template 2018-02-03 00:45:05 +01:00
Manuel Sabban
2c3de75f3d Logtunnel (#1)
* * When a new channel is opened we got stuck in the select loop in
bastionsession.go, and we couldn't open a new channel. The fix is
easy it calls the bastionsession.ChannelHandler in a goroutine,
at the cost of some error management. I think this is ok because
we can allow a channel to fail on his own. This seems to be
* This add the tunnel feature, which use a new concurrent channel.
* This add some pcap logging for tunnel.
For now it is logged only one way, and the logged ip packet seems
buggy.

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

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

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

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

View File

@@ -1,11 +1,12 @@
defaults: &defaults
working_directory: /go/src/github.com/moul/sshportal
docker:
- image: circleci/golang:1.8
version: 2
jobs:
build:
docker:
- image: circleci/golang:1.8
# - image: circleci/mysql:9.4
working_directory: /go/src/github.com/moul/sshportal
go.build:
<<: *defaults
steps:
- checkout
- run: make install
@@ -14,4 +15,26 @@ jobs:
# - run: make integration
- run: go get -u github.com/alecthomas/gometalinter
- run: gometalinter --install
- run: make lint
- 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

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

@@ -0,0 +1,25 @@
<!-- Thanks for filling an issue!
If this is a BUG REPORT, please:
- Fill in as much of the template below as you can
If this is a FEATURE REQUEST, please:
- Describe *in detail* the feature/behavior/change you would like to see
-->
**What happened**:
**What you expected to happen**:
**How to reproduce it (as minimally and precisely as possible)**:
**Anything else we need to know?**:
<!--
**Environment**:
- sshportal --version
- ssh sshportal info
- OS (e.g. from /etc/os-release):
- install method (e.g. go/docker/brew/...):
- others:
-->

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

@@ -0,0 +1,7 @@
<!-- Thanks for sending a pull request! Here are some tips for you -->
**What this PR does / why we need it**:
**Which issue this PR fixes**: fixes #xxx, fixes #xxx...
**Special notes for your reviewer**:

1
.gitignore vendored
View File

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

View File

@@ -1,5 +1,36 @@
# Changelog
## v1.8.0 (2018-04-02)
* The default created user now has the same username as the user starting sshportal (was hardcoded "admin")
* Add Telnet support
* Add TTY audit feature ([#23](https://github.com/moul/sshportal/issues/23)) by [@sabban](https://github.com/sabban)
* Fix `--assign-*` commands when using MySQL driver ([#45](https://github.com/moul/sshportal/issues/45))
* Add *HOP* support, an efficient and integrated way of using a jump host transparently ([#47](https://github.com/moul/sshportal/issues/47)) by [@mathieui](https://github.com/mathieui)
* Fix panic on some `ls` commands ([#54](https://github.com/moul/sshportal/pull/54)) by [@jle64](https://github.com/jle64)
* Add tunnels (`direct-tcp`) support with logging ([#44](https://github.com/moul/sshportal/issues/44)) by [@sabban](https://github.com/sabban)
* Add `key import` command ([#52](https://github.com/moul/sshportal/issues/52)) by [@adyxax](https://github.com/adyxax)
* Add 'exec' logging ([#40](https://github.com/moul/sshportal/issues/40)) by [@sabban](https://github.com/sabban)
## v1.7.1 (2018-01-03)
* Return non-null exit-code on authentication error
* **hotfix**: repair invite system (broken in v1.7.0)
## v1.7.0 (2018-01-02)
Breaking changes:
* Use `sshportal server` instead of `sshportal` to start a new server (nothing to change if using the docker image)
* Remove `--config-user` and `--healthcheck-user` global options
Changes:
* Fix connection failure when sending too many environment variables (fix [#22](https://github.com/moul/sshportal/issues/22))
* Fix panic when entering empty command (fix [#13](https://github.com/moul/sshportal/issues/13))
* Add `config backup --ignore-events` option
* Add `sshportal healthcheck [--addr=] [--wait] [--quiet]` cli command
* Add [Docker Healthcheck](https://docs.docker.com/engine/reference/builder/#healthcheck) helper
* Support Putty (fix [#24](https://github.com/moul/sshportal/issues/24))
## v1.6.0 (2017-12-12)
* Add `--latest` and `--quiet` options to `ls` commands

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

View File

@@ -3,7 +3,6 @@ GIT_TAG ?= $(shell git describe --tags --always)
GIT_BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
LDFLAGS ?= -X main.GitSha=$(GIT_SHA) -X main.GitTag=$(GIT_TAG) -X main.GitBranch=$(GIT_BRANCH)
VERSION ?= $(shell grep 'VERSION =' main.go | cut -d'"' -f2)
PORT ?= 2222
AES_KEY ?= my-dummy-aes-key
.PHONY: install
@@ -16,7 +15,7 @@ docker.build:
.PHONY: integration
integration:
PORT="$(PORT)" bash ./examples/integration/test.sh
cd ./examples/integration && make
.PHONY: _docker_install
_docker_install:
@@ -25,7 +24,7 @@ _docker_install:
.PHONY: dev
dev:
-go get github.com/githubnemo/CompileDaemon
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" .
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal server --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" .
.PHONY: test
test:
@@ -34,9 +33,14 @@ test:
.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 .
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=60s .
.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

153
README.md
View File

@@ -2,9 +2,11 @@
[![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
@@ -12,50 +14,57 @@ Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
---
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
DMZ │
┌────────┐ │ ┌────────┐
│ homer │───▶╔═════════════════╗───▶│ host1 │ │
└────────┘ ║ ║ └────────┘
┌────────┐ ║ ║ ┌────────┐ │
│ bart │───▶║ sshportal ║───▶│ host2 │
└────────┘ ║ ║ └────────┘ │
┌────────┐ ║ ║ ┌────────┐
│ lisa │───▶╚═════════════════╝───▶│ host3 │ │
└────────┘ │ └────────┘
┌────────┐ ┌────────┐ │
│ ... │ │ │ ... │
└────────┘ └────────┘ │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
```
## 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
* User Roles
* User invitations
* Easy authorized_keys installation
* Host management
* User management (invite, group, stats)
* Host Key management (create, remove, update, import)
* Automatic remote host key learning
* User Key management (multile keys per user)
* ACL management (acl+user-groups+host-groups)
* User roles (admin, trusted, standard, ...)
* User invitations (no more "give me your public ssh key please")
* Easy server installation (generate shell command to setup `authorized_keys`)
* Sensitive data encryption
* Session management
* Audit log
* Session management (see active connections, history, stats, stop)
* Audit log (logging every user action)
* Record TTY Session
* Tunnels logging
* Host Keys verifications shared across users
* Healthcheck user
* Healthcheck user (replying OK to any user)
* SSH compatibility
* ipv4 and ipv6 support
* [`scp`](https://linux.die.net/man/1/scp) support
* [`rsync`](https://linux.die.net/man/1/rsync) support
* [tunneling](https://www.ssh.com/ssh/tunneling/example) (local forward, remote forward, dynamic forward) support
* [`sftp`](https://www.ssh.com/ssh/sftp/) support
* [`ssh-agent`](https://www.ssh.com/ssh/agent) support
* [`X11 forwarding`](http://en.tldp.org/HOWTO/XDMCP-HOWTO/ssh.html) support
* Git support (can be used to easily use multiple user keys on GitHub, or access your own firewalled gitlab server)
* Do not require any SSH client modification or custom `.ssh/config`, works with every tested SSH programming libraries and every tested SSH clients
* SSH to non-SSH proxy
* [Telnet](https://www.ssh.com/ssh/telnet) support
## (Known) limitations
* Does not work (yet?) with [`mosh`](https://mosh.org/)
## 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
```
@@ -121,14 +130,20 @@ 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
![Flow Diagram](https://raw.github.com/moul/sshportal/master/.assets/flow-diagram.svg?sanitize=true)
## built-in shell
`sshportal` embeds a configuration CLI.
@@ -165,11 +180,11 @@ event inspect [-h] EVENT...
# host management
host help
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] <username>[:<password>]@<host>[:<port>]
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] [--hop=HOST] <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...
host update [-h] [--name=<value>] [--comment=<value>] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] [--set-hop=HOST] [--unset-hop] HOST...
# hostgroup management
hostgroup help
@@ -181,6 +196,7 @@ hostgroup rm [-h] HOSTGROUP...
# key management
key help
key create [-h] [--name=<value>] [--type=<value>] [--length=<value>] [--comment=<value>]
key import [-h] [--name=<value>] [--comment=<value>]
key inspect [-h] [--decrypt] KEY...
key ls [-h] [--latest] [--quiet]
key rm [-h] KEY...
@@ -223,7 +239,7 @@ An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/ss
```console
# Start a server in background
# mount `pwd` to persist the sqlite database file
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.6.0
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.8.0
# check logs (mandatory on first run to get the administrator invite token)
docker logs -f sshportal
@@ -232,7 +248,7 @@ docker logs -f sshportal
The easier way to upgrade sshportal is to do the following:
```sh
# we consider you were using an old version and you want to use the new version v1.6.0
# we consider you were using an old version and you want to use the new version v1.8.0
# stop and rename the last working container + backup the database
docker stop sshportal
@@ -240,7 +256,7 @@ docker rename sshportal sshportal_old
cp sshportal.db sshportal.db.bkp
# run the new version
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.6.0
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.8.0
# check the logs for migration or cross-version incompabitility errors
docker logs -f sshportal
```
@@ -340,3 +356,62 @@ $
```
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)

2
acl.go
View File

@@ -30,7 +30,7 @@ func CheckACLs(user User, host Host) (string, error) {
}
// transform map to slice and sort it
acls := []*ACL{}
acls := make([]*ACL, 0, len(aclMap))
for _, acl := range aclMap {
acls = append(acls, acl)
}

49
config.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
"os"
"github.com/urfave/cli"
)
type configServe struct {
aesKey string
dbDriver, dbURL string
logsLocation string
bindAddr string
debug, demo bool
}
func parseServeConfig(c *cli.Context) (*configServe, error) {
ret := &configServe{
aesKey: c.String("aes-key"),
dbDriver: c.String("db-driver"),
dbURL: c.String("db-conn"),
bindAddr: c.String("bind-address"),
debug: c.Bool("debug"),
demo: c.Bool("demo"),
logsLocation: c.String("logs-location"),
}
switch len(ret.aesKey) {
case 0, 16, 24, 32:
default:
return nil, fmt.Errorf("invalid aes key size, should be 16 or 24, 32")
}
return ret, nil
}
func ensureLogDirectory(location string) error {
// check for the logdir existence
logsLocation, err := os.Stat(location)
if err != nil {
if os.IsNotExist(err) {
return os.MkdirAll(location, os.ModeDir|os.FileMode(0750))
}
return err
}
if !logsLocation.IsDir() {
return fmt.Errorf("log directory cannot be created")
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"strings"
@@ -52,6 +53,42 @@ func NewSSHKey(keyType string, length uint) (*SSHKey, error) {
return &key, nil
}
func ImportSSHKey(keyValue string) (*SSHKey, error) {
key := SSHKey{
Type: "rsa",
}
parsedKey, err := gossh.ParseRawPrivateKey([]byte(keyValue))
if err != nil {
return nil, err
}
var privateKey *rsa.PrivateKey
var ok bool
if privateKey, ok = parsedKey.(*rsa.PrivateKey); !ok {
return nil, errors.New("key type not supported")
}
key.Length = uint(privateKey.PublicKey.N.BitLen())
// convert priv key to x509 format
var pemKey = &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
buf := bytes.NewBufferString("")
if err = pem.Encode(buf, pemKey); err != nil {
return nil, err
}
key.PrivKey = buf.String()
// generte authorized-key formatted pubkey output
pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, err
}
key.PubKey = strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub)))
return &key, nil
}
func encrypt(key []byte, text string) (string, error) {
plaintext := []byte(text)
block, err := aes.NewCipher(key)
@@ -95,17 +132,14 @@ func safeDecrypt(key []byte, cryptoText string) string {
return out
}
func HostEncrypt(aesKey string, host *Host) error {
func HostEncrypt(aesKey string, host *Host) (err error) {
if aesKey == "" {
return nil
}
var err error
if host.Password != "" {
if host.Password, err = encrypt([]byte(aesKey), host.Password); err != nil {
return err
}
host.Password, err = encrypt([]byte(aesKey), host.Password)
}
return nil
return
}
func HostDecrypt(aesKey string, host *Host) {
if aesKey == "" {
@@ -116,13 +150,12 @@ func HostDecrypt(aesKey string, host *Host) {
}
}
func SSHKeyEncrypt(aesKey string, key *SSHKey) error {
func SSHKeyEncrypt(aesKey string, key *SSHKey) (err error) {
if aesKey == "" {
return nil
}
var err error
key.PrivKey, err = encrypt([]byte(aesKey), key.PrivKey)
return err
return
}
func SSHKeyDecrypt(aesKey string, key *SSHKey) {
if aesKey == "" {

198
db.go
View File

@@ -1,4 +1,3 @@
//go:generate stringer -type=SessionStatus
package main
import (
@@ -7,12 +6,13 @@ import (
"log"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/asaskevich/govalidator"
"github.com/gliderlabs/ssh"
"github.com/jinzhu/gorm"
gossh "golang.org/x/crypto/ssh"
)
type Config struct {
@@ -54,14 +54,17 @@ 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"`
Addr string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL
User string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL
Password string `valid:"optional"` // FIXME: to be removed in a future version in favor of URL
URL string `valid:"optional"`
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"` // SSHKey used to connect by the client
SSHKeyID uint `gorm:"index"`
HostKey []byte `sql:"size:10000" valid:"optional"`
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
Comment string `valid:"optional"`
Hop *Host
HopID uint
}
// UserKey defines a user public key used by sshportal to identify the user
@@ -157,6 +160,13 @@ const (
ACLActionDeny = "deny"
)
type BastionScheme string
const (
BastionSchemeSSH BastionScheme = "ssh"
BastionSchemeTelnet = "telnet"
)
func init() {
unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*")
@@ -169,53 +179,150 @@ func init() {
}))
}
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
}
// Host helpers
func (host *Host) URL() string {
return fmt.Sprintf("%s@%s", host.User, host.Addr)
}
func NewHostFromURL(rawurl string) (*Host, error) {
if !strings.Contains(rawurl, "://") {
rawurl = "ssh://" + rawurl
func ParseInputURL(input string) (*url.URL, error) {
if !strings.Contains(input, "://") {
input = "ssh://" + input
}
u, err := url.Parse(rawurl)
u, err := url.Parse(input)
if err != nil {
return nil, err
}
host := Host{Addr: u.Host}
if !strings.Contains(host.Addr, ":") {
host.Addr += ":22" // add port if not present
}
host.User = "root" // default username
if u.User != nil {
password, _ := u.User.Password()
host.Password = password
host.User = u.User.Username()
}
return &host, nil
return u, nil
}
func (host *Host) DialAddr() string {
return fmt.Sprintf("%s:%d", host.Hostname(), host.Port())
}
func (host *Host) String() string {
if host.URL != "" {
return host.URL
} else if host.Addr != "" { // to be removed in a future version in favor of URL
if host.Password != "" {
return fmt.Sprintf("ssh://%s:%s@%s", host.User, strings.Repeat("*", 4), host.Addr)
}
return fmt.Sprintf("ssh://%s@%s", host.User, host.Addr)
}
return ""
}
func (host *Host) Scheme() BastionScheme {
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return BastionSchemeSSH
}
return BastionScheme(u.Scheme)
} else if host.Addr != "" {
return BastionSchemeSSH
}
return ""
}
func (host *Host) Hostname() string {
return strings.Split(host.Addr, ":")[0]
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return ""
}
return u.Hostname()
} else if host.Addr != "" { // to be removed in a future version in favor of URL
return strings.Split(host.Addr, ":")[0]
}
return ""
}
func (host *Host) Username() string {
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return "root"
}
if u.User != nil {
return u.User.Username()
}
} else if host.User != "" { // to be removed in a future version in favor of URL
return host.User
}
return "root"
}
func (host *Host) Passwd() string {
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
return ""
}
if u.User != nil {
password, _ := u.User.Password()
return password
}
} else if host.Password != "" { // to be removed in a future version in favor of URL
return host.Password
}
return ""
}
func (host *Host) Port() uint64 {
var portString string
if host.URL != "" {
u, err := url.Parse(host.URL)
if err != nil {
goto defaultPort
}
portString = u.Port()
} else if host.Addr != "" { // to be removed in a future version in favor of URL
portString = strings.Split(host.Addr, ":")[1]
}
if portString != "" {
port, err := strconv.ParseUint(portString, 10, 64)
if err != nil {
goto defaultPort
}
return port
}
defaultPort:
switch host.Scheme() {
case BastionSchemeSSH:
return 22
case BastionSchemeTelnet:
return 23
default:
return 0
}
}
// Host helpers
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
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 (host *Host) clientConfig(hk gossh.HostKeyCallback) (*gossh.ClientConfig, error) {
config := gossh.ClientConfig{
User: host.Username(),
HostKeyCallback: hk,
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.Passwd() != "" {
config.Auth = append(config.Auth, gossh.Password(host.Passwd()))
}
if len(config.Auth) == 0 {
return nil, fmt.Errorf("no valid authentication method for host %q", host.Name)
}
return &config, nil
}
// SSHKey helpers
@@ -252,25 +359,20 @@ func UsersPreload(db *gorm.DB) *gorm.DB {
func UsersByIdentifiers(db *gorm.DB, identifiers []string) *gorm.DB {
return db.Where("id IN (?)", identifiers).Or("email IN (?)", identifiers).Or("name IN (?)", identifiers)
}
func UserHasRole(user User, name string) bool {
for _, role := range user.Roles {
func (u *User) HasRole(name string) bool {
for _, role := range u.Roles {
if role.Name == name {
return true
}
}
return false
}
func UserCheckRoles(user User, names []string) error {
ok := false
func (u *User) CheckRoles(names []string) error {
for _, name := range names {
if UserHasRole(user, name) {
ok = true
break
if u.HasRole(name) {
return nil
}
}
if ok {
return nil
}
return fmt.Errorf("you don't have permission to access this feature (requires any of these roles: '%s')", strings.Join(names, "', '"))
}

View File

@@ -5,6 +5,8 @@ import (
"io/ioutil"
"log"
"os"
"os/user"
"strings"
"time"
"github.com/go-gormigrate/gormigrate"
@@ -434,6 +436,52 @@ func dbInit(db *gorm.DB) error {
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "28",
Migrate: func(tx *gorm.DB) error {
type Host struct {
// FIXME: use uuid for ID
gorm.Model
Name string `gorm:"size:32"`
Addr string
User string
Password string
URL string
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"`
SSHKeyID uint `gorm:"index"`
HostKey []byte `sql:"size:10000"`
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
Comment string
}
return tx.AutoMigrate(&Host{}).Error
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
}, {
ID: "29",
Migrate: func(tx *gorm.DB) error {
type Host struct {
// FIXME: use uuid for ID
gorm.Model
Name string `gorm:"size:32"`
Addr string
User string
Password string
URL string
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"`
SSHKeyID uint `gorm:"index"`
HostKey []byte `sql:"size:10000"`
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
Comment string
Hop *Host
HopID uint
}
return tx.AutoMigrate(&Host{}).Error
},
Rollback: func(tx *gorm.DB) error {
return fmt.Errorf("not implemented")
},
},
})
if err := m.Migrate(); err != nil {
@@ -511,7 +559,9 @@ func dbInit(db *gorm.DB) error {
// create admin user
var defaultUserGroup UserGroup
db.Where("name = ?", "default").First(&defaultUserGroup)
db.Table("users").Count(&count)
if err := db.Table("users").Count(&count).Error; err != nil {
return err
}
if count == 0 {
// if no admin, create an account for the first connection
inviteToken := randStringBytes(16)
@@ -522,9 +572,20 @@ func dbInit(db *gorm.DB) error {
if err := db.Where("name = ?", "admin").First(&adminRole).Error; err != nil {
return err
}
var username string
if currentUser, err := user.Current(); err == nil {
username = currentUser.Username
}
if username == "" {
username = os.Getenv("USER")
}
username = strings.ToLower(username)
if username == "" {
username = "admin" // fallback username
}
user := User{
Name: "admin",
Email: "admin@sshportal",
Name: username,
Email: fmt.Sprintf("%s@localhost", username),
Comment: "created by sshportal",
Roles: []*UserRole{&adminRole},
InviteToken: inviteToken,
@@ -553,14 +614,10 @@ func dbInit(db *gorm.DB) error {
}
// close unclosed connections
if err := db.Table("sessions").Where("status = ?", "active").Updates(&Session{
return 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
}).Error
}
func hardDeleteCallback(scope *gorm.Scope) {

View File

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

View File

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

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

@@ -0,0 +1,75 @@
#!/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 update --assign-group=hg1 test42
ssh sshportal -l admin host update --unassign-group=hg1 test42
ssh sshportal -l admin host update --assign-group=hg1 test42
ssh sshportal -l admin host update --assign-group=hg2 --unassign-group=hg2 test42
ssh sshportal -l admin host ls
# backup/restore
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

@@ -1,87 +0,0 @@
#!/bin/sh -e
# Setup a new sshportal and performs some checks
PORT=${PORT:-2222}
SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN=integration
# tempdir
WORK_DIR=`mktemp -d`
if [[ ! "$WORK_DIR" || ! -d "$WORK_DIR" ]]; then
echo "Could not create temp dir"
exit 1
fi
cd "${WORK_DIR}"
# pre cleanup
docker_cleanup() {
( set -x
docker rm -f -v sshportal-integration 2>/dev/null >/dev/null || true
)
}
tempdir_cleanup() {
rm -rf "${WORK_DIR}"
}
docker_cleanup
trap tempdir_cleanup EXIT
# start server
( set -xe;
docker run \
-d \
-e SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN=${SSHPORTAL_DEFAULT_ADMIN_INVITE_TOKEN} \
--name=sshportal-integration \
-p${PORT}:2222 \
moul/sshportal --debug
)
while ! nc -z localhost ${PORT}; do
sleep 1
done
sleep 3
# integration suite
xssh() {
set -e
echo "+ ssh {sshportal} $@" >&2
ssh -q -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no localhost -p ${PORT} $@
}
# login
xssh -l invite:integration
# hostgroup/usergroup/acl
xssh -l admin hostgroup create
xssh -l admin hostgroup create --name=hg1
xssh -l admin hostgroup create --name=hg2 --comment=test
xssh -l admin usergroup inspect hg1 hg2
xssh -l admin hostgroup ls
xssh -l admin usergroup create
xssh -l admin usergroup create --name=ug1
xssh -l admin usergroup create --name=ug2 --comment=test
xssh -l admin usergroup inspect ug1 ug2
xssh -l admin usergroup ls
xssh -l admin acl create --ug=ug1 --ug=ug2 --hg=hg1 --hg=hg2 --comment=test --action=allow --weight=42
xssh -l admin acl inspect 2
xssh -l admin acl ls
# basic host create
xssh -l admin host create bob@example.org:1234
xssh -l admin host create test42
xssh -l admin host create --name=testtest --comment=test --password=test test@test.test
xssh -l admin host create --group=hg1 --group=hg2 hostwithgroups.org
xssh -l admin host inspect example test42 testtest hostwithgroups
xssh -l admin host ls
# backup/restore
xssh -l admin config backup --indent > backup-1
xssh -l admin config restore --confirm < backup-1
xssh -l admin config backup --indent > backup-2
(
cat backup-1 | grep -v '"date":' > backup-1.clean
cat backup-2 | grep -v '"date":' > backup-2.clean
set -xe
diff backup-1.clean backup-2.clean
)
# post cleanup
#cleanup

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

73
healthcheck.go Normal file
View File

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

78
hidden.go Normal file
View File

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

322
main.go
View File

@@ -1,13 +1,12 @@
package main
import (
"errors"
"fmt"
"log"
"math/rand"
"net"
"os"
"path"
"strings"
"time"
"github.com/gliderlabs/ssh"
@@ -15,12 +14,11 @@ import (
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/urfave/cli"
gossh "golang.org/x/crypto/ssh"
)
var (
// Version should be updated by hand at each release
Version = "1.6.0"
Version = "1.8.0"
// GitTag will be overwritten automatically by the build system
GitTag string
// GitSha will be overwritten automatically by the build system
@@ -29,14 +27,6 @@ var (
GitBranch string
)
type sshportalContextKey string
var (
userContextKey = sshportalContextKey("user")
messageContextKey = sshportalContextKey("message")
errorContextKey = sshportalContextKey("error")
)
func main() {
rand.Seed(time.Now().UnixNano())
@@ -45,230 +35,128 @@ func main() {
app.Author = "Manfred Touron"
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.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",
},
cli.StringFlag{
Name: "healthcheck-user",
Usage: "SSH user that returns healthcheck status without checking the SSH key",
Value: "healthcheck",
},
cli.StringFlag{
Name: "aes-key",
Usage: "Encrypt sensitive data in database (length: 16, 24 or 32)",
app.Commands = []cli.Command{
{
Name: "server",
Usage: "Start sshportal server",
Action: func(c *cli.Context) error {
if err := ensureLogDirectory(c.String("logs-location")); err != nil {
return err
}
cfg, err := parseServeConfig(c)
if err != nil {
return err
}
return server(cfg)
},
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)",
},
cli.StringFlag{
Name: "logs-location",
Value: "./log",
Usage: "Store user session files",
},
},
}, {
Name: "healthcheck",
Action: func(c *cli.Context) error { return healthcheck(c.String("addr"), c.Bool("wait"), c.Bool("quiet")) },
Flags: []cli.Flag{
cli.StringFlag{
Name: "addr, a",
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 {
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
func server(c *configServe) (err error) {
var db = (*gorm.DB)(nil)
// try to setup the local DB
if db, err = gorm.Open(c.dbDriver, c.dbURL); err != nil {
return
}
defer func() {
if err2 := db.Close(); err2 != nil {
panic(err2)
origErr := err
err = db.Close()
if origErr != nil {
err = origErr
}
}()
if err = db.DB().Ping(); err != nil {
return err
return
}
if c.Bool("debug") {
db.LogMode(true)
db.LogMode(c.debug)
if err = dbInit(db); err != nil {
return
}
if err := dbInit(db); err != nil {
// create TCP listening socket
ln, err := net.Listen("tcp", c.bindAddr)
if err != nil {
return err
}
// ssh server
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)
// configure server
srv := &ssh.Server{
Addr: c.bindAddr,
Handler: shellHandler, // ssh.Server.Handler is the handler for the DefaultSessionHandler
Version: fmt.Sprintf("sshportal-%s", Version),
ChannelHandler: channelHandler,
}
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("healthcheck-user"):
fmt.Fprintln(s, "OK")
return
case username == currentUser.Name || username == currentUser.Email || username == c.String("config-user"):
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 err2 := db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", currentUser.ID).First(&tmpUser).Error; err2 != nil {
fmt.Fprintf(s, "error: %v\n", err2)
return
}
var tmpHost Host
if err2 := db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err2 != nil {
fmt.Fprintf(s, "error: %v\n", err2)
return
}
action, err2 := CheckACLs(tmpUser, tmpHost)
if err2 != nil {
fmt.Fprintf(s, "error: %v\n", err2)
return
}
// decrypt key and password
HostDecrypt(c.String("aes-key"), host)
SSHKeyDecrypt(c.String("aes-key"), host.SSHKey)
switch action {
case ACLActionAllow:
sess := Session{
UserID: currentUser.ID,
HostID: host.ID,
Status: SessionStatusActive,
}
if err2 := db.Create(&sess).Error; err2 != nil {
fmt.Fprintf(s, "error: %v\n", err2)
return
}
sessUpdate := Session{}
if err2 := proxy(s, host, DynamicHostKey(db, host)); err2 != nil {
fmt.Fprintf(s, "error: %v\n", err2)
sessUpdate.ErrMsg = fmt.Sprintf("%v", err2)
switch sessUpdate.ErrMsg {
case "lch closed the connection", "rch closed the connection":
sessUpdate.ErrMsg = ""
}
}
sessUpdate.Status = SessionStatusClosed
now := time.Now()
sessUpdate.StoppedAt = &now
db.Model(&sess).Updates(&sessUpdate)
case ACLActionDeny:
fmt.Fprintf(s, "You don't have permission to that host.\n")
default:
fmt.Fprintf(s, "error: invalid ACL action: %q\n", action)
}
}
})
opts := []ssh.Option{}
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("authorized_key = ?", string(gossh.MarshalAuthorizedKey(key))).First(&userKey)
if userKey.UserID > 0 {
db.Preload("Roles").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) > 0 {
db.Where("invite_token = ?", inputToken).First(&user)
}
if user.ID > 0 {
userKey = UserKey{
UserID: user.ID,
Key: key.Marshal(),
Comment: "created by sshportal",
AuthorizedKey: string(gossh.MarshalAuthorizedKey(key)),
}
db.Create(&userKey)
// token is only usable once
user.InviteToken = ""
db.Model(&user).Updates(&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
}))
opts = append(opts, func(srv *ssh.Server) error {
var key SSHKey
if err := SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
for _, opt := range []ssh.Option{
// custom PublicKeyAuth handler
ssh.PublicKeyAuth(publicKeyAuthHandler(db, c)),
ssh.PasswordAuth(passwordAuthHandler(db, c)),
// retrieve sshportal SSH private key from database
privateKeyFromDB(db, c.aesKey),
} {
if err := srv.SetOption(opt); err != nil {
return err
}
SSHKeyDecrypt(c.String("aes-key"), &key)
}
signer, err := gossh.ParsePrivateKey([]byte(key.PrivKey))
if err != nil {
return err
}
srv.AddHostKey(signer)
return nil
})
log.Printf("info: SSH Server accepting connections on %s", c.String("bind-address"))
return ssh.ListenAndServe(c.String("bind-address"), nil, opts...)
log.Printf("info: SSH Server accepting connections on %s", c.bindAddr)
return srv.Serve(ln)
}

View File

@@ -0,0 +1,201 @@
package bastionsession
import (
"errors"
"io"
"log"
"os"
"strings"
"time"
"github.com/arkan/bastion/pkg/logchannel"
"github.com/gliderlabs/ssh"
"github.com/moul/sshportal/pkg/logtunnel"
gossh "golang.org/x/crypto/ssh"
)
type ForwardData struct {
DestinationHost string
DestinationPort uint32
SourceHost string
SourcePort uint32
}
type Config struct {
Addr string
Logs string
ClientConfig *gossh.ClientConfig
}
func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, configs []Config) error {
var lastClient *gossh.Client
switch newChan.ChannelType() {
case "session":
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
// go through all the hops
for _, config := range configs {
var client *gossh.Client
if lastClient == nil {
client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig)
} else {
rconn, err := lastClient.Dial("tcp", config.Addr)
if err != nil {
return err
}
ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig)
if err != nil {
return err
}
client = gossh.NewClient(ncc, chans, reqs)
}
if err != nil {
return err
}
defer func() { _ = client.Close() }()
lastClient = client
}
rch, rreqs, err := lastClient.OpenChannel("session", []byte{})
if err != nil {
return err
}
user := conn.User()
// pipe everything
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1].Logs, user, newChan)
case "direct-tcpip":
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
// go through all the hops
for _, config := range configs {
var client *gossh.Client
if lastClient == nil {
client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig)
} else {
rconn, err := lastClient.Dial("tcp", config.Addr)
if err != nil {
return err
}
ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig)
if err != nil {
return err
}
client = gossh.NewClient(ncc, chans, reqs)
}
if err != nil {
return err
}
defer func() { _ = client.Close() }()
lastClient = client
}
d := logtunnel.ForwardData{}
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
return err
}
rch, rreqs, err := lastClient.OpenChannel("direct-tcpip", newChan.ExtraData())
if err != nil {
return err
}
user := conn.User()
// pipe everything
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1].Logs, user, newChan)
default:
newChan.Reject(gossh.UnknownChannelType, "unsupported channel type")
return nil
}
}
func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, logsLocation string, user string, newChan gossh.NewChannel) error {
defer func() {
_ = lch.Close()
_ = rch.Close()
}()
errch := make(chan error, 1)
channeltype := newChan.ChannelType()
file_name := strings.Join([]string{logsLocation, "/", user, "-", channeltype, "-", time.Now().Format(time.RFC3339)}, "") // get user
f, err := os.OpenFile(file_name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
defer f.Close()
if err != nil {
log.Fatalf("error: %v", err)
}
log.Printf("Session %v is recorded in %v", channeltype, file_name)
if channeltype == "session" {
wrappedlch := logchannel.New(lch, f)
go func() {
_, _ = io.Copy(wrappedlch, rch)
errch <- errors.New("lch closed the connection")
}()
go func() {
_, _ = io.Copy(rch, lch)
errch <- errors.New("rch closed the connection")
}()
}
if channeltype == "direct-tcpip" {
d := logtunnel.ForwardData{}
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
return err
}
wrappedlch := logtunnel.New(lch, f, d.SourceHost)
wrappedrch := logtunnel.New(rch, f, d.DestinationHost)
go func() {
_, _ = io.Copy(wrappedlch, rch)
errch <- errors.New("lch closed the connection")
}()
go func() {
_, _ = io.Copy(wrappedrch, lch)
errch <- errors.New("rch closed the connection")
}()
}
for {
select {
case req := <-lreqs: // forward ssh requests from local to remote
if req == nil {
return nil
}
b, err := rch.SendRequest(req.Type, req.WantReply, req.Payload)
if req.Type == "exec" {
wrappedlch := logchannel.New(lch, f)
command := append(req.Payload, []byte("\n")...)
wrappedlch.LogWrite(command)
}
if err != nil {
return err
}
if err2 := req.Reply(b, nil); err2 != nil {
return err2
}
case req := <-rreqs: // forward ssh requests from remote to local
if req == nil {
return nil
}
b, err := lch.SendRequest(req.Type, req.WantReply, req.Payload)
if err != nil {
return err
}
if err2 := req.Reply(b, nil); err2 != nil {
return err2
}
case err := <-errch:
return err
}
}
}

View File

@@ -0,0 +1,59 @@
package logtunnel
import (
"encoding/binary"
"io"
"syscall"
"time"
"golang.org/x/crypto/ssh"
)
type logTunnel struct {
host string
channel ssh.Channel
writer io.WriteCloser
}
type ForwardData struct {
DestinationHost string
DestinationPort uint32
SourceHost string
SourcePort uint32
}
func writeHeader(fd io.Writer, length int) {
t := time.Now()
tv := syscall.NsecToTimeval(t.UnixNano())
binary.Write(fd, binary.LittleEndian, int32(tv.Sec))
binary.Write(fd, binary.LittleEndian, int32(tv.Usec))
binary.Write(fd, binary.LittleEndian, int32(length))
}
func New(channel ssh.Channel, writer io.WriteCloser, host string) *logTunnel {
return &logTunnel{
host: host,
channel: channel,
writer: writer,
}
}
func (l *logTunnel) Read(data []byte) (int, error) {
return l.Read(data)
}
func (l *logTunnel) Write(data []byte) (int, error) {
writeHeader(l.writer, len(data) + len(l.host + ": "))
l.writer.Write([]byte(l.host + ": "))
l.writer.Write(data)
return l.channel.Write(data)
}
func (l *logTunnel) Close() error {
l.writer.Close()
return l.channel.Close()
}

102
proxy.go
View File

@@ -1,102 +0,0 @@
package main
import (
"errors"
"fmt"
"io"
"log"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
)
func proxy(s ssh.Session, host *Host, hk gossh.HostKeyCallback) error {
config, err := host.clientConfig(s, hk)
if err != nil {
return err
}
rconn, err := gossh.Dial("tcp", host.Addr, config)
if err != nil {
return err
}
defer func() { _ = rconn.Close() }()
rch, rreqs, err := rconn.OpenChannel("session", []byte{})
if err != nil {
return err
}
log.Println("SSH Connection established")
return pipe(s.MaskedReqs(), rreqs, s, rch)
}
func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel) error {
defer func() {
_ = lch.Close()
_ = rch.Close()
}()
errch := make(chan error, 1)
go func() {
_, _ = io.Copy(lch, rch)
errch <- errors.New("lch closed the connection")
}()
go func() {
_, _ = io.Copy(rch, lch)
errch <- errors.New("rch closed the connection")
}()
for {
select {
case req := <-lreqs: // forward ssh requests from local to remote
if req == nil {
return nil
}
b, err := rch.SendRequest(req.Type, req.WantReply, req.Payload)
if err != nil {
return err
}
if err2 := req.Reply(b, nil); err2 != nil {
return err2
}
case req := <-rreqs: // forward ssh requests from remote to local
if req == nil {
return nil
}
b, err := lch.SendRequest(req.Type, req.WantReply, req.Payload)
if err != nil {
return err
}
if err2 := req.Reply(b, nil); err2 != nil {
return err2
}
case err := <-errch:
return err
}
}
}
func (host *Host) clientConfig(_ ssh.Session, 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
}
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
}

348
shell.go
View File

@@ -14,7 +14,6 @@ import (
"github.com/asaskevich/govalidator"
humanize "github.com/dustin/go-humanize"
"github.com/gliderlabs/ssh"
"github.com/jinzhu/gorm"
"github.com/mgutz/ansi"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/olekukonko/tablewriter"
@@ -33,7 +32,15 @@ var banner = `
`
var startTime = time.Now()
func shell(globalContext *cli.Context, s ssh.Session, sshCommand []string, db *gorm.DB) error {
const (
naMessage = "n/a"
)
func shell(s ssh.Session) error {
var (
sshCommand = s.Command()
actx = s.Context().Value(authContextKey).(*authContext)
)
if len(sshCommand) == 0 {
if _, err := fmt.Fprint(s, banner); err != nil {
return err
@@ -55,7 +62,11 @@ GLOBAL OPTIONS:
app.Writer = s
app.HideVersion = true
myself := s.Context().Value(userContextKey).(User)
var (
myself = &actx.user
db = actx.db
)
app.Commands = []cli.Command{
{
Name: "acl",
@@ -67,14 +78,14 @@ GLOBAL OPTIONS:
Description: "$> acl create -",
Flags: []cli.Flag{
cli.StringSliceFlag{Name: "hostgroup, hg", Usage: "Assigns `HOSTGROUPS` to the acl"},
cli.StringSliceFlag{Name: "usergroup, ug", Usage: "Assigns `HOSTGROUPS` to the acl"},
cli.StringSliceFlag{Name: "usergroup, ug", Usage: "Assigns `USERGROUP` to the acl"},
cli.StringFlag{Name: "pattern", Usage: "Assigns a host pattern to the acl"},
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
cli.StringFlag{Name: "action", Usage: "Assigns the ACL action (allow,deny)", Value: ACLActionAllow},
cli.UintFlag{Name: "weight, w", Usage: "Assigns the ACL weight (priority)"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
acl := ACL{
@@ -124,7 +135,7 @@ GLOBAL OPTIONS:
if c.NArg() < 1 {
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -145,7 +156,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -206,7 +217,7 @@ GLOBAL OPTIONS:
if c.NArg() < 1 {
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -230,7 +241,7 @@ GLOBAL OPTIONS:
if c.NArg() < 1 {
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -264,10 +275,16 @@ GLOBAL OPTIONS:
tx.Rollback()
return err
}
if err := model.Association("UserGroups").Append(&appendUserGroups).Delete(deleteUserGroups).Error; err != nil {
if err := model.Association("UserGroups").Append(&appendUserGroups).Error; err != nil {
tx.Rollback()
return err
}
if len(deleteUserGroups) > 0 {
if err := model.Association("UserGroups").Delete(deleteUserGroups).Error; err != nil {
tx.Rollback()
return err
}
}
var appendHostGroups []HostGroup
var deleteHostGroups []HostGroup
@@ -279,10 +296,16 @@ GLOBAL OPTIONS:
tx.Rollback()
return err
}
if err := model.Association("HostGroups").Append(&appendHostGroups).Delete(deleteHostGroups).Error; err != nil {
if err := model.Association("HostGroups").Append(&appendHostGroups).Error; err != nil {
tx.Rollback()
return err
}
if len(deleteHostGroups) > 0 {
if err := model.Association("HostGroups").Delete(deleteHostGroups).Error; err != nil {
tx.Rollback()
return err
}
}
}
return tx.Commit().Error
@@ -299,10 +322,11 @@ GLOBAL OPTIONS:
Flags: []cli.Flag{
cli.BoolFlag{Name: "indent", Usage: "uses indented JSON"},
cli.BoolFlag{Name: "decrypt", Usage: "decrypt sensitive data"},
cli.BoolFlag{Name: "ignore-events", Usage: "do not backup events data"},
},
Description: "ssh admin@portal config backup > sshportal.bkp",
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -315,11 +339,11 @@ GLOBAL OPTIONS:
return err
}
for _, key := range config.SSHKeys {
SSHKeyDecrypt(globalContext.String("aes-key"), key)
SSHKeyDecrypt(actx.config.aesKey, key)
}
if !c.Bool("decrypt") {
for _, key := range config.SSHKeys {
if err := SSHKeyEncrypt(globalContext.String("aes-key"), key); err != nil {
if err := SSHKeyEncrypt(actx.config.aesKey, key); err != nil {
return err
}
}
@@ -329,11 +353,11 @@ GLOBAL OPTIONS:
return err
}
for _, host := range config.Hosts {
HostDecrypt(globalContext.String("aes-key"), host)
HostDecrypt(actx.config.aesKey, host)
}
if !c.Bool("decrypt") {
for _, host := range config.Hosts {
if err := HostEncrypt(globalContext.String("aes-key"), host); err != nil {
if err := HostEncrypt(actx.config.aesKey, host); err != nil {
return err
}
}
@@ -360,8 +384,10 @@ GLOBAL OPTIONS:
if err := SessionsPreload(db).Find(&config.Sessions).Error; err != nil {
return err
}
if err := EventsPreload(db).Find(&config.Events).Error; err != nil {
return err
if !c.Bool("ignore-events") {
if err := EventsPreload(db).Find(&config.Events).Error; err != nil {
return err
}
}
config.Date = time.Now()
enc := json.NewEncoder(s)
@@ -379,7 +405,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "decrypt", Usage: "do not encrypt sensitive data"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -446,9 +472,9 @@ GLOBAL OPTIONS:
}
}
for _, host := range config.Hosts {
HostDecrypt(globalContext.String("aes-key"), host)
HostDecrypt(actx.config.aesKey, host)
if !c.Bool("decrypt") {
if err := HostEncrypt(globalContext.String("aes-key"), host); err != nil {
if err := HostEncrypt(actx.config.aesKey, host); err != nil {
return err
}
}
@@ -482,9 +508,9 @@ GLOBAL OPTIONS:
}
}
for _, sshKey := range config.SSHKeys {
SSHKeyDecrypt(globalContext.String("aes-key"), sshKey)
SSHKeyDecrypt(actx.config.aesKey, sshKey)
if !c.Bool("decrypt") {
if err := SSHKeyEncrypt(globalContext.String("aes-key"), sshKey); err != nil {
if err := SSHKeyEncrypt(actx.config.aesKey, sshKey); err != nil {
return err
}
}
@@ -540,7 +566,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -569,7 +595,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -625,13 +651,14 @@ GLOBAL OPTIONS:
{
Name: "create",
Usage: "Creates a new host",
ArgsUsage: "<user>[:<password>]@<host>[:<port>]",
ArgsUsage: "[scheme://]<user>[:<password>]@<host>[:<port>]",
Description: "$> host create bart@foo.org\n $> host create bob:marley@example.com:2222",
Flags: []cli.Flag{
cli.StringFlag{Name: "name, n", Usage: "Assigns a name to the host"},
cli.StringFlag{Name: "password, p", Usage: "If present, sshportal will use password-based authentication"},
cli.StringFlag{Name: "comment, c"},
cli.StringFlag{Name: "key, k", Usage: "`KEY` to use for authentication"},
cli.StringFlag{Name: "hop, o", Usage: "Hop to use for connecting to the server"},
cli.StringSliceFlag{Name: "group, g", Usage: "Assigns the host to `HOSTGROUPS` (default: \"default\")"},
},
Action: func(c *cli.Context) error {
@@ -639,24 +666,33 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
host, err := NewHostFromURL(c.Args().First())
u, err := ParseInputURL(c.Args().First())
if err != nil {
return err
}
host := &Host{
URL: u.String(),
Comment: c.String("comment"),
}
if c.String("password") != "" {
host.Password = c.String("password")
}
host.Name = strings.Split(host.Hostname(), ".")[0]
if c.String("hop") != "" {
hop, err := HostByName(db, c.String("hop"))
if err != nil {
return err
}
host.Hop = hop
}
if c.String("name") != "" {
host.Name = c.String("name")
}
// FIXME: check if name already exists
host.Comment = c.String("comment")
if _, err := govalidator.ValidateStruct(host); err != nil {
return err
@@ -684,7 +720,7 @@ GLOBAL OPTIONS:
}
// encrypt
if err := HostEncrypt(globalContext.String("aes-key"), host); err != nil {
if err := HostEncrypt(actx.config.aesKey, host); err != nil {
return err
}
@@ -706,13 +742,13 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin", "listhosts"}); err != nil {
if err := myself.CheckRoles([]string{"admin", "listhosts"}); err != nil {
return err
}
var hosts []*Host
db = db.Preload("Groups")
if UserHasRole(myself, "admin") {
if myself.HasRole("admin") {
db = db.Preload("SSHKey")
}
if err := HostsByIdentifiers(db, c.Args()).Find(&hosts).Error; err != nil {
@@ -721,7 +757,7 @@ GLOBAL OPTIONS:
if c.Bool("decrypt") {
for _, host := range hosts {
HostDecrypt(globalContext.String("aes-keuy"), host)
HostDecrypt(actx.config.aesKey, host)
}
}
@@ -737,7 +773,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin", "listhosts"}); err != nil {
if err := myself.CheckRoles([]string{"admin", "listhosts"}); err != nil {
return err
}
@@ -763,14 +799,11 @@ GLOBAL OPTIONS:
}
table := tablewriter.NewWriter(s)
table.SetHeader([]string{"ID", "Name", "URL", "Key", "Pass", "Groups", "Updated", "Created", "Comment"})
table.SetHeader([]string{"ID", "Name", "URL", "Key", "Groups", "Updated", "Created", "Comment", "Hop"})
table.SetBorder(false)
table.SetCaption(true, fmt.Sprintf("Total: %d hosts.", len(hosts)))
for _, host := range hosts {
authKey, authPass := "", ""
if host.Password != "" {
authPass = "yes"
}
authKey := ""
if host.SSHKeyID > 0 {
var key SSHKey
db.Model(&host).Related(&key)
@@ -780,16 +813,24 @@ GLOBAL OPTIONS:
for _, hostGroup := range host.Groups {
groupNames = append(groupNames, hostGroup.Name)
}
var hop string
if host.HopID != 0 {
var hopHost Host
db.Model(&host).Related(&hopHost, "HopID")
hop = hopHost.Name
} else {
hop = ""
}
table.Append([]string{
fmt.Sprintf("%d", host.ID),
host.Name,
host.URL(),
host.String(),
authKey,
authPass,
strings.Join(groupNames, ", "),
humanize.Time(host.UpdatedAt),
humanize.Time(host.CreatedAt),
host.Comment,
hop,
//FIXME: add some stats about last access time etc
})
}
@@ -805,7 +846,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -817,9 +858,11 @@ GLOBAL OPTIONS:
ArgsUsage: "HOST...",
Flags: []cli.Flag{
cli.StringFlag{Name: "name, n", Usage: "Rename the host"},
cli.StringFlag{Name: "password, p", Usage: "Update/set a password, use \"none\" to unset"},
cli.StringFlag{Name: "url, u", Usage: "Update connection URL"},
cli.StringFlag{Name: "comment, c", Usage: "Update/set a host comment"},
cli.StringFlag{Name: "key, k", Usage: "Link a `KEY` to use for authentication"},
cli.StringFlag{Name: "hop, o", Usage: "Change the hop to use for connecting to the server"},
cli.BoolFlag{Name: "unset-hop", Usage: "Remove the hop set for this host"},
cli.StringSliceFlag{Name: "assign-group, g", Usage: "Assign the host to a new `HOSTGROUPS`"},
cli.StringSliceFlag{Name: "unassign-group", Usage: "Unassign the host from a `HOSTGROUPS`"},
},
@@ -828,7 +871,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -845,7 +888,7 @@ GLOBAL OPTIONS:
for _, host := range hosts {
model := tx.Model(&host)
// simple fields
for _, fieldname := range []string{"name", "comment", "password"} {
for _, fieldname := range []string{"name", "comment"} {
if c.String(fieldname) != "" {
if err := model.Update(fieldname, c.String(fieldname)).Error; err != nil {
tx.Rollback()
@@ -854,6 +897,42 @@ GLOBAL OPTIONS:
}
}
// url
if c.String("url") != "" {
u, err := ParseInputURL(c.String("url"))
if err != nil {
tx.Rollback()
return err
}
if err := model.Update("url", u.String()).Error; err != nil {
tx.Rollback()
return err
}
}
// hop
if c.String("hop") != "" {
hop, err := HostByName(db, c.String("hop"))
if err != nil {
tx.Rollback()
return err
}
if err := model.Association("Hop").Replace(hop).Error; err != nil {
tx.Rollback()
return err
}
}
// remove the hop
if c.Bool("unset-hop") {
var hopHost Host
db.Model(&host).Related(&hopHost, "HopID")
if err := model.Association("Hop").Delete(hopHost).Error; err != nil {
tx.Rollback()
return err
}
}
// associations
if c.String("key") != "" {
var key SSHKey
@@ -876,10 +955,16 @@ GLOBAL OPTIONS:
tx.Rollback()
return err
}
if err := model.Association("Groups").Append(&appendGroups).Delete(deleteGroups).Error; err != nil {
if err := model.Association("Groups").Append(&appendGroups).Error; err != nil {
tx.Rollback()
return err
}
if len(deleteGroups) > 0 {
if err := model.Association("Groups").Delete(deleteGroups).Error; err != nil {
tx.Rollback()
return err
}
}
}
return tx.Commit().Error
@@ -899,7 +984,7 @@ GLOBAL OPTIONS:
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -930,7 +1015,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -951,7 +1036,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1004,7 +1089,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1016,18 +1101,18 @@ GLOBAL OPTIONS:
Name: "info",
Usage: "Shows system-wide information",
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
fmt.Fprintf(s, "Debug mode (server): %v\n", globalContext.Bool("debug"))
fmt.Fprintf(s, "debug mode (server): %v\n", actx.config.debug)
hostname, _ := os.Hostname()
fmt.Fprintf(s, "Hostname: %s\n", hostname)
fmt.Fprintf(s, "CPUs: %d\n", runtime.NumCPU())
fmt.Fprintf(s, "Demo mode: %v\n", globalContext.Bool("demo"))
fmt.Fprintf(s, "DB Driver: %s\n", globalContext.String("db-driver"))
fmt.Fprintf(s, "DB Conn: %s\n", globalContext.String("db-conn"))
fmt.Fprintf(s, "Bind Address: %s\n", globalContext.String("bind-address"))
fmt.Fprintf(s, "Demo mode: %v\n", actx.config.demo)
fmt.Fprintf(s, "DB Driver: %s\n", actx.config.dbDriver)
fmt.Fprintf(s, "DB Conn: %s\n", actx.config.dbURL)
fmt.Fprintf(s, "Bind Address: %s\n", actx.config.bindAddr)
fmt.Fprintf(s, "System Time: %v\n", time.Now().Format(time.RFC3339Nano))
fmt.Fprintf(s, "OS Type: %s\n", runtime.GOOS)
fmt.Fprintf(s, "OS Architecture: %s\n", runtime.GOARCH)
@@ -1035,7 +1120,7 @@ GLOBAL OPTIONS:
fmt.Fprintf(s, "Go version (build): %v\n", runtime.Version())
fmt.Fprintf(s, "Uptime: %v\n", time.Since(startTime))
fmt.Fprintf(s, "User email: %v\n", myself.ID)
fmt.Fprintf(s, "User ID: %v\n", myself.ID)
fmt.Fprintf(s, "User email: %s\n", myself.Email)
fmt.Fprintf(s, "Version: %s\n", Version)
fmt.Fprintf(s, "GIT SHA: %s\n", GitSha)
@@ -1063,7 +1148,7 @@ GLOBAL OPTIONS:
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1073,8 +1158,8 @@ GLOBAL OPTIONS:
}
key, err := NewSSHKey(c.String("type"), c.Uint("length"))
if globalContext.String("aes-key") != "" {
if err2 := SSHKeyEncrypt(globalContext.String("aes-key"), key); err2 != nil {
if actx.config.aesKey != "" {
if err2 := SSHKeyEncrypt(actx.config.aesKey, key); err2 != nil {
return err2
}
}
@@ -1097,6 +1182,60 @@ GLOBAL OPTIONS:
return nil
},
}, {
Name: "import",
Usage: "Imports an existing private key",
Description: "$> key import\n $> key import --name=mykey",
Flags: []cli.Flag{
cli.StringFlag{Name: "name", Usage: "Assigns a name to the key"},
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
},
Action: func(c *cli.Context) error {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
var name string
if c.String("name") != "" {
name = c.String("name")
} else {
name = namesgenerator.GetRandomName(0)
}
var value string
term := terminal.NewTerminal(s, "Paste your key and end with a blank line> ")
for {
line, err := term.ReadLine()
if err != nil {
return err
}
if line != "" {
value += line + "\n"
} else {
break
}
}
key, err := ImportSSHKey(value)
if err != nil {
return err
}
key.Name = name
key.Comment = c.String("comment")
if _, err := govalidator.ValidateStruct(key); err != nil {
return err
}
// FIXME: check if name already exists
// save the key in database
if err := db.Create(&key).Error; err != nil {
return err
}
fmt.Fprintf(s, "%d\n", key.ID)
return nil
},
}, {
Name: "inspect",
Usage: "Shows detailed information on one or more keys",
ArgsUsage: "KEY...",
@@ -1108,7 +1247,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1119,7 +1258,7 @@ GLOBAL OPTIONS:
if c.Bool("decrypt") {
for _, key := range keys {
SSHKeyDecrypt(globalContext.String("aes-key"), key)
SSHKeyDecrypt(actx.config.aesKey, key)
}
}
@@ -1135,7 +1274,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1189,7 +1328,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1228,7 +1367,7 @@ GLOBAL OPTIONS:
if err := SSHKeysByIdentifiers(SSHKeysPreload(db), c.Args()).First(&key).Error; err != nil {
return err
}
SSHKeyDecrypt(globalContext.String("aes-key"), &key)
SSHKeyDecrypt(actx.config.aesKey, &key)
type line struct {
key string
@@ -1302,7 +1441,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1330,7 +1469,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1366,7 +1505,7 @@ GLOBAL OPTIONS:
if err := db.Create(&user).Error; err != nil {
return err
}
fmt.Fprintf(s, "User %d created.\nTo associate this account with a key, use the following SSH user: 'invite-%s'.\n", user.ID, user.InviteToken)
fmt.Fprintf(s, "User %d created.\nTo associate this account with a key, use the following SSH user: 'invite:%s'.\n", user.ID, user.InviteToken)
return nil
},
}, {
@@ -1377,7 +1516,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1438,7 +1577,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1461,7 +1600,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1503,11 +1642,16 @@ GLOBAL OPTIONS:
tx.Rollback()
return err
}
if err := model.Association("Groups").Append(&appendGroups).Delete(deleteGroups).Error; err != nil {
if err := model.Association("Groups").Append(&appendGroups).Error; err != nil {
tx.Rollback()
return err
}
if len(deleteGroups) > 0 {
if err := model.Association("Groups").Delete(deleteGroups).Error; err != nil {
tx.Rollback()
return err
}
}
var appendRoles []UserRole
if err := UserRolesByIdentifiers(db, c.StringSlice("assign-role")).Find(&appendRoles).Error; err != nil {
tx.Rollback()
@@ -1518,12 +1662,17 @@ GLOBAL OPTIONS:
tx.Rollback()
return err
}
if err := model.Association("Roles").Append(&appendRoles).Delete(deleteRoles).Error; err != nil {
if err := model.Association("Roles").Append(&appendRoles).Error; err != nil {
tx.Rollback()
return err
}
if len(deleteRoles) > 0 {
if err := model.Association("Roles").Delete(deleteRoles).Error; err != nil {
tx.Rollback()
return err
}
}
}
return tx.Commit().Error
},
},
@@ -1541,7 +1690,7 @@ GLOBAL OPTIONS:
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1559,7 +1708,7 @@ GLOBAL OPTIONS:
// FIXME: check if name already exists
// FIXME: add myself to the new group
userGroup.Users = []*User{&myself}
userGroup.Users = []*User{myself}
if err := db.Create(&userGroup).Error; err != nil {
return err
@@ -1576,7 +1725,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1597,7 +1746,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1649,7 +1798,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1674,7 +1823,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1721,7 +1870,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1742,7 +1891,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1771,9 +1920,13 @@ GLOBAL OPTIONS:
table.SetBorder(false)
table.SetCaption(true, fmt.Sprintf("Total: %d userkeys.", len(userKeys)))
for _, userkey := range userKeys {
email := naMessage
if userkey.User != nil {
email = userkey.User.Email
}
table.Append([]string{
fmt.Sprintf("%d", userkey.ID),
userkey.User.Email,
email,
// FIXME: add fingerprint
humanize.Time(userkey.UpdatedAt),
humanize.Time(userkey.CreatedAt),
@@ -1792,7 +1945,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1813,7 +1966,7 @@ GLOBAL OPTIONS:
return cli.ShowSubcommandHelp(c)
}
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1834,7 +1987,7 @@ GLOBAL OPTIONS:
cli.BoolFlag{Name: "quiet, q", Usage: "Only display IDs"},
},
Action: func(c *cli.Context) error {
if err := UserCheckRoles(myself, []string{"admin"}); err != nil {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
@@ -1870,10 +2023,18 @@ GLOBAL OPTIONS:
duration = humanize.RelTime(session.CreatedAt, *session.StoppedAt, "", "")
}
duration = strings.Replace(duration, "now", "1 second", 1)
hostname := naMessage
if session.Host != nil {
hostname = session.Host.Name
}
username := naMessage
if session.User != nil {
username = session.User.Name
}
table.Append([]string{
fmt.Sprintf("%d", session.ID),
session.User.Name,
session.Host.Name,
username,
hostname,
session.Status,
humanize.Time(session.CreatedAt),
duration,
@@ -1918,7 +2079,10 @@ GLOBAL OPTIONS:
if len(words) == 1 && strings.ToLower(words[0]) == "exit" {
return s.Exit(0)
}
NewEvent("shell", words[0]).SetAuthor(&myself).SetArg("interactive", true).SetArg("args", words[1:]).Log(db)
if len(words) == 0 {
continue
}
NewEvent("shell", words[0]).SetAuthor(myself).SetArg("interactive", true).SetArg("args", words[1:]).Log(db)
if err := app.Run(append([]string{"config"}, words...)); err != nil {
if cliErr, ok := err.(*cli.ExitError); ok {
if cliErr.ExitCode() != 0 {
@@ -1931,7 +2095,7 @@ GLOBAL OPTIONS:
}
}
} else { // oneshot mode
NewEvent("shell", sshCommand[0]).SetAuthor(&myself).SetArg("interactive", false).SetArg("args", sshCommand[1:]).Log(db)
NewEvent("shell", sshCommand[0]).SetAuthor(myself).SetArg("interactive", false).SetArg("args", sshCommand[1:]).Log(db)
if err := app.Run(append([]string{"config"}, sshCommand...)); err != nil {
if errMsg := err.Error(); errMsg != "" {
fmt.Fprintf(s, "error: %s\n", errMsg)

346
ssh.go
View File

@@ -2,35 +2,349 @@ package main
import (
"bytes"
"errors"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/gliderlabs/ssh"
"github.com/jinzhu/gorm"
"github.com/moul/sshportal/pkg/bastionsession"
gossh "golang.org/x/crypto/ssh"
)
type dynamicHostKey struct {
db *gorm.DB
host *Host
type sshportalContextKey string
var authContextKey = sshportalContextKey("auth")
type authContext struct {
message string
err error
user User
inputUsername string
db *gorm.DB
userKey UserKey
config *configServe
authMethod string
authSuccess bool
}
func (d *dynamicHostKey) check(hostname string, remote net.Addr, key gossh.PublicKey) error {
if len(d.host.HostKey) == 0 {
log.Println("Discovering host fingerprint...")
return d.db.Model(d.host).Update("HostKey", key.Marshal()).Error
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":
case "direct-tcpip":
default:
// TODO: handle direct-tcp (only for ssh scheme)
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
log.Printf("error: failed to reject channel: %v", err)
}
return
}
if !bytes.Equal(d.host.HostKey, key.Marshal()) {
return fmt.Errorf("ssh: host key mismatch")
actx := ctx.Value(authContextKey).(*authContext)
switch actx.userType() {
case UserTypeBastion:
log.Printf("New connection(bastion): sshUser=%q remote=%q local=%q dbUser=id:%q,email:%s", conn.User(), conn.RemoteAddr(), conn.LocalAddr(), actx.user.ID, actx.user.Email)
host, err := HostByName(actx.db, actx.inputUsername)
if err != nil {
ch, _, err2 := newChan.Accept()
if err2 != nil {
return
}
fmt.Fprintf(ch, "error: %v\n", err)
// FIXME: force close all channels
_ = ch.Close()
return
}
switch host.Scheme() {
case BastionSchemeSSH:
sessionConfigs := make([]bastionsession.Config, 0)
currentHost := host
for currentHost != nil {
clientConfig, err2 := bastionClientConfig(ctx, currentHost)
if err2 != nil {
ch, _, err3 := newChan.Accept()
if err3 != nil {
return
}
fmt.Fprintf(ch, "error: %v\n", err2)
// FIXME: force close all channels
_ = ch.Close()
return
}
sessionConfigs = append([]bastionsession.Config{{
Addr: currentHost.DialAddr(),
ClientConfig: clientConfig,
Logs: actx.config.logsLocation,
}}, sessionConfigs...)
if currentHost.HopID != 0 {
var newHost Host
actx.db.Model(currentHost).Related(&newHost, "HopID")
hostname := newHost.Name
currentHost, _ = HostByName(actx.db, hostname)
} else {
currentHost = nil
}
}
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
}
go func() {
err = bastionsession.MultiChannelHandler(srv, conn, newChan, ctx, sessionConfigs)
if err != nil {
log.Printf("Error: %v", err)
}
}()
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)
case BastionSchemeTelnet:
tmpSrv := ssh.Server{
// PtyCallback: srv.PtyCallback,
Handler: telnetHandler(host),
}
ssh.DefaultChannelHandler(&tmpSrv, conn, newChan, ctx)
default:
ch, _, err2 := newChan.Accept()
if err2 != nil {
return
}
fmt.Fprintf(ch, "error: unknown bastion scheme: %q\n", host.Scheme())
// FIXME: force close all channels
_ = ch.Close()
}
default: // shell
ssh.DefaultChannelHandler(srv, conn, newChan, ctx)
}
return nil
}
// DynamicHostKey returns a function for use in
// ClientConfig.HostKeyCallback to dynamically learn or accept host key.
func DynamicHostKey(db *gorm.DB, host *Host) gossh.HostKeyCallback {
// FIXME: forward interactively the host key checking
hk := &dynamicHostKey{db, host}
return hk.check
func bastionClientConfig(ctx ssh.Context, host *Host) (*gossh.ClientConfig, error) {
actx := ctx.Value(authContextKey).(*authContext)
clientConfig, err := host.clientConfig(dynamicHostKey(actx.db, host))
if err != nil {
return 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, err
}
var tmpHost Host
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err != nil {
return nil, err
}
action, err2 := CheckACLs(tmpUser, tmpHost)
if err2 != nil {
return nil, err2
}
HostDecrypt(actx.config.aesKey, host)
SSHKeyDecrypt(actx.config.aesKey, host.SSHKey)
switch action {
case ACLActionAllow:
case ACLActionDeny:
return nil, fmt.Errorf("you don't have permission to that host")
default:
return nil, fmt.Errorf("invalid ACL action: %q", action)
}
return clientConfig, nil
}
func shellHandler(s ssh.Session) {
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, cfg *configServe) ssh.PasswordHandler {
return func(ctx ssh.Context, pass string) bool {
actx := &authContext{
db: db,
inputUsername: ctx.User(),
config: cfg,
authMethod: "password",
}
actx.authSuccess = actx.userType() == UserTypeHealthcheck
ctx.SetValue(authContextKey, actx)
return actx.authSuccess
}
}
func privateKeyFromDB(db *gorm.DB, aesKey string) func(*ssh.Server) error {
return func(srv *ssh.Server) error {
var key SSHKey
if err := SSHKeysByIdentifiers(db, []string{"host"}).First(&key).Error; err != nil {
return err
}
SSHKeyDecrypt(aesKey, &key)
signer, err := gossh.ParsePrivateKey([]byte(key.PrivKey))
if err != nil {
return err
}
srv.AddHostKey(signer)
return nil
}
}
func publicKeyAuthHandler(db *gorm.DB, cfg *configServe) ssh.PublicKeyHandler {
return func(ctx ssh.Context, key ssh.PublicKey) bool {
actx := &authContext{
db: db,
inputUsername: ctx.User(),
config: cfg,
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
}
}

87
telnet.go Normal file
View File

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

22
vendor/github.com/arkan/bastion/LICENSE generated vendored Normal file
View File

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

View File

@@ -0,0 +1,54 @@
package logchannel
import (
"encoding/binary"
"io"
"syscall"
"time"
"golang.org/x/crypto/ssh"
)
type logChannel struct {
channel ssh.Channel
writer io.WriteCloser
}
func writeTTYRecHeader(fd io.Writer, length int) {
t := time.Now()
tv := syscall.NsecToTimeval(t.UnixNano())
binary.Write(fd, binary.LittleEndian, int32(tv.Sec))
binary.Write(fd, binary.LittleEndian, int32(tv.Usec))
binary.Write(fd, binary.LittleEndian, int32(length))
}
func New(channel ssh.Channel, writer io.WriteCloser) *logChannel {
return &logChannel{
channel: channel,
writer: writer,
}
}
func (l *logChannel) Read(data []byte) (int, error) {
return l.Read(data)
}
func (l *logChannel) Write(data []byte) (int, error) {
writeTTYRecHeader(l.writer, len(data))
l.writer.Write(data)
return l.channel.Write(data)
}
func (l *logChannel) LogWrite(data []byte) (int, error) {
writeTTYRecHeader(l.writer, len(data))
return l.writer.Write(data)
}
func (l *logChannel) Close() error {
l.writer.Close()
return l.channel.Close()
}

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
}

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
)

19
vendor/github.com/reiver/go-oi/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2016 Charles Iliya Krempeaux <charles@reptile.ca> :: http://changelog.ca/
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.

64
vendor/github.com/reiver/go-oi/README.md generated vendored Normal file
View File

@@ -0,0 +1,64 @@
# go-oi
Package **oi** provides useful tools to be used with the Go programming language's standard "io" package.
For example, did you know that when you call the `Write` method on something that fits the `io.Writer`
interface, that it is possible that not everything was be written?!
I.e., that a _**short write**_ happened.
That just doing the following is (in general) **not** enough:
```
n, err := writer.Write(p)
```
That, for example, you should be checking if `err == io.ErrShortWrite`, and then maybe calling the `Write`
method again but only with what didn't get written.
For a simple example of this (that actually is **not** sufficient to solve this problem, but illustrates
the direction you would need to go to solve this problem is):
```
n, err := w.Write(p)
if io.ErrShortWrite == err {
n2, err2 := w.Write(p[n:])
}
```
Note that the second call to the `Write` method passed `p[n:]` (instead of just `p`), to account for the `n` bytes
already being written (with the first call to the `Write` method).
A more "production quality" version of this would likely be in a loop, but such that that the loop had "guards"
against looping forever, and also possibly looping for "too long".
Well package **oi** provides tools that helps you deal with this and other problems. For example, you
can handle a _**short write**_ with the following **oi** func:
```
n, err := oi.LongWrite(writer, p)
```
## Documention
Online documentation, which includes examples, can be found at: http://godoc.org/github.com/reiver/go-oi
[![GoDoc](https://godoc.org/github.com/reiver/go-oi?status.svg)](https://godoc.org/github.com/reiver/go-oi)
## Example
```
import (
"github.com/reiver/go-oi"
)
// ...
p := []byte("It is important that this message be written!!!")
n, err := oi.LongWrite(writer, p)
if nil != err {
//@TODO: Handle error.
return
}
```

39
vendor/github.com/reiver/go-oi/doc.go generated vendored Normal file
View File

@@ -0,0 +1,39 @@
/*
Package oi provides useful tools to be used with Go's standard "io" package.
For example, did you know that when you call the Write method on something that fits the io.Writer
interface, that it is possible that not everything was be written?!
I.e., that a 'short write' happened.
That just doing the following is (in general) not enough:
n, err := writer.Write(p)
That, for example, you should be checking if "err == io.ErrShortWrite", and then maybe calling the Write
method again but only with what didn't get written.
For a simple example of this (that actually is not sufficient to solve this problem, but illustrates
the direction you would need to go to solve this problem is):
n, err := w.Write(p)
if io.ErrShortWrite == err {
n2, err2 := w.Write(p[n:])
}
Note that the second call to the Write method passed "p[n:]" (instead of just "p"), to account for the "n" bytes
already being written (with the first call to the `Write` method).
A more "production quality" version of this would likely be in a loop, but such that that the loop had "guards"
against looping forever, and also possibly looping for "too long".
Well package oi provides tools that helps you deal with this and other problems. For example, you
can handle a 'short write' with the following oi func:
```
n, err := oi.LongWrite(writer, p)
```
*/
package oi

39
vendor/github.com/reiver/go-oi/longwrite.go generated vendored Normal file
View File

@@ -0,0 +1,39 @@
package oi
import (
"io"
)
// LongWrite tries to write the bytes from 'p' to the writer 'w', such that it deals
// with "short writes" where w.Write would return an error of io.ErrShortWrite and
// n < len(p).
//
// Note that LongWrite still could return the error io.ErrShortWrite; but this
// would only be after trying to handle the io.ErrShortWrite a number of times, and
// then eventually giving up.
func LongWrite(w io.Writer, p []byte) (int64, error) {
numWritten := int64(0)
for {
//@TODO: Should check to make sure this doesn't get stuck in an infinite loop writting nothing!
n, err := w.Write(p)
numWritten += int64(n)
if nil != err && io.ErrShortWrite != err {
return numWritten, err
}
if !(n < len(p)) {
break
}
p = p[n:]
if len(p) < 1 {
break
}
}
return numWritten, nil
}

28
vendor/github.com/reiver/go-oi/longwritebyte.go generated vendored Normal file
View File

@@ -0,0 +1,28 @@
package oi
import (
"io"
)
// LongWriteByte trys to write the byte from 'b' to the writer 'w', such that it deals
// with "short writes" where w.Write would return an error of io.ErrShortWrite and
// n < 1.
//
// Note that LongWriteByte still could return the error io.ErrShortWrite; but this
// would only be after trying to handle the io.ErrShortWrite a number of times, and
// then eventually giving up.
func LongWriteByte(w io.Writer, b byte) error {
var buffer [1]byte
p := buffer[:]
buffer[0] = b
numWritten, err := LongWrite(w, p)
if 1 != numWritten {
return io.ErrShortWrite
}
return err
}

39
vendor/github.com/reiver/go-oi/longwritestring.go generated vendored Normal file
View File

@@ -0,0 +1,39 @@
package oi
import (
"io"
)
// LongWriteString tries to write the bytes from 's' to the writer 'w', such that it deals
// with "short writes" where w.Write (or w.WriteString) would return an error of io.ErrShortWrite
// and n < len(s).
//
// Note that LongWriteString still could return the error io.ErrShortWrite; but this
// would only be after trying to handle the io.ErrShortWrite a number of times, and
// then eventually giving up.
func LongWriteString(w io.Writer, s string) (int64, error) {
numWritten := int64(0)
for {
//@TODO: Should check to make sure this doesn't get stuck in an infinite loop writting nothing!
n, err := io.WriteString(w, s)
numWritten += int64(n)
if nil != err && io.ErrShortWrite != err {
return numWritten, err
}
if !(n < len(s)) {
break
}
s = s[n:]
if len(s) < 1 {
break
}
}
return numWritten, nil
}

38
vendor/github.com/reiver/go-oi/writenopcloser.go generated vendored Normal file
View File

@@ -0,0 +1,38 @@
package oi
import (
"io"
)
// WriteNopCloser takes an io.Writer and returns an io.WriteCloser where
// calling the Write method on the returned io.WriterCloser calls the
// Write method on the io.Writer it received, but whre calling the Close
// method on the returned io.WriterCloser does "nothing" (i.e., is a "nop").
//
// This is useful in cases where an io.WriteCloser is expected, but you
// only have an io.Writer (where closing doesn't make sense) and you
// need to make your io.Writer fit. (I.e., you need an adaptor.)
func WriteNopCloser(w io.Writer) io.WriteCloser {
wc := internalWriteNopCloser{
writer:w,
}
return &wc
}
type internalWriteNopCloser struct {
writer io.Writer
}
func (wc * internalWriteNopCloser) Write(p []byte) (n int, err error) {
return wc.writer.Write(p)
}
func (wc * internalWriteNopCloser) Close() error {
return nil
}

19
vendor/github.com/reiver/go-telnet/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2016 Charles Iliya Krempeaux <charles@reptile.ca> :: http://changelog.ca/
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.

252
vendor/github.com/reiver/go-telnet/README.md generated vendored Normal file
View File

@@ -0,0 +1,252 @@
# go-telnet
Package **telnet** provides TELNET and TELNETS client and server implementations, for the Go programming language.
The **telnet** package provides an API in a style similar to the "net/http" library that is part of the Go standard library, including support for "middleware".
(TELNETS is *secure TELNET*, with the TELNET protocol over a secured TLS (or SSL) connection.)
## Documention
Online documentation, which includes examples, can be found at: http://godoc.org/github.com/reiver/go-telnet
[![GoDoc](https://godoc.org/github.com/reiver/go-telnet?status.svg)](https://godoc.org/github.com/reiver/go-telnet)
## Very Simple TELNET Server Example
A very very simple TELNET server is shown in the following code.
This particular TELNET server just echos back to the user anything they "submit" to the server.
(By default, a TELNET client does *not* send anything to the server until the [Enter] key is pressed.
"Submit" means typing something and then pressing the [Enter] key.)
```
package main
import (
"github.com/reiver/go-telnet"
)
func main() {
var handler telnet.Handler = telnet.EchoHandler
err := telnet.ListenAndServe(":5555", handler)
if nil != err {
//@TODO: Handle this error better.
panic(err)
}
}
```
If you wanted to test out this very very simple TELNET server, if you were on the same computer it was
running, you could connect to it using the bash command:
```
telnet localhost 5555
```
(Note that we use the same TCP port number -- "5555" -- as we had in our code. That is important, as the
value used by your TELNET server and the value used by your TELNET client **must** match.)
## Very Simple (Secure) TELNETS Server Example
TELNETS is the secure version of TELNET.
The code to make a TELNETS server is very similar to the code to make a TELNET server.
(The difference between we use the `telnet.ListenAndServeTLS` func instead of the
`telnet.ListenAndServe` func.)
```
package main
import (
"github.com/reiver/go-telnet"
)
func main() {
var handler telnet.Handler = telnet.EchoHandler
err := telnet.ListenAndServeTLS(":5555", "cert.pem", "key.pem", handler)
if nil != err {
//@TODO: Handle this error better.
panic(err)
}
}
```
If you wanted to test out this very very simple TELNETS server, get the `telnets` client program from here:
https://github.com/reiver/telnets
## TELNET Client Example:
```
package main
import (
"github.com/reiver/go-telnet"
)
func main() {
var caller Caller = telnet.StandardCaller
//@TOOD: replace "example.net:5555" with address you want to connect to.
telnet.DialToAndCall("example.net:5555", caller)
}
```
## TELNETS Client Example:
```
package main
import (
"github.com/reiver/go-telnet"
"crypto/tls"
)
func main() {
//@TODO: Configure the TLS connection here, if you need to.
tlsConfig := &tls.Config{}
var caller Caller = telnet.StandardCaller
//@TOOD: replace "example.net:5555" with address you want to connect to.
telnet.DialToAndCallTLS("example.net:5555", caller, tlsConfig)
}
```
## TELNET Shell Server Example
A more useful TELNET servers can be made using the `"github.com/reiver/go-telnet/telsh"` sub-package.
For example:
```
package main
import (
"github.com/reiver/go-oi"
"github.com/reiver/go-telnet"
"github.com/reiver/go-telnet/telsh"
"fmt"
"io"
"time"
)
func fiveHandler(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser)error {
oi.LongWriteString(stdout, "The number FIVE looks like this: 5\r\n")
return nil
}
func fiveProducer(ctx telsh.Context, name string, args ...string) telsh.Handler{
return telsh.PromoteHandlerFunc(fiveHandler)
}
func danceHandler(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser)error {
for i:=0; i<20; i++ {
oi.LongWriteString(stdout, "\r⠋")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠙")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠹")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠸")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠼")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠴")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠦")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠧")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠇")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠏")
time.Sleep(50*time.Millisecond)
}
oi.LongWriteString(stdout, "\r \r\n")
return nil
}
func danceProducer(ctx telsh.Context, name string, args ...string) telsh.Handler{
return telsh.PromoteHandlerFunc(danceHandler)
}
func main() {
shellHandler := telsh.NewShellHandler()
shellHandler.WelcomeMessage = `
__ __ ______ _ _____ ____ __ __ ______
\ \ / /| ____|| | / ____| / __ \ | \/ || ____|
\ \ /\ / / | |__ | | | | | | | || \ / || |__
\ \/ \/ / | __| | | | | | | | || |\/| || __|
\ /\ / | |____ | |____ | |____ | |__| || | | || |____
\/ \/ |______||______| \_____| \____/ |_| |_||______|
`
// Register the "five" command.
commandName := "five"
commandProducer := telsh.ProducerFunc(fiveProducer)
shellHandler.Register(commandName, commandProducer)
// Register the "dance" command.
commandName = "dance"
commandProducer = telsh.ProducerFunc(danceProducer)
shellHandler.Register(commandName, commandProducer)
shellHandler.Register("dance", telsh.ProducerFunc(danceProducer))
addr := ":5555"
if err := telnet.ListenAndServe(addr, shellHandler); nil != err {
panic(err)
}
}
```
TELNET servers made using the `"github.com/reiver/go-telnet/telsh"` sub-package will often be more useful
as it makes it easier for you to create a *shell* interface.
# More Information
There is a lot more information about documentation on all this here: http://godoc.org/github.com/reiver/go-telnet
(You should really read those.)

18
vendor/github.com/reiver/go-telnet/caller.go generated vendored Normal file
View File

@@ -0,0 +1,18 @@
package telnet
// A Caller represents the client end of a TELNET (or TELNETS) connection.
//
// Writing data to the Writer passed as an argument to the CallTELNET method
// will send data to the TELNET (or TELNETS) server.
//
// Reading data from the Reader passed as an argument to the CallTELNET method
// will receive data from the TELNET server.
//
// The Writer's Write method sends "escaped" TELNET (and TELNETS) data.
//
// The Reader's Read method "un-escapes" TELNET (and TELNETS) data, and filters
// out TELNET (and TELNETS) command sequences.
type Caller interface {
CallTELNET(Context, Writer, Reader)
}

100
vendor/github.com/reiver/go-telnet/client.go generated vendored Normal file
View File

@@ -0,0 +1,100 @@
package telnet
import (
"crypto/tls"
)
func DialAndCall(caller Caller) error {
conn, err := Dial()
if nil != err {
return err
}
client := &Client{Caller:caller}
return client.Call(conn)
}
func DialToAndCall(srvAddr string, caller Caller) error {
conn, err := DialTo(srvAddr)
if nil != err {
return err
}
client := &Client{Caller:caller}
return client.Call(conn)
}
func DialAndCallTLS(caller Caller, tlsConfig *tls.Config) error {
conn, err := DialTLS(tlsConfig)
if nil != err {
return err
}
client := &Client{Caller:caller}
return client.Call(conn)
}
func DialToAndCallTLS(srvAddr string, caller Caller, tlsConfig *tls.Config) error {
conn, err := DialToTLS(srvAddr, tlsConfig)
if nil != err {
return err
}
client := &Client{Caller:caller}
return client.Call(conn)
}
type Client struct {
Caller Caller
Logger Logger
}
func (client *Client) Call(conn *Conn) error {
logger := client.logger()
caller := client.Caller
if nil == caller {
logger.Debug("Defaulted caller to StandardCaller.")
caller = StandardCaller
}
var ctx Context = NewContext().InjectLogger(logger)
var w Writer = conn
var r Reader = conn
caller.CallTELNET(ctx, w, r)
conn.Close()
return nil
}
func (client *Client) logger() Logger {
logger := client.Logger
if nil == logger {
logger = internalDiscardLogger{}
}
return logger
}
func (client *Client) SetAuth(username string) {
//@TODO: #################################################
}

148
vendor/github.com/reiver/go-telnet/conn.go generated vendored Normal file
View File

@@ -0,0 +1,148 @@
package telnet
import (
"crypto/tls"
"net"
)
type Conn struct {
conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() net.Addr
RemoteAddr() net.Addr
}
dataReader *internalDataReader
dataWriter *internalDataWriter
}
// Dial makes a (un-secure) TELNET client connection to the system's 'loopback address'
// (also known as "localhost" or 127.0.0.1).
//
// If a secure connection is desired, use `DialTLS` instead.
func Dial() (*Conn, error) {
return DialTo("")
}
// DialTo makes a (un-secure) TELNET client connection to the the address specified by
// 'addr'.
//
// If a secure connection is desired, use `DialToTLS` instead.
func DialTo(addr string) (*Conn, error) {
const network = "tcp"
if "" == addr {
addr = "127.0.0.1:telnet"
}
conn, err := net.Dial(network, addr)
if nil != err {
return nil, err
}
dataReader := newDataReader(conn)
dataWriter := newDataWriter(conn)
clientConn := Conn{
conn:conn,
dataReader:dataReader,
dataWriter:dataWriter,
}
return &clientConn, nil
}
// DialTLS makes a (secure) TELNETS client connection to the system's 'loopback address'
// (also known as "localhost" or 127.0.0.1).
func DialTLS(tlsConfig *tls.Config) (*Conn, error) {
return DialToTLS("", tlsConfig)
}
// DialToTLS makes a (secure) TELNETS client connection to the the address specified by
// 'addr'.
func DialToTLS(addr string, tlsConfig *tls.Config) (*Conn, error) {
const network = "tcp"
if "" == addr {
addr = "127.0.0.1:telnets"
}
conn, err := tls.Dial(network, addr, tlsConfig)
if nil != err {
return nil, err
}
dataReader := newDataReader(conn)
dataWriter := newDataWriter(conn)
clientConn := Conn{
conn:conn,
dataReader:dataReader,
dataWriter:dataWriter,
}
return &clientConn, nil
}
// Close closes the client connection.
//
// Typical usage might look like:
//
// telnetsClient, err = telnet.DialToTLS(addr, tlsConfig)
// if nil != err {
// //@TODO: Handle error.
// return err
// }
// defer telnetsClient.Close()
func (clientConn *Conn) Close() error {
return clientConn.conn.Close()
}
// Read receives `n` bytes sent from the server to the client,
// and "returns" into `p`.
//
// Note that Read can only be used for receiving TELNET (and TELNETS) data from the server.
//
// TELNET (and TELNETS) command codes cannot be received using this method, as Read deals
// with TELNET (and TELNETS) "unescaping", and (when appropriate) filters out TELNET (and TELNETS)
// command codes.
//
// Read makes Client fit the io.Reader interface.
func (clientConn *Conn) Read(p []byte) (n int, err error) {
return clientConn.dataReader.Read(p)
}
// Write sends `n` bytes from 'p' to the server.
//
// Note that Write can only be used for sending TELNET (and TELNETS) data to the server.
//
// TELNET (and TELNETS) command codes cannot be sent using this method, as Write deals with
// TELNET (and TELNETS) "escaping", and will properly "escape" anything written with it.
//
// Write makes Conn fit the io.Writer interface.
func (clientConn *Conn) Write(p []byte) (n int, err error) {
return clientConn.dataWriter.Write(p)
}
// LocalAddr returns the local network address.
func (clientConn *Conn) LocalAddr() net.Addr {
return clientConn.conn.LocalAddr()
}
// RemoteAddr returns the remote network address.
func (clientConn *Conn) RemoteAddr() net.Addr {
return clientConn.conn.RemoteAddr()
}

31
vendor/github.com/reiver/go-telnet/context.go generated vendored Normal file
View File

@@ -0,0 +1,31 @@
package telnet
type Context interface {
Logger() Logger
InjectLogger(Logger) Context
}
type internalContext struct {
logger Logger
}
func NewContext() Context {
ctx := internalContext{}
return &ctx
}
func (ctx *internalContext) Logger() Logger {
return ctx.logger
}
func (ctx *internalContext) InjectLogger(logger Logger) Context {
ctx.logger = logger
return ctx
}

173
vendor/github.com/reiver/go-telnet/data_reader.go generated vendored Normal file
View File

@@ -0,0 +1,173 @@
package telnet
import (
"bufio"
"errors"
"io"
)
var (
errCorrupted = errors.New("Corrupted")
)
// An internalDataReader deals with "un-escaping" according to the TELNET protocol.
//
// In the TELNET protocol byte value 255 is special.
//
// The TELNET protocol calls byte value 255: "IAC". Which is short for "interpret as command".
//
// The TELNET protocol also has a distinction between 'data' and 'commands'.
//
//(DataReader is targetted toward TELNET 'data', not TELNET 'commands'.)
//
// If a byte with value 255 (=IAC) appears in the data, then it must be escaped.
//
// Escaping byte value 255 (=IAC) in the data is done by putting 2 of them in a row.
//
// So, for example:
//
// []byte{255} -> []byte{255, 255}
//
// Or, for a more complete example, if we started with the following:
//
// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20}
//
// ... TELNET escaping would produce the following:
//
// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20}
//
// (Notice that each "255" in the original byte array became 2 "255"s in a row.)
//
// DataReader deals with "un-escaping". In other words, it un-does what was shown
// in the examples.
//
// So, for example, it does this:
//
// []byte{255, 255} -> []byte{255}
//
// And, for example, goes from this:
//
// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20}
//
// ... to this:
//
// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20}
type internalDataReader struct {
wrapped io.Reader
buffered *bufio.Reader
}
// newDataReader creates a new DataReader reading from 'r'.
func newDataReader(r io.Reader) *internalDataReader {
buffered := bufio.NewReader(r)
reader := internalDataReader{
wrapped:r,
buffered:buffered,
}
return &reader
}
// Read reads the TELNET escaped data from the wrapped io.Reader, and "un-escapes" it into 'data'.
func (r *internalDataReader) Read(data []byte) (n int, err error) {
const IAC = 255
const SB = 250
const SE = 240
const WILL = 251
const WONT = 252
const DO = 253
const DONT = 254
p := data
for len(p) > 0 {
var b byte
b, err = r.buffered.ReadByte()
if nil != err {
return n, err
}
if IAC == b {
var peeked []byte
peeked, err = r.buffered.Peek(1)
if nil != err {
return n, err
}
switch peeked[0] {
case WILL, WONT, DO, DONT:
_, err = r.buffered.Discard(2)
if nil != err {
return n, err
}
case IAC:
p[0] = IAC
n++
p = p[1:]
_, err = r.buffered.Discard(1)
if nil != err {
return n, err
}
case SB:
for {
var b2 byte
b2, err = r.buffered.ReadByte()
if nil != err {
return n, err
}
if IAC == b2 {
peeked, err = r.buffered.Peek(1)
if nil != err {
return n, err
}
if IAC == peeked[0] {
_, err = r.buffered.Discard(1)
if nil != err {
return n, err
}
}
if SE == peeked[0] {
_, err = r.buffered.Discard(1)
if nil != err {
return n, err
}
break
}
}
}
case SE:
_, err = r.buffered.Discard(1)
if nil != err {
return n, err
}
default:
// If we get in here, this is not following the TELNET protocol.
//@TODO: Make a better error.
err = errCorrupted
return n, err
}
} else {
p[0] = b
n++
p = p[1:]
}
}
return n, nil
}

142
vendor/github.com/reiver/go-telnet/data_writer.go generated vendored Normal file
View File

@@ -0,0 +1,142 @@
package telnet
import (
"github.com/reiver/go-oi"
"bytes"
"errors"
"io"
)
var iaciac []byte = []byte{255, 255}
var errOverflow = errors.New("Overflow")
var errPartialIACIACWrite = errors.New("Partial IAC IAC write.")
// An internalDataWriter deals with "escaping" according to the TELNET (and TELNETS) protocol.
//
// In the TELNET (and TELNETS) protocol byte value 255 is special.
//
// The TELNET (and TELNETS) protocol calls byte value 255: "IAC". Which is short for "interpret as command".
//
// The TELNET (and TELNETS) protocol also has a distinction between 'data' and 'commands'.
//
//(DataWriter is targetted toward TELNET (and TELNETS) 'data', not TELNET (and TELNETS) 'commands'.)
//
// If a byte with value 255 (=IAC) appears in the data, then it must be escaped.
//
// Escaping byte value 255 (=IAC) in the data is done by putting 2 of them in a row.
//
// So, for example:
//
// []byte{255} -> []byte{255, 255}
//
// Or, for a more complete example, if we started with the following:
//
// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20}
//
// ... TELNET escaping would produce the following:
//
// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20}
//
// (Notice that each "255" in the original byte array became 2 "255"s in a row.)
//
// internalDataWriter takes care of all this for you, so you do not have to do it.
type internalDataWriter struct {
wrapped io.Writer
}
// newDataWriter creates a new internalDataWriter writing to 'w'.
//
// 'w' receives what is written to the *internalDataWriter but escaped according to
// the TELNET (and TELNETS) protocol.
//
// I.e., byte 255 (= IAC) gets encoded as 255, 255.
//
// For example, if the following it written to the *internalDataWriter's Write method:
//
// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20}
//
// ... then (conceptually) the following is written to 'w's Write method:
//
// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20}
//
// (Notice that each "255" in the original byte array became 2 "255"s in a row.)
//
// *internalDataWriter takes care of all this for you, so you do not have to do it.
func newDataWriter(w io.Writer) *internalDataWriter {
writer := internalDataWriter{
wrapped:w,
}
return &writer
}
// Write writes the TELNET (and TELNETS) escaped data for of the data in 'data' to the wrapped io.Writer.
func (w *internalDataWriter) Write(data []byte) (n int, err error) {
var n64 int64
n64, err = w.write64(data)
n = int(n64)
if int64(n) != n64 {
panic(errOverflow)
}
return n, err
}
func (w *internalDataWriter) write64(data []byte) (n int64, err error) {
if len(data) <= 0 {
return 0, nil
}
const IAC = 255
var buffer bytes.Buffer
for _, datum := range data {
if IAC == datum {
if buffer.Len() > 0 {
var numWritten int64
numWritten, err = oi.LongWrite(w.wrapped, buffer.Bytes())
n += numWritten
if nil != err {
return n, err
}
buffer.Reset()
}
var numWritten int64
//@TODO: Should we worry about "iaciac" potentially being modified by the .Write()?
numWritten, err = oi.LongWrite(w.wrapped, iaciac)
if int64(len(iaciac)) != numWritten {
//@TODO: Do we really want to panic() here?
panic(errPartialIACIACWrite)
}
n += 1
if nil != err {
return n, err
}
} else {
buffer.WriteByte(datum) // The returned error is always nil, so we ignore it.
}
}
if buffer.Len() > 0 {
var numWritten int64
numWritten, err = oi.LongWrite(w.wrapped, buffer.Bytes())
n += numWritten
}
return n, err
}

21
vendor/github.com/reiver/go-telnet/discard_logger.go generated vendored Normal file
View File

@@ -0,0 +1,21 @@
package telnet
type internalDiscardLogger struct{}
func (internalDiscardLogger) Debug(...interface{}) {}
func (internalDiscardLogger) Debugf(string, ...interface{}) {}
func (internalDiscardLogger) Debugln(...interface{}) {}
func (internalDiscardLogger) Error(...interface{}) {}
func (internalDiscardLogger) Errorf(string, ...interface{}) {}
func (internalDiscardLogger) Errorln(...interface{}) {}
func (internalDiscardLogger) Trace(...interface{}) {}
func (internalDiscardLogger) Tracef(string, ...interface{}) {}
func (internalDiscardLogger) Traceln(...interface{}) {}
func (internalDiscardLogger) Warn(...interface{}) {}
func (internalDiscardLogger) Warnf(string, ...interface{}) {}
func (internalDiscardLogger) Warnln(...interface{}) {}

450
vendor/github.com/reiver/go-telnet/doc.go generated vendored Normal file
View File

@@ -0,0 +1,450 @@
/*
Package telnet provides TELNET and TELNETS client and server implementations
in a style similar to the "net/http" library that is part of the Go standard library,
including support for "middleware"; TELNETS is secure TELNET, with the TELNET protocol
over a secured TLS (or SSL) connection.
Example TELNET Server
ListenAndServe starts a (un-secure) TELNET server with a given address and handler.
handler := telnet.EchoHandler
err := telnet.ListenAndServe(":23", handler)
if nil != err {
panic(err)
}
Example TELNETS Server
ListenAndServeTLS starts a (secure) TELNETS server with a given address and handler,
using the specified "cert.pem" and "key.pem" files.
handler := telnet.EchoHandler
err := telnet.ListenAndServeTLS(":992", "cert.pem", "key.pem", handler)
if nil != err {
panic(err)
}
Example TELNET Client:
DialToAndCall creates a (un-secure) TELNET client, which connects to a given address using the specified caller.
package main
import (
"github.com/reiver/go-telnet"
)
func main() {
var caller Caller = telnet.StandardCaller
//@TOOD: replace "example.net:23" with address you want to connect to.
telnet.DialToAndCall("example.net:23", caller)
}
Example TELNETS Client:
DialToAndCallTLS creates a (secure) TELNETS client, which connects to a given address using the specified caller.
package main
import (
"github.com/reiver/go-telnet"
"crypto/tls"
)
func main() {
//@TODO: Configure the TLS connection here, if you need to.
tlsConfig := &tls.Config{}
var caller Caller = telnet.StandardCaller
//@TOOD: replace "example.net:992" with address you want to connect to.
telnet.DialToAndCallTLS("example.net:992", caller, tlsConfig)
}
TELNET vs TELNETS
If you are communicating over the open Internet, you should be using (the secure) TELNETS protocol and ListenAndServeTLS.
If you are communicating just on localhost, then using just (the un-secure) TELNET protocol and telnet.ListenAndServe may be OK.
If you are not sure which to use, use TELNETS and ListenAndServeTLS.
Example TELNET Shell Server
The previous 2 exaple servers were very very simple. Specifically, they just echoed back whatever
you submitted to it.
If you typed:
Apple Banana Cherry\r\n
... it would send back:
Apple Banana Cherry\r\n
(Exactly the same data you sent it.)
A more useful TELNET server can be made using the "github.com/reiver/go-telnet/telsh" sub-package.
The `telsh` sub-package provides "middleware" that enables you to create a "shell" interface (also
called a "command line interface" or "CLI") which most people would expect when using TELNET OR TELNETS.
For example:
package main
import (
"github.com/reiver/go-oi"
"github.com/reiver/go-telnet"
"github.com/reiver/go-telnet/telsh"
"time"
)
func main() {
shellHandler := telsh.NewShellHandler()
commandName := "date"
shellHandler.Register(commandName, danceProducer)
commandName = "animate"
shellHandler.Register(commandName, animateProducer)
addr := ":23"
if err := telnet.ListenAndServe(addr, shellHandler); nil != err {
panic(err)
}
}
Note that in the example, so far, we have registered 2 commands: `date` and `animate`.
For this to actually work, we need to have code for the `date` and `animate` commands.
The actual implemenation for the `date` command could be done like the following:
func dateHandlerFunc(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser) error {
const layout = "Mon Jan 2 15:04:05 -0700 MST 2006"
s := time.Now().Format(layout)
if _, err := oi.LongWriteString(stdout, s); nil != err {
return err
}
return nil
}
func dateProducerFunc(ctx telsh.Context, name string, args ...string) telsh.Handler{
return telsh.PromoteHandlerFunc(dateHandler)
}
var dateProducer = ProducerFunc(dateProducerFunc)
Note that your "real" work is in the `dateHandlerFunc` func.
And the actual implementation for the `animate` command could be done as follows:
func animateHandlerFunc(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser) error {
for i:=0; i<20; i++ {
oi.LongWriteString(stdout, "\r⠋")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠙")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠹")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠸")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠼")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠴")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠦")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠧")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠇")
time.Sleep(50*time.Millisecond)
oi.LongWriteString(stdout, "\r⠏")
time.Sleep(50*time.Millisecond)
}
oi.LongWriteString(stdout, "\r \r\n")
return nil
}
func animateProducerFunc(ctx telsh.Context, name string, args ...string) telsh.Handler{
return telsh.PromoteHandlerFunc(animateHandler)
}
var animateProducer = ProducerFunc(animateProducerFunc)
Again, note that your "real" work is in the `animateHandlerFunc` func.
Generating PEM Files
If you are using the telnet.ListenAndServeTLS func or the telnet.Server.ListenAndServeTLS method, you will need to
supply "cert.pem" and "key.pem" files.
If you do not already have these files, the Go soure code contains a tool for generating these files for you.
It can be found at:
$GOROOT/src/crypto/tls/generate_cert.go
So, for example, if your `$GOROOT` is the "/usr/local/go" directory, then it would be at:
/usr/local/go/src/crypto/tls/generate_cert.go
If you run the command:
go run $GOROOT/src/crypto/tls/generate_cert.go --help
... then you get the help information for "generate_cert.go".
Of course, you would replace or set `$GOROOT` with whatever your path actually is. Again, for example,
if your `$GOROOT` is the "/usr/local/go" directory, then it would be:
go run /usr/local/go/src/crypto/tls/generate_cert.go --help
To demonstrate the usage of "generate_cert.go", you might run the following to generate certificates
that were bound to the hosts `127.0.0.1` and `localhost`:
go run /usr/local/go/src/crypto/tls/generate_cert.go --ca --host='127.0.0.1,localhost'
If you are not sure where "generate_cert.go" is on your computer, on Linux and Unix based systems, you might
be able to find the file with the command:
locate /src/crypto/tls/generate_cert.go
(If it finds it, it should output the full path to this file.)
Example TELNET Client
You can make a simple (un-secure) TELNET client with code like the following:
package main
import (
"github.com/reiver/go-telnet"
)
func main() {
var caller Caller = telnet.StandardCaller
//@TOOD: replace "example.net:5555" with address you want to connect to.
telnet.DialToAndCall("example.net:5555", caller)
}
Example TELNETS Client
You can make a simple (secure) TELNETS client with code like the following:
package main
import (
"github.com/reiver/go-telnet"
)
func main() {
var caller Caller = telnet.StandardCaller
//@TOOD: replace "example.net:5555" with address you want to connect to.
telnet.DialToAndCallTLS("example.net:5555", caller)
}
TELNET Story
The TELNET protocol is best known for providing a means of connecting to a remote computer, using a (text-based) shell interface, and being able to interact with it, (more or less) as if you were sitting at that computer.
(Shells are also known as command-line interfaces or CLIs.)
Although this was the original usage of the TELNET protocol, it can be (and is) used for other purposes as well.
The Era
The TELNET protocol came from an era in computing when text-based shell interface where the common way of interacting with computers.
The common interface for computers during this era was a keyboard and a monochromatic (i.e., single color) text-based monitors called "video terminals".
(The word "video" in that era of computing did not refer to things such as movies. But instead was meant to contrast it with paper. In particular, the teletype machines, which were typewriter like devices that had a keyboard, but instead of having a monitor had paper that was printed onto.)
Early Office Computers
In that era, in the early days of office computers, it was rare that an individual would have a computer at their desk. (A single computer was much too expensive.)
Instead, there would be a single central computer that everyone would share. The style of computer used (for the single central shared computer) was called a "mainframe".
What individuals would have at their desks, instead of their own compuer, would be some type of video terminal.
The different types of video terminals had named such as:
• VT52
• VT100
• VT220
• VT240
("VT" in those named was short for "video terminal".)
Teletype
To understand this era, we need to go back a bit in time to what came before it: teletypes.
Terminal Codes
Terminal codes (also sometimes called 'terminal control codes') are used to issue various kinds of commands
to the terminal.
(Note that 'terminal control codes' are a completely separate concept for 'TELNET commands',
and the two should NOT be conflated or confused.)
The most common types of 'terminal codes' are the 'ANSI escape codes'. (Although there are other types too.)
ANSI Escape Codes
ANSI escape codes (also sometimes called 'ANSI escape sequences') are a common type of 'terminal code' used
to do things such as:
• moving the cursor,
• erasing the display,
• erasing the line,
• setting the graphics mode,
• setting the foregroup color,
• setting the background color,
• setting the screen resolution, and
• setting keyboard strings.
Setting The Foreground Color With ANSI Escape Codes
One of the abilities of ANSI escape codes is to set the foreground color.
Here is a table showing codes for this:
| ANSI Color | Go string | Go []byte |
| ------------ | ---------- | ----------------------------- |
| Black | "\x1b[30m" | []byte{27, '[', '3','0', 'm'} |
| Red | "\x1b[31m" | []byte{27, '[', '3','1', 'm'} |
| Green | "\x1b[32m" | []byte{27, '[', '3','2', 'm'} |
| Brown/Yellow | "\x1b[33m" | []byte{27, '[', '3','3', 'm'} |
| Blue | "\x1b[34m" | []byte{27, '[', '3','4', 'm'} |
| Magenta | "\x1b[35m" | []byte{27, '[', '3','5', 'm'} |
| Cyan | "\x1b[36m" | []byte{27, '[', '3','6', 'm'} |
| Gray/White | "\x1b[37m" | []byte{27, '[', '3','7', 'm'} |
(Note that in the `[]byte` that the first `byte` is the number `27` (which
is the "escape" character) where the third and fouth characters are the
**not** number literals, but instead character literals `'3'` and whatever.)
Setting The Background Color With ANSI Escape Codes
Another of the abilities of ANSI escape codes is to set the background color.
| ANSI Color | Go string | Go []byte |
| ------------ | ---------- | ----------------------------- |
| Black | "\x1b[40m" | []byte{27, '[', '4','0', 'm'} |
| Red | "\x1b[41m" | []byte{27, '[', '4','1', 'm'} |
| Green | "\x1b[42m" | []byte{27, '[', '4','2', 'm'} |
| Brown/Yellow | "\x1b[43m" | []byte{27, '[', '4','3', 'm'} |
| Blue | "\x1b[44m" | []byte{27, '[', '4','4', 'm'} |
| Magenta | "\x1b[45m" | []byte{27, '[', '4','5', 'm'} |
| Cyan | "\x1b[46m" | []byte{27, '[', '4','6', 'm'} |
| Gray/White | "\x1b[47m" | []byte{27, '[', '4','7', 'm'} |
(Note that in the `[]byte` that the first `byte` is the number `27` (which
is the "escape" character) where the third and fouth characters are the
**not** number literals, but instead character literals `'4'` and whatever.)
Using ANSI Escape Codes
In Go code, if I wanted to use an ANSI escape code to use a blue background,
a white foreground, and bold, I could do that with the ANSI escape code:
"\x1b[44;37;1m"
Note that that start with byte value 27, which we have encoded as hexadecimal
as \x1b. Followed by the '[' character.
Coming after that is the sub-string "44", which is the code that sets our background color to blue.
We follow that with the ';' character (which separates codes).
And the after that comes the sub-string "37", which is the code that set our foreground color to white.
After that, we follow with another ";" character (which, again, separates codes).
And then we follow it the sub-string "1", which is the code that makes things bold.
And finally, the ANSI escape sequence is finished off with the 'm' character.
To show this in a more complete example, our `dateHandlerFunc` from before could incorporate ANSI escape sequences as follows:
func dateHandlerFunc(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser) error {
const layout = "Mon Jan 2 15:04:05 -0700 MST 2006"
s := "\x1b[44;37;1m" + time.Now().Format(layout) + "\x1b[0m"
if _, err := oi.LongWriteString(stdout, s); nil != err {
return err
}
return nil
}
Note that in that example, in addition to using the ANSI escape sequence "\x1b[44;37;1m"
to set the background color to blue, set the foreground color to white, and make it bold,
we also used the ANSI escape sequence "\x1b[0m" to reset the background and foreground colors
and boldness back to "normal".
*/
package telnet

33
vendor/github.com/reiver/go-telnet/echo_handler.go generated vendored Normal file
View File

@@ -0,0 +1,33 @@
package telnet
import (
"github.com/reiver/go-oi"
)
// EchoHandler is a simple TELNET server which "echos" back to the client any (non-command)
// data back to the TELNET client, it received from the TELNET client.
var EchoHandler Handler = internalEchoHandler{}
type internalEchoHandler struct{}
func (handler internalEchoHandler) ServeTELNET(ctx Context, w Writer, r Reader) {
var buffer [1]byte // Seems like the length of the buffer needs to be small, otherwise will have to wait for buffer to fill up.
p := buffer[:]
for {
n, err := r.Read(p)
if n > 0 {
oi.LongWrite(w, p[:n])
}
if nil != err {
break
}
}
}

18
vendor/github.com/reiver/go-telnet/handler.go generated vendored Normal file
View File

@@ -0,0 +1,18 @@
package telnet
// A Handler serves a TELNET (or TELNETS) connection.
//
// Writing data to the Writer passed as an argument to the ServeTELNET method
// will send data to the TELNET (or TELNETS) client.
//
// Reading data from the Reader passed as an argument to the ServeTELNET method
// will receive data from the TELNET client.
//
// The Writer's Write method sends "escaped" TELNET (and TELNETS) data.
//
// The Reader's Read method "un-escapes" TELNET (and TELNETS) data, and filters
// out TELNET (and TELNETS) command sequences.
type Handler interface {
ServeTELNET(Context, Writer, Reader)
}

16
vendor/github.com/reiver/go-telnet/logger.go generated vendored Normal file
View File

@@ -0,0 +1,16 @@
package telnet
type Logger interface{
Debug(...interface{})
Debugf(string, ...interface{})
Error(...interface{})
Errorf(string, ...interface{})
Trace(...interface{})
Tracef(string, ...interface{})
Warn(...interface{})
Warnf(string, ...interface{})
}

6
vendor/github.com/reiver/go-telnet/reader.go generated vendored Normal file
View File

@@ -0,0 +1,6 @@
package telnet
type Reader interface {
Read([]byte) (int, error)
}

191
vendor/github.com/reiver/go-telnet/server.go generated vendored Normal file
View File

@@ -0,0 +1,191 @@
package telnet
import (
"crypto/tls"
"net"
)
// ListenAndServe listens on the TCP network address `addr` and then spawns a call to the ServeTELNET
// method on the `handler` to serve each incoming connection.
//
// For a very simple example:
//
// package main
//
// import (
// "github.com/reiver/go-telnet"
// )
//
// func main() {
//
// //@TODO: In your code, you would probably want to use a different handler.
// var handler telnet.Handler = telnet.EchoHandler
//
// err := telnet.ListenAndServe(":5555", handler)
// if nil != err {
// //@TODO: Handle this error better.
// panic(err)
// }
// }
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
// Serve accepts an incoming TELNET or TELNETS client connection on the net.Listener `listener`.
func Serve(listener net.Listener, handler Handler) error {
server := &Server{Handler: handler}
return server.Serve(listener)
}
// A Server defines parameters of a running TELNET server.
//
// For a simple example:
//
// package main
//
// import (
// "github.com/reiver/go-telnet"
// )
//
// func main() {
//
// var handler telnet.Handler = telnet.EchoHandler
//
// server := &telnet.Server{
// Addr:":5555",
// Handler:handler,
// }
//
// err := server.ListenAndServe()
// if nil != err {
// //@TODO: Handle this error better.
// panic(err)
// }
// }
type Server struct {
Addr string // TCP address to listen on; ":telnet" or ":telnets" if empty (when used with ListenAndServe or ListenAndServeTLS respectively).
Handler Handler // handler to invoke; telnet.EchoServer if nil
TLSConfig *tls.Config // optional TLS configuration; used by ListenAndServeTLS.
Logger Logger
}
// ListenAndServe listens on the TCP network address 'server.Addr' and then spawns a call to the ServeTELNET
// method on the 'server.Handler' to serve each incoming connection.
//
// For a simple example:
//
// package main
//
// import (
// "github.com/reiver/go-telnet"
// )
//
// func main() {
//
// var handler telnet.Handler = telnet.EchoHandler
//
// server := &telnet.Server{
// Addr:":5555",
// Handler:handler,
// }
//
// err := server.ListenAndServe()
// if nil != err {
// //@TODO: Handle this error better.
// panic(err)
// }
// }
func (server *Server) ListenAndServe() error {
addr := server.Addr
if "" == addr {
addr = ":telnet"
}
listener, err := net.Listen("tcp", addr)
if nil != err {
return err
}
return server.Serve(listener)
}
// Serve accepts an incoming TELNET client connection on the net.Listener `listener`.
func (server *Server) Serve(listener net.Listener) error {
defer listener.Close()
logger := server.logger()
handler := server.Handler
if nil == handler {
//@TODO: Should this be a "ShellHandler" instead, that gives a shell-like experience by default
// If this is changd, then need to change the comment in the "type Server struct" definition.
logger.Debug("Defaulted handler to EchoHandler.")
handler = EchoHandler
}
for {
// Wait for a new TELNET client connection.
logger.Debugf("Listening at %q.", listener.Addr())
conn, err := listener.Accept()
if err != nil {
//@TODO: Could try to recover from certain kinds of errors. Maybe waiting a while before trying again.
return err
}
logger.Debugf("Received new connection from %q.", conn.RemoteAddr())
// Handle the new TELNET client connection by spawning
// a new goroutine.
go server.handle(conn, handler)
logger.Debugf("Spawned handler to handle connection from %q.", conn.RemoteAddr())
}
}
func (server *Server) handle(c net.Conn, handler Handler) {
defer c.Close()
logger := server.logger()
defer func(){
if r := recover(); nil != r {
if nil != logger {
logger.Errorf("Recovered from: (%T) %v", r, r)
}
}
}()
var ctx Context = NewContext().InjectLogger(logger)
var w Writer = newDataWriter(c)
var r Reader = newDataReader(c)
handler.ServeTELNET(ctx, w, r)
c.Close()
}
func (server *Server) logger() Logger {
logger := server.Logger
if nil == logger {
logger = internalDiscardLogger{}
}
return logger
}

93
vendor/github.com/reiver/go-telnet/standard_caller.go generated vendored Normal file
View File

@@ -0,0 +1,93 @@
package telnet
import (
"github.com/reiver/go-oi"
"bufio"
"bytes"
"fmt"
"io"
"os"
"time"
)
// StandardCaller is a simple TELNET client which sends to the server any data it gets from os.Stdin
// as TELNET (and TELNETS) data, and writes any TELNET (or TELNETS) data it receives from
// the server to os.Stdout, and writes any error it has to os.Stderr.
var StandardCaller Caller = internalStandardCaller{}
type internalStandardCaller struct{}
func (caller internalStandardCaller) CallTELNET(ctx Context, w Writer, r Reader) {
standardCallerCallTELNET(os.Stdin, os.Stdout, os.Stderr, ctx, w, r)
}
func standardCallerCallTELNET(stdin io.ReadCloser, stdout io.WriteCloser, stderr io.WriteCloser, ctx Context, w Writer, r Reader) {
go func(writer io.Writer, reader io.Reader) {
var buffer [1]byte // Seems like the length of the buffer needs to be small, otherwise will have to wait for buffer to fill up.
p := buffer[:]
for {
// Read 1 byte.
n, err := reader.Read(p)
if n <= 0 && nil == err {
continue
} else if n <= 0 && nil != err {
break
}
oi.LongWrite(writer, p)
}
}(stdout, r)
var buffer bytes.Buffer
var p []byte
var crlfBuffer [2]byte = [2]byte{'\r','\n'}
crlf := crlfBuffer[:]
scanner := bufio.NewScanner(stdin)
scanner.Split(scannerSplitFunc)
for scanner.Scan() {
buffer.Write(scanner.Bytes())
buffer.Write(crlf)
p = buffer.Bytes()
n, err := oi.LongWrite(w, p)
if nil != err {
break
}
if expected, actual := int64(len(p)), n; expected != actual {
err := fmt.Errorf("Transmission problem: tried sending %d bytes, but actually only sent %d bytes.", expected, actual)
fmt.Fprint(stderr, err.Error())
return
}
buffer.Reset()
}
// Wait a bit to receive data from the server (that we would send to io.Stdout).
time.Sleep(3 * time.Millisecond)
}
func scannerSplitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF {
return 0, nil, nil
}
return bufio.ScanLines(data, atEOF)
}

113
vendor/github.com/reiver/go-telnet/tls.go generated vendored Normal file
View File

@@ -0,0 +1,113 @@
package telnet
import (
"crypto/tls"
"net"
)
// ListenAndServeTLS acts identically to ListenAndServe, except that it
// uses the TELNET protocol over TLS.
//
// From a TELNET protocol point-of-view, it allows for 'secured telnet', also known as TELNETS,
// which by default listens to port 992.
//
// Of course, this port can be overridden using the 'addr' argument.
//
// For a very simple example:
//
// package main
//
// import (
// "github.com/reiver/go-telnet"
// )
//
// func main() {
//
// //@TODO: In your code, you would probably want to use a different handler.
// var handler telnet.Handler = telnet.EchoHandler
//
// err := telnet.ListenAndServeTLS(":5555", "cert.pem", "key.pem", handler)
// if nil != err {
// //@TODO: Handle this error better.
// panic(err)
// }
// }
func ListenAndServeTLS(addr string, certFile string, keyFile string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServeTLS(certFile, keyFile)
}
// ListenAndServeTLS acts identically to ListenAndServe, except that it
// uses the TELNET protocol over TLS.
//
// From a TELNET protocol point-of-view, it allows for 'secured telnet', also known as TELNETS,
// which by default listens to port 992.
func (server *Server) ListenAndServeTLS(certFile string, keyFile string) error {
addr := server.Addr
if "" == addr {
addr = ":telnets"
}
listener, err := net.Listen("tcp", addr)
if nil != err {
return err
}
// Apparently have to make a copy of the TLS config this way, rather than by
// simple assignment, to prevent some unexported fields from being copied over.
//
// It would be nice if tls.Config had a method that would do this "safely".
// (I.e., what happens if in the future more exported fields are added to
// tls.Config?)
var tlsConfig *tls.Config = nil
if nil == server.TLSConfig {
tlsConfig = &tls.Config{}
} else {
tlsConfig = &tls.Config{
Rand: server.TLSConfig.Rand,
Time: server.TLSConfig.Time,
Certificates: server.TLSConfig.Certificates,
NameToCertificate: server.TLSConfig.NameToCertificate,
GetCertificate: server.TLSConfig.GetCertificate,
RootCAs: server.TLSConfig.RootCAs,
NextProtos: server.TLSConfig.NextProtos,
ServerName: server.TLSConfig.ServerName,
ClientAuth: server.TLSConfig.ClientAuth,
ClientCAs: server.TLSConfig.ClientCAs,
InsecureSkipVerify: server.TLSConfig.InsecureSkipVerify,
CipherSuites: server.TLSConfig.CipherSuites,
PreferServerCipherSuites: server.TLSConfig.PreferServerCipherSuites,
SessionTicketsDisabled: server.TLSConfig.SessionTicketsDisabled,
SessionTicketKey: server.TLSConfig.SessionTicketKey,
ClientSessionCache: server.TLSConfig.ClientSessionCache,
MinVersion: server.TLSConfig.MinVersion,
MaxVersion: server.TLSConfig.MaxVersion,
CurvePreferences: server.TLSConfig.CurvePreferences,
}
}
tlsConfigHasCertificate := len(tlsConfig.Certificates) > 0 || nil != tlsConfig.GetCertificate
if "" == certFile || "" == keyFile || !tlsConfigHasCertificate {
tlsConfig.Certificates = make([]tls.Certificate, 1)
var err error
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(certFile, keyFile)
if nil != err {
return err
}
}
tlsListener := tls.NewListener(listener, tlsConfig)
return server.Serve(tlsListener)
}

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