Compare commits

...

219 Commits
v1.6 ... master

Author SHA1 Message Date
Andrey Petrov
2dc875817d
Merge pull request #439 from jagtalon/jag/readme
README: Change whitelist to allowlist
2025-03-06 12:47:46 -05:00
Jag Talon
6dd5f27334
README: Change whitelist to allowlist 2025-03-06 12:34:58 -05:00
Andrey Petrov
635673882b
Update BUG.yml 2024-09-06 14:10:40 -04:00
Andrey Petrov
e16725f08e
Update BUG.yml 2024-09-06 14:09:21 -04:00
Andrey Petrov
89b7218461
Update and rename bug_report.md to BUG.yml 2024-09-06 14:09:03 -04:00
Andrey Petrov
daf4677fe3
Merge pull request #427 from bsiegert/crypto
Bump golang.org/x/crypto to 0.17.0 (security)
2023-12-22 13:27:17 -05:00
Benny Siegert
bdd716e621 Bump golang.org/x/crypto to 0.17.0 (security)
This fixes the following vulnerabilities, as reported by govulncheck:

Vulnerability #1: GO-2023-2402
    Man-in-the-middle attacker can compromise integrity of secure channel in
    golang.org/x/crypto
  More info: https://pkg.go.dev/vuln/GO-2023-2402
  Module: golang.org/x/crypto
    Found in: golang.org/x/crypto@v0.0.0-20200420104511-884d27f42877
    Fixed in: golang.org/x/crypto@v0.17.0
    Example traces found:
      #1: work/ssh-chat-1.10/sshd/client.go:42:33: sshd.ConnectShell calls ssh.Client.NewSession
      #2: work/ssh-chat-1.10/sshd/client.go:36:23: sshd.ConnectShell calls ssh.Dial
      #3: work/ssh-chat-1.10/sshd/net.go:49:2: sshd.SSHListener.handleConn calls ssh.DiscardRequests
      #4: work/ssh-chat-1.10/sshd/net.go:43:55: sshd.SSHListener.handleConn calls ssh.NewServerConn
      #5: work/ssh-chat-1.10/sshd/terminal.go:222:13: sshd.Terminal.listen calls ssh.Request.Reply
      #6: work/ssh-chat-1.10/sshd/client.go:46:2: sshd.ConnectShell calls ssh.Session.Close
      #7: work/ssh-chat-1.10/sshd/client.go:70:30: sshd.ConnectShell calls ssh.Session.SendRequest
      #8: work/ssh-chat-1.10/sshd/client.go:65:21: sshd.ConnectShell calls ssh.Session.Shell
      #9: work/ssh-chat-1.10/cmd/ssh-chat/cmd.go:243:14: ssh.main calls fmt.Fprintln, which eventually calls ssh.channel.Read
      #10: work/ssh-chat-1.10/sshd/terminal/terminal.go:954:17: terminal.Terminal.SetBracketedPasteMode calls io.WriteString, which calls ssh.channel.Write
      #11: work/ssh-chat-1.10/cmd/ssh-chat/cmd.go:243:14: ssh.main calls fmt.Fprintln, which eventually calls ssh.extChannel.Read

Vulnerability #4: GO-2022-0968
    Panic on malformed packets in golang.org/x/crypto/ssh
  More info: https://pkg.go.dev/vuln/GO-2022-0968
  Module: golang.org/x/crypto
    Found in: golang.org/x/crypto@v0.0.0-20200420104511-884d27f42877
    Fixed in: golang.org/x/crypto@v0.0.0-20211202192323-5770296d904e
    Example traces found:
      #1: work/ssh-chat-1.10/sshd/client.go:36:23: sshd.ConnectShell calls ssh.Dial
      #2: work/ssh-chat-1.10/sshd/net.go:43:55: sshd.SSHListener.handleConn calls ssh.NewServerConn

Vulnerability #5: GO-2021-0356
    Denial of service via crafted Signer in golang.org/x/crypto/ssh
  More info: https://pkg.go.dev/vuln/GO-2021-0356
  Module: golang.org/x/crypto
    Found in: golang.org/x/crypto@v0.0.0-20200420104511-884d27f42877
    Fixed in: golang.org/x/crypto@v0.0.0-20220314234659-1baeb1ce4c0b
    Example traces found:
      #1: work/ssh-chat-1.10/cmd/ssh-chat/cmd.go:122:19: ssh.main calls ssh.ServerConfig.AddHostKey

Vulnerability #6: GO-2021-0227
    Panic on crafted authentication request message in golang.org/x/crypto/ssh
  More info: https://pkg.go.dev/vuln/GO-2021-0227
  Module: golang.org/x/crypto
    Found in: golang.org/x/crypto@v0.0.0-20200420104511-884d27f42877
    Fixed in: golang.org/x/crypto@v0.0.0-20201216223049-8b5274cf687f
    Example traces found:
      #1: work/ssh-chat-1.10/sshd/net.go:43:55: sshd.SSHListener.handleConn calls ssh.NewServerConn
2023-12-22 18:25:25 +01:00
Andrey Petrov
1fc7f7b10b
Merge pull request #421 from DejavuMoe/build/linux-arm64
add: build releases for linux/arm64
2023-02-02 12:16:01 -05:00
DejavuMoe
c884aee673
add: build releases for linux/arm64 2023-02-02 15:23:02 +08:00
Andrey Petrov
aaf0671f01 go mod update
Fixes #419 #409
2022-11-27 20:15:03 -06:00
Andrey Petrov
748fc819e7
Merge pull request #416 from sleibrock/master
Fixing emojis being sent in PMs when no theme is set (#414)
2022-07-31 10:08:56 -04:00
Andrey Petrov
4b4270f0ca
Merge pull request #417 from sleibrock/motd-bot-fix
host.go: avoiding motd output if bot mode set
2022-07-30 15:05:16 -04:00
Steven Leibrock
ae585079e7 host.go: avoiding motd output if bot mode set 2022-07-29 21:57:49 -04:00
Steven L
1102162d1f message.go: stripping emoji for when no theme is set 2022-07-28 12:29:53 -04:00
Andrey Petrov
68e9d6880d
Merge pull request #410 from pataquets/master
Docker Compose manifest: mount host's keys and few other improvements.
2022-03-07 14:44:07 -05:00
pataquets
3f857cf1f5 Docker Compose manifest: mount host's keys and few other improvements.
* Add SSH keys mount (mimicking default non-Docker behaviour).
* Increase manifest version to lowest 3.x supporting bind mounts.
* Change restart policy from `always` to `unless-stopped`.
* Set a container name.
* Fix port indentation to 2 spaces, as done elsewhere.
2022-03-07 20:22:01 +01:00
Andrey Petrov
df72223a5f go mod update 2022-01-29 15:05:59 -05:00
mik2k2
621ae1b0d3
Add /allowlist command (#399)
* move loading whitelist+ops from file to auth and save the loaded files fro reloading

* add /whitelist command with lots of open questions

* add test for /whitelist

* gofmt

* use the same auth (the tests don't seem to care, but htis is more right)

* mutex whitelistMode and remove some deferred TODOs

* s/whitelist/allowlist/ (user-facing); move helper functions outside the handler function

* check for ops in Auth.CheckPublicKey and move /allowlist handling to helper functions

* possibly fix the test timeout in HostNameCollision

* Revert "possibly fix the test timeout in HostNameCollision" (didn't work)

This reverts commit 664dbb0976f8f10ea7a673950a879591c2e7c320.

* managed to reproduce the timeout after updating, hopefully it's the same one

* remove some unimportant TODOs; add a message when reverify kicks people; add a reverify test

* add client connection with key; add test for /allowlist import AGE

* hopefully make test less racy

* s/whitelist/allowlist/

* fix crash on specifying exactly one more -v flag than the max level

* use a key loader function to move file reading out of auth

* add loader to allowlist test

* minor message changes

* add --whitelist with a warning; update tests for messages

* apparently, we have another prefix

* check names directly on the User objects in TestHostNameCollision

* not allowlisted -> not allowed

* small message change

* update test
2022-01-06 09:09:51 -05:00
Andrey Petrov
84bc5c76dd go mod update for golang.org/x/crypto/ssh 2021-12-03 11:03:08 -05:00
Andrey Petrov
82526e9123
Update ssh.chat pubkey 2021-10-13 11:30:58 -04:00
Akshay Shekher
d25630020d
/back, /away: Change no-op to return err
Fixes #402

When the user is not set as away, using the
`/back` or `/away` command should return error.
The previous behaviour was inconsistent,
`/away` sent a message and `/back` ignored it.
New behaviour is error for both cases.

Co-authored-by: Akshay <akshay.shekher@gmail.com>
2021-10-13 11:00:11 -04:00
Andrey Petrov
0eebb64c1d sshd/terminal/terminal.go: Clamp pos to protect from some fuzzing failures 2021-10-13 10:43:49 -04:00
Andrey Petrov
db14517499 cmd/ssh-chat: Accept multiple --identity keys
Fixes #401
2021-10-13 10:27:04 -04:00
Andrey Petrov
88fa53fd16 Makefile: deploy tweak 2021-10-11 10:18:06 -04:00
mik2k2
7628a47c4c
set: Allow nil/expired items
Fixes #397
2021-07-03 13:37:09 -04:00
mik2k2
7413539965
main, sshd: Refactor authentication, add IP throttling, improve passphrase auth
* Move password authentication handling into sshd/auth (fixes #394).

Password authentication is now completely handeled in Auth. The normal
keyboard-interactive handler checks if passwords are supported and asks
for them, removing the need to override the callbacks.

Brute force throttling is removed; I'd like to base it on IP address
banning, which requires changes to the checks.

I'm not sure, but I think timing attacks against the password are fixed:
- The hashing of the real password happens only at startup.
- The hashing of a provided password is something an attacker can do
themselves; It doesn't leak anything about the real password.
- The hash comparison is constant-time.

* refactor checks, IP-ban incorrect passphrases, renames

- s/assword/assphrase/, typo fixes
- bans are checked separately from public keys
- an incorrect passphrase results in a one-minute IP ban
- whitelists no longer override bans (i.e. you can get banned if you're 
whitelisted)

* (hopefully) final changes
2021-05-31 10:08:30 -04:00
Akshay Shekher
c3b589b286
tests: Fixed flaky test by using user joined callback. (#393)
Instead of relying on the go scheduler to do the expected thing >_>

Co-authored-by: Akshay <akshay.shekher@gmail.com>
2021-05-02 13:02:39 -04:00
Akshay Shekher
e1e534344e
Fix SSHCHAT_TIMESTAMP env variables (#392)
* Fixes Env Vars to pass config to ssh-chat.

The env vars were beign parsed and set to the host
before the user was even added to the host and
hence ignored. This change moves the env var parsing
to after initializing the user.

TODO: tests, completeness+reliability

* cleaned up the test

* reduced test flakyness by adding wait instead of being optimistic

Co-authored-by: Akshay <akshay.shekher@gmail.com>
2021-05-02 12:18:31 -04:00
Andrey Petrov
46eaf037e3
Merge pull request #390 from shazow/shazow-patch-3
tests: Skip flakey TestHostNameCollision
2021-04-24 12:49:09 -04:00
Andrey Petrov
3c246777a1
tests: Skip flakey TestHostNameCollision 2021-04-24 12:22:42 -04:00
Andrey Petrov
fef128b91f
Merge pull request #389 from shazow/shazow-patch-2
ci: Test all sub-packages
2021-04-24 12:18:44 -04:00
Andrey Petrov
1ef05d0c26
ci: Test all sub-packages 2021-04-24 12:16:10 -04:00
Andrey Petrov
af502977e6
Merge pull request #388 from voldyman/ill-be-back
Added /back and tests for all away commands
2021-04-24 12:15:22 -04:00
Andrey Petrov
c3dccfd3eb
chat: /back help formatting. 2021-04-24 12:14:24 -04:00
Akshay
aae5bc8d2e Added /back and tests for all away commands 2021-04-24 07:54:50 -07:00
Andrey Petrov
fb73ace458
Merge pull request #385 from sytranvn/build-apple-silocon
Add build script for apple silicon
2021-04-19 09:45:02 -04:00
Sy Tran
3557bf762d Add build script for apple silicon 2021-04-18 07:31:13 +07:00
Andrey Petrov
fa3146c800 Makefile: Add deploy helper 2021-04-13 11:32:24 -04:00
Andrey Petrov
badcaa6e3b /away: Fix output for admin whois
cc #377
2021-04-13 11:27:44 -04:00
Andrey Petrov
9bf66ea992
Merge pull request #383 from shazow/add-mute
chat: Add /mute command for op
2021-04-13 11:24:53 -04:00
Andrey Petrov
37b101c3c1 chat: Add /mute command for op 2021-04-13 11:21:16 -04:00
Andrey Petrov
b73b45640c host: Fix /msg vs /reply message formatting
Closes #382
2021-04-06 09:28:48 -04:00
Andrey Petrov
7a783d46af sshd, chat/message: Add more debug logging for close failures 2021-04-05 11:06:44 -04:00
Andrey Petrov
3848014d41 main: Update host_test.go to pass vet, use errgroup 2021-03-26 12:49:08 -04:00
Andrey Petrov
3f81d84cf1 cmd/ssh-chat: Use x/term instead of howeyc/gopass, update prompt
Fixes #380
2021-03-26 12:26:18 -04:00
Andrey Petrov
4840634434 go mod update 2021-03-26 12:17:55 -04:00
Andrey Petrov
8257ada10d host: Factor out PM code, add away status 2021-03-15 11:07:52 -04:00
Andrey Petrov
9329227403 chat: /away tweaks 2021-03-15 10:53:00 -04:00
Akshay
0338cb824d chat: Added support for user away status, fixes #377
made away toggle status, like irc

updated /away feature

* added away message
* added broadcast away message as emote
* updated names list to show away users on the same line, with colors

added /away -> back message

Update away time to be time since marked away

reverted changes made for /list
2021-03-15 10:30:54 -04:00
Andrey Petrov
c8bfc34704
Merge pull request #376 from medinae/reply-to-user-with-symbol-fix
Fix ~ Reply to user with symbol returning Err user not found
2021-03-13 10:08:49 -05:00
Abdelkader Bouadjadja
ebbbc3b6d9 Fix ~ Reply to user with symbol returning Err user not found 2021-03-13 14:56:36 +04:00
Andrey Petrov
d8183dd305
Update bug_report.md 2021-02-06 09:33:28 -05:00
Andrey Petrov
37c3e52309
Delete issue_template.md 2021-02-06 09:30:05 -05:00
Andrey Petrov
8eab0c9ead Update issue templates 2021-02-06 09:29:53 -05:00
Andrey Petrov
1a00bd81f2 go mod update 2020-11-11 15:44:24 -05:00
Andrey Petrov
5fb947fa6e
Merge pull request #366 from Niwla23/dockerfiles
Add Dockerfile and docker-compose.yml
2020-10-29 09:48:30 -04:00
Alwin Lohrie
d42996c14b Add Dockerfile and docker-compose.yml 2020-10-28 16:19:16 +01:00
Andrey Petrov
4fe43746fe main: /rename should not complain when symbol is set and name is unchanged 2020-08-03 14:13:51 -04:00
Andrey Petrov
7461ca8e39 chat/message: Use user.ID() names for mono bot theme 2020-08-03 13:57:11 -04:00
Andrey Petrov
53ae43fb1b /motd: Add reload functionality when msg is @ 2020-08-03 13:26:12 -04:00
Andrey Petrov
0c7f325499 motd.txt: Sync up the latest motd 2020-08-03 12:34:43 -04:00
Andrey Petrov
512d38d86f
Update CODE_OF_CONDUCT.md 2020-08-03 12:32:03 -04:00
Andrey Petrov
5b2c6e7d8e
README: Add CoC link 2020-08-03 11:51:56 -04:00
Andrey Petrov
b2f0496695
Update CODE_OF_CONDUCT.md 2020-08-03 11:48:40 -04:00
Andrey Petrov
d1bd40775c
Create CODE_OF_CONDUCT.md 2020-08-03 11:45:22 -04:00
Andrey Petrov
32a3860ea2
Merge pull request #352 from shazow/sponsor-prefix
chat, main: Add /rename op command, optional symbol prefix
2020-08-03 11:43:17 -04:00
Andrey Petrov
e0ab53500e
Merge branch 'master' into sponsor-prefix 2020-08-03 11:41:19 -04:00
Andrey Petrov
ab46dd9a98
Merge pull request #356 from shazow/focus-cmd
/focus: Add command to only show messages from focused users
2020-08-03 11:40:28 -04:00
Andrey Petrov
aa78d0eb22 chat: Add /focus command
Only show messages from focused users
2020-08-03 11:32:55 -04:00
Andrey Petrov
8cd06e33b5 set: Add Interface, ZeroValue helper 2020-08-03 11:32:16 -04:00
Andrey Petrov
a9b08a7b17 /whois: Add extra room info for admins
Will need to add room context to non-admins eventually too
2020-07-30 13:10:41 -04:00
Andrey Petrov
987a2e870a chat/message: Set LastMsg during render of self public messages, fix sorting
Also fixed chat tests
2020-07-30 12:52:32 -04:00
Andrey Petrov
86b70a1fc7 chat: go fmt 2020-07-30 12:05:38 -04:00
Andrey Petrov
fa8df8140d
Merge pull request #355 from pavelz/my_name_last_autocomplete
main: Autocomplete deprioritize own name
2020-07-30 12:03:35 -04:00
Andrey Petrov
7822904afc
chat/message: Fix RecentActiveUsers sort order 2020-07-30 12:02:07 -04:00
Pavel Zaitsev
5885f7fbdd updated tests, moved code closer to the caller.
* addded condition for zero time on lastMsg.

* removed extra paramter in NamePrefix
* moved code from NamePrefix to completeName
* removed extra parameter in tests calling to NamePrefix
2020-07-29 18:23:34 -04:00
Pavel Zaitsev
e5374b7111 update, to fix tests. 2020-07-24 10:44:23 -04:00
Pavel Zaitsev
5ff2ea085b in autocomplete list moves your name to last item in the list of sorted current users 2020-07-24 10:16:29 -04:00
Andrey Petrov
208a6a4712
Merge pull request #353 from pavelz/show_admin_status
/whois: Show op status for ops
2020-07-20 12:27:34 -04:00
Pavel Zaitsev
b6a8763f3b updated in line with comments in PR
* reduce change footprint to parameter list
* moved Op flag display to last line as to not break bots
2020-07-16 20:57:48 -04:00
Andrey Petrov
d4a5299e1c
Merge pull request #354 from lucash-diskkun/master2
chat: Sort /names output
2020-07-16 13:29:38 -04:00
Lucas Hourahine
09c8bbdd4c sorting nicks on /names and /list 2020-07-16 13:25:14 -04:00
Pavel Zaitsev
5f201e0f20 now if both are ops it will be reflected in output of whois command 2020-07-07 23:24:25 -04:00
Andrey Petrov
ced5767bbb main: Add symbol support 2020-06-24 13:53:24 -04:00
Andrey Petrov
cb0bdc8e0e chat: Use user.ID() instead of user.Name() 2020-06-24 13:53:14 -04:00
Andrey Petrov
36843252b4 chat, main: Add /rename op command 2020-06-24 12:36:02 -04:00
Andrey Petrov
3c4d90cfc1
Merge pull request #350 from shazow/shazow-patch-1
.github: Improve CI test step
2020-05-01 12:03:20 -04:00
Andrey Petrov
8f854130fa
.github: Improve CI test step 2020-05-01 11:50:16 -04:00
Andrey Petrov
fb4be4b24d
.github: Add github action for CI 2020-05-01 11:49:42 -04:00
Andrey Petrov
544f208473 chat: Fix ignore test, reduce flakeyness 2020-05-01 11:41:35 -04:00
Andrey Petrov
fda705a2dc chat: Clean up ignore comparisons 2020-05-01 11:41:19 -04:00
Abdelkader Bouadjadja
6fe0b40327 Ignored people still show up when they send private /msg 2020-04-30 05:09:21 +04:00
Abdelkader Bouadjadja
fde04365ac Fix test comment 2020-04-30 00:34:40 +04:00
Abdelkader Bouadjadja
7169ee1240 Ignored people still show up when they /me emote 2020-04-30 00:28:06 +04:00
Andrey Petrov
403bb5911b
Merge pull request #347 from shazow/fix-key-parse
main: Use new x/crypto/ssh key parsing helpers
2020-04-20 15:36:38 -04:00
Andrey Petrov
daad9ba07b main: Use x/crypto/ssh helpers for parsing passworded keys 2020-04-20 15:34:42 -04:00
Andrey Petrov
5c71e9b242 go mod: Update, mostly for x/crypto 2020-04-20 15:34:28 -04:00
Andrey Petrov
6aa7a42083
Merge pull request #343 from shazow/term-bot-nopty
sshd: Terminal.Term() fallback to Env TERM
2020-04-17 12:28:27 -04:00
Andrey Petrov
f113a130ae sshd: Terminal.Term() fallback to Env TERM 2020-04-17 12:22:31 -04:00
Andrey Petrov
b9aa7a6a0c main: Sort flags, unhide --unsafe-passphrase for now 2020-04-16 12:56:15 -04:00
Andrey Petrov
adef8d65a2
Merge pull request #342 from shazow/unsafe-password
main: Add --unsafe-passphrase
2020-04-16 12:46:32 -04:00
Andrey Petrov
99d303e196 main: Add extraHelp 2020-04-16 12:44:20 -04:00
Andrey Petrov
6e9705faf5 main: Clarify passphrase shenanigans 2020-04-16 12:32:12 -04:00
Andrey Petrov
b1bce027ad main: Force passphrase auth even with pubkey auth 2020-04-16 11:30:13 -04:00
Andrey Petrov
3b8e644c9e
Merge pull request #341 from shazow/term-bot
TERM=bot mode
2020-04-16 11:07:06 -04:00
Andrey Petrov
77143ad1e6 main: Add --unsafe-passphrase 2020-04-15 14:19:28 -04:00
Andrey Petrov
096b548200
Create FUNDING.yml 2020-04-14 11:13:33 -04:00
Andrey Petrov
a3f41865fd main: Skip prompt, highlight, autocomplete in bot mode 2020-04-13 11:33:12 -04:00
Andrey Petrov
5055bbc859 sshd: Remove temporary "Connecting..." prompt 2020-04-13 11:32:38 -04:00
Andrey Petrov
cce7defd5e main: Add TERM=bot handling for Host 2020-04-13 11:23:11 -04:00
Andrey Petrov
2076980aea chat/message: Add UserConfig.Echo 2020-04-13 11:22:53 -04:00
Andrey Petrov
c0f9814ae1 chat: Disable broken TestIgnore test 2020-04-13 10:55:41 -04:00
Andrey Petrov
fdfdcf96b7 main, sshd: Add comments and TODOs 2020-04-12 13:04:53 -04:00
Chris Miller
fe84822f5d Get the term value 2020-04-12 12:37:55 -04:00
Chris Miller
dd2fadd6c1 Apply env vars SSHCHAT_TIMESTAMP and SSHCHAT_THEME 2020-04-12 12:37:55 -04:00
Chris Miller
c0a2f32bd4 Wait for shell 2020-04-12 12:37:55 -04:00
Andrey Petrov
5dfa194ec8
Merge pull request #309 from shazow/req-env
sshd: Add Terminal.Env()
2020-04-12 12:36:07 -04:00
Andrey Petrov
5bad08c340 sshd/terminal: gofmt 2020-04-01 20:40:56 -04:00
Andrey Petrov
2fb89142a5
Merge pull request #339 from yumaokao/cjk-fullwidth
sshd/terminal: Add fullwidth check for CJK in visualLength
2020-04-01 20:37:43 -04:00
yumaokao
8c7ea173ad sshd/terminal: Add fullwidth check for CJK in visualLength 2020-03-21 19:57:39 +08:00
Andrey Petrov
80ddf1f43a
Merge pull request #338 from 42wim/f-imports
Remove import comment. Fixes #337
2020-03-08 18:46:26 -04:00
Wim
d8f9bc9006 Remove import comment. Fixes #337
Fix issue with go1.13+
See https://github.com/golang/go/issues/37747
2020-03-08 23:32:06 +01:00
Andrey Petrov
6701cbcbf7
sshd: Fix env error check
Co-Authored-By: Chris Miller <millerlogic@users.noreply.github.com>
2020-01-10 09:25:59 -05:00
Andrey Petrov
d8e68d57bc Makefile: Fix binary ldflags missing 2020-01-06 20:14:32 -05:00
Andrey Petrov
5af617f3b9
sshd: Apply read deadline to connection handler (#331)
This should prevent connections from stalling out and eating up file descriptors without ever joining the chat.
2020-01-06 20:09:34 -05:00
Akshay Shekher
1b2a3e97a0 sshd/terminal: Add more readline-compatible navigation
- Alt-F: jump forward by a word
- Alt-B: jump backword by a word
- Ctrl-F: jump forward by a character
- Ctrl-B: jump backword by a character
2020-01-05 10:51:10 -05:00
Juan Pablo Ossa Zapata
0a122be81e sshd/terminal: Fix import comment 2019-11-04 11:42:10 -05:00
Andrey Petrov
413cba485e
Merge pull request #317 from tyrelsouza/master
Best domain Ever.
2019-07-11 15:16:24 -04:00
Tyrel Souza
50ca8b043a
better domain 2019-07-11 14:44:21 -04:00
Andrey Petrov
3e37ebf85a sshd/terminal: Undo emoji offset bugfix
Introduced another bug: #316
2019-04-21 16:48:12 -04:00
Andrey Petrov
5949f9792f sshd: Close connection on failed handshake
Hopefully fixed #315
2019-04-17 16:57:30 -04:00
Andrey Petrov
67163a93a8 sshchat: term.Write on empty lines to fix prompt reset bug 2019-03-29 17:26:23 -04:00
Andrey Petrov
479a391d55 sshd/terminal: Use clearline and clearscreen codes for enterClear
Fixes emoji offset bug
2019-03-29 17:26:23 -04:00
Andrey Petrov
c9b58a80fa sshd/terminal: Import test from upstream patch 2019-03-29 17:26:23 -04:00
Andrey Petrov
91c6e55432
Merge pull request #311 from tyrelsouza/patch-1
Update README to match current --help
2019-03-29 14:15:39 -04:00
Tyrel Souza
60c1fe4220
Update README to match current --help 2019-03-29 14:14:57 -04:00
Andrey Petrov
290ca46577
README: Are you sure you don't just want the binary release? 2019-03-25 10:18:27 -04:00
Andrey Petrov
40d17570b9
README: Badge color 2019-03-25 10:16:12 -04:00
Andrey Petrov
a61b9a4a5c
README: Badges
Add downloads badge
2019-03-25 10:13:54 -04:00
Andrey Petrov
5057b891c0 chat/message: Handle nil theme (mono)
Fixes #310
2019-03-24 18:59:33 -04:00
Andrey Petrov
4aa2460d82 sshd: Add Terminal.Env() 2019-03-24 13:41:33 -04:00
Andrey Petrov
338ded7a4e chat/message: Fix tests 2019-03-22 16:04:59 -04:00
Andrey Petrov
87024f3ded sshd/terminal: Clear screen below on enterClear 2019-03-22 15:31:17 -04:00
Andrey Petrov
c058f72fe4 /timestamp, /theme: Fix rendering, add tests 2019-03-22 15:27:00 -04:00
Andrey Petrov
d72aaf6bae
Merge pull request #308 from shazow/timestamp-both
/timestamp: time and datetime modes
2019-03-21 18:21:29 -04:00
Andrey Petrov
c5cc4b0594 /timestamp: time and datetime modes 2019-03-21 17:12:32 -04:00
Andrey Petrov
ee8d60ec00
Merge pull request #306 from shazow/fork-terminal
Fork golang.org/x/crypto/ssh/terminal, remove echo override hack
2019-03-21 15:40:28 -04:00
Andrey Petrov
69c496424e go mod tidy 2019-03-21 15:33:18 -04:00
Andrey Petrov
6acb0bf809 sshchat, host: Switch to new terminal clearline api 2019-03-21 15:33:18 -04:00
Andrey Petrov
b4ba8226c6 sshd/terminal: Switch terminal.ClearLine to termina.SetEnterClear(...) 2019-03-21 15:33:18 -04:00
Andrey Petrov
9d2230eaff sshchat: Fix tests to use new rendering format. 2019-03-21 15:33:18 -04:00
Andrey Petrov
418c991677 sshd/terminal: Use N-moves in Terminal.move when possible 2019-03-21 15:33:18 -04:00
Andrey Petrov
8b710da728 sshd/terminal: Fix mid-line enter and reflow bugs 2019-03-19 12:09:54 -04:00
Andrey Petrov
4240130978 legal: Put sshd/terminal notice in root 2019-03-18 15:36:21 -04:00
Andrey Petrov
8653f0a730 sshchat: Replace terminal echo hack with our forked terminal 2019-03-18 10:08:39 -04:00
Andrey Petrov
1ba36b785c sshd/terminal: Add Terminal.ClearLine option 2019-03-18 10:08:39 -04:00
Andrey Petrov
596d41ff29 sshd/terminal: Add original LICENSE 2019-03-18 10:08:39 -04:00
Andrey Petrov
aecd8c66c3 sshd/terminal: Import fork of x/crypto/ssh 2019-03-18 10:08:39 -04:00
Andrey Petrov
2089eda486 chat: Fix runtime bug with uninitialized strings builder 2019-03-18 10:08:12 -04:00
Andrey Petrov
b8fb704c8f sshchat: Fix empty messages borking rendering 2019-03-17 16:10:16 -04:00
Andrey Petrov
5ff61f0a0f /timestamp: Remove timezone by default for now 2019-03-17 16:05:38 -04:00
Andrey Petrov
a90574126e /timestamp: Switch TZINFO with UTC offset 2019-03-17 16:00:48 -04:00
Andrey Petrov
078fbbe828 chat, internal/humantime: Tweak departure message 2019-03-17 15:01:41 -04:00
Andrey Petrov
fa516ba085
Merge pull request #304 from shazow/autocomplete-sort
chat: Sort NamesPrefix by recently active
2019-03-17 14:42:04 -04:00
Andrey Petrov
1c44c714e6 chat: Sort NamesPrefix by recently active 2019-03-17 14:41:03 -04:00
Andrey Petrov
c02b6390d2
/timestamp now prefixes each line with a timestamp
Freakin' Timestamps On Every Freakin' Line
2019-03-17 14:05:22 -04:00
Andrey Petrov
9c3db55c83 sshchat: Echo command messages with the new timestamp code. 2019-03-17 13:50:01 -04:00
Andrey Petrov
8282fad7dc chat: Add timezone support to /timestamp 2019-03-16 12:20:43 -04:00
Andrey Petrov
54ce8bb08d chat: Remove injectTimestamp after 30min stuff 2019-03-16 11:52:07 -04:00
Andrey Petrov
783c607fad chat/message: Add second resolution 2019-03-16 11:52:07 -04:00
Andrey Petrov
33d0c08ffe chat/message: Add PublicMsg.RenderSelf(...) to support old-style formatting 2019-03-16 11:52:07 -04:00
Andrey Petrov
eb10bad08e sshchat, chat: Override terminal echo and do our own echo 2019-03-16 11:52:07 -04:00
Andrey Petrov
6d3fce47cc Revert "host: Add timestamps into prompt when enabled"
This reverts commit 6ce14bfc33e5a5aeb6003cdec9a873588eabe8f0.

We're going to do another approach.
2019-03-16 11:52:07 -04:00
Andrey Petrov
4188a3bdac host: Add timestamps into prompt when enabled 2019-03-16 11:52:07 -04:00
Andrey Petrov
dca2cec67f chat/message: Add timestamp prefix support 2019-03-16 11:52:07 -04:00
Andrey Petrov
924c6bd234 Makefile: Use static builds by default 2019-03-15 22:56:48 -04:00
Andrey Petrov
d3eda56f82 build: Update go modules 2019-03-15 22:40:30 -04:00
Andrey Petrov
3f0faf761e main: Skip command autocomplete if space-prefixed
Fixes #300
2019-03-15 18:39:53 -04:00
Andrey Petrov
ae46a91e0f /reply: Set reply target properly
Fixes #299
2019-03-15 18:32:46 -04:00
Andrey Petrov
0b06b56c0e main: --admin and --whitelist: Skip non-key lines
Closes #298
2019-03-15 18:30:21 -04:00
Andrey Petrov
60701198fc /op: Fix room op being tied to the nick, add remove option
Closes #301
2019-03-15 18:18:20 -04:00
Ulisse Mini
953c3d46b2 chat: Use strings.Builder 2019-02-24 09:40:47 -06:00
UlisseMini
7b06848c75 Makefile: Remove unneeded variable 2019-02-24 09:40:47 -06:00
UlisseMini
f3c80047b9 Makefile: Use go test ./... to test all packages. 2019-02-24 09:40:47 -06:00
UlisseMini
c1a8912297 sanitize: Move global variables to top of file 2019-02-24 09:40:47 -06:00
UlisseMini
4c968fc966 chat: Use a string instead of a bytes.Buffer 2019-02-24 09:40:47 -06:00
UlisseMini
9c918676ed sshd: Better comments and changed += 1 to ++ 2019-02-24 09:40:47 -06:00
UlisseMini
e6233daefd sshd: Better comments 2019-02-24 09:40:47 -06:00
UlisseMini
40f6957e93 chat: Better comments 2019-02-24 09:40:47 -06:00
UlisseMini
a1b891aae2 host.go: drop = 0 from declaration; it is the zero value (golint) 2019-02-24 09:40:47 -06:00
UlisseMini
57c6abe86c Better comments 2019-02-24 09:40:47 -06:00
Andrey Petrov
3813360d91 cmd/ssh-chat: Set ServerVersion to include ssh-chat 2019-02-11 15:21:56 -05:00
Ulisse mini
81d7e16862 cmd, main: Return instead of os.Exit(0), use ioutil.Discard for default logger
* logger: change nil bytes.Buffer into ioutil.Discard

* cmd.go: Clearify imports

* cmd.go: Use return instead of os.Exit(0)
2019-01-25 13:42:27 -05:00
Ulisse mini
429ebbf60d logger: change nil bytes.Buffer into ioutil.Discard (#293) 2019-01-23 11:31:33 -05:00
Robert Hailey
40bf204058 chat: When a user leaves, add time since they joined to the exit message.
* Show the connection duration upon departure

* humantime: ugly sub-second precision replaces grammatically incorrect "1 seconds" message

* simplify function name

* humantime: repair unit test

* move: util/humantime -> internal/humantime

* go fmt everything
2019-01-02 15:46:21 -05:00
Andrey Petrov
60f3202818 go mod tidy 2018-12-27 13:46:04 -05:00
Andrey Petrov
a998e91639
travisci: Remove explicit dep fetch (#287) 2018-12-25 14:53:38 -05:00
Andrey Petrov
903d6c9420
/ban query support (#286)
For #285 

Turns out there were some bugs in Set, and I was using it incorrectly too.

The query syntax is a little awkward but couldn't find a nicer easy to parse format that worked with quoted string values.
2018-12-25 14:29:19 -05:00
Andrey Petrov
bdd9274aaa set: Fix race condition 2018-12-15 20:37:46 -05:00
Andrey Petrov
3572c4674c main: Add /banned command to list banned entries for ops. 2018-12-15 19:04:42 -05:00
Andrey Petrov
86dae2a53e main: auth: Fix ban by IP, also improve log formatting.
Closes #284
2018-12-15 18:47:35 -05:00
Harald Nordgren
f36d7eb9cc travisci: Bump Go versions and use '.x' to always get latest patch versions (#281) 2018-10-28 11:25:05 -04:00
Boris Verhovsky
75492825e9 /shrug: Ignore additional arguments 2018-09-06 14:04:26 -05:00
Andrey Petrov
9697c7d37f
Switch to go modules, update travisci for Go 1.11 (#279)
* Switch to go modules, update travisci for go 1.11

* Add go.{mod,sum}

* travisci: Merge envs, oops

Closes #271
2018-09-06 14:03:53 -05:00
Andrey Petrov
e72af3ad8e README: Link deploy examples 2018-02-15 12:47:46 -05:00
Andrey Petrov
d73aae0f15 /shrug ¯\_(ツ)_/¯ 2018-01-19 13:41:24 -05:00
Andrey Petrov
87dd859fed /timestamp: Toggle timestamps after 30min of inactivity
Closes #244

CC #194, #99, #242
2018-01-19 13:35:42 -05:00
Andrey Petrov
66fee99a81 /uptime and /whois relative timestamps made more precise
Replaced go-humanize dependency with our own logic.

Fixes #259.
2018-01-19 12:35:45 -05:00
Andrey Petrov
14b380b473 dep ensure -update 2018-01-19 11:36:43 -05:00
goose121
fd77009f8d Strip control characters from metadata inputs (#257) 2018-01-02 20:27:54 -05:00
Steven L
2078e13819 vendor: Switch to using go dep
* Adding go dep to the project

* Adding lockfile and changing README Go version
2017-10-12 13:40:35 -04:00
Steven L
206a9073f0 Colorize /name output with selected theme (#249)
* command.go: colorizing names according to theme (#205)

* Adding safety check for nil and mono

* Refactoring coloring into a function
2017-10-04 13:20:01 -04:00
Steven L
80a5879f04 Adding a contribituion file and issue template (#248) 2017-10-03 13:49:52 -04:00
Sahil Goel
0cb58facef tests: Fix flake in TestSetExpiring
* Fix Test Case 3 of #187
2017-08-28 14:34:47 -04:00
Dmitri Shuralyov
50001bf172 Travis: Check for gofmt issues. (#241)
* Travis: Check for gofmt issues.

This will help catch issues like https://github.com/shazow/ssh-chat/pull/240#discussion_r127626103
in CI.

* chat: Fix gofmt issue.
2017-07-17 15:03:55 -04:00
Andrey Petrov
e3b5c3712c chat/doc: gofmt 2017-07-17 10:28:33 -04:00
JimmyBot
2c1e5239c5 docs: Typo and odd grammar fix (#240)
* Typo and odd grammar

* Forgot a word in my edit
2017-07-16 10:47:45 -04:00
Oliver Graff
05597b3e6a Fix unit tests / Travis (#238)
* Fix net_test

* Update host test to add carriage return char

* Fix host_test so it will not hang if the SSH connection fails

Fixes #231, closes #235.
2017-06-14 09:07:24 -04:00
Oliver Graff
a631215f5b /nick: Avoid announcing when it's the same nick (#237)
Fixes #234
2017-06-13 11:12:32 -04:00
66 changed files with 4786 additions and 605 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
github: shazow

63
.github/ISSUE_TEMPLATE/BUG.yml vendored Normal file
View File

@ -0,0 +1,63 @@
name: Bug Report
description: Create a report to fix something that is broken
title: "[Bug]: "
labels: ["bug", "needs-triage"]
body:
- type: textarea
id: summary
validations:
required: true
attributes:
label: Summary
description: A clear and concise description of what the bug is.
- type: input
id: client-version
attributes:
label: Client version
description: Paste output of `ssh -V`
validations:
required: true
- type: input
id: server-version
attributes:
label: Server version
description: Paste output of `ssh-chat --version`
placeholder: e.g., ssh-chat v0.1.0
validations:
required: true
- type: input
id: latest-server-version
attributes:
label: Latest server version available (at time of report)
description: Check https://github.com/shazow/ssh-chat/releases and paste the latest version
placeholder: e.g., v0.2.0
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: To Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Full command to run...
2. Resulting output...
render: markdown
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
placeholder: Describe the expected behavior
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

32
.github/workflows/go.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Go
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get -v -t -d ./...
- name: Build
run: go build -v .
- name: Test
run: go test -race -vet "all" -v ./...

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ host_key.pub
ssh-chat
*.log
.*
vendor/

19
.gitmodules vendored
View File

@ -1,19 +0,0 @@
[submodule "vendor/github.com/alexcesaro/log"]
path = vendor/github.com/alexcesaro/log
url = https://github.com/alexcesaro/log
branch = master
[submodule "vendor/github.com/jessevdk/go-flags"]
path = vendor/github.com/jessevdk/go-flags
url = https://github.com/jessevdk/go-flags
branch = master
[submodule "vendor/github.com/shazow/rateio"]
path = vendor/github.com/shazow/rateio
url = https://github.com/shazow/rateio
branch = master
[submodule "vendor/github.com/dustin/go-humanize"]
path = vendor/github.com/dustin/go-humanize
url = https://github.com/dustin/go-humanize
branch = master
[submodule "vendor/github.com/howeyc/gopass"]
path = vendor/github.com/howeyc/gopass
url = https://github.com/howeyc/gopass.git

View File

@ -4,17 +4,15 @@ notifications:
language: go
go:
- 1.6
- 1.7
- 1.x
env:
- CGO_ENABLED=0
- CGO_ENABLED=0 GO111MODULE=on
install:
- go get -t ./...
- go get github.com/gordonklaus/ineffassign
script:
- diff -u <(echo -n) <(gofmt -d .)
- ineffassign .
- go vet $(go list ./... | grep -v /vendor/)
- go test -v $(go list ./... | grep -v /vendor/)
- go test -vet "all" -v ./...

78
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,78 @@
# Code of Conduct
This code of conduct applies to both: The `ssh.chat` code participants and the `ssh-chat` code contributors.
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces (such as inside the chat) and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at andrey.petrov@shazow.net. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

44
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,44 @@
# How to Contribute
This is a brief guide on how you can contribute to `ssh-chat`
## Getting Started
Contributions come in the form of bug reports, feature requests, documentation and wiki edits, and pull requests. If you have an issue with a certain feature or encountered a bug, you will refer to the Issues section.
### Submitting an Issue
`ssh-chat` has a lot of issues, and we try to help every one of them as best we can. The best way to submit an issue is simple: check if it already exists using the search bar. If you encounter a bug or want a certain feature, make sure no one else has submitted it before so we can avoid duplicate issues.
When submitting a bug report, make sure you submit very specific details surrounding the bug:
* What did you do to create the bug?
* Was there any error code given or exceptions thrown?
* What operating system are you and which version of OpenSSH are you using?
* If you built from source, what version of Golang did you use to build `ssh-chat`?
These details should help us to come to a solution.
For feature requests, use the search bar to look up if a feature you want has already been requested. If there was an issue already create, you can vote on it using the "thumbs up" emoji.
### Submitting Code
Submitting code is another way to contribute. The best way to start contributing code would be to look at all the open Issues and see if you can find an interesting bug to tackle. Or if there's a feature you want to implement, check if an Issue was opened for it, or even submit the feature request yourself to open up a discussion.
When submitting code, you should, in your commit message, refer to which issue you are working on. That way when the issue is resolved, or if future bugs are introduced because of it, we can refer to the pull request made and try to fix any bugs.
Once submitted, the code must meet the following conditions in order to be accepted:
* Code must be formatted using `gofmt`
* Code must pass code review
* Code must pass the Travis CI testing stage
If the code meets these conditions, then it will be merged into the `master` branch.
### Discussion Channels
Development discussion of `ssh-chat` can be found on Shazow's public `ssh-chat` server. Connect using any `ssh` client with the following:
```bash
$ ssh username@chat.shazow.net
```

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM golang:alpine AS builder
WORKDIR /usr/src/app
COPY . .
RUN apk add make openssh
RUN make build
FROM alpine
RUN apk add openssh
RUN mkdir /root/.ssh
WORKDIR /root/.ssh
RUN ssh-keygen -t rsa -C "chatkey" -f id_rsa
WORKDIR /usr/local/bin
COPY --from=builder /usr/src/app/ssh-chat .
RUN chmod +x ssh-chat
CMD ["/usr/local/bin/ssh-chat"]

45
Gopkg.lock generated Normal file
View File

@ -0,0 +1,45 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
branch = "master"
name = "github.com/alexcesaro/log"
packages = [".","golog"]
revision = "61e686294e58a8698a9e1091268bb4ac1116bd5e"
[[projects]]
branch = "master"
name = "github.com/howeyc/gopass"
packages = ["."]
revision = "bf9dde6d0d2c004a008c27aaee91170c786f6db8"
[[projects]]
name = "github.com/jessevdk/go-flags"
packages = ["."]
revision = "96dc06278ce32a0e9d957d590bb987c81ee66407"
version = "v1.3.0"
[[projects]]
branch = "master"
name = "github.com/shazow/rateio"
packages = ["."]
revision = "e8e00881e5c12090412414be41c04ca9c8a71106"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["curve25519","ed25519","ed25519/internal/edwards25519","internal/chacha20","poly1305","ssh","ssh/terminal"]
revision = "ee41a25c63fb5b74abf2213abb6dee3751e6ac4a"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix","windows"]
revision = "2c42eef0765b9837fbdab12011af7830f55f88f0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "48a7f7477a28e61efdd4256fe7f426bfaf93df53b5731e905088c0e9c2f10d3b"
solver-name = "gps-cdcl"
solver-version = 1

46
Gopkg.toml Normal file
View File

@ -0,0 +1,46 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
[[constraint]]
branch = "master"
name = "github.com/alexcesaro/log"
[[constraint]]
branch = "master"
name = "github.com/dustin/go-humanize"
[[constraint]]
branch = "master"
name = "github.com/howeyc/gopass"
[[constraint]]
name = "github.com/jessevdk/go-flags"
version = "1.3.0"
[[constraint]]
branch = "master"
name = "github.com/shazow/rateio"
[[constraint]]
branch = "master"
name = "golang.org/x/crypto"

View File

@ -4,17 +4,12 @@ PORT = 2022
SRCS = %.go
VERSION := $(shell git describe --tags --dirty --always 2> /dev/null || echo "dev")
LDFLAGS = LDFLAGS="-X main.Version=$(VERSION)"
SUBPACKAGES := $(shell go list ./... | grep -v /vendor/)
LDFLAGS = -X main.Version=$(VERSION) -extldflags "-static"
all: $(BINARY)
$(BINARY): deps **/**/*.go **/*.go *.go
go build $(BUILDFLAGS) ./cmd/ssh-chat
deps:
go get ./...
$(BINARY): **/**/*.go **/*.go *.go
go build -ldflags "$(LDFLAGS)" ./cmd/ssh-chat
build: $(BINARY)
@ -31,12 +26,22 @@ debug: $(BINARY) $(KEY)
./$(BINARY) --pprof 6060 -i $(KEY) --bind ":$(PORT)" -vv
test:
go test -v $(SUBPACKAGES)
go test -race -test.timeout 5s ./...
release:
GOOS=linux GOARCH=arm GOARM=6 $(LDFLAGS) ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
GOOS=linux GOARCH=amd64 $(LDFLAGS) ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
GOOS=linux GOARCH=386 $(LDFLAGS) ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
GOOS=darwin GOARCH=amd64 $(LDFLAGS) ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
GOOS=freebsd GOARCH=amd64 $(LDFLAGS) ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
GOOS=windows GOARCH=386 $(LDFLAGS) ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
# We use static linking for release build. LDFLAGS via
# https://github.com/golang/go/issues/26492
# Can replace LDFLAGS with -static once the issue has been resolved.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=windows GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
deploy: build/ssh-chat-linux_amd64.tgz
ssh -p 2022 ssh.chat tar xvz < build/ssh-chat-linux_amd64.tgz
@echo " --- Ready to deploy ---"
@echo "Run: ssh -t -p 2022 ssh.chat sudo systemctl restart ssh-chat"

33
NOTICE Normal file
View File

@ -0,0 +1,33 @@
## x/crypto/ssh/terminal
This project contains a fork of https://github.com/golang/crypto/tree/master/ssh/terminal
under the sshd/terminal directory. The project's original license applies:
Copyright (c) 2009 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -1,6 +1,8 @@
[![Build Status](https://travis-ci.org/shazow/ssh-chat.svg?branch=master)](https://travis-ci.org/shazow/ssh-chat)
[![Bountysource](https://www.bountysource.com/badge/team?team_id=52292&style=bounties_received)](https://www.bountysource.com/teams/ssh-chat/issues?utm_source=ssh-chat&utm_medium=shield&utm_campaign=bounties_received)
[![GoDoc](https://godoc.org/github.com/shazow/ssh-chat?status.svg)](https://godoc.org/github.com/shazow/ssh-chat)
[![Downloads](https://img.shields.io/github/downloads/shazow/ssh-chat/total.svg?color=orange)](https://github.com/shazow/ssh-chat/releases)
[![Bountysource](https://www.bountysource.com/badge/team?team_id=52292&style=bounties_received)](https://www.bountysource.com/teams/ssh-chat/issues?utm_source=ssh-chat&utm_medium=shield&utm_campaign=bounties_received)
# ssh-chat
@ -10,11 +12,15 @@ Custom SSH server written in Go. Instead of a shell, you get a chat prompt.
Join the party:
```
$ ssh chat.shazow.net
``` console
$ ssh ssh.chat
```
The server's RSA key fingerprint is `MD5:e5:d5:d1:75:90:38:42:f6:c7:03:d7:d0:56:7d:6a:db` or `SHA256:HQDLlZsXL3t0lV5CHM0OXeZ5O6PcfHuzkS8cRbbTLBI`. If you see something different, you might be [MITM](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)'d.
Please abide by our [project's Code of Conduct](https://github.com/shazow/ssh-chat/blob/master/CODE_OF_CONDUCT.md) while participating in chat.
The host's public key is `ssh.chat ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKPrQofxXqoz2y9A7NFkkENt6iW8/mvpfes3RY/41Oyt` and the fingerprint is `SHA256:yoqMXkCysMTBsvhu2yRoMUl+EmZKlvkN+ZKmL3115xU` (as of 2021-10-13).
If you see something different, you might be [MITM](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)'d.
(Apologies if the server is down, try again shortly.)
@ -24,13 +30,17 @@ The server's RSA key fingerprint is `MD5:e5:d5:d1:75:90:38:42:f6:c7:03:d7:d0:56:
Recent releases include builds for MacOS (darwin/amd64) and Linux (386,
amd64, and ARM6 for your RaspberryPi).
**[Grab the latest release here](https://github.com/shazow/ssh-chat/releases/)**.
**[Grab the latest binary release here](https://github.com/shazow/ssh-chat/releases/)**.
Play around with it. Additional [deploy examples are here](https://github.com/shazow/ssh-chat/wiki/Deployment).
## Compiling / Developing
Most people just want the [latest binary release](https://github.com/shazow/ssh-chat/releases/). If you're sure you want to compile it from source, read on:
You can compile ssh-chat by using `make build`. The resulting binary is portable and
can be run on any system with a similar OS and CPU arch. Go 1.3 or higher is required to compile.
can be run on any system with a similar OS and CPU arch. Go 1.8 or higher is required to compile.
If you're developing on this repo, there is a handy Makefile that should set
things up with `make run`.
@ -42,20 +52,20 @@ Additionally, `make debug` runs the server with an http `pprof` server. This all
## Quick Start
```
``` console
Usage:
ssh-chat [OPTIONS]
Application Options:
-v, --verbose Show verbose logging.
--version Print version and exit.
-i, --identity= Private key to identify server with. (~/.ssh/id_rsa)
--bind= Host and port to listen on. (0.0.0.0:2022)
--admin= Fingerprint of pubkey to mark as admin.
--whitelist= Optional file of pubkey fingerprints that are allowed to connect
--motd= Message of the Day file (optional)
-i, --identity= Private key to identify server with. (default: ~/.ssh/id_rsa)
--bind= Host and port to listen on. (default: 0.0.0.0:2022)
--admin= File of public keys who are admins.
--allowlist= Optional file of public keys who are allowed to connect.
--motd= Optional Message of the Day file.
--log= Write chat log to this file.
--pprof= enable http server for pprof
--pprof= Enable pprof http server for profiling.
Help Options:
-h, --help Show this help message
@ -64,7 +74,7 @@ Help Options:
After doing `go get github.com/shazow/ssh-chat/...` on this repo, you should be able
to run a command like:
```
``` console
$ ssh-chat --verbose --bind ":22" --identity ~/.ssh/id_dsa
```
@ -78,4 +88,4 @@ Feel free to submit more questions to be answered and added to the page.
## License
This project is licensed under the MIT open source license.
MIT

284
auth.go
View File

@ -1,8 +1,14 @@
package sshchat
import (
"crypto/sha256"
"crypto/subtle"
"encoding/csv"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/shazow/ssh-chat/set"
@ -10,12 +16,20 @@ import (
"golang.org/x/crypto/ssh"
)
// The error returned a key is checked that is not whitelisted, with whitelisting required.
var ErrNotWhitelisted = errors.New("not whitelisted")
// KeyLoader loads public keys, e.g. from an authorized_keys file.
// It must return a nil slice on error.
type KeyLoader func() ([]ssh.PublicKey, error)
// The error returned a key is checked that is banned.
// ErrNotAllowed Is the error returned when a key is checked that is not allowlisted,
// when allowlisting is enabled.
var ErrNotAllowed = errors.New("not allowed")
// ErrBanned is the error returned when a client is banned.
var ErrBanned = errors.New("banned")
// ErrIncorrectPassphrase is the error returned when a provided passphrase is incorrect.
var ErrIncorrectPassphrase = errors.New("incorrect passphrase")
// newAuthKey returns string from an ssh.PublicKey used to index the key in our lookup.
func newAuthKey(key ssh.PublicKey) string {
if key == nil {
@ -38,51 +52,109 @@ func newAuthAddr(addr net.Addr) string {
return host
}
// Auth stores lookups for bans, whitelists, and ops. It implements the sshd.Auth interface.
// Auth stores lookups for bans, allowlists, and ops. It implements the sshd.Auth interface.
// If the contained passphrase is not empty, it complements a allowlist.
type Auth struct {
bannedAddr *set.Set
banned *set.Set
whitelist *set.Set
ops *set.Set
passphraseHash []byte
bannedAddr *set.Set
bannedClient *set.Set
banned *set.Set
allowlist *set.Set
ops *set.Set
settingsMu sync.RWMutex
allowlistMode bool
opLoader KeyLoader
allowlistLoader KeyLoader
}
// NewAuth creates a new empty Auth.
func NewAuth() *Auth {
return &Auth{
bannedAddr: set.New(),
banned: set.New(),
whitelist: set.New(),
ops: set.New(),
bannedAddr: set.New(),
bannedClient: set.New(),
banned: set.New(),
allowlist: set.New(),
ops: set.New(),
}
}
func (a *Auth) AllowlistMode() bool {
a.settingsMu.RLock()
defer a.settingsMu.RUnlock()
return a.allowlistMode
}
func (a *Auth) SetAllowlistMode(value bool) {
a.settingsMu.Lock()
defer a.settingsMu.Unlock()
a.allowlistMode = value
}
// SetPassphrase enables passphrase authentication with the given passphrase.
// If an empty passphrase is given, disable passphrase authentication.
func (a *Auth) SetPassphrase(passphrase string) {
if passphrase == "" {
a.passphraseHash = nil
} else {
hashArray := sha256.Sum256([]byte(passphrase))
a.passphraseHash = hashArray[:]
}
}
// AllowAnonymous determines if anonymous users are permitted.
func (a *Auth) AllowAnonymous() bool {
return a.whitelist.Len() == 0
return !a.AllowlistMode() && a.passphraseHash == nil
}
// Check determines if a pubkey fingerprint is permitted.
func (a *Auth) Check(addr net.Addr, key ssh.PublicKey) (bool, error) {
// AcceptPassphrase determines if passphrase authentication is accepted.
func (a *Auth) AcceptPassphrase() bool {
return a.passphraseHash != nil
}
// CheckBans checks IP, key and client bans.
func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
authkey := newAuthKey(key)
if a.whitelist.Len() != 0 {
// Only check whitelist if there is something in it, otherwise it's disabled.
whitelisted := a.whitelist.In(authkey)
if !whitelisted {
return false, ErrNotWhitelisted
}
return true, nil
var banned bool
if authkey != "" {
banned = a.banned.In(authkey)
}
banned := a.banned.In(authkey)
if !banned {
banned = a.bannedAddr.In(newAuthAddr(addr))
}
if banned {
return false, ErrBanned
if !banned {
banned = a.bannedClient.In(clientVersion)
}
// Ops can bypass bans, just in case we ban ourselves.
if banned && !a.IsOp(key) {
return ErrBanned
}
return true, nil
return nil
}
// CheckPubkey determines if a pubkey fingerprint is permitted.
func (a *Auth) CheckPublicKey(key ssh.PublicKey) error {
authkey := newAuthKey(key)
allowlisted := a.allowlist.In(authkey)
if a.AllowAnonymous() || allowlisted || a.IsOp(key) {
return nil
} else {
return ErrNotAllowed
}
}
// CheckPassphrase determines if a passphrase is permitted.
func (a *Auth) CheckPassphrase(passphrase string) error {
if !a.AcceptPassphrase() {
return errors.New("passphrases not accepted") // this should never happen
}
passedPassphraseHash := sha256.Sum256([]byte(passphrase))
if subtle.ConstantTimeCompare(passedPassphraseHash[:], a.passphraseHash) == 0 {
return ErrIncorrectPassphrase
}
return nil
}
// Op sets a public key as a known operator.
@ -92,34 +164,77 @@ func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
}
authItem := newAuthItem(key)
if d != 0 {
a.ops.Add(set.Expire(authItem, d))
a.ops.Set(set.Expire(authItem, d))
} else {
a.ops.Add(authItem)
a.ops.Set(authItem)
}
logger.Debugf("Added to ops: %s (for %s)", authItem.Key(), d)
logger.Debugf("Added to ops: %q (for %s)", authItem.Key(), d)
}
// IsOp checks if a public key is an op.
func (a *Auth) IsOp(key ssh.PublicKey) bool {
if key == nil {
return false
}
authkey := newAuthKey(key)
return a.ops.In(authkey)
}
// Whitelist will set a public key as a whitelisted user.
func (a *Auth) Whitelist(key ssh.PublicKey, d time.Duration) {
// LoadOps sets the public keys form loader to operators and saves the loader for later use
func (a *Auth) LoadOps(loader KeyLoader) error {
a.settingsMu.Lock()
a.opLoader = loader
a.settingsMu.Unlock()
return a.ReloadOps()
}
// ReloadOps sets the public keys from a loader saved in the last call to operators
func (a *Auth) ReloadOps() error {
a.settingsMu.RLock()
defer a.settingsMu.RUnlock()
return addFromLoader(a.opLoader, a.Op)
}
// Allowlist will set a public key as a allowlisted user.
func (a *Auth) Allowlist(key ssh.PublicKey, d time.Duration) {
if key == nil {
return
}
var err error
authItem := newAuthItem(key)
if d != 0 {
a.whitelist.Add(set.Expire(authItem, d))
err = a.allowlist.Set(set.Expire(authItem, d))
} else {
a.whitelist.Add(authItem)
err = a.allowlist.Set(authItem)
}
logger.Debugf("Added to whitelist: %s (for %s)", authItem.Key(), d)
if err == nil {
logger.Debugf("Added to allowlist: %q (for %s)", authItem.Key(), d)
} else {
logger.Errorf("Error adding %q to allowlist for %s: %s", authItem.Key(), d, err)
}
}
// LoadAllowlist adds the public keys from the loader to the allowlist and saves the loader for later use
func (a *Auth) LoadAllowlist(loader KeyLoader) error {
a.settingsMu.Lock()
a.allowlistLoader = loader
a.settingsMu.Unlock()
return a.ReloadAllowlist()
}
// LoadAllowlist adds the public keys from a loader saved in a previous call to the allowlist
func (a *Auth) ReloadAllowlist() error {
a.settingsMu.RLock()
defer a.settingsMu.RUnlock()
return addFromLoader(a.allowlistLoader, a.Allowlist)
}
func addFromLoader(loader KeyLoader, adder func(ssh.PublicKey, time.Duration)) error {
if loader == nil {
return nil
}
keys, err := loader()
for _, key := range keys {
adder(key, 0)
}
return err
}
// Ban will set a public key as banned.
@ -132,22 +247,97 @@ func (a *Auth) Ban(key ssh.PublicKey, d time.Duration) {
// BanFingerprint will set a public key fingerprint as banned.
func (a *Auth) BanFingerprint(authkey string, d time.Duration) {
// FIXME: This is a case insensitive key, which isn't great...
authItem := set.StringItem(authkey)
if d != 0 {
a.banned.Add(set.Expire(authItem, d))
a.banned.Set(set.Expire(authItem, d))
} else {
a.banned.Add(authItem)
a.banned.Set(authItem)
}
logger.Debugf("Added to banned: %s (for %s)", authItem.Key(), d)
logger.Debugf("Added to banned: %q (for %s)", authItem.Key(), d)
}
// Ban will set an IP address as banned.
func (a *Auth) BanAddr(addr net.Addr, d time.Duration) {
authItem := set.StringItem(addr.String())
// BanClient will set client version as banned. Useful for misbehaving bots.
func (a *Auth) BanClient(client string, d time.Duration) {
item := set.StringItem(client)
if d != 0 {
a.bannedAddr.Add(set.Expire(authItem, d))
a.bannedClient.Set(set.Expire(item, d))
} else {
a.bannedAddr.Add(authItem)
a.bannedClient.Set(item)
}
logger.Debugf("Added to bannedAddr: %s (for %s)", authItem.Key(), d)
logger.Debugf("Added to banned: %q (for %s)", item.Key(), d)
}
// Banned returns the list of banned keys.
func (a *Auth) Banned() (ip []string, fingerprint []string, client []string) {
a.banned.Each(func(key string, _ set.Item) error {
fingerprint = append(fingerprint, key)
return nil
})
a.bannedAddr.Each(func(key string, _ set.Item) error {
ip = append(ip, key)
return nil
})
a.bannedClient.Each(func(key string, _ set.Item) error {
client = append(client, key)
return nil
})
return
}
// BanAddr will set an IP address as banned.
func (a *Auth) BanAddr(addr net.Addr, d time.Duration) {
authItem := set.StringItem(newAuthAddr(addr))
if d != 0 {
a.bannedAddr.Set(set.Expire(authItem, d))
} else {
a.bannedAddr.Set(authItem)
}
logger.Debugf("Added to bannedAddr: %q (for %s)", authItem.Key(), d)
}
// BanQuery takes space-separated key="value" pairs to ban, including ip, fingerprint, client.
// Fields without an = will be treated as a duration, applied to the next field.
// For example: 5s client=foo 10min ip=1.1.1.1
// Will ban client foo for 5 seconds, and ip 1.1.1.1 for 10min.
func (a *Auth) BanQuery(q string) error {
r := csv.NewReader(strings.NewReader(q))
r.Comma = ' '
fields, err := r.Read()
if err != nil {
return err
}
var d time.Duration
if last := fields[len(fields)-1]; !strings.Contains(last, "=") {
d, err = time.ParseDuration(last)
if err != nil {
return err
}
fields = fields[:len(fields)-1]
}
for _, field := range fields {
parts := strings.SplitN(field, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid query: %q", q)
}
key, value := parts[0], parts[1]
switch key {
case "client":
a.BanClient(value, d)
case "fingerprint":
// TODO: Add a validity check?
a.BanFingerprint(value, d)
case "ip":
ip := net.ParseIP(value)
if ip.String() == "" {
return fmt.Errorf("invalid ip value: %q", ip)
}
a.BanAddr(&net.TCPAddr{IP: ip}, d)
default:
return fmt.Errorf("unknown query field: %q", field)
}
}
return nil
}

View File

@ -21,19 +21,20 @@ func ClonePublicKey(key ssh.PublicKey) (ssh.PublicKey, error) {
return ssh.ParsePublicKey(key.Marshal())
}
func TestAuthWhitelist(t *testing.T) {
func TestAuthAllowlist(t *testing.T) {
key, err := NewRandomPublicKey(512)
if err != nil {
t.Fatal(err)
}
auth := NewAuth()
ok, err := auth.Check(nil, key)
if !ok || err != nil {
err = auth.CheckPublicKey(key)
if err != nil {
t.Error("Failed to permit in default state:", err)
}
auth.Whitelist(key, 0)
auth.Allowlist(key, 0)
auth.SetAllowlistMode(true)
keyClone, err := ClonePublicKey(key)
if err != nil {
@ -44,9 +45,9 @@ func TestAuthWhitelist(t *testing.T) {
t.Error("Clone key does not match.")
}
ok, err = auth.Check(nil, keyClone)
if !ok || err != nil {
t.Error("Failed to permit whitelisted:", err)
err = auth.CheckPublicKey(keyClone)
if err != nil {
t.Error("Failed to permit allowlisted:", err)
}
key2, err := NewRandomPublicKey(512)
@ -54,9 +55,42 @@ func TestAuthWhitelist(t *testing.T) {
t.Fatal(err)
}
ok, err = auth.Check(nil, key2)
if ok || err == nil {
t.Error("Failed to restrict not whitelisted:", err)
err = auth.CheckPublicKey(key2)
if err == nil {
t.Error("Failed to restrict not allowlisted:", err)
}
}
func TestAuthPassphrases(t *testing.T) {
auth := NewAuth()
if auth.AcceptPassphrase() {
t.Error("Doesn't known it won't accept passphrases.")
}
auth.SetPassphrase("")
if auth.AcceptPassphrase() {
t.Error("Doesn't known it won't accept passphrases.")
}
err := auth.CheckPassphrase("Pa$$w0rd")
if err == nil {
t.Error("Failed to deny without passphrase:", err)
}
auth.SetPassphrase("Pa$$w0rd")
err = auth.CheckPassphrase("Pa$$w0rd")
if err != nil {
t.Error("Failed to allow vaild passphrase:", err)
}
err = auth.CheckPassphrase("something else")
if err == nil {
t.Error("Failed to restrict wrong passphrase:", err)
}
auth.SetPassphrase("")
if auth.AcceptPassphrase() {
t.Error("Didn't clear passphrase.")
}
}

View File

@ -3,39 +3,39 @@ package chat
// FIXME: Would be sweet if we could piggyback on a cli parser or something.
import (
"bytes"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/internal/sanitize"
"github.com/shazow/ssh-chat/set"
)
// The error returned when an invalid command is issued.
// ErrInvalidCommand is the error returned when an invalid command is issued.
var ErrInvalidCommand = errors.New("invalid command")
// The error returned when a command is given without an owner.
// ErrNoOwner is the error returned when a command is given without an owner.
var ErrNoOwner = errors.New("command without owner")
// The error returned when a command is performed without the necessary number
// of arguments.
// ErrMissingArg is the error returned when a command is performed without the necessary
// number of arguments.
var ErrMissingArg = errors.New("missing argument")
// The error returned when a command is added without a prefix.
// ErrMissingPrefix is the error returned when a command is added without a prefix.
var ErrMissingPrefix = errors.New("command missing prefix")
// Command is a definition of a handler for a command.
type Command struct {
// The command's key, such as /foo
Prefix string
// Extra help regarding arguments
PrefixHelp string
// If omitted, command is hidden from /help
Help string
Prefix string // The command's key, such as /foo
PrefixHelp string // Extra help regarding arguments
Help string // help text, if omitted, command is hidden from /help
Op bool // does the command require Op permissions?
// Handler for the command
Handler func(*Room, message.CommandMsg) error
// Command requires Op permissions
Op bool
}
// Commands is a registry of available commands.
@ -95,6 +95,10 @@ func (c Commands) Help(showOp bool) string {
return help
}
var timeformatDatetime = "2006-01-02 15:04:05"
var timeformatTime = "15:04"
var defaultCommands *Commands
func init() {
@ -155,7 +159,11 @@ func InitCommands(c *Commands) {
}
oldID := member.ID()
member.SetID(SanitizeName(args[0]))
newID := sanitize.Name(args[0])
if newID == oldID {
return errors.New("new name is the same as the original")
}
member.SetID(newID)
err := room.Rename(oldID, member)
if err != nil {
member.SetID(oldID)
@ -169,9 +177,26 @@ func InitCommands(c *Commands) {
Prefix: "/names",
Help: "List users who are connected.",
Handler: func(room *Room, msg message.CommandMsg) error {
// TODO: colorize
names := room.NamesPrefix("")
body := fmt.Sprintf("%d connected: %s", len(names), strings.Join(names, ", "))
theme := msg.From().Config().Theme
colorize := func(u *message.User) string {
return theme.ColorName(u)
}
if theme == nil {
colorize = func(u *message.User) string {
return u.Name()
}
}
names := room.Members.ListPrefix("")
sort.Slice(names, func(i, j int) bool { return names[i].Key() < names[j].Key() })
colNames := make([]string, len(names))
for i, uname := range names {
colNames[i] = colorize(uname.Value().(*Member).User)
}
body := fmt.Sprintf("%d connected: %s", len(colNames), strings.Join(colNames, ", "))
room.Send(message.NewSystemMsg(body, msg.From()))
return nil
},
@ -191,13 +216,14 @@ func InitCommands(c *Commands) {
if cfg.Theme != nil {
theme = cfg.Theme.ID()
}
var output bytes.Buffer
var output strings.Builder
fmt.Fprintf(&output, "Current theme: %s%s", theme, message.Newline)
fmt.Fprintf(&output, " Themes available: ")
for i, t := range message.Themes {
fmt.Fprintf(&output, t.ID())
output.WriteString(t.ID())
if i < len(message.Themes)-1 {
fmt.Fprintf(&output, ", ")
output.WriteString(", ")
}
}
room.Send(message.NewSystemMsg(output.String(), user))
@ -255,6 +281,74 @@ func InitCommands(c *Commands) {
},
})
c.Add(Command{
Prefix: "/shrug",
Handler: func(room *Room, msg message.CommandMsg) error {
room.Send(message.NewEmoteMsg(`¯\_(ツ)_/¯`, msg.From()))
return nil
},
})
c.Add(Command{
Prefix: "/timestamp",
PrefixHelp: "[time|datetime]",
Help: "Prefix messages with a timestamp. You can also provide the UTC offset: /timestamp time +5h45m",
Handler: func(room *Room, msg message.CommandMsg) error {
u := msg.From()
cfg := u.Config()
args := msg.Args()
mode := ""
if len(args) >= 1 {
mode = args[0]
}
if len(args) >= 2 {
// FIXME: This is an annoying format to demand from users, but
// hopefully we can make it a non-primary flow if we add GeoIP
// someday.
offset, err := time.ParseDuration(args[1])
if err != nil {
return err
}
cfg.Timezone = time.FixedZone("", int(offset.Seconds()))
}
switch mode {
case "time":
cfg.Timeformat = &timeformatTime
case "datetime":
cfg.Timeformat = &timeformatDatetime
case "":
// Toggle
if cfg.Timeformat != nil {
cfg.Timeformat = nil
} else {
cfg.Timeformat = &timeformatTime
}
case "off":
cfg.Timeformat = nil
default:
return errors.New("timestamp value must be one of: time, datetime, off")
}
u.SetConfig(cfg)
var body string
if cfg.Timeformat != nil {
if cfg.Timezone != nil {
tzname := time.Now().In(cfg.Timezone).Format("MST")
body = fmt.Sprintf("Timestamp is toggled ON, timezone is %q", tzname)
} else {
body = "Timestamp is toggled ON, timezone is UTC"
}
} else {
body = "Timestamp is toggled OFF"
}
room.Send(message.NewSystemMsg(body, u))
return nil
},
})
c.Add(Command{
Prefix: "/ignore",
PrefixHelp: "[USER]",
@ -317,4 +411,119 @@ func InitCommands(c *Commands) {
return nil
},
})
c.Add(Command{
Prefix: "/focus",
PrefixHelp: "[USER ...]",
Help: "Only show messages from focused users, or $ to reset.",
Handler: func(room *Room, msg message.CommandMsg) error {
ids := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/focus"))
if ids == "" {
// Print focused names, if any.
var names []string
msg.From().Focused.Each(func(_ string, item set.Item) error {
names = append(names, item.Key())
return nil
})
var systemMsg string
if len(names) == 0 {
systemMsg = "Unfocused."
} else {
systemMsg = fmt.Sprintf("Focusing on %d users: %s", len(names), strings.Join(names, ", "))
}
room.Send(message.NewSystemMsg(systemMsg, msg.From()))
return nil
}
n := msg.From().Focused.Clear()
if ids == "$" {
room.Send(message.NewSystemMsg(fmt.Sprintf("Removed focus from %d users.", n), msg.From()))
return nil
}
var focused []string
for _, name := range strings.Split(ids, " ") {
id := sanitize.Name(name)
if id == "" {
continue // Skip
}
focused = append(focused, id)
if err := msg.From().Focused.Set(set.Itemize(id, set.ZeroValue)); err != nil {
return err
}
}
room.Send(message.NewSystemMsg(fmt.Sprintf("Focusing: %s", strings.Join(focused, ", ")), msg.From()))
return nil
},
})
c.Add(Command{
Prefix: "/away",
PrefixHelp: "[REASON]",
Help: "Set away reason, or empty to unset.",
Handler: func(room *Room, msg message.CommandMsg) error {
awayMsg := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/away"))
isAway, _, _ := msg.From().GetAway()
msg.From().SetAway(awayMsg)
if awayMsg != "" {
room.Send(message.NewEmoteMsg("has gone away: "+awayMsg, msg.From()))
return nil
}
if isAway {
room.Send(message.NewEmoteMsg("is back.", msg.From()))
return nil
}
return errors.New("not away. Append a reason message to set away")
},
})
c.Add(Command{
Prefix: "/back",
Help: "Clear away status.",
Handler: func(room *Room, msg message.CommandMsg) error {
isAway, _, _ := msg.From().GetAway()
if isAway {
msg.From().SetAway("")
room.Send(message.NewEmoteMsg("is back.", msg.From()))
return nil
}
return errors.New("must be away to be back")
},
})
c.Add(Command{
Op: true,
Prefix: "/mute",
PrefixHelp: "USER",
Help: "Toggle muting USER, preventing messages from broadcasting.",
Handler: func(room *Room, msg message.CommandMsg) error {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) == 0 {
return errors.New("must specify user")
}
member, ok := room.MemberByID(args[0])
if !ok {
return errors.New("user not found")
}
setMute := !member.IsMuted()
member.SetMute(setMute)
id := member.ID()
if setMute {
room.Send(message.NewSystemMsg("Muted: "+id, msg.From()))
} else {
room.Send(message.NewSystemMsg("Unmuted: "+id, msg.From()))
}
return nil
},
})
}

70
chat/command_test.go Normal file
View File

@ -0,0 +1,70 @@
package chat
import (
"fmt"
"testing"
"github.com/shazow/ssh-chat/chat/message"
)
func TestAwayCommands(t *testing.T) {
cmds := &Commands{}
InitCommands(cmds)
room := NewRoom()
go room.Serve()
defer room.Close()
// steps are order dependent
// User can be "away" or "not away" using 3 commands "/away [msg]", "/away", "/back"
// 2^3 possible cases, run all and verify state at the end
type step struct {
// input
Msg string
// expected output
IsUserAway bool
AwayMessage string
// expected state change
ExpectsError func(awayBefore bool) bool
}
neverError := func(_ bool) bool { return false }
// if the user was away before, then the error is expected
errorIfAwayBefore := func(awayBefore bool) bool { return awayBefore }
awayStep := step{"/away snorkling", true, "snorkling", neverError}
notAwayStep := step{"/away", false, "", errorIfAwayBefore}
backStep := step{"/back", false, "", errorIfAwayBefore}
steps := []step{awayStep, notAwayStep, backStep}
cases := [][]int{
{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0},
}
for _, c := range cases {
t.Run(fmt.Sprintf("Case: %d, %d, %d", c[0], c[1], c[2]), func(t *testing.T) {
u := message.NewUser(message.SimpleID("shark"))
for _, s := range []step{steps[c[0]], steps[c[1]], steps[c[2]]} {
msg, _ := message.NewPublicMsg(s.Msg, u).ParseCommand()
awayBeforeCommand, _, _ := u.GetAway()
err := cmds.Run(room, *msg)
if err != nil && s.ExpectsError(awayBeforeCommand) {
t.Fatalf("unexpected error running the command: %+v", err)
}
isAway, _, awayMsg := u.GetAway()
if isAway != s.IsUserAway {
t.Fatalf("expected user away state '%t' not equals to actual '%t' after message '%s'", s.IsUserAway, isAway, s.Msg)
}
if awayMsg != s.AwayMessage {
t.Fatalf("expected user away message '%s' not equal to actual '%s' after message '%s'", s.AwayMessage, awayMsg, s.Msg)
}
}
})
}
}

View File

@ -1,7 +1,6 @@
/*
`chat` package is a server-agnostic implementation of a chat interface, built
with the intention of using with the intention of using as the backend for
ssh-chat.
to be used as the backend for ssh-chat.
This package should not know anything about sockets. It should expose io-style
interfaces and rooms for communicating with any method of transnport.

View File

@ -1,10 +1,13 @@
package chat
import "io"
import stdlog "log"
import (
"io"
stdlog "log"
)
var logger *stdlog.Logger
// SetLogger changes the logger used for logging inside the package
func SetLogger(w io.Writer) {
flags := stdlog.Flags()
prefix := "[chat] "

View File

@ -112,6 +112,7 @@ func (m PublicMsg) Render(t *Theme) string {
return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body)
}
// RenderFor renders the message for other users to see.
func (m PublicMsg) RenderFor(cfg UserConfig) string {
if cfg.Highlight == nil || cfg.Theme == nil {
return m.Render(cfg.Theme)
@ -128,13 +129,19 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string {
return fmt.Sprintf("%s: %s", cfg.Theme.ColorName(m.from), body)
}
// RenderSelf renders the message for when it's echoing your own message.
func (m PublicMsg) RenderSelf(cfg UserConfig) string {
if cfg.Theme == nil {
return fmt.Sprintf("[%s] %s", m.from.Name(), m.body)
}
return fmt.Sprintf("[%s] %s", cfg.Theme.ColorName(m.from), m.body)
}
func (m PublicMsg) String() string {
return fmt.Sprintf("%s: %s", m.from.Name(), m.body)
}
// EmoteMsg is a /me message sent to the room. It specifically does not
// extend PublicMsg because it doesn't implement MessageFrom to allow the
// sender to see the emote.
// EmoteMsg is a /me message sent to the room.
type EmoteMsg struct {
Msg
from *User
@ -150,6 +157,10 @@ func NewEmoteMsg(body string, from *User) *EmoteMsg {
}
}
func (m EmoteMsg) From() *User {
return m.from
}
func (m EmoteMsg) Render(t *Theme) string {
return fmt.Sprintf("** %s %s", m.from.Name(), m.body)
}
@ -175,11 +186,16 @@ func (m PrivateMsg) To() *User {
return m.to
}
func (m PrivateMsg) From() *User {
return m.from
}
func (m PrivateMsg) Render(t *Theme) string {
s := fmt.Sprintf("[PM from %s] %s", m.from.Name(), m.body)
format := "[PM from %s] %s"
if t == nil {
return s
return fmt.Sprintf(format, m.from.ID(), m.body)
}
s := fmt.Sprintf(format, m.from.Name(), m.body)
return t.ColorPM(s)
}

View File

@ -1,6 +1,8 @@
package message
import "fmt"
import (
"fmt"
)
const (
// Reset resets the color
@ -120,45 +122,60 @@ type Theme struct {
pm Style
highlight Style
names *Palette
useID bool
}
func (t Theme) ID() string {
return t.id
func (theme Theme) ID() string {
return theme.id
}
// Colorize name string given some index
func (t Theme) ColorName(u *User) string {
if t.names == nil {
return u.Name()
func (theme Theme) ColorName(u *User) string {
var name string
if theme.useID {
name = u.ID()
} else {
name = u.Name()
}
if theme.names == nil {
return name
}
return t.names.Get(u.colorIdx).Format(u.Name())
return theme.names.Get(u.colorIdx).Format(name)
}
// Colorize the PM string
func (t Theme) ColorPM(s string) string {
if t.pm == nil {
func (theme Theme) ColorPM(s string) string {
if theme.pm == nil {
return s
}
return t.pm.Format(s)
return theme.pm.Format(s)
}
// Colorize the Sys message
func (t Theme) ColorSys(s string) string {
if t.sys == nil {
func (theme Theme) ColorSys(s string) string {
if theme.sys == nil {
return s
}
return t.sys.Format(s)
return theme.sys.Format(s)
}
// Highlight a matched string, usually name
func (t Theme) Highlight(s string) string {
if t.highlight == nil {
func (theme Theme) Highlight(s string) string {
if theme.highlight == nil {
return s
}
return t.highlight.Format(s)
return theme.highlight.Format(s)
}
// Timestamp colorizes the timestamp.
func (theme Theme) Timestamp(s string) string {
if theme.sys == nil {
return s
}
return theme.sys.Format(s)
}
// List of initialzied themes
@ -167,6 +184,9 @@ var Themes []Theme
// Default theme to use
var DefaultTheme *Theme
// MonoTheme is a simple theme without colors, useful for testing and bots.
var MonoTheme *Theme
func allColors256() *Palette {
colors := []uint8{}
var i uint8
@ -213,11 +233,13 @@ func init() {
highlight: style(Bold + "\033[48;5;22m\033[38;5;46m"), // Green on dark green
},
{
id: "mono",
id: "mono",
useID: true,
},
}
DefaultTheme = &Themes[0]
MonoTheme = &Themes[3]
/* Some debug helpers for your convenience:

View File

@ -15,13 +15,16 @@ import (
const messageBuffer = 5
const messageTimeout = 5 * time.Second
const reHighlight = `\b(%s)\b`
const timestampTimeout = 30 * time.Minute
var ErrUserClosed = errors.New("user closed")
// User definition, implemented set Item interface and io.Writer
type User struct {
Identifier
Ignored *set.Set
OnChange func()
Ignored set.Interface
Focused set.Interface
colorIdx int
joined time.Time
msg chan Message
@ -30,9 +33,12 @@ type User struct {
screen io.WriteCloser
closeOnce sync.Once
mu sync.Mutex
config UserConfig
replyTo *User // Set when user gets a /msg, for replying.
mu sync.Mutex
config UserConfig
replyTo *User // Set when user gets a /msg, for replying.
lastMsg time.Time // When the last message was rendered.
awayReason string // Away reason, "" when not away.
awaySince time.Time // When away was set, 0 when not away.
}
func NewUser(identity Identifier) *User {
@ -43,6 +49,7 @@ func NewUser(identity Identifier) *User {
msg: make(chan Message, messageBuffer),
done: make(chan struct{}),
Ignored: set.New(),
Focused: set.New(),
}
u.setColorIdx(rand.Int())
@ -56,6 +63,36 @@ func NewUserScreen(identity Identifier, screen io.WriteCloser) *User {
return u
}
func (u *User) Joined() time.Time {
return u.joined
}
func (u *User) LastMsg() time.Time {
u.mu.Lock()
defer u.mu.Unlock()
return u.lastMsg
}
// SetAway sets the users away reason and state.
func (u *User) SetAway(msg string) {
u.mu.Lock()
defer u.mu.Unlock()
u.awayReason = msg
if msg == "" {
u.awaySince = time.Time{}
} else {
// Reset away timer even if already away
u.awaySince = time.Now()
}
}
// GetAway returns if the user is away, when they went away, and the reason.
func (u *User) GetAway() (bool, time.Time, string) {
u.mu.Lock()
defer u.mu.Unlock()
return u.awayReason != "", u.awaySince, u.awayReason
}
func (u *User) Config() UserConfig {
u.mu.Lock()
defer u.mu.Unlock()
@ -66,12 +103,20 @@ func (u *User) SetConfig(cfg UserConfig) {
u.mu.Lock()
u.config = cfg
u.mu.Unlock()
if u.OnChange != nil {
u.OnChange()
}
}
// Rename the user with a new Identifier.
func (u *User) SetID(id string) {
u.Identifier.SetID(id)
u.setColorIdx(rand.Int())
if u.OnChange != nil {
u.OnChange()
}
}
// ReplyTo returns the last user that messaged this user.
@ -98,7 +143,9 @@ func (u *User) setColorIdx(idx int) {
func (u *User) Close() {
u.closeOnce.Do(func() {
if u.screen != nil {
u.screen.Close()
if err := u.screen.Close(); err != nil {
logger.Printf("Failed to close user %q screen: %s", u.ID(), err)
}
}
// close(u.msg) TODO: Close?
close(u.done)
@ -151,28 +198,61 @@ func (u *User) SetHighlight(s string) error {
func (u *User) render(m Message) string {
cfg := u.Config()
var out string
switch m := m.(type) {
case PublicMsg:
return m.RenderFor(cfg) + Newline
case *PrivateMsg:
if cfg.Bell {
return m.Render(cfg.Theme) + Bel + Newline
if u == m.From() {
u.mu.Lock()
u.lastMsg = m.Timestamp()
u.mu.Unlock()
if !cfg.Echo {
return ""
}
out += m.RenderSelf(cfg)
} else if u.Focused.Len() > 0 && !u.Focused.In(m.From().ID()) {
// Skip message during focus
return ""
} else {
out += m.RenderFor(cfg)
}
return m.Render(cfg.Theme) + Newline
case *PrivateMsg:
out += m.Render(cfg.Theme)
if cfg.Bell {
out += Bel
}
case *CommandMsg:
out += m.RenderSelf(cfg)
default:
return m.Render(cfg.Theme) + Newline
out += m.Render(cfg.Theme)
}
if cfg.Timeformat != nil {
ts := m.Timestamp()
if cfg.Timezone != nil {
ts = ts.In(cfg.Timezone)
} else {
ts = ts.UTC()
}
return cfg.Theme.Timestamp(ts.Format(*cfg.Timeformat)) + " " + out + Newline
}
return out + Newline
}
// writeMsg renders the message and attempts to write it, will Close the user
// if it fails.
func (u *User) writeMsg(m Message) error {
r := u.render(m)
_, err := u.screen.Write([]byte(r))
if err != nil {
logger.Printf("Write failed to %s, closing: %s", u.ID(), err)
u.Close()
}
return err
}
// HandleMsg will render the message to the screen, blocking.
func (u *User) HandleMsg(m Message) error {
r := u.render(m)
_, err := u.screen.Write([]byte(r))
if err != nil {
logger.Printf("Write failed to %s, closing: %s", u.Name(), err)
u.Close()
}
return err
return u.writeMsg(m)
}
// Add message to consume by user
@ -182,7 +262,7 @@ func (u *User) Send(m Message) error {
return ErrUserClosed
case u.msg <- m:
case <-time.After(messageTimeout):
logger.Printf("Message buffer full, closing: %s", u.Name())
logger.Printf("Message buffer full, closing: %s", u.ID())
u.Close()
return ErrUserClosed
}
@ -191,10 +271,13 @@ func (u *User) Send(m Message) error {
// Container for per-user configurations.
type UserConfig struct {
Highlight *regexp.Regexp
Bell bool
Quiet bool
Theme *Theme
Highlight *regexp.Regexp
Bell bool
Quiet bool
Echo bool // Echo shows your own messages after sending, disabled for bots
Timeformat *string
Timezone *time.Location
Theme *Theme
}
// Default user configuration to use
@ -203,8 +286,35 @@ var DefaultUserConfig UserConfig
func init() {
DefaultUserConfig = UserConfig{
Bell: true,
Echo: true,
Quiet: false,
}
// TODO: Seed random?
}
// RecentActiveUsers is a slice of *Users that knows how to be sorted by the
// time of the last message. If no message has been sent, then fall back to the
// time joined instead.
type RecentActiveUsers []*User
func (a RecentActiveUsers) Len() int { return len(a) }
func (a RecentActiveUsers) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a RecentActiveUsers) Less(i, j int) bool {
a[i].mu.Lock()
defer a[i].mu.Unlock()
a[j].mu.Lock()
defer a[j].mu.Unlock()
ai := a[i].lastMsg
if ai.IsZero() {
ai = a[i].joined
}
aj := a[j].lastMsg
if aj.IsZero() {
aj = a[j].joined
}
return ai.After(aj)
}

View File

@ -1,6 +1,7 @@
package message
import (
"math/rand"
"reflect"
"testing"
)
@ -10,6 +11,11 @@ func TestMakeUser(t *testing.T) {
s := &MockScreen{}
u := NewUserScreen(SimpleID("foo"), s)
cfg := u.Config()
cfg.Theme = MonoTheme // Mono
u.SetConfig(cfg)
m := NewAnnounceMsg("hello")
defer u.Close()
@ -22,3 +28,34 @@ func TestMakeUser(t *testing.T) {
t.Errorf("Got: `%s`; Expected: `%s`", actual, expected)
}
}
func TestRenderTimestamp(t *testing.T) {
var actual, expected []byte
// Reset seed for username color
rand.Seed(1)
s := &MockScreen{}
u := NewUserScreen(SimpleID("foo"), s)
cfg := u.Config()
timefmt := "AA:BB"
cfg.Theme = DefaultTheme
cfg.Timeformat = &timefmt
u.SetConfig(cfg)
if got, want := cfg.Theme.Timestamp("foo"), `foo`+Reset; got != want {
t.Errorf("Wrong timestamp formatting:\n got: %q\nwant: %q", got, want)
}
m := NewPublicMsg("hello", u)
defer u.Close()
u.Send(m)
u.HandleMsg(u.ConsumeOne())
s.Read(&actual)
expected = []byte(`AA:BB` + Reset + ` [foo] hello` + Newline)
if !reflect.DeepEqual(actual, expected) {
t.Errorf("Wrong screen output:\n Got: `%q`;\nWant: `%q`", actual, expected)
}
}

View File

@ -4,26 +4,46 @@ import (
"errors"
"fmt"
"io"
"sort"
"sync"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/internal/humantime"
"github.com/shazow/ssh-chat/set"
)
const historyLen = 20
const roomBuffer = 10
// The error returned when a message is sent to a room that is already
// ErrRoomClosed is the error returned when a message is sent to a room that is already
// closed.
var ErrRoomClosed = errors.New("room closed")
// The error returned when a user attempts to join with an invalid name, such
// as empty string.
// ErrInvalidName is the error returned when a user attempts to join with an invalid name,
// such as empty string.
var ErrInvalidName = errors.New("invalid name")
// Member is a User with per-Room metadata attached to it.
type Member struct {
*message.User
IsOp bool
// TODO: Move IsOp under mu?
mu sync.Mutex
isMuted bool // When true, messages should not be broadcasted.
}
func (m *Member) IsMuted() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.isMuted
}
func (m *Member) SetMute(muted bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.isMuted = muted
}
// Room definition, also a Set of User Items
@ -36,7 +56,6 @@ type Room struct {
closeOnce sync.Once
Members *set.Set
Ops *set.Set
}
// NewRoom creates a new room.
@ -49,7 +68,6 @@ func NewRoom() *Room {
commands: *defaultCommands,
Members: set.New(),
Ops: set.New(),
}
}
@ -78,6 +96,25 @@ func (r *Room) SetLogging(out io.Writer) {
// HandleMsg reacts to a message, will block until done.
func (r *Room) HandleMsg(m message.Message) {
var fromID string
if fromMsg, ok := m.(message.MessageFrom); ok {
fromID = fromMsg.From().ID()
}
if fromID != "" {
if item, err := r.Members.Get(fromID); err != nil {
// Message from a member who is not in the room, this should not happen.
logger.Printf("Room received unexpected message from a non-member: %v", m)
return
} else if member, ok := item.Value().(*Member); ok && member.IsMuted() {
// Short circuit message handling for muted users
if _, ok = m.(*message.CommandMsg); !ok {
member.User.Send(m)
}
return
}
}
switch m := m.(type) {
case *message.CommandMsg:
cmd := *m
@ -88,31 +125,23 @@ func (r *Room) HandleMsg(m message.Message) {
}
case message.MessageTo:
user := m.To()
user.Send(m)
default:
fromMsg, skip := m.(message.MessageFrom)
var skipUser *message.User
if skip {
skipUser = fromMsg.From()
if user.Ignored.In(fromID) {
return // Skip ignored
}
user.Send(m)
default:
r.history.Add(m)
r.Members.Each(func(_ string, item set.Item) (err error) {
user := item.Value().(*Member).User
if fromMsg != nil && user.Ignored.In(fromMsg.From().ID()) {
// Skip because ignored
return
if user.Ignored.In(fromID) {
return // Skip ignored
}
if skip && skipUser == user {
// Skip self
return
}
if _, ok := m.(*message.AnnounceMsg); ok {
if user.Config().Quiet {
// Skip announcements
return
return // Skip announcements
}
}
user.Send(m)
@ -147,11 +176,12 @@ func (r *Room) Join(u *message.User) (*Member, error) {
if u.ID() == "" {
return nil, ErrInvalidName
}
member := &Member{u}
member := &Member{User: u}
err := r.Members.Add(set.Itemize(u.ID(), member))
if err != nil {
return nil, err
}
// TODO: Remove user ID from sets, probably referring to a prior user.
r.History(u)
s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), r.Members.Len())
r.Send(message.NewAnnounceMsg(s))
@ -159,13 +189,12 @@ func (r *Room) Join(u *message.User) (*Member, error) {
}
// Leave the room as a user, will announce. Mostly used during setup.
func (r *Room) Leave(u message.Identifier) error {
func (r *Room) Leave(u *message.User) error {
err := r.Members.Remove(u.ID())
if err != nil {
return err
}
r.Ops.Remove(u.ID())
s := fmt.Sprintf("%s left.", u.Name())
s := fmt.Sprintf("%s left. (After %s)", u.Name(), humantime.Since(u.Joined()))
r.Send(message.NewAnnounceMsg(s))
return nil
}
@ -199,6 +228,7 @@ func (r *Room) Member(u *message.User) (*Member, bool) {
return m, true
}
// MemberByID Gets a member by an id / name
func (r *Room) MemberByID(id string) (*Member, bool) {
m, err := r.Members.Get(id)
if err != nil {
@ -209,7 +239,11 @@ func (r *Room) MemberByID(id string) (*Member, bool) {
// IsOp returns whether a user is an operator in this room.
func (r *Room) IsOp(u *message.User) bool {
return r.Ops.In(u.ID())
m, ok := r.Member(u)
if !ok {
return false
}
return m.IsOp
}
// Topic of the room.
@ -223,12 +257,21 @@ func (r *Room) SetTopic(s string) {
}
// NamesPrefix lists all members' names with a given prefix, used to query
// for autocompletion purposes.
// for autocompletion purposes. Sorted by which user was last active.
func (r *Room) NamesPrefix(prefix string) []string {
items := r.Members.ListPrefix(prefix)
names := make([]string, len(items))
for i, item := range items {
names[i] = item.Value().(*Member).User.Name()
// Sort results by recently active
users := make([]*message.User, 0, len(items))
for _, item := range items {
users = append(users, item.Value().(*Member).User)
}
sort.Sort(message.RecentActiveUsers(users))
// Pull out names
names := make([]string, 0, len(items))
for _, user := range users {
names = append(names, user.ID())
}
return names
}

View File

@ -5,9 +5,9 @@ import (
"fmt"
"reflect"
"testing"
"time"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/set"
)
// Used for testing
@ -104,8 +104,18 @@ func TestIgnore(t *testing.T) {
t.Fatalf("should have %d ignored users, has %d", 1, len(ignoredList))
}
// when an emote is sent by an ignored user, it should not be displayed for ignorer
ch.HandleMsg(message.NewEmoteMsg("is crying", ignored.user))
if ignorer.user.HasMessages() {
t.Fatal("should not have emote messages")
}
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
expectOutput(t, buffer, "** "+ignored.user.Name()+" is crying"+message.Newline)
// when a message is sent from the ignored user, it is delivered to non-ignoring users
ch.Send(message.NewPublicMsg("hello", ignored.user))
ch.HandleMsg(message.NewPublicMsg("hello", ignored.user))
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
expectOutput(t, buffer, ignored.user.Name()+": hello"+message.Newline)
@ -137,14 +147,10 @@ func TestIgnore(t *testing.T) {
}
// after unignoring a user, its messages can be received again
ch.Send(message.NewPublicMsg("hello again!", ignored.user))
// give some time for the channel to get the message
time.Sleep(100)
ch.HandleMsg(message.NewPublicMsg("hello again!", ignored.user))
// ensure ignorer has received the message
if !ignorer.user.HasMessages() {
// FIXME: This is flaky :/
t.Fatal("should have messages")
}
ignorer.user.HandleMsg(ignorer.user.ConsumeOne())
@ -152,7 +158,97 @@ func TestIgnore(t *testing.T) {
expectOutput(t, buffer, ignored.user.Name()+": hello again!"+message.Newline)
}
func TestMute(t *testing.T) {
var buffer []byte
ch := NewRoom()
go ch.Serve()
defer ch.Close()
// Create 3 users, join the room and clear their screen buffers
users := make([]ScreenedUser, 3)
members := make([]*Member, 3)
for i := 0; i < 3; i++ {
screen := &MockScreen{}
user := message.NewUserScreen(message.SimpleID(fmt.Sprintf("user%d", i)), screen)
users[i] = ScreenedUser{
user: user,
screen: screen,
}
member, err := ch.Join(user)
if err != nil {
t.Fatal(err)
}
members[i] = member
}
for _, u := range users {
for i := 0; i < 3; i++ {
u.user.HandleMsg(u.user.ConsumeOne())
u.screen.Read(&buffer)
}
}
// Use some handy variable names for distinguish between roles
muter := users[0]
muted := users[1]
other := users[2]
members[0].IsOp = true
// test muting unexisting user
if err := sendCommand("/mute test", muter, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Err: user not found"+message.Newline)
// test muting by non-op
if err := sendCommand("/mute "+muted.user.Name(), other, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Err: must be op"+message.Newline)
// test muting existing user
if err := sendCommand("/mute "+muted.user.Name(), muter, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Muted: "+muted.user.Name()+message.Newline)
if got, want := members[1].IsMuted(), true; got != want {
t.Error("muted user failed to set mute flag")
}
// when an emote is sent by a muted user, it should not be displayed for anyone
ch.HandleMsg(message.NewPublicMsg("hello!", muted.user))
ch.HandleMsg(message.NewEmoteMsg("is crying", muted.user))
if muter.user.HasMessages() {
muter.user.HandleMsg(muter.user.ConsumeOne())
muter.screen.Read(&buffer)
t.Errorf("muter should not have messages: %s", buffer)
}
if other.user.HasMessages() {
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
t.Errorf("other should not have messages: %s", buffer)
}
// test unmuting
if err := sendCommand("/mute "+muted.user.Name(), muter, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Unmuted: "+muted.user.Name()+message.Newline)
ch.HandleMsg(message.NewPublicMsg("hello again!", muted.user))
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
expectOutput(t, buffer, muted.user.Name()+": hello again!"+message.Newline)
}
func expectOutput(t *testing.T, buffer []byte, expected string) {
t.Helper()
bytes := []byte(expected)
if !reflect.DeepEqual(buffer, bytes) {
t.Errorf("Got: %q; Expected: %q", buffer, expected)
@ -362,3 +458,49 @@ func TestRoomNames(t *testing.T) {
t.Errorf("Got: %q; Expected: %q", actual, expected)
}
}
func TestRoomNamesPrefix(t *testing.T) {
r := NewRoom()
s := &MockScreen{}
members := []*Member{
&Member{User: message.NewUserScreen(message.SimpleID("aaa"), s)},
&Member{User: message.NewUserScreen(message.SimpleID("aab"), s)},
&Member{User: message.NewUserScreen(message.SimpleID("aac"), s)},
&Member{User: message.NewUserScreen(message.SimpleID("foo"), s)},
}
for _, m := range members {
if err := r.Members.Add(set.Itemize(m.ID(), m)); err != nil {
t.Fatal(err)
}
}
sendMsg := func(from *Member, body string) {
// lastMsg is set during render of self messags, so we can't use NewMsg
from.HandleMsg(message.NewPublicMsg(body, from.User))
}
// Inject some activity
sendMsg(members[2], "hi") // aac
sendMsg(members[0], "hi") // aaa
sendMsg(members[3], "hi") // foo
sendMsg(members[1], "hi") // aab
if got, want := r.NamesPrefix("a"), []string{"aab", "aaa", "aac"}; !reflect.DeepEqual(got, want) {
t.Errorf("got: %q; want: %q", got, want)
}
sendMsg(members[2], "hi") // aac
if got, want := r.NamesPrefix("a"), []string{"aac", "aab", "aaa"}; !reflect.DeepEqual(got, want) {
t.Errorf("got: %q; want: %q", got, want)
}
if got, want := r.NamesPrefix("f"), []string{"foo"}; !reflect.DeepEqual(got, want) {
t.Errorf("got: %q; want: %q", got, want)
}
if got, want := r.NamesPrefix("bar"), []string{}; !reflect.DeepEqual(got, want) {
t.Errorf("got: %q; want: %q", got, want)
}
}

View File

@ -1,24 +0,0 @@
package chat
import "regexp"
var reStripName = regexp.MustCompile("[^\\w.-]")
const maxLength = 16
// SanitizeName returns a name with only allowed characters and a reasonable length
func SanitizeName(s string) string {
s = reStripName.ReplaceAllString(s, "")
nameLength := maxLength
if len(s) <= maxLength {
nameLength = len(s)
}
s = s[:nameLength]
return s
}
var reStripData = regexp.MustCompile("[^[:ascii:]]")
// SanitizeData returns a string with only allowed characters for client-provided metadata inputs.
func SanitizeData(s string) string {
return reStripData.ReplaceAllString(s, "")
}

View File

@ -12,32 +12,44 @@ import (
"github.com/alexcesaro/log"
"github.com/alexcesaro/log/golog"
"github.com/jessevdk/go-flags"
flags "github.com/jessevdk/go-flags"
"golang.org/x/crypto/ssh"
"github.com/shazow/ssh-chat"
sshchat "github.com/shazow/ssh-chat"
"github.com/shazow/ssh-chat/chat"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/sshd"
_ "net/http/pprof"
)
import _ "net/http/pprof"
// Version of the binary, assigned during build.
var Version string = "dev"
// Options contains the flag options
type Options struct {
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
Version bool `long:"version" description:"Print version and exit."`
Identity string `short:"i" long:"identity" description:"Private key to identify server with." default:"~/.ssh/id_rsa"`
Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"`
Admin string `long:"admin" description:"File of public keys who are admins."`
Whitelist string `long:"whitelist" description:"Optional file of public keys who are allowed to connect."`
Motd string `long:"motd" description:"Optional Message of the Day file."`
Log string `long:"log" description:"Write chat log to this file."`
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
Admin string `long:"admin" description:"File of public keys who are admins."`
Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"`
Identity []string `short:"i" long:"identity" description:"Private key to identify server with." default:"~/.ssh/id_rsa"`
Log string `long:"log" description:"Write chat log to this file."`
Motd string `long:"motd" description:"Optional Message of the Day file."`
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
Version bool `long:"version" description:"Print version and exit."`
Allowlist string `long:"allowlist" description:"Optional file of public keys who are allowed to connect."`
Whitelist string `long:"whitelist" dexcription:"Old name for allowlist option"`
Passphrase string `long:"unsafe-passphrase" description:"Require an interactive passphrase to connect. Allowlist feature is more secure."`
}
const extraHelp = `There are hidden options and easter eggs in ssh-chat. The source code is a good
place to start looking. Some useful links:
* Project Repository:
https://github.com/shazow/ssh-chat
* Project Wiki FAQ:
https://github.com/shazow/ssh-chat/wiki/FAQ
`
var logLevels = []log.Level{
log.Warning,
log.Info,
@ -57,7 +69,9 @@ func main() {
if p == nil {
fmt.Print(err)
}
os.Exit(1)
if flagErr, ok := err.(*flags.Error); ok && flagErr.Type == flags.ErrHelp {
fmt.Print(extraHelp)
}
return
}
@ -69,17 +83,18 @@ func main() {
if options.Version {
fmt.Println(Version)
os.Exit(0)
return
}
// Figure out the log level
numVerbose := len(options.Verbose)
if numVerbose > len(logLevels) {
if numVerbose >= len(logLevels) {
numVerbose = len(logLevels) - 1
}
logLevel := logLevels[numVerbose]
sshchat.SetLogger(golog.New(os.Stderr, logLevel))
logger := golog.New(os.Stderr, logLevel)
sshchat.SetLogger(logger)
if logLevel == log.Debug {
// Enable logging from submodules
@ -88,27 +103,27 @@ func main() {
message.SetLogger(os.Stderr)
}
privateKeyPath := options.Identity
if strings.HasPrefix(privateKeyPath, "~/") {
user, err := user.Current()
if err == nil {
privateKeyPath = strings.Replace(privateKeyPath, "~", user.HomeDir, 1)
}
}
privateKey, err := ReadPrivateKey(privateKeyPath)
if err != nil {
fail(2, "Couldn't read private key: %v\n", err)
}
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
fail(3, "Failed to parse key: %v\n", err)
}
auth := sshchat.NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(signer)
config.ServerVersion = "SSH-2.0-Go ssh-chat"
// FIXME: Should we be using config.NoClientAuth = true by default?
for _, privateKeyPath := range options.Identity {
if strings.HasPrefix(privateKeyPath, "~/") {
user, err := user.Current()
if err == nil {
privateKeyPath = strings.Replace(privateKeyPath, "~", user.HomeDir, 1)
}
}
signer, err := ReadPrivateKey(privateKeyPath)
if err != nil {
fail(3, "Failed to read identity private key: %v\n", err)
}
config.AddHostKey(signer)
fmt.Printf("Added server identity: %s\n", sshd.Fingerprint(signer.PublicKey()))
}
s, err := sshd.ListenSSH(options.Bind, config)
if err != nil {
@ -123,40 +138,42 @@ func main() {
host.SetTheme(message.Themes[0])
host.Version = Version
err = fromFile(options.Admin, func(line []byte) error {
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
return err
}
auth.Op(key, 0)
return nil
})
if options.Passphrase != "" {
auth.SetPassphrase(options.Passphrase)
}
err = auth.LoadOps(loaderFromFile(options.Admin, logger))
if err != nil {
fail(5, "Failed to load admins: %v\n", err)
}
err = fromFile(options.Whitelist, func(line []byte) error {
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
return err
}
auth.Whitelist(key, 0)
return nil
})
if err != nil {
fail(6, "Failed to load whitelist: %v\n", err)
if options.Allowlist == "" && options.Whitelist != "" {
fmt.Println("--whitelist was renamed to --allowlist.")
options.Allowlist = options.Whitelist
}
err = auth.LoadAllowlist(loaderFromFile(options.Allowlist, logger))
if err != nil {
fail(6, "Failed to load allowlist: %v\n", err)
}
auth.SetAllowlistMode(options.Allowlist != "")
if options.Motd != "" {
motd, err := ioutil.ReadFile(options.Motd)
if err != nil {
fail(7, "Failed to load MOTD file: %v\n", err)
host.GetMOTD = func() (string, error) {
motd, err := ioutil.ReadFile(options.Motd)
if err != nil {
return "", err
}
motdString := string(motd)
// hack to normalize line endings into \r\n
motdString = strings.Replace(motdString, "\r\n", "\n", -1)
motdString = strings.Replace(motdString, "\n", "\r\n", -1)
return motdString, nil
}
if motdString, err := host.GetMOTD(); err != nil {
fail(7, "Failed to load MOTD file: %v\n", err)
} else {
host.SetMotd(motdString)
}
motdString := string(motd)
// hack to normalize line endings into \r\n
motdString = strings.Replace(motdString, "\r\n", "\n", -1)
motdString = strings.Replace(motdString, "\n", "\r\n", -1)
host.SetMotd(motdString)
}
if options.Log == "-" {
@ -177,27 +194,34 @@ func main() {
<-sig // Wait for ^C signal
fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.")
os.Exit(0)
}
func fromFile(path string, handler func(line []byte) error) error {
func loaderFromFile(path string, logger *golog.Logger) sshchat.KeyLoader {
if path == "" {
// Skip
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
err := handler(scanner.Bytes())
return func() ([]ssh.PublicKey, error) {
file, err := os.Open(path)
if err != nil {
return err
return nil, err
}
defer file.Close()
var keys []ssh.PublicKey
scanner := bufio.NewScanner(file)
for scanner.Scan() {
key, _, _, _, err := ssh.ParseAuthorizedKey(scanner.Bytes())
if err != nil {
if err.Error() == "ssh: no key found" {
continue // Skip line
}
return nil, err
}
keys = append(keys, key)
}
if keys == nil {
logger.Warning("file", path, "contained no keys")
}
return keys, nil
}
return nil
}

View File

@ -1,50 +1,38 @@
package main
import (
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"os"
"syscall"
"github.com/howeyc/gopass"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
)
// ReadPrivateKey attempts to read your private key and possibly decrypt it if it
// requires a passphrase.
// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`),
// is not set.
func ReadPrivateKey(path string) ([]byte, error) {
func ReadPrivateKey(path string) (ssh.Signer, error) {
privateKey, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to load identity: %v", err)
}
block, rest := pem.Decode(privateKey)
if len(rest) > 0 {
return nil, fmt.Errorf("extra data when decoding private key")
}
if !x509.IsEncryptedPEMBlock(block) {
return privateKey, nil
}
passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE"))
if len(passphrase) == 0 {
fmt.Print("Enter passphrase: ")
passphrase, err = gopass.GetPasswd()
if err != nil {
return nil, fmt.Errorf("couldn't read passphrase: %v", err)
pk, err := ssh.ParsePrivateKey(privateKey)
if err == nil {
} else if _, ok := err.(*ssh.PassphraseMissingError); ok {
passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE"))
if len(passphrase) == 0 {
fmt.Println("Enter passphrase to unlock identity private key:", path)
passphrase, err = term.ReadPassword(int(syscall.Stdin))
if err != nil {
return nil, fmt.Errorf("couldn't read passphrase: %v", err)
}
}
}
der, err := x509.DecryptPEMBlock(block, passphrase)
if err != nil {
return nil, fmt.Errorf("decrypt failed: %v", err)
return ssh.ParsePrivateKeyWithPassphrase(privateKey, passphrase)
}
privateKey = pem.EncodeToMemory(&pem.Block{
Type: block.Type,
Bytes: der,
})
return privateKey, nil
return pk, err
}

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
version: '3.2'
services:
app:
container_name: ssh-chat
build: .
ports:
- 2022:2022
restart: unless-stopped
volumes:
- type: bind
source: ~/.ssh/
target: /root/.ssh/
read_only: true

14
go.mod Normal file
View File

@ -0,0 +1,14 @@
module github.com/shazow/ssh-chat
require (
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58
github.com/jessevdk/go-flags v1.5.0
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4
golang.org/x/crypto v0.17.0
golang.org/x/sync v0.1.0
golang.org/x/sys v0.15.0
golang.org/x/term v0.15.0
golang.org/x/text v0.14.0
)
go 1.13

50
go.sum Normal file
View File

@ -0,0 +1,50 @@
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58 h1:MkpmYfld/S8kXqTYI68DfL8/hHXjHogL120Dy00TIxc=
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58/go.mod h1:YNfsMyWSs+h+PaYkxGeMVmVCX75Zj/pqdjbu12ciCYE=
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4 h1:zwQ1HBo5FYwn1ksMd19qBCKO8JAWE9wmHivEpkw/DvE=
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -1,5 +1,5 @@
/*
sshchat package is an implementation of an ssh server which serves a chat room
Package sshchat is an implementation of an ssh server which serves a chat room
instead of a shell.
sshd subdirectory contains the ssh-related pieces which know nothing about chat.

492
host.go
View File

@ -1,6 +1,7 @@
package sshchat
import (
"bytes"
"errors"
"fmt"
"io"
@ -8,10 +9,13 @@ import (
"sync"
"time"
"github.com/dustin/go-humanize"
"golang.org/x/crypto/ssh"
"github.com/shazow/rateio"
"github.com/shazow/ssh-chat/chat"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/internal/humantime"
"github.com/shazow/ssh-chat/internal/sanitize"
"github.com/shazow/ssh-chat/set"
"github.com/shazow/ssh-chat/sshd"
)
@ -45,6 +49,11 @@ type Host struct {
mu sync.Mutex
motd string
count int
// GetMOTD is used to reload the motd from an external source
GetMOTD func() (string, error)
// OnUserJoined is used to notify when a user joins a host
OnUserJoined func(*message.User)
}
// NewHost creates a Host on top of an existing listener.
@ -74,6 +83,7 @@ func (h *Host) SetTheme(theme message.Theme) {
}
// SetMotd sets the host's message of the day.
// TODO: Change to SetMOTD
func (h *Host) SetMotd(motd string) {
h.mu.Lock()
h.motd = motd
@ -92,8 +102,22 @@ func (h *Host) isOp(conn sshd.Connection) bool {
func (h *Host) Connect(term *sshd.Terminal) {
id := NewIdentity(term.Conn)
user := message.NewUserScreen(id, term)
user.OnChange = func() {
term.SetPrompt(GetPrompt(user))
user.SetHighlight(user.ID())
}
cfg := user.Config()
cfg.Theme = &h.theme
apiMode := strings.ToLower(term.Term()) == "bot"
if apiMode {
cfg.Theme = message.MonoTheme
cfg.Echo = false
} else {
term.SetEnterClear(true) // We provide our own echo rendering
cfg.Theme = &h.theme
}
user.SetConfig(cfg)
go user.Consume()
@ -108,7 +132,7 @@ func (h *Host) Connect(term *sshd.Terminal) {
h.mu.Unlock()
// Send MOTD
if motd != "" {
if motd != "" && !apiMode {
user.Send(message.NewAnnounceMsg(motd))
}
@ -123,19 +147,50 @@ func (h *Host) Connect(term *sshd.Terminal) {
return
}
// Load user config overrides from ENV
// TODO: Would be nice to skip the command parsing pipeline just to load
// config values. Would need to factor out some command handler logic into
// accessible helpers.
env := term.Env()
for _, e := range env {
switch e.Key {
case "SSHCHAT_TIMESTAMP":
if e.Value != "" && e.Value != "0" {
cmd := "/timestamp"
if e.Value != "1" {
cmd += " " + e.Value
}
if msg, ok := message.NewPublicMsg(cmd, user).ParseCommand(); ok {
h.Room.HandleMsg(msg)
}
}
case "SSHCHAT_THEME":
cmd := "/theme " + e.Value
if msg, ok := message.NewPublicMsg(cmd, user).ParseCommand(); ok {
h.Room.HandleMsg(msg)
}
}
}
// Successfully joined.
term.SetPrompt(GetPrompt(user))
term.AutoCompleteCallback = h.AutoCompleteFunction(user)
user.SetHighlight(user.Name())
if !apiMode {
term.SetPrompt(GetPrompt(user))
term.AutoCompleteCallback = h.AutoCompleteFunction(user)
user.SetHighlight(user.Name())
}
// Should the user be op'd on join?
if h.isOp(term.Conn) {
h.Room.Ops.Add(set.Itemize(member.ID(), member))
member.IsOp = true
}
ratelimit := rateio.NewSimpleLimiter(3, time.Second*3)
logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name())
if h.OnUserJoined != nil {
h.OnUserJoined(user)
}
for {
line, err := term.ReadLine()
if err == io.EOF {
@ -157,23 +212,26 @@ func (h *Host) Connect(term *sshd.Terminal) {
}
if line == "" {
// Silently ignore empty lines.
term.Write([]byte{})
continue
}
m := message.ParseInput(line, user)
if !apiMode {
if m, ok := m.(*message.CommandMsg); ok {
// Other messages render themselves by the room, commands we'll
// have to re-echo ourselves manually.
user.HandleMsg(m)
}
}
// FIXME: Any reason to use h.room.Send(m) instead?
h.HandleMsg(m)
cmd := m.Command()
if cmd == "/nick" || cmd == "/theme" {
// Hijack /nick command to update terminal synchronously. Wouldn't
// work if we use h.room.Send(m) above.
//
// FIXME: This is hacky, how do we improve the API to allow for
// this? Chat module shouldn't know about terminals.
term.SetPrompt(GetPrompt(user))
user.SetHighlight(user.Name())
if apiMode {
// Skip the remaining rendering workarounds
continue
}
}
@ -191,14 +249,19 @@ func (h *Host) Serve() {
h.listener.Serve()
}
func (h *Host) completeName(partial string) string {
func (h *Host) completeName(partial string, skipName string) string {
names := h.NamesPrefix(partial)
if len(names) == 0 {
// Didn't find anything
return ""
} else if name := names[0]; name != skipName {
// First name is not the skipName, great
return name
} else if len(names) > 1 {
// Next candidate
return names[1]
}
return names[len(names)-1]
return ""
}
func (h *Host) completeCommand(partial string) string {
@ -231,13 +294,13 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int,
posPartial := pos - len(partial)
var completed string
if isFirst && strings.HasPrefix(partial, "/") {
if isFirst && strings.HasPrefix(line, "/") {
// Command
completed = h.completeCommand(partial)
if completed == "/reply" {
replyTo := u.ReplyTo()
if replyTo != nil {
name := replyTo.Name()
name := replyTo.ID()
_, found := h.GetUser(name)
if found {
completed = "/msg " + name
@ -248,7 +311,7 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int,
}
} else {
// Name
completed = h.completeName(partial)
completed = h.completeName(partial, u.Name())
if completed == "" {
return
}
@ -279,6 +342,20 @@ func (h *Host) GetUser(name string) (*message.User, bool) {
// InitCommands adds host-specific commands to a Commands container. These will
// override any existing commands.
func (h *Host) InitCommands(c *chat.Commands) {
sendPM := func(room *chat.Room, msg string, from *message.User, target *message.User) error {
m := message.NewPrivateMsg(msg, from, target)
room.Send(&m)
txt := fmt.Sprintf("[Sent PM to %s]", target.Name())
if isAway, _, awayReason := target.GetAway(); isAway {
txt += " Away: " + awayReason
}
sysMsg := message.NewSystemMsg(txt, from)
room.Send(sysMsg)
target.SetReplyTo(from)
return nil
}
c.Add(chat.Command{
Prefix: "/msg",
PrefixHelp: "USER MESSAGE",
@ -297,14 +374,7 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("user not found")
}
m := message.NewPrivateMsg(strings.Join(args[1:], " "), msg.From(), target)
room.Send(&m)
txt := fmt.Sprintf("[Sent PM to %s]", target.Name())
ms := message.NewSystemMsg(txt, msg.From())
room.Send(ms)
target.SetReplyTo(msg.From())
return nil
return sendPM(room, strings.Join(args[1:], " "), msg.From(), target)
},
})
@ -324,19 +394,12 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("no message to reply to")
}
name := target.Name()
_, found := h.GetUser(name)
_, found := h.GetUser(target.ID())
if !found {
return errors.New("user not found")
}
m := message.NewPrivateMsg(strings.Join(args, " "), msg.From(), target)
room.Send(&m)
txt := fmt.Sprintf("[Sent PM to %s]", name)
ms := message.NewSystemMsg(txt, msg.From())
room.Send(ms)
return nil
return sendPM(room, strings.Join(args, " "), msg.From(), target)
},
})
@ -354,14 +417,13 @@ func (h *Host) InitCommands(c *chat.Commands) {
if !ok {
return errors.New("user not found")
}
id := target.Identifier.(*Identity)
var whois string
switch room.IsOp(msg.From()) {
case true:
whois = id.WhoisAdmin()
whois = id.WhoisAdmin(room)
case false:
whois = id.Whois()
whois = id.Whois(room)
}
room.Send(message.NewSystemMsg(whois, msg.From()))
@ -382,7 +444,7 @@ func (h *Host) InitCommands(c *chat.Commands) {
c.Add(chat.Command{
Prefix: "/uptime",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
room.Send(message.NewSystemMsg(humanize.Time(timeStarted), msg.From()))
room.Send(message.NewSystemMsg(humantime.Since(timeStarted), msg.From()))
return nil
},
})
@ -418,8 +480,8 @@ func (h *Host) InitCommands(c *chat.Commands) {
c.Add(chat.Command{
Op: true,
Prefix: "/ban",
PrefixHelp: "USER [DURATION]",
Help: "Ban USER from the server.",
PrefixHelp: "QUERY [DURATION]",
Help: "Ban from the server. QUERY can be a username to ban the fingerprint and ip, or quoted \"key=value\" pairs with keys like ip, fingerprint, client.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
// TODO: Would be nice to specify what to ban. Key? Ip? etc.
if !room.IsOp(msg.From()) {
@ -431,12 +493,17 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("must specify user")
}
target, ok := h.GetUser(args[0])
query := args[0]
target, ok := h.GetUser(query)
if !ok {
query = strings.Join(args, " ")
if strings.Contains(query, "=") {
return h.auth.BanQuery(query)
}
return errors.New("user not found")
}
var until time.Duration = 0
var until time.Duration
if len(args) > 1 {
until, _ = time.ParseDuration(args[1])
}
@ -449,7 +516,36 @@ func (h *Host) InitCommands(c *chat.Commands) {
room.Send(message.NewAnnounceMsg(body))
target.Close()
logger.Debugf("Banned: \n-> %s", id.Whois())
logger.Debugf("Banned: \n-> %s", id.Whois(room))
return nil
},
})
c.Add(chat.Command{
Op: true,
Prefix: "/banned",
Help: "List the current ban conditions.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
bannedIPs, bannedFingerprints, bannedClients := h.auth.Banned()
buf := bytes.Buffer{}
fmt.Fprintf(&buf, "Banned:")
for _, key := range bannedIPs {
fmt.Fprintf(&buf, "\n \"ip=%s\"", key)
}
for _, key := range bannedFingerprints {
fmt.Fprintf(&buf, "\n \"fingerprint=%s\"", key)
}
for _, key := range bannedClients {
fmt.Fprintf(&buf, "\n \"client=%s\"", key)
}
room.Send(message.NewSystemMsg(buf.String(), msg.From()))
return nil
},
@ -459,7 +555,7 @@ func (h *Host) InitCommands(c *chat.Commands) {
Op: true,
Prefix: "/motd",
PrefixHelp: "[MESSAGE]",
Help: "Set a new MESSAGE of the day, print the current motd without parameters.",
Help: "Set a new MESSAGE of the day, or print the motd if no MESSAGE.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
args := msg.Args()
user := msg.From()
@ -476,10 +572,21 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("must be OP to modify the MOTD")
}
motd = strings.Join(args, " ")
h.SetMotd(motd)
var err error
var s string = strings.Join(args, " ")
if s == "@" {
if h.GetMOTD == nil {
return errors.New("motd reload not set")
}
if s, err = h.GetMOTD(); err != nil {
return err
}
}
h.SetMotd(s)
fromMsg := fmt.Sprintf("New message of the day set by %s:", msg.From().Name())
room.Send(message.NewAnnounceMsg(fromMsg + message.Newline + "-> " + motd))
room.Send(message.NewAnnounceMsg(fromMsg + message.Newline + "-> " + s))
return nil
},
@ -488,8 +595,8 @@ func (h *Host) InitCommands(c *chat.Commands) {
c.Add(chat.Command{
Op: true,
Prefix: "/op",
PrefixHelp: "USER [DURATION]",
Help: "Set USER as admin.",
PrefixHelp: "USER [DURATION|remove]",
Help: "Set USER as admin. Duration only applies to pubkey reconnects.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
@ -500,24 +607,293 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("must specify user")
}
var until time.Duration = 0
opValue := true
var until time.Duration
if len(args) > 1 {
until, _ = time.ParseDuration(args[1])
if args[1] == "remove" {
// Expire instantly
until = time.Duration(1)
opValue = false
} else {
until, _ = time.ParseDuration(args[1])
}
}
member, ok := room.MemberByID(args[0])
if !ok {
return errors.New("user not found")
}
room.Ops.Add(set.Itemize(member.ID(), member))
member.IsOp = opValue
id := member.Identifier.(*Identity)
h.auth.Op(id.PublicKey(), until)
body := fmt.Sprintf("Made op by %s.", msg.From().Name())
var body string
if opValue {
body = fmt.Sprintf("Made op by %s.", msg.From().Name())
} else {
body = fmt.Sprintf("Removed op by %s.", msg.From().Name())
}
room.Send(message.NewSystemMsg(body, member.User))
return nil
},
})
c.Add(chat.Command{
Op: true,
Prefix: "/rename",
PrefixHelp: "USER NEW_NAME [SYMBOL]",
Help: "Rename USER to NEW_NAME, add optional SYMBOL prefix",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) < 2 {
return errors.New("must specify user and new name")
}
member, ok := room.MemberByID(args[0])
if !ok {
return errors.New("user not found")
}
symbolSet := false
if len(args) == 3 {
s := args[2]
if id, ok := member.Identifier.(*Identity); ok {
id.SetSymbol(s)
} else {
return errors.New("user does not support setting symbol")
}
body := fmt.Sprintf("Assigned symbol %q by %s.", s, msg.From().Name())
room.Send(message.NewSystemMsg(body, member.User))
symbolSet = true
}
oldID := member.ID()
newID := sanitize.Name(args[1])
if newID == oldID && !symbolSet {
return errors.New("new name is the same as the original")
} else if (newID == "" || newID == oldID) && symbolSet {
if member.User.OnChange != nil {
member.User.OnChange()
}
return nil
}
member.SetID(newID)
err := room.Rename(oldID, member)
if err != nil {
member.SetID(oldID)
return err
}
body := fmt.Sprintf("%s was renamed by %s.", oldID, msg.From().Name())
room.Send(message.NewAnnounceMsg(body))
return nil
},
})
forConnectedUsers := func(cmd func(*chat.Member, ssh.PublicKey) error) error {
return h.Members.Each(func(key string, item set.Item) error {
v := item.Value()
if v == nil { // expired between Each and here
return nil
}
user := v.(*chat.Member)
pk := user.Identifier.(*Identity).PublicKey()
return cmd(user, pk)
})
}
forPubkeyUser := func(args []string, cmd func(ssh.PublicKey)) (errors []string) {
invalidUsers := []string{}
invalidKeys := []string{}
noKeyUsers := []string{}
var keyType string
for _, v := range args {
switch {
case keyType != "":
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyType + " " + v))
if err == nil {
cmd(pk)
} else {
invalidKeys = append(invalidKeys, keyType+" "+v)
}
keyType = ""
case strings.HasPrefix(v, "ssh-"):
keyType = v
default:
user, ok := h.GetUser(v)
if ok {
pk := user.Identifier.(*Identity).PublicKey()
if pk == nil {
noKeyUsers = append(noKeyUsers, user.Identifier.Name())
} else {
cmd(pk)
}
} else {
invalidUsers = append(invalidUsers, v)
}
}
}
if len(noKeyUsers) != 0 {
errors = append(errors, fmt.Sprintf("users without a public key: %v", noKeyUsers))
}
if len(invalidUsers) != 0 {
errors = append(errors, fmt.Sprintf("invalid users: %v", invalidUsers))
}
if len(invalidKeys) != 0 {
errors = append(errors, fmt.Sprintf("invalid keys: %v", invalidKeys))
}
return
}
allowlistHelptext := []string{
"Usage: /allowlist help | on | off | add {PUBKEY|USER}... | remove {PUBKEY|USER}... | import [AGE] | reload {keep|flush} | reverify | status",
"help: this help message",
"on, off: set allowlist mode (applies to new connections)",
"add, remove: add or remove keys from the allowlist",
"import: add all keys of users connected since AGE (default 0) ago to the allowlist",
"reload: re-read the allowlist file and keep or discard entries in the current allowlist but not in the file",
"reverify: kick all users not in the allowlist if allowlisting is enabled",
"status: show status information",
}
allowlistImport := func(args []string) (msgs []string, err error) {
var since time.Duration
if len(args) > 0 {
since, err = time.ParseDuration(args[0])
if err != nil {
return
}
}
cutoff := time.Now().Add(-since)
noKeyUsers := []string{}
forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if user.Joined().Before(cutoff) {
if pk == nil {
noKeyUsers = append(noKeyUsers, user.Identifier.Name())
} else {
h.auth.Allowlist(pk, 0)
}
}
return nil
})
if len(noKeyUsers) != 0 {
msgs = []string{fmt.Sprintf("users without a public key: %v", noKeyUsers)}
}
return
}
allowlistReload := func(args []string) error {
if !(len(args) > 0 && (args[0] == "keep" || args[0] == "flush")) {
return errors.New("must specify whether to keep or flush current entries")
}
if args[0] == "flush" {
h.auth.allowlist.Clear()
}
return h.auth.ReloadAllowlist()
}
allowlistReverify := func(room *chat.Room) []string {
if !h.auth.AllowlistMode() {
return []string{"allowlist is disabled, so nobody will be kicked"}
}
var kicked []string
forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if h.auth.CheckPublicKey(pk) != nil && !user.IsOp { // we do this check here as well for ops without keys
kicked = append(kicked, user.Name())
user.Close()
}
return nil
})
if kicked != nil {
room.Send(message.NewAnnounceMsg("Kicked during pubkey reverification: " + strings.Join(kicked, ", ")))
}
return nil
}
allowlistStatus := func() (msgs []string) {
if h.auth.AllowlistMode() {
msgs = []string{"allowlist enabled"}
} else {
msgs = []string{"allowlist disabled"}
}
allowlistedUsers := []string{}
allowlistedKeys := []string{}
h.auth.allowlist.Each(func(key string, item set.Item) error {
keyFP := item.Key()
if forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if pk != nil && sshd.Fingerprint(pk) == keyFP {
allowlistedUsers = append(allowlistedUsers, user.Name())
return io.EOF
}
return nil
}) == nil {
// if we land here, the key matches no users
allowlistedKeys = append(allowlistedKeys, keyFP)
}
return nil
})
if len(allowlistedUsers) != 0 {
msgs = append(msgs, "Connected users on the allowlist: "+strings.Join(allowlistedUsers, ", "))
}
if len(allowlistedKeys) != 0 {
msgs = append(msgs, "Keys on the allowlist without connected user: "+strings.Join(allowlistedKeys, ", "))
}
return
}
c.Add(chat.Command{
Op: true,
Prefix: "/allowlist",
PrefixHelp: "COMMAND [ARGS...]",
Help: "Modify the allowlist or allowlist state. See /allowlist help for subcommands",
Handler: func(room *chat.Room, msg message.CommandMsg) (err error) {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) == 0 {
args = []string{"help"}
}
// send exactly one message to preserve order
var replyLines []string
switch args[0] {
case "help":
replyLines = allowlistHelptext
case "on":
h.auth.SetAllowlistMode(true)
case "off":
h.auth.SetAllowlistMode(false)
case "add":
replyLines = forPubkeyUser(args[1:], func(pk ssh.PublicKey) { h.auth.Allowlist(pk, 0) })
case "remove":
replyLines = forPubkeyUser(args[1:], func(pk ssh.PublicKey) { h.auth.Allowlist(pk, 1) })
case "import":
replyLines, err = allowlistImport(args[1:])
case "reload":
err = allowlistReload(args[1:])
case "reverify":
replyLines = allowlistReverify(room)
case "status":
replyLines = allowlistStatus()
default:
err = errors.New("invalid subcommand: " + args[0])
}
if err == nil && replyLines != nil {
room.Send(message.NewSystemMsg(strings.Join(replyLines, "\r\n"), msg.From()))
}
return
},
})
}

View File

@ -2,37 +2,81 @@ package sshchat
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"io"
"io/ioutil"
mathRand "math/rand"
"strings"
"testing"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/set"
"github.com/shazow/ssh-chat/sshd"
"golang.org/x/crypto/ssh"
"golang.org/x/sync/errgroup"
)
func stripPrompt(s string) string {
pos := strings.LastIndex(s, "\033[K")
if pos < 0 {
return s
// FIXME: Is there a better way to do this?
if endPos := strings.Index(s, "\x1b[K "); endPos > 0 {
return s[endPos+3:]
}
if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 {
return s[endPos+4:]
}
if endPos := strings.Index(s, "\x1b[K-> "); endPos > 0 {
return s[endPos+6:]
}
if endPos := strings.Index(s, "] "); endPos > 0 {
return s[endPos+2:]
}
if strings.HasPrefix(s, "-> ") {
return s[3:]
}
return s
}
func TestStripPrompt(t *testing.T) {
tests := []struct {
Input string
Want string
}{
{
Input: "\x1b[A\x1b[2K[quux] hello",
Want: "hello",
},
{
Input: "[foo] \x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[K * Guest1 joined. (Connected: 2)\r",
Want: " * Guest1 joined. (Connected: 2)\r",
},
{
Input: "[foo] \x1b[6D\x1b[K-> From your friendly system.\r",
Want: "From your friendly system.\r",
},
{
Input: "-> Err: must be op.\r",
Want: "Err: must be op.\r",
},
}
for i, tc := range tests {
if got, want := stripPrompt(tc.Input), tc.Want; got != want {
t.Errorf("case #%d:\n got: %q\nwant: %q", i, got, want)
}
}
return s[pos+3:]
}
func TestHostGetPrompt(t *testing.T) {
var expected, actual string
// Make the random colors consistent across tests
mathRand.Seed(1)
u := message.NewUser(&Identity{id: "foo"})
actual = GetPrompt(u)
expected = "[foo] "
if actual != expected {
t.Errorf("Got: %q; Expected: %q", actual, expected)
t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected)
}
u.SetConfig(message.UserConfig{
@ -41,187 +85,394 @@ func TestHostGetPrompt(t *testing.T) {
actual = GetPrompt(u)
expected = "[\033[38;05;88mfoo\033[0m] "
if actual != expected {
t.Errorf("Got: %q; Expected: %q", actual, expected)
t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected)
}
}
func getHost(t *testing.T, auth *Auth) (*sshd.SSHListener, *Host) {
key, err := sshd.NewRandomSigner(1024)
if err != nil {
t.Fatal(err)
}
var config *ssh.ServerConfig
if auth == nil {
config = sshd.MakeNoAuth()
} else {
config = sshd.MakeAuth(auth)
}
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
t.Fatal(err)
}
return s, NewHost(s, auth)
}
func TestHostNameCollision(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
config := sshd.MakeNoAuth()
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
t.Fatal(err)
}
s, host := getHost(t, nil)
defer s.Close()
host := NewHost(s, nil)
newUsers := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
newUsers <- u
}
go host.Serve()
done := make(chan struct{}, 1)
g := errgroup.Group{}
// First client
go func() {
err := sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
scanner.Scan()
actual := stripPrompt(scanner.Text())
expected := " * foo joined. (Connected: 1)"
if actual != expected {
t.Errorf("Got %q; expected %q", actual, expected)
g.Go(func() error {
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
// second client
name := (<-newUsers).Name()
if name != "Guest1" {
t.Errorf("Second client did not get Guest1 name: %q", name)
}
// Ready for second client
done <- struct{}{}
scanner.Scan()
actual = scanner.Text()
// This check has to happen second because prompt doesn't always
// get set before the first message.
if !strings.HasPrefix(actual, "[foo] ") {
t.Errorf("First client failed to get 'foo' name: %q", actual)
}
actual = stripPrompt(actual)
expected = " * Guest1 joined. (Connected: 2)"
if actual != expected {
t.Errorf("Got %q; expected %q", actual, expected)
}
// Wrap it up.
close(done)
return nil
})
if err != nil {
t.Fatal(err)
}
}()
// Wait for first client
<-done
})
// Second client
err = sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
scanner.Scan()
scanner.Scan()
scanner.Scan()
actual := scanner.Text()
if !strings.HasPrefix(actual, "[Guest1] ") {
t.Errorf("Second client did not get Guest1 name: %q", actual)
g.Go(func() error {
// first client
name := (<-newUsers).Name()
if name != "foo" {
t.Errorf("First client did not get foo name: %q", name)
}
return nil
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
return nil
})
})
if err != nil {
t.Fatal(err)
}
<-done
if err := g.Wait(); err != nil {
t.Error(err)
}
}
func TestHostWhitelist(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
func TestHostAllowlist(t *testing.T) {
auth := NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
t.Fatal(err)
}
s, host := getHost(t, auth)
defer s.Close()
host := NewHost(s, auth)
go host.Serve()
target := s.Addr().String()
clientPrivateKey, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
clientKey := clientPrivateKey.PublicKey()
loadCount := -1
loader := func() ([]ssh.PublicKey, error) {
loadCount++
return [][]ssh.PublicKey{
{},
{clientKey},
}[loadCount], nil
}
auth.LoadAllowlist(loader)
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
if err != nil {
t.Error(err)
}
clientkey, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatal(err)
}
clientpubkey, _ := ssh.NewPublicKey(clientkey.Public())
auth.Whitelist(clientpubkey, 0)
auth.SetAllowlistMode(true)
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
if err == nil {
t.Error("Failed to block unwhitelisted connection.")
t.Error(err)
}
err = sshd.ConnectShellWithKey(target, "foo", clientPrivateKey, func(r io.Reader, w io.WriteCloser) error { return nil })
if err == nil {
t.Error(err)
}
auth.ReloadAllowlist()
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
if err == nil {
t.Error("Failed to block unallowlisted connection.")
}
}
func TestHostKick(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
auth := NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
t.Fatal(err)
}
func TestHostAllowlistCommand(t *testing.T) {
s, host := getHost(t, NewAuth())
defer s.Close()
addr := s.Addr().String()
host := NewHost(s, nil)
go host.Serve()
connected := make(chan struct{})
done := make(chan struct{})
users := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
users <- u
}
go func() {
kickSignal := make(chan struct{})
clientKey, err := sshd.NewRandomSigner(1024)
if err != nil {
t.Fatal(err)
}
clientKeyFP := sshd.Fingerprint(clientKey.PublicKey())
go sshd.ConnectShellWithKey(s.Addr().String(), "bar", clientKey, func(r io.Reader, w io.WriteCloser) error {
<-kickSignal
n, err := w.Write([]byte("alive and well"))
if n != 0 || err == nil {
t.Error("could write after being kicked")
}
return nil
})
sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
<-users
<-users
m, ok := host.MemberByID("foo")
if !ok {
t.Fatal("can't get member foo")
}
scanner := bufio.NewScanner(r)
scanner.Scan() // Joined
scanner.Scan()
assertLineEq := func(expected ...string) {
if !scanner.Scan() {
t.Error("no line available")
}
actual := stripPrompt(scanner.Text())
for _, exp := range expected {
if exp == actual {
return
}
}
t.Errorf("expected %#v, got %q", expected, actual)
}
sendCmd := func(cmd string, formatting ...interface{}) {
host.HandleMsg(message.ParseInput(fmt.Sprintf(cmd, formatting...), m.User))
}
sendCmd("/allowlist")
assertLineEq("Err: must be op\r")
m.IsOp = true
sendCmd("/allowlist")
for _, expected := range [...]string{"Usage", "help", "on, off", "add, remove", "import", "reload", "reverify", "status"} {
if !scanner.Scan() {
t.Error("no line available")
}
if actual := stripPrompt(scanner.Text()); !strings.HasPrefix(actual, expected) {
t.Errorf("Unexpected help message order: have %q, want prefix %q", actual, expected)
}
}
sendCmd("/allowlist on")
if !host.auth.AllowlistMode() {
t.Error("allowlist not enabled after /allowlist on")
}
sendCmd("/allowlist off")
if host.auth.AllowlistMode() {
t.Error("allowlist not disabled after /allowlist off")
}
testKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPUiNw0nQku4pcUCbZcJlIEAIf5bXJYTy/DKI1vh5b+P"
testKeyFP := "SHA256:GJNSl9NUcOS2pZYALn0C5Qgfh5deT+R+FfqNIUvpM9s="
if host.auth.allowlist.Len() != 0 {
t.Error("allowlist not empty before adding anyone")
}
sendCmd("/allowlist add ssh-invalid blah ssh-rsa wrongAsWell invalid foo bar %s", testKey)
assertLineEq("users without a public key: [foo]\r")
assertLineEq("invalid users: [invalid]\r")
assertLineEq("invalid keys: [ssh-invalid blah ssh-rsa wrongAsWell]\r")
if !host.auth.allowlist.In(testKeyFP) || !host.auth.allowlist.In(clientKeyFP) {
t.Error("failed to add keys to allowlist")
}
sendCmd("/allowlist remove invalid bar")
assertLineEq("invalid users: [invalid]\r")
if host.auth.allowlist.In(clientKeyFP) {
t.Error("failed to remove key from allowlist")
}
if !host.auth.allowlist.In(testKeyFP) {
t.Error("removed wrong key")
}
sendCmd("/allowlist import 5h")
if host.auth.allowlist.In(clientKeyFP) {
t.Error("imporrted key not seen long enough")
}
sendCmd("/allowlist import")
assertLineEq("users without a public key: [foo]\r")
if !host.auth.allowlist.In(clientKeyFP) {
t.Error("failed to import key")
}
sendCmd("/allowlist reload keep")
if !host.auth.allowlist.In(testKeyFP) {
t.Error("cleared allowlist to be kept")
}
sendCmd("/allowlist reload flush")
if host.auth.allowlist.In(testKeyFP) {
t.Error("kept allowlist to be cleared")
}
sendCmd("/allowlist reload thisIsWrong")
assertLineEq("Err: must specify whether to keep or flush current entries\r")
sendCmd("/allowlist reload")
assertLineEq("Err: must specify whether to keep or flush current entries\r")
sendCmd("/allowlist reverify")
assertLineEq("allowlist is disabled, so nobody will be kicked\r")
sendCmd("/allowlist on")
sendCmd("/allowlist reverify")
assertLineEq(" * Kicked during pubkey reverification: bar\r", " * bar left. (After 0 seconds)\r")
assertLineEq(" * Kicked during pubkey reverification: bar\r", " * bar left. (After 0 seconds)\r")
kickSignal <- struct{}{}
sendCmd("/allowlist add " + testKey)
sendCmd("/allowlist status")
assertLineEq("allowlist enabled\r")
assertLineEq(fmt.Sprintf("Keys on the allowlist without connected user: %s\r", testKeyFP))
sendCmd("/allowlist invalidSubcommand")
assertLineEq("Err: invalid subcommand: invalidSubcommand\r")
return nil
})
}
func TestHostKick(t *testing.T) {
s, host := getHost(t, NewAuth())
defer s.Close()
go host.Serve()
g := errgroup.Group{}
connected := make(chan struct{})
kicked := make(chan struct{})
g.Go(func() error {
// First client
err := sshd.ConnectShell(addr, "foo", func(r io.Reader, w io.WriteCloser) error {
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
scanner.Scan() // Joined
// Make op
member, _ := host.Room.MemberByID("foo")
if member == nil {
return errors.New("failed to load MemberByID")
}
host.Room.Ops.Add(set.Itemize(member.ID(), member))
member.IsOp = true
// Change nicks, make sure op sticks
w.Write([]byte("/nick quux\r\n"))
scanner.Scan() // Prompt
scanner.Scan() // Nick change response
// Block until second client is here
connected <- struct{}{}
scanner.Scan() // Connected message
w.Write([]byte("/kick bar\r\n"))
scanner.Scan() // Prompt
scanner.Scan() // Kick result
if actual, expected := stripPrompt(scanner.Text()), " * bar was kicked by quux.\r"; actual != expected {
t.Errorf("Failed to detect kick:\n Got: %q;\nWant: %q", actual, expected)
}
kicked <- struct{}{}
return nil
})
if err != nil {
close(connected)
t.Fatal(err)
}
}()
})
go func() {
g.Go(func() error {
// Second client
err := sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error {
return sshd.ConnectShell(s.Addr().String(), "bar", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
<-connected
scanner.Scan()
// Consume while we're connected. Should break when kicked.
ioutil.ReadAll(r)
return nil
<-kicked
if _, err := w.Write([]byte("am I still here?\r\n")); err != io.EOF {
return errors.New("expected to be kicked")
}
scanner.Scan()
if err := scanner.Err(); err == io.EOF {
// All good, we got kicked.
return nil
} else {
return err
}
})
if err != nil {
t.Fatal(err)
}
close(done)
}()
})
<-done
if err := g.Wait(); err != nil {
t.Error(err)
}
}
func TestTimestampEnvConfig(t *testing.T) {
cases := []struct {
input string
timeformat *string
}{
{"", strptr("15:04")},
{"1", strptr("15:04")},
{"0", nil},
{"time +8h", strptr("15:04")},
{"datetime +8h", strptr("2006-01-02 15:04:05")},
}
for _, tc := range cases {
u := connectUserWithConfig(t, "dingus", map[string]string{
"SSHCHAT_TIMESTAMP": tc.input,
})
userConfig := u.Config()
if userConfig.Timeformat != nil && tc.timeformat != nil {
if *userConfig.Timeformat != *tc.timeformat {
t.Fatal("unexpected timeformat:", *userConfig.Timeformat, "expected:", *tc.timeformat)
}
}
}
}
func strptr(s string) *string {
return &s
}
func connectUserWithConfig(t *testing.T, name string, envConfig map[string]string) *message.User {
s, host := getHost(t, nil)
defer s.Close()
newUsers := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
newUsers <- u
}
go host.Serve()
clientConfig := sshd.NewClientConfig(name)
conn, err := ssh.Dial("tcp", s.Addr().String(), clientConfig)
if err != nil {
t.Fatal("unable to connect to test ssh-chat server:", err)
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
t.Fatal("unable to open session:", err)
}
defer session.Close()
for key := range envConfig {
session.Setenv(key, envConfig[key])
}
err = session.Shell()
if err != nil {
t.Fatal("unable to open shell:", err)
}
for u := range newUsers {
if u.Name() == name {
return u
}
}
t.Fatalf("user %s not found in the host", name)
return nil
}

View File

@ -1,12 +1,15 @@
package sshchat
import (
"fmt"
"net"
"strings"
"time"
"github.com/dustin/go-humanize"
"github.com/shazow/ssh-chat/chat"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/internal/humantime"
"github.com/shazow/ssh-chat/internal/sanitize"
"github.com/shazow/ssh-chat/sshd"
)
@ -14,6 +17,7 @@ import (
type Identity struct {
sshd.Connection
id string
symbol string // symbol is displayed as a prefix to the name
created time.Time
}
@ -21,49 +25,89 @@ type Identity struct {
func NewIdentity(conn sshd.Connection) *Identity {
return &Identity{
Connection: conn,
id: chat.SanitizeName(conn.Name()),
id: sanitize.Name(conn.Name()),
created: time.Now(),
}
}
// ID returns the name for the Identity
func (i Identity) ID() string {
return i.id
}
// SetID Changes the Identity's name
func (i *Identity) SetID(id string) {
i.id = id
}
// SetName Changes the Identity's name
func (i *Identity) SetName(name string) {
i.SetID(name)
}
func (i *Identity) SetSymbol(symbol string) {
i.symbol = symbol
}
// Name returns the name for the Identity
func (i Identity) Name() string {
if i.symbol != "" {
return i.symbol + " " + i.id
}
return i.id
}
// Whois returns a whois description for non-admin users.
func (i Identity) Whois() string {
func (i Identity) Whois(room *chat.Room) string {
fingerprint := "(no public key)"
if i.PublicKey() != nil {
fingerprint = sshd.Fingerprint(i.PublicKey())
}
// TODO: Rewrite this using strings.Builder like WhoisAdmin
awayMsg := ""
if m, ok := room.MemberByID(i.ID()); ok {
isAway, awaySince, awayMessage := m.GetAway()
if isAway {
awayMsg = fmt.Sprintf("%s > away: (%s ago) %s", message.Newline, humantime.Since(awaySince), awayMessage)
}
}
return "name: " + i.Name() + message.Newline +
" > fingerprint: " + fingerprint + message.Newline +
" > client: " + chat.SanitizeData(string(i.ClientVersion())) + message.Newline +
" > joined: " + humanize.Time(i.created)
" > client: " + sanitize.Data(string(i.ClientVersion()), 64) + message.Newline +
" > joined: " + humantime.Since(i.created) + " ago" +
awayMsg
}
// WhoisAdmin returns a whois description for admin users.
func (i Identity) WhoisAdmin() string {
func (i Identity) WhoisAdmin(room *chat.Room) string {
ip, _, _ := net.SplitHostPort(i.RemoteAddr().String())
fingerprint := "(no public key)"
if i.PublicKey() != nil {
fingerprint = sshd.Fingerprint(i.PublicKey())
}
return "name: " + i.Name() + message.Newline +
out := strings.Builder{}
out.WriteString("name: " + i.Name() + message.Newline +
" > ip: " + ip + message.Newline +
" > fingerprint: " + fingerprint + message.Newline +
" > client: " + chat.SanitizeData(string(i.ClientVersion())) + message.Newline +
" > joined: " + humanize.Time(i.created)
" > client: " + sanitize.Data(string(i.ClientVersion()), 64) + message.Newline +
" > joined: " + humantime.Since(i.created) + " ago")
if member, ok := room.MemberByID(i.ID()); ok {
// Add room-specific whois
if isAway, awaySince, awayMessage := member.GetAway(); isAway {
fmt.Fprintf(&out, message.Newline+" > away: (%s ago) %s", humantime.Since(awaySince), awayMessage)
}
// FIXME: Should these always be present, even if they're false? Maybe
// change that once we add room context to Whois() above.
if !member.LastMsg().IsZero() {
out.WriteString(message.Newline + " > room/messaged: " + humantime.Since(member.LastMsg()) + " ago")
}
if room.IsOp(member.User) {
out.WriteString(message.Newline + " > room/op: true")
}
}
return out.String()
}

View File

@ -0,0 +1,21 @@
package humantime
import (
"fmt"
"time"
)
// since returns a human-friendly relative time string
func Since(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute*2:
return fmt.Sprintf("%0.f seconds", d.Seconds())
case d < time.Hour*2:
return fmt.Sprintf("%0.f minutes", d.Minutes())
case d < time.Hour*48:
return fmt.Sprintf("%0.1f hours", d.Minutes()/60)
}
days := d.Minutes() / (24 * 60)
return fmt.Sprintf("%0.1f days", days)
}

View File

@ -0,0 +1,41 @@
package humantime
import (
"testing"
"time"
)
func TestHumanSince(t *testing.T) {
tests := []struct {
input time.Duration
expected string
}{
{
time.Second * 42,
"42 seconds",
},
{
time.Second * 60 * 5,
"5 minutes",
},
{
time.Minute * 185,
"3.1 hours",
},
{
time.Hour * 49,
"2.0 days",
},
{
time.Hour * (24*900 + 12),
"900.5 days",
},
}
for _, test := range tests {
absolute := time.Now().Add(test.input * -1)
if actual, expected := Since(absolute), test.expected; actual != expected {
t.Errorf("Got: %q; Expected: %q", actual, expected)
}
}
}

View File

@ -0,0 +1,29 @@
package sanitize
import "regexp"
var (
reStripName = regexp.MustCompile("[^\\w.-]")
reStripData = regexp.MustCompile("[^[:ascii:]]|[[:cntrl:]]")
)
const maxLength = 16
// Name returns a name with only allowed characters and a reasonable length
func Name(s string) string {
s = reStripName.ReplaceAllString(s, "")
nameLength := maxLength
if len(s) <= maxLength {
nameLength = len(s)
}
s = s[:nameLength]
return s
}
// Data returns a string with only allowed characters for client-provided metadata inputs.
func Data(s string, maxlen int) string {
if len(s) > maxlen {
s = s[:maxlen]
}
return reStripData.ReplaceAllString(s, "")
}

View File

@ -1,7 +1,7 @@
package sshchat
import (
"bytes"
"io/ioutil"
"github.com/alexcesaro/log"
"github.com/alexcesaro/log/golog"
@ -9,12 +9,12 @@ import (
var logger *golog.Logger
// SetLogger sets the package logging to use l.
func SetLogger(l *golog.Logger) {
logger = l
}
func init() {
// Set a default null logger
var b bytes.Buffer
SetLogger(golog.New(&b, log.Debug))
SetLogger(golog.New(ioutil.Discard, log.Debug))
}

View File

@ -1 +1,4 @@
Welcome to chat.shazow.net, enter /help for more. 
Welcome to ssh-chat, enter /help for more.
🐛 Please enjoy our selection of bugs, but run your own server if you want to crash it: https://ssh.chat/issues
🍮 Sponsors get an emoji prefix: https://ssh.chat/sponsor
😌 Be nice and follow our Code of Conduct: https://ssh.chat/conduct

View File

@ -12,8 +12,24 @@ var ErrCollision = errors.New("key already exists")
// Returned when a requested item does not exist in the set.
var ErrMissing = errors.New("item does not exist")
// Returned when a nil item is added. Nil values are considered expired and invalid.
var ErrNil = errors.New("item value must not be nil")
// ZeroValue can be used when we only care about the key, not about the value.
var ZeroValue = struct{}{}
// Interface is the Set interface
type Interface interface {
Clear() int
Each(fn IterFunc) error
// Add only if the item does not already exist
Add(item Item) error
// Set item, override if it already exists
Set(item Item) error
Get(key string) (Item, error)
In(key string) bool
Len() int
ListPrefix(prefix string) []Item
Remove(key string) error
Replace(oldKey string, item Item) error
}
type IterFunc func(key string, item Item) error
@ -55,6 +71,7 @@ func (s *Set) In(key string) bool {
s.RUnlock()
if ok && item.Value() == nil {
s.cleanup(key)
ok = false
}
return ok
}
@ -80,7 +97,7 @@ func (s *Set) Get(key string) (Item, error) {
func (s *Set) cleanup(key string) {
s.Lock()
item, ok := s.lookup[key]
if ok && item == nil {
if ok && item.Value() == nil {
delete(s.lookup, key)
}
s.Unlock()
@ -88,9 +105,6 @@ func (s *Set) cleanup(key string) {
// Add item to this set if it does not exist already.
func (s *Set) Add(item Item) error {
if item.Value() == nil {
return ErrNil
}
key := s.normalize(item.Key())
s.Lock()
@ -105,6 +119,16 @@ func (s *Set) Add(item Item) error {
return nil
}
// Set item to this set, even if it already exists.
func (s *Set) Set(item Item) error {
key := s.normalize(item.Key())
s.Lock()
defer s.Unlock()
s.lookup[key] = item
return nil
}
// Remove item from this set.
func (s *Set) Remove(key string) error {
key = s.normalize(key)
@ -123,9 +147,6 @@ func (s *Set) Remove(key string) error {
// Replace oldKey with a new item, which might be a new key.
// Can be used to rename items.
func (s *Set) Replace(oldKey string, item Item) error {
if item.Value() == nil {
return ErrNil
}
newKey := s.normalize(item.Key())
oldKey = s.normalize(oldKey)
@ -156,19 +177,21 @@ func (s *Set) Replace(oldKey string, item Item) error {
// Each loops over every item while holding a read lock and applies fn to each
// element.
func (s *Set) Each(fn IterFunc) error {
var err error
s.RLock()
defer s.RUnlock()
for key, item := range s.lookup {
if item.Value() == nil {
// Expired
defer s.cleanup(key)
continue
}
if err := fn(key, item); err != nil {
if err = fn(key, item); err != nil {
// Abort early
return err
break
}
}
return nil
s.RUnlock()
return err
}
// ListPrefix returns a list of items with a prefix, normalized.

View File

@ -21,14 +21,23 @@ func TestSetExpiring(t *testing.T) {
t.Error("not len 1 after set")
}
item := &ExpiringItem{nil, time.Now().Add(-time.Nanosecond * 1)}
item := Expire(StringItem("asdf"), -time.Nanosecond).(*ExpiringItem)
if !item.Expired() {
t.Errorf("ExpiringItem a nanosec ago is not expiring")
}
if err := s.Add(item); err != nil {
t.Error("Error adding expired item to set: ", err)
}
if s.In("asdf") {
t.Error("Expired item in set")
}
if s.Len() != 1 {
t.Error("not len 1 after expired item")
}
item = &ExpiringItem{nil, time.Now().Add(time.Minute * 5)}
if item.Expired() {
t.Errorf("ExpiringItem in 2 minutes is expiring now")
t.Errorf("ExpiringItem in 5 minutes is expiring now")
}
item = Expire(StringItem("bar"), time.Minute*5).(*ExpiringItem)
@ -42,11 +51,13 @@ func TestSetExpiring(t *testing.T) {
if err := s.Add(item); err != nil {
t.Fatalf("failed to add item: %s", err)
}
_, ok := s.lookup["bar"]
itemInLookup, ok := s.lookup["bar"]
if !ok {
t.Fatalf("expired bar added to lookup")
t.Fatalf("bar not present in lookup even though it's not expired")
}
if itemInLookup != item {
t.Fatalf("present item %#v != %#v original item", itemInLookup, item)
}
s.lookup["bar"] = item
if !s.In("bar") {
t.Errorf("not matched after timed set")
@ -74,7 +85,7 @@ func TestSetExpiring(t *testing.T) {
t.Errorf("failed to get barbar: %s", err)
}
b := s.ListPrefix("b")
if len(b) != 2 || b[0].Key() != "bar" || b[1].Key() != "barbar" {
if len(b) != 2 || !anyItemPresentWithKey(b, "bar") || !anyItemPresentWithKey(b, "barbar") {
t.Errorf("b-prefix incorrect: %q", b)
}
@ -89,3 +100,13 @@ func TestSetExpiring(t *testing.T) {
t.Error("not len 0 after clear")
}
}
func anyItemPresentWithKey(items []Item, key string) bool {
for _, item := range items {
if item.Key() == key {
return true
}
}
return false
}

View File

@ -5,26 +5,41 @@ import (
"encoding/base64"
"errors"
"net"
"time"
"github.com/shazow/ssh-chat/internal/sanitize"
"golang.org/x/crypto/ssh"
)
// Auth is used to authenticate connections based on public keys.
// Auth is used to authenticate connections.
type Auth interface {
// Whether to allow connections without a public key.
AllowAnonymous() bool
// Given address and public key, return if the connection should be permitted.
Check(net.Addr, ssh.PublicKey) (bool, error)
// If passphrase authentication is accepted
AcceptPassphrase() bool
// Given address and public key and client agent string, returns nil if the connection is not banned.
CheckBans(net.Addr, ssh.PublicKey, string) error
// Given a public key, returns nil if the connection should be allowed.
CheckPublicKey(ssh.PublicKey) error
// Given a passphrase, returns nil if the connection should be allowed.
CheckPassphrase(string) error
// BanAddr bans an IP address for the specified amount of time.
BanAddr(net.Addr, time.Duration)
}
// MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation.
// TODO: Switch to using ssh.AuthMethod instead?
func MakeAuth(auth Auth) *ssh.ServerConfig {
config := ssh.ServerConfig{
NoClientAuth: false,
// Auth-related things should be constant-time to avoid timing attacks.
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
ok, err := auth.Check(conn.RemoteAddr(), key)
if !ok {
err := auth.CheckBans(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
if err != nil {
return nil, err
}
err = auth.CheckPublicKey(key)
if err != nil {
return nil, err
}
perm := &ssh.Permissions{Extensions: map[string]string{
@ -32,11 +47,31 @@ func MakeAuth(auth Auth) *ssh.ServerConfig {
}}
return perm, nil
},
// We use KeyboardInteractiveCallback instead of PasswordCallback to
// avoid preventing the client from including a pubkey in the user
// identification.
KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
if !auth.AllowAnonymous() {
return nil, errors.New("public key authentication required")
err := auth.CheckBans(conn.RemoteAddr(), nil, sanitize.Data(string(conn.ClientVersion()), 64))
if err != nil {
return nil, err
}
if auth.AcceptPassphrase() {
var answers []string
answers, err = challenge("", "", []string{"Passphrase required to connect: "}, []bool{true})
if err == nil {
if len(answers) != 1 {
err = errors.New("didn't get passphrase")
} else {
err = auth.CheckPassphrase(answers[0])
if err != nil {
auth.BanAddr(conn.RemoteAddr(), time.Second*2)
}
}
}
} else if !auth.AllowAnonymous() {
err = errors.New("public key authentication required")
}
_, err := auth.Check(conn.RemoteAddr(), nil)
return nil, err
},
}

View File

@ -26,12 +26,28 @@ func NewClientConfig(name string) *ssh.ClientConfig {
return
}),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
}
func NewClientConfigWithKey(name string, key ssh.Signer) *ssh.ClientConfig {
return &ssh.ClientConfig{
User: name,
Auth: []ssh.AuthMethod{ssh.PublicKeys(key)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
}
// ConnectShell makes a barebones SSH client session, used for testing.
func ConnectShell(host string, name string, handler func(r io.Reader, w io.WriteCloser) error) error {
config := NewClientConfig(name)
return connectShell(host, NewClientConfig(name), handler)
}
func ConnectShellWithKey(host string, name string, key ssh.Signer, handler func(r io.Reader, w io.WriteCloser) error) error {
return connectShell(host, NewClientConfigWithKey(name, key), handler)
}
func connectShell(host string, config *ssh.ClientConfig, handler func(r io.Reader, w io.WriteCloser) error) error {
conn, err := ssh.Dial("tcp", host, config)
if err != nil {
return err

View File

@ -4,6 +4,7 @@ import (
"errors"
"net"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
@ -15,9 +16,19 @@ type RejectAuth struct{}
func (a RejectAuth) AllowAnonymous() bool {
return false
}
func (a RejectAuth) Check(net.Addr, ssh.PublicKey) (bool, error) {
return false, errRejectAuth
func (a RejectAuth) AcceptPassphrase() bool {
return false
}
func (a RejectAuth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
return errRejectAuth
}
func (a RejectAuth) CheckPublicKey(ssh.PublicKey) error {
return errRejectAuth
}
func (a RejectAuth) CheckPassphrase(string) error {
return errRejectAuth
}
func (a RejectAuth) BanAddr(net.Addr, time.Duration) {}
func TestClientReject(t *testing.T) {
signer, err := NewRandomSigner(512)

View File

@ -5,6 +5,7 @@ import stdlog "log"
var logger *stdlog.Logger
// SetLogger sets the package logging output to use w.
func SetLogger(w io.Writer) {
flags := stdlog.Flags()
prefix := "[sshd] "

View File

@ -2,12 +2,13 @@ package sshd
import (
"net"
"time"
"github.com/shazow/rateio"
"golang.org/x/crypto/ssh"
)
// Container for the connection and ssh-related configuration
// SSHListener is the container for the connection and ssh-related configuration
type SSHListener struct {
net.Listener
config *ssh.ServerConfig
@ -16,7 +17,7 @@ type SSHListener struct {
HandlerFunc func(term *Terminal)
}
// Make an SSH listener socket
// ListenSSH makes an SSH listener socket
func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) {
socket, err := net.Listen("tcp", laddr)
if err != nil {
@ -32,6 +33,12 @@ func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) {
conn = ReadLimitConn(conn, l.RateLimit())
}
// If the connection doesn't write anything back for too long before we get
// a valid session, it should be dropped.
var handleTimeout = 20 * time.Second
conn.SetReadDeadline(time.Now().Add(handleTimeout))
defer conn.SetReadDeadline(time.Time{})
// Upgrade TCP connection to SSH connection
sshConn, channels, requests, err := ssh.NewServerConn(conn, l.config)
if err != nil {
@ -43,7 +50,7 @@ func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) {
return NewSession(sshConn, channels)
}
// Accept incoming connections as terminal requests and yield them
// Serve Accepts incoming connections as terminal requests and yield them
func (l *SSHListener) Serve() {
defer l.Close()
for {
@ -59,6 +66,7 @@ func (l *SSHListener) Serve() {
term, err := l.handleConn(conn)
if err != nil {
logger.Printf("[%s] Failed to handshake: %s", conn.RemoteAddr(), err)
conn.Close() // Must be closed to avoid a leak
return
}
l.HandlerFunc(term)

View File

@ -25,7 +25,7 @@ func TestServerInit(t *testing.T) {
}
func TestServeTerminals(t *testing.T) {
signer, err := NewRandomSigner(512)
signer, err := NewRandomSigner(1024)
if err != nil {
t.Fatal(err)
}
@ -52,7 +52,7 @@ func TestServeTerminals(t *testing.T) {
if err != nil {
t.Error(err)
}
_, err = term.Write([]byte("echo: " + line + "\r\n"))
_, err = term.Write([]byte("echo: " + line + "\n"))
if err != nil {
t.Error(err)
}

View File

@ -6,8 +6,8 @@ import "encoding/binary"
// parsePtyRequest parses the payload of the pty-req message and extracts the
// dimensions of the terminal. See RFC 4254, section 6.2.
func parsePtyRequest(s []byte) (width, height int, ok bool) {
_, s, ok = parseString(s)
func parsePtyRequest(s []byte) (term string, width, height int, ok bool) {
term, s, ok = parseString(s)
if !ok {
return
}

View File

@ -7,14 +7,17 @@ import (
"sync"
"time"
"github.com/shazow/ssh-chat/sshd/terminal"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/terminal"
)
var keepaliveInterval = time.Second * 30
var keepaliveRequest = "keepalive@ssh-chat"
// ErrNoSessionChannel is returned when there is no session channel.
var ErrNoSessionChannel = errors.New("no session channel")
// ErrNotSessionChannel is returned when a channel is not a session channel.
var ErrNotSessionChannel = errors.New("terminal requires session channel")
// Connection is an interface with fields necessary to operate an sshd host.
@ -52,7 +55,30 @@ func (c sshConn) Name() string {
return c.User()
}
// Extending ssh/terminal to include a closer interface
// EnvVar is an environment variable key-value pair
type EnvVar struct {
Key string
Value string
}
func (v EnvVar) String() string {
return v.Key + "=" + v.Value
}
// Env is a wrapper type around []EnvVar with some helper methods
type Env []EnvVar
// Get returns the latest value for a given key, or empty string if not found
func (e Env) Get(key string) string {
for i := len(e) - 1; i >= 0; i-- {
if e[i].Key == key {
return e[i].Value
}
}
return ""
}
// Terminal extends ssh/terminal to include a close method
type Terminal struct {
terminal.Terminal
Conn Connection
@ -60,9 +86,14 @@ type Terminal struct {
done chan struct{}
closeOnce sync.Once
mu sync.Mutex
env []EnvVar
term string
}
// Make new terminal from a session channel
// TODO: For v2, make a separate `Serve(ctx context.Context) error` method to activate the Terminal
func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) {
if ch.ChannelType() != "session" {
return nil, ErrNotSessionChannel
@ -72,14 +103,15 @@ func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) {
return nil, err
}
term := Terminal{
Terminal: *terminal.NewTerminal(channel, "Connecting..."),
Terminal: *terminal.NewTerminal(channel, ""),
Conn: sshConn{conn},
Channel: channel,
done: make(chan struct{}),
}
go term.listen(requests)
ready := make(chan struct{})
go term.listen(requests, ready)
go func() {
// Keep-Alive Ticker
@ -100,10 +132,21 @@ func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) {
}
}()
return &term, nil
// We need to wait for term.ready to acquire a shell before we return, this
// gives the SSH session a chance to populate the env vars and other state.
// TODO: Make the timeout configurable
// TODO: Use context.Context for abort/timeout in the future, will need to change the API.
select {
case <-ready: // shell acquired
return &term, nil
case <-term.done:
return nil, errors.New("terminal aborted")
case <-time.NewTimer(time.Minute).C:
return nil, errors.New("timed out starting terminal")
}
}
// Find session channel and make a Terminal from it
// NewSession Finds a session channel and make a Terminal from it
func NewSession(conn *ssh.ServerConn, channels <-chan ssh.NewChannel) (*Terminal, error) {
// Make a terminal from the first session found
for ch := range channels {
@ -124,14 +167,17 @@ func (t *Terminal) Close() error {
var err error
t.closeOnce.Do(func() {
close(t.done)
t.Channel.Close()
if err := t.Channel.Close(); err != nil {
logger.Printf("[%s] Failed to close terminal channel: %s", t.Conn.RemoteAddr(), err)
}
err = t.Conn.Close()
})
return err
}
// Negotiate terminal type and settings
func (t *Terminal) listen(requests <-chan *ssh.Request) {
// listen negotiates the terminal type and state
// ready is closed when the terminal is ready.
func (t *Terminal) listen(requests <-chan *ssh.Request, ready chan<- struct{}) {
hasShell := false
for req := range requests {
@ -143,13 +189,19 @@ func (t *Terminal) listen(requests <-chan *ssh.Request) {
if !hasShell {
ok = true
hasShell = true
close(ready)
}
case "pty-req":
width, height, ok = parsePtyRequest(req.Payload)
var term string
term, width, height, ok = parsePtyRequest(req.Payload)
if ok {
// TODO: Hardcode width to 100000?
err := t.SetSize(width, height)
ok = err == nil
// Save the term:
t.mu.Lock()
t.term = term
t.mu.Unlock()
}
case "window-change":
width, height, ok = parseWinchRequest(req.Payload)
@ -158,6 +210,14 @@ func (t *Terminal) listen(requests <-chan *ssh.Request) {
err := t.SetSize(width, height)
ok = err == nil
}
case "env":
var v EnvVar
if err := ssh.Unmarshal(req.Payload, &v); err == nil {
t.mu.Lock()
t.env = append(t.env, v)
t.mu.Unlock()
ok = true
}
}
if req.WantReply {
@ -165,3 +225,24 @@ func (t *Terminal) listen(requests <-chan *ssh.Request) {
}
}
}
// Env returns a list of environment key-values that have been set. They are
// returned in the order that they have been set, there is no deduplication or
// other pre-processing applied.
func (t *Terminal) Env() Env {
t.mu.Lock()
defer t.mu.Unlock()
return Env(t.env)
}
// Term returns the terminal string value as set by the pty.
// If there was no pty request, it falls back to the TERM value passed in as an
// Env variable.
func (t *Terminal) Term() string {
t.mu.Lock()
defer t.mu.Unlock()
if t.term != "" {
return t.term
}
return Env(t.env).Get("TERM")
}

27
sshd/terminal/LICENSE Normal file
View File

@ -0,0 +1,27 @@
Copyright (c) 2009 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1031
sshd/terminal/terminal.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,435 @@
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build aix darwin dragonfly freebsd linux,!appengine netbsd openbsd windows plan9 solaris
package terminal
import (
"bytes"
"io"
"os"
"runtime"
"testing"
"unicode/utf8"
)
type MockTerminal struct {
toSend []byte
bytesPerRead int
received []byte
}
func (c *MockTerminal) Read(data []byte) (n int, err error) {
n = len(data)
if n == 0 {
return
}
if n > len(c.toSend) {
n = len(c.toSend)
}
if n == 0 {
return 0, io.EOF
}
if c.bytesPerRead > 0 && n > c.bytesPerRead {
n = c.bytesPerRead
}
copy(data, c.toSend[:n])
c.toSend = c.toSend[n:]
return
}
func (c *MockTerminal) Write(data []byte) (n int, err error) {
c.received = append(c.received, data...)
return len(data), nil
}
func TestClose(t *testing.T) {
c := &MockTerminal{}
ss := NewTerminal(c, "> ")
line, err := ss.ReadLine()
if line != "" {
t.Errorf("Expected empty line but got: %s", line)
}
if err != io.EOF {
t.Errorf("Error should have been EOF but got: %s", err)
}
}
var keyPressTests = []struct {
in string
line string
received string
err error
throwAwayLines int
}{
{
err: io.EOF,
},
{
in: "\r",
line: "",
},
{
in: "foo\r",
line: "foo",
},
{
in: "a\x1b[Cb\r", // right
line: "ab",
},
{
in: "a\x1b[Db\r", // left
line: "ba",
},
{
in: "a\177b\r", // backspace
line: "b",
},
{
in: "\x1b[A\r", // up
},
{
in: "\x1b[B\r", // down
},
{
in: "\016\r", // ^P
},
{
in: "\014\r", // ^N
},
{
in: "line\x1b[A\x1b[B\r", // up then down
line: "line",
},
{
in: "line1\rline2\x1b[A\r", // recall previous line.
line: "line1",
throwAwayLines: 1,
},
{
// recall two previous lines and append.
in: "line1\rline2\rline3\x1b[A\x1b[Axxx\r",
line: "line1xxx",
throwAwayLines: 2,
},
{
// Ctrl-A to move to beginning of line followed by ^K to kill
// line.
in: "a b \001\013\r",
line: "",
},
{
// Ctrl-A to move to beginning of line, Ctrl-E to move to end,
// finally ^K to kill nothing.
in: "a b \001\005\013\r",
line: "a b ",
},
{
in: "\027\r",
line: "",
},
{
in: "a\027\r",
line: "",
},
{
in: "a \027\r",
line: "",
},
{
in: "a b\027\r",
line: "a ",
},
{
in: "a b \027\r",
line: "a ",
},
{
in: "one two thr\x1b[D\027\r",
line: "one two r",
},
{
in: "\013\r",
line: "",
},
{
in: "a\013\r",
line: "a",
},
{
in: "ab\x1b[D\013\r",
line: "a",
},
{
in: "Ξεσκεπάζω\r",
line: "Ξεσκεπάζω",
},
{
in: "£\r\x1b[A\177\r", // non-ASCII char, enter, up, backspace.
line: "",
throwAwayLines: 1,
},
{
in: "£\r££\x1b[A\x1b[B\177\r", // non-ASCII char, enter, 2x non-ASCII, up, down, backspace, enter.
line: "£",
throwAwayLines: 1,
},
{
// Ctrl-D at the end of the line should be ignored.
in: "a\004\r",
line: "a",
},
{
// a, b, left, Ctrl-D should erase the b.
in: "ab\x1b[D\004\r",
line: "a",
},
{
// a, b, c, d, left, left, ^U should erase to the beginning of
// the line.
in: "abcd\x1b[D\x1b[D\025\r",
line: "cd",
},
{
// Bracketed paste mode: control sequences should be returned
// verbatim in paste mode.
in: "abc\x1b[200~de\177f\x1b[201~\177\r",
line: "abcde\177",
},
{
// Enter in bracketed paste mode should still work.
in: "abc\x1b[200~d\refg\x1b[201~h\r",
line: "efgh",
throwAwayLines: 1,
},
{
// Lines consisting entirely of pasted data should be indicated as such.
in: "\x1b[200~a\r",
line: "a",
err: ErrPasteIndicator,
},
}
func TestKeyPresses(t *testing.T) {
for i, test := range keyPressTests {
for j := 1; j < len(test.in); j++ {
c := &MockTerminal{
toSend: []byte(test.in),
bytesPerRead: j,
}
ss := NewTerminal(c, "> ")
for k := 0; k < test.throwAwayLines; k++ {
_, err := ss.ReadLine()
if err != nil {
t.Errorf("Throwaway line %d from test %d resulted in error: %s", k, i, err)
}
}
line, err := ss.ReadLine()
if line != test.line {
t.Errorf("Line resulting from test %d (%d bytes per read) was '%s', expected '%s'", i, j, line, test.line)
break
}
if err != test.err {
t.Errorf("Error resulting from test %d (%d bytes per read) was '%v', expected '%v'", i, j, err, test.err)
break
}
}
}
}
var renderTests = []struct {
in string
received string
err error
}{
{
// Cursor move after keyHome (left 4) then enter (right 4, newline)
in: "abcd\x1b[H\r",
received: "> abcd\x1b[4D\x1b[4C\r\n",
},
{
// Write, home, prepend, enter. Prepends rewrites the line.
in: "cdef\x1b[Hab\r",
received: "> cdef" + // Initial input
"\x1b[4Da" + // Move cursor back, insert first char
"cdef" + // Copy over original string
"\x1b[4Dbcdef" + // Repeat for second char with copy
"\x1b[4D" + // Put cursor back in position to insert again
"\x1b[4C\r\n", // Put cursor at the end of the line and newline.
},
}
func TestRender(t *testing.T) {
for i, test := range renderTests {
for j := 1; j < len(test.in); j++ {
c := &MockTerminal{
toSend: []byte(test.in),
bytesPerRead: j,
}
ss := NewTerminal(c, "> ")
_, err := ss.ReadLine()
if err != test.err {
t.Errorf("Error resulting from test %d (%d bytes per read) was '%v', expected '%v'", i, j, err, test.err)
break
}
if test.received != string(c.received) {
t.Errorf("Results rendered from test %d (%d bytes per read) was '%s', expected '%s'", i, j, c.received, test.received)
break
}
}
}
}
func TestPasswordNotSaved(t *testing.T) {
c := &MockTerminal{
toSend: []byte("password\r\x1b[A\r"),
bytesPerRead: 1,
}
ss := NewTerminal(c, "> ")
pw, _ := ss.ReadPassword("> ")
if pw != "password" {
t.Fatalf("failed to read password, got %s", pw)
}
line, _ := ss.ReadLine()
if len(line) > 0 {
t.Fatalf("password was saved in history")
}
}
var setSizeTests = []struct {
width, height int
}{
{40, 13},
{80, 24},
{132, 43},
}
func TestTerminalSetSize(t *testing.T) {
for _, setSize := range setSizeTests {
c := &MockTerminal{
toSend: []byte("password\r\x1b[A\r"),
bytesPerRead: 1,
}
ss := NewTerminal(c, "> ")
ss.SetSize(setSize.width, setSize.height)
pw, _ := ss.ReadPassword("Password: ")
if pw != "password" {
t.Fatalf("failed to read password, got %s", pw)
}
if string(c.received) != "Password: \r\n" {
t.Errorf("failed to set the temporary prompt expected %q, got %q", "Password: ", c.received)
}
}
}
func TestReadPasswordLineEnd(t *testing.T) {
var tests = []struct {
input string
want string
}{
{"\n", ""},
{"\r\n", ""},
{"test\r\n", "test"},
{"testtesttesttes\n", "testtesttesttes"},
{"testtesttesttes\r\n", "testtesttesttes"},
{"testtesttesttesttest\n", "testtesttesttesttest"},
{"testtesttesttesttest\r\n", "testtesttesttesttest"},
}
for _, test := range tests {
buf := new(bytes.Buffer)
if _, err := buf.WriteString(test.input); err != nil {
t.Fatal(err)
}
have, err := readPasswordLine(buf)
if err != nil {
t.Errorf("readPasswordLine(%q) failed: %v", test.input, err)
continue
}
if string(have) != test.want {
t.Errorf("readPasswordLine(%q) returns %q, but %q is expected", test.input, string(have), test.want)
continue
}
if _, err = buf.WriteString(test.input); err != nil {
t.Fatal(err)
}
have, err = readPasswordLine(buf)
if err != nil {
t.Errorf("readPasswordLine(%q) failed: %v", test.input, err)
continue
}
if string(have) != test.want {
t.Errorf("readPasswordLine(%q) returns %q, but %q is expected", test.input, string(have), test.want)
continue
}
}
}
func TestMakeRawState(t *testing.T) {
fd := int(os.Stdout.Fd())
if !IsTerminal(fd) {
t.Skip("stdout is not a terminal; skipping test")
}
st, err := GetState(fd)
if err != nil {
t.Fatalf("failed to get terminal state from GetState: %s", err)
}
if runtime.GOOS == "darwin" && (runtime.GOARCH == "arm" || runtime.GOARCH == "arm64") {
t.Skip("MakeRaw not allowed on iOS; skipping test")
}
defer Restore(fd, st)
raw, err := MakeRaw(fd)
if err != nil {
t.Fatalf("failed to get terminal state from MakeRaw: %s", err)
}
if *st != *raw {
t.Errorf("states do not match; was %v, expected %v", raw, st)
}
}
func TestOutputNewlines(t *testing.T) {
// \n should be changed to \r\n in terminal output.
buf := new(bytes.Buffer)
term := NewTerminal(buf, ">")
term.Write([]byte("1\n2\n"))
output := string(buf.Bytes())
const expected = "1\r\n2\r\n"
if output != expected {
t.Errorf("incorrect output: was %q, expected %q", output, expected)
}
}
func TestTerminalvisualLength(t *testing.T) {
var tests = []struct {
input string
want int
}{
{"hello world", 11},
{"babalala", 8},
{"端子", 4},
{"を搭載", 6},
{"baba端子lalaを搭載", 18},
}
for _, test := range tests {
var runes []rune
for i, w := 0, 0; i < len(test.input); i += w {
runeValue, width := utf8.DecodeRuneInString(test.input[i:])
runes = append(runes, runeValue)
w = width
}
output := visualLength(runes)
if output != test.want {
t.Errorf("incorrect [%s] output: was %d, expected %d",
test.input, output, test.want)
}
}
}

114
sshd/terminal/util.go Normal file
View File

@ -0,0 +1,114 @@
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build aix darwin dragonfly freebsd linux,!appengine netbsd openbsd
// Package terminal provides support functions for dealing with terminals, as
// commonly found on UNIX systems.
//
// Putting a terminal into raw mode is the most common requirement:
//
// oldState, err := terminal.MakeRaw(0)
// if err != nil {
// panic(err)
// }
// defer terminal.Restore(0, oldState)
package terminal
import (
"golang.org/x/sys/unix"
)
// State contains the state of a terminal.
type State struct {
termios unix.Termios
}
// IsTerminal returns whether the given file descriptor is a terminal.
func IsTerminal(fd int) bool {
_, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
return err == nil
}
// MakeRaw put the terminal connected to the given file descriptor into raw
// mode and returns the previous state of the terminal so that it can be
// restored.
func MakeRaw(fd int) (*State, error) {
termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
if err != nil {
return nil, err
}
oldState := State{termios: *termios}
// This attempts to replicate the behaviour documented for cfmakeraw in
// the termios(3) manpage.
termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON
termios.Oflag &^= unix.OPOST
termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN
termios.Cflag &^= unix.CSIZE | unix.PARENB
termios.Cflag |= unix.CS8
termios.Cc[unix.VMIN] = 1
termios.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, ioctlWriteTermios, termios); err != nil {
return nil, err
}
return &oldState, nil
}
// GetState returns the current state of a terminal which may be useful to
// restore the terminal after a signal.
func GetState(fd int) (*State, error) {
termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
if err != nil {
return nil, err
}
return &State{termios: *termios}, nil
}
// Restore restores the terminal connected to the given file descriptor to a
// previous state.
func Restore(fd int, state *State) error {
return unix.IoctlSetTermios(fd, ioctlWriteTermios, &state.termios)
}
// GetSize returns the dimensions of the given terminal.
func GetSize(fd int) (width, height int, err error) {
ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
if err != nil {
return -1, -1, err
}
return int(ws.Col), int(ws.Row), nil
}
// passwordReader is an io.Reader that reads from a specific file descriptor.
type passwordReader int
func (r passwordReader) Read(buf []byte) (int, error) {
return unix.Read(int(r), buf)
}
// ReadPassword reads a line of input from a terminal without local echo. This
// is commonly used for inputting passwords and other sensitive data. The slice
// returned does not include the \n.
func ReadPassword(fd int) ([]byte, error) {
termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
if err != nil {
return nil, err
}
newState := *termios
newState.Lflag &^= unix.ECHO
newState.Lflag |= unix.ICANON | unix.ISIG
newState.Iflag |= unix.ICRNL
if err := unix.IoctlSetTermios(fd, ioctlWriteTermios, &newState); err != nil {
return nil, err
}
defer unix.IoctlSetTermios(fd, ioctlWriteTermios, termios)
return readPasswordLine(passwordReader(fd))
}

12
sshd/terminal/util_aix.go Normal file
View File

@ -0,0 +1,12 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build aix
package terminal
import "golang.org/x/sys/unix"
const ioctlReadTermios = unix.TCGETS
const ioctlWriteTermios = unix.TCSETS

12
sshd/terminal/util_bsd.go Normal file
View File

@ -0,0 +1,12 @@
// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build darwin dragonfly freebsd netbsd openbsd
package terminal
import "golang.org/x/sys/unix"
const ioctlReadTermios = unix.TIOCGETA
const ioctlWriteTermios = unix.TIOCSETA

View File

@ -0,0 +1,10 @@
// Copyright 2013 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package terminal
import "golang.org/x/sys/unix"
const ioctlReadTermios = unix.TCGETS
const ioctlWriteTermios = unix.TCSETS

View File

@ -0,0 +1,58 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package terminal provides support functions for dealing with terminals, as
// commonly found on UNIX systems.
//
// Putting a terminal into raw mode is the most common requirement:
//
// oldState, err := terminal.MakeRaw(0)
// if err != nil {
// panic(err)
// }
// defer terminal.Restore(0, oldState)
package terminal
import (
"fmt"
"runtime"
)
type State struct{}
// IsTerminal returns whether the given file descriptor is a terminal.
func IsTerminal(fd int) bool {
return false
}
// MakeRaw put the terminal connected to the given file descriptor into raw
// mode and returns the previous state of the terminal so that it can be
// restored.
func MakeRaw(fd int) (*State, error) {
return nil, fmt.Errorf("terminal: MakeRaw not implemented on %s/%s", runtime.GOOS, runtime.GOARCH)
}
// GetState returns the current state of a terminal which may be useful to
// restore the terminal after a signal.
func GetState(fd int) (*State, error) {
return nil, fmt.Errorf("terminal: GetState not implemented on %s/%s", runtime.GOOS, runtime.GOARCH)
}
// Restore restores the terminal connected to the given file descriptor to a
// previous state.
func Restore(fd int, state *State) error {
return fmt.Errorf("terminal: Restore not implemented on %s/%s", runtime.GOOS, runtime.GOARCH)
}
// GetSize returns the dimensions of the given terminal.
func GetSize(fd int) (width, height int, err error) {
return 0, 0, fmt.Errorf("terminal: GetSize not implemented on %s/%s", runtime.GOOS, runtime.GOARCH)
}
// ReadPassword reads a line of input from a terminal without local echo. This
// is commonly used for inputting passwords and other sensitive data. The slice
// returned does not include the \n.
func ReadPassword(fd int) ([]byte, error) {
return nil, fmt.Errorf("terminal: ReadPassword not implemented on %s/%s", runtime.GOOS, runtime.GOARCH)
}

View File

@ -0,0 +1,125 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build solaris
package terminal
import (
"io"
"syscall"
"golang.org/x/sys/unix"
)
// State contains the state of a terminal.
type State struct {
termios unix.Termios
}
// IsTerminal returns whether the given file descriptor is a terminal.
func IsTerminal(fd int) bool {
_, err := unix.IoctlGetTermio(fd, unix.TCGETA)
return err == nil
}
// ReadPassword reads a line of input from a terminal without local echo. This
// is commonly used for inputting passwords and other sensitive data. The slice
// returned does not include the \n.
func ReadPassword(fd int) ([]byte, error) {
// see also: http://src.illumos.org/source/xref/illumos-gate/usr/src/lib/libast/common/uwin/getpass.c
val, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
oldState := *val
newState := oldState
newState.Lflag &^= syscall.ECHO
newState.Lflag |= syscall.ICANON | syscall.ISIG
newState.Iflag |= syscall.ICRNL
err = unix.IoctlSetTermios(fd, unix.TCSETS, &newState)
if err != nil {
return nil, err
}
defer unix.IoctlSetTermios(fd, unix.TCSETS, &oldState)
var buf [16]byte
var ret []byte
for {
n, err := syscall.Read(fd, buf[:])
if err != nil {
return nil, err
}
if n == 0 {
if len(ret) == 0 {
return nil, io.EOF
}
break
}
if buf[n-1] == '\n' {
n--
}
ret = append(ret, buf[:n]...)
if n < len(buf) {
break
}
}
return ret, nil
}
// MakeRaw puts the terminal connected to the given file descriptor into raw
// mode and returns the previous state of the terminal so that it can be
// restored.
// see http://cr.illumos.org/~webrev/andy_js/1060/
func MakeRaw(fd int) (*State, error) {
termios, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
oldState := State{termios: *termios}
termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON
termios.Oflag &^= unix.OPOST
termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN
termios.Cflag &^= unix.CSIZE | unix.PARENB
termios.Cflag |= unix.CS8
termios.Cc[unix.VMIN] = 1
termios.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TCSETS, termios); err != nil {
return nil, err
}
return &oldState, nil
}
// Restore restores the terminal connected to the given file descriptor to a
// previous state.
func Restore(fd int, oldState *State) error {
return unix.IoctlSetTermios(fd, unix.TCSETS, &oldState.termios)
}
// GetState returns the current state of a terminal which may be useful to
// restore the terminal after a signal.
func GetState(fd int) (*State, error) {
termios, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
return &State{termios: *termios}, nil
}
// GetSize returns the dimensions of the given terminal.
func GetSize(fd int) (width, height int, err error) {
ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
if err != nil {
return 0, 0, err
}
return int(ws.Col), int(ws.Row), nil
}

View File

@ -0,0 +1,103 @@
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build windows
// Package terminal provides support functions for dealing with terminals, as
// commonly found on UNIX systems.
//
// Putting a terminal into raw mode is the most common requirement:
//
// oldState, err := terminal.MakeRaw(0)
// if err != nil {
// panic(err)
// }
// defer terminal.Restore(0, oldState)
package terminal
import (
"os"
"golang.org/x/sys/windows"
)
type State struct {
mode uint32
}
// IsTerminal returns whether the given file descriptor is a terminal.
func IsTerminal(fd int) bool {
var st uint32
err := windows.GetConsoleMode(windows.Handle(fd), &st)
return err == nil
}
// MakeRaw put the terminal connected to the given file descriptor into raw
// mode and returns the previous state of the terminal so that it can be
// restored.
func MakeRaw(fd int) (*State, error) {
var st uint32
if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil {
return nil, err
}
raw := st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT)
if err := windows.SetConsoleMode(windows.Handle(fd), raw); err != nil {
return nil, err
}
return &State{st}, nil
}
// GetState returns the current state of a terminal which may be useful to
// restore the terminal after a signal.
func GetState(fd int) (*State, error) {
var st uint32
if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil {
return nil, err
}
return &State{st}, nil
}
// Restore restores the terminal connected to the given file descriptor to a
// previous state.
func Restore(fd int, state *State) error {
return windows.SetConsoleMode(windows.Handle(fd), state.mode)
}
// GetSize returns the dimensions of the given terminal.
func GetSize(fd int) (width, height int, err error) {
var info windows.ConsoleScreenBufferInfo
if err := windows.GetConsoleScreenBufferInfo(windows.Handle(fd), &info); err != nil {
return 0, 0, err
}
return int(info.Size.X), int(info.Size.Y), nil
}
// ReadPassword reads a line of input from a terminal without local echo. This
// is commonly used for inputting passwords and other sensitive data. The slice
// returned does not include the \n.
func ReadPassword(fd int) ([]byte, error) {
var st uint32
if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil {
return nil, err
}
old := st
st &^= (windows.ENABLE_ECHO_INPUT)
st |= (windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT)
if err := windows.SetConsoleMode(windows.Handle(fd), st); err != nil {
return nil, err
}
defer windows.SetConsoleMode(windows.Handle(fd), old)
var h windows.Handle
p, _ := windows.GetCurrentProcess()
if err := windows.DuplicateHandle(p, windows.Handle(fd), p, &h, 0, false, windows.DUPLICATE_SAME_ACCESS); err != nil {
return nil, err
}
f := os.NewFile(uintptr(h), "stdin")
defer f.Close()
return readPasswordLine(f)
}

1
vendor/github.com/alexcesaro/log generated vendored

@ -1 +0,0 @@
Subproject commit 61e686294e58a8698a9e1091268bb4ac1116bd5e

@ -1 +0,0 @@
Subproject commit 2fcb5204cdc65b4bec9fd0a87606bb0d0e3c54e8

1
vendor/github.com/howeyc/gopass generated vendored

@ -1 +0,0 @@
Subproject commit 26c6e1184fd5255fa5f5289d0b789a4819c203a4

@ -1 +0,0 @@
Subproject commit f2785f5820ec967043de79c8be97edfc464ca745

1
vendor/github.com/shazow/rateio generated vendored

@ -1 +0,0 @@
Subproject commit e8e00881e5c12090412414be41c04ca9c8a71106