mirror of
https://github.com/shazow/ssh-chat.git
synced 2025-04-20 18:57:41 +03:00
Compare commits
219 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
2dc875817d | ||
|
6dd5f27334 | ||
|
635673882b | ||
|
e16725f08e | ||
|
89b7218461 | ||
|
daf4677fe3 | ||
|
bdd716e621 | ||
|
1fc7f7b10b | ||
|
c884aee673 | ||
|
aaf0671f01 | ||
|
748fc819e7 | ||
|
4b4270f0ca | ||
|
ae585079e7 | ||
|
1102162d1f | ||
|
68e9d6880d | ||
|
3f857cf1f5 | ||
|
df72223a5f | ||
|
621ae1b0d3 | ||
|
84bc5c76dd | ||
|
82526e9123 | ||
|
d25630020d | ||
|
0eebb64c1d | ||
|
db14517499 | ||
|
88fa53fd16 | ||
|
7628a47c4c | ||
|
7413539965 | ||
|
c3b589b286 | ||
|
e1e534344e | ||
|
46eaf037e3 | ||
|
3c246777a1 | ||
|
fef128b91f | ||
|
1ef05d0c26 | ||
|
af502977e6 | ||
|
c3dccfd3eb | ||
|
aae5bc8d2e | ||
|
fb73ace458 | ||
|
3557bf762d | ||
|
fa3146c800 | ||
|
badcaa6e3b | ||
|
9bf66ea992 | ||
|
37b101c3c1 | ||
|
b73b45640c | ||
|
7a783d46af | ||
|
3848014d41 | ||
|
3f81d84cf1 | ||
|
4840634434 | ||
|
8257ada10d | ||
|
9329227403 | ||
|
0338cb824d | ||
|
c8bfc34704 | ||
|
ebbbc3b6d9 | ||
|
d8183dd305 | ||
|
37c3e52309 | ||
|
8eab0c9ead | ||
|
1a00bd81f2 | ||
|
5fb947fa6e | ||
|
d42996c14b | ||
|
4fe43746fe | ||
|
7461ca8e39 | ||
|
53ae43fb1b | ||
|
0c7f325499 | ||
|
512d38d86f | ||
|
5b2c6e7d8e | ||
|
b2f0496695 | ||
|
d1bd40775c | ||
|
32a3860ea2 | ||
|
e0ab53500e | ||
|
ab46dd9a98 | ||
|
aa78d0eb22 | ||
|
8cd06e33b5 | ||
|
a9b08a7b17 | ||
|
987a2e870a | ||
|
86b70a1fc7 | ||
|
fa8df8140d | ||
|
7822904afc | ||
|
5885f7fbdd | ||
|
e5374b7111 | ||
|
5ff2ea085b | ||
|
208a6a4712 | ||
|
b6a8763f3b | ||
|
d4a5299e1c | ||
|
09c8bbdd4c | ||
|
5f201e0f20 | ||
|
ced5767bbb | ||
|
cb0bdc8e0e | ||
|
36843252b4 | ||
|
3c4d90cfc1 | ||
|
8f854130fa | ||
|
fb4be4b24d | ||
|
544f208473 | ||
|
fda705a2dc | ||
|
6fe0b40327 | ||
|
fde04365ac | ||
|
7169ee1240 | ||
|
403bb5911b | ||
|
daad9ba07b | ||
|
5c71e9b242 | ||
|
6aa7a42083 | ||
|
f113a130ae | ||
|
b9aa7a6a0c | ||
|
adef8d65a2 | ||
|
99d303e196 | ||
|
6e9705faf5 | ||
|
b1bce027ad | ||
|
3b8e644c9e | ||
|
77143ad1e6 | ||
|
096b548200 | ||
|
a3f41865fd | ||
|
5055bbc859 | ||
|
cce7defd5e | ||
|
2076980aea | ||
|
c0f9814ae1 | ||
|
fdfdcf96b7 | ||
|
fe84822f5d | ||
|
dd2fadd6c1 | ||
|
c0a2f32bd4 | ||
|
5dfa194ec8 | ||
|
5bad08c340 | ||
|
2fb89142a5 | ||
|
8c7ea173ad | ||
|
80ddf1f43a | ||
|
d8f9bc9006 | ||
|
6701cbcbf7 | ||
|
d8e68d57bc | ||
|
5af617f3b9 | ||
|
1b2a3e97a0 | ||
|
0a122be81e | ||
|
413cba485e | ||
|
50ca8b043a | ||
|
3e37ebf85a | ||
|
5949f9792f | ||
|
67163a93a8 | ||
|
479a391d55 | ||
|
c9b58a80fa | ||
|
91c6e55432 | ||
|
60c1fe4220 | ||
|
290ca46577 | ||
|
40d17570b9 | ||
|
a61b9a4a5c | ||
|
5057b891c0 | ||
|
4aa2460d82 | ||
|
338ded7a4e | ||
|
87024f3ded | ||
|
c058f72fe4 | ||
|
d72aaf6bae | ||
|
c5cc4b0594 | ||
|
ee8d60ec00 | ||
|
69c496424e | ||
|
6acb0bf809 | ||
|
b4ba8226c6 | ||
|
9d2230eaff | ||
|
418c991677 | ||
|
8b710da728 | ||
|
4240130978 | ||
|
8653f0a730 | ||
|
1ba36b785c | ||
|
596d41ff29 | ||
|
aecd8c66c3 | ||
|
2089eda486 | ||
|
b8fb704c8f | ||
|
5ff61f0a0f | ||
|
a90574126e | ||
|
078fbbe828 | ||
|
fa516ba085 | ||
|
1c44c714e6 | ||
|
c02b6390d2 | ||
|
9c3db55c83 | ||
|
8282fad7dc | ||
|
54ce8bb08d | ||
|
783c607fad | ||
|
33d0c08ffe | ||
|
eb10bad08e | ||
|
6d3fce47cc | ||
|
4188a3bdac | ||
|
dca2cec67f | ||
|
924c6bd234 | ||
|
d3eda56f82 | ||
|
3f0faf761e | ||
|
ae46a91e0f | ||
|
0b06b56c0e | ||
|
60701198fc | ||
|
953c3d46b2 | ||
|
7b06848c75 | ||
|
f3c80047b9 | ||
|
c1a8912297 | ||
|
4c968fc966 | ||
|
9c918676ed | ||
|
e6233daefd | ||
|
40f6957e93 | ||
|
a1b891aae2 | ||
|
57c6abe86c | ||
|
3813360d91 | ||
|
81d7e16862 | ||
|
429ebbf60d | ||
|
40bf204058 | ||
|
60f3202818 | ||
|
a998e91639 | ||
|
903d6c9420 | ||
|
bdd9274aaa | ||
|
3572c4674c | ||
|
86dae2a53e | ||
|
f36d7eb9cc | ||
|
75492825e9 | ||
|
9697c7d37f | ||
|
e72af3ad8e | ||
|
d73aae0f15 | ||
|
87dd859fed | ||
|
66fee99a81 | ||
|
14b380b473 | ||
|
fd77009f8d | ||
|
2078e13819 | ||
|
206a9073f0 | ||
|
80a5879f04 | ||
|
0cb58facef | ||
|
50001bf172 | ||
|
e3b5c3712c | ||
|
2c1e5239c5 | ||
|
05597b3e6a | ||
|
a631215f5b |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
github: shazow
|
63
.github/ISSUE_TEMPLATE/BUG.yml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/BUG.yml
vendored
Normal 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
32
.github/workflows/go.yml
vendored
Normal 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
1
.gitignore
vendored
@ -4,3 +4,4 @@ host_key.pub
|
||||
ssh-chat
|
||||
*.log
|
||||
.*
|
||||
vendor/
|
||||
|
19
.gitmodules
vendored
19
.gitmodules
vendored
@ -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
|
10
.travis.yml
10
.travis.yml
@ -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
78
CODE_OF_CONDUCT.md
Normal 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
44
CONTRIBUTING.md
Normal 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
21
Dockerfile
Normal 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
45
Gopkg.lock
generated
Normal 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
46
Gopkg.toml
Normal 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"
|
35
Makefile
35
Makefile
@ -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
33
NOTICE
Normal 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.
|
40
README.md
40
README.md
@ -1,6 +1,8 @@
|
||||
[](https://travis-ci.org/shazow/ssh-chat)
|
||||
[](https://www.bountysource.com/teams/ssh-chat/issues?utm_source=ssh-chat&utm_medium=shield&utm_campaign=bounties_received)
|
||||
[](https://godoc.org/github.com/shazow/ssh-chat)
|
||||
[](https://github.com/shazow/ssh-chat/releases)
|
||||
[](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
284
auth.go
@ -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
|
||||
}
|
||||
|
54
auth_test.go
54
auth_test.go
@ -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.")
|
||||
}
|
||||
}
|
||||
|
251
chat/command.go
251
chat/command.go
@ -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
70
chat/command_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -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] "
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"), `[38;05;245mfoo`+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(`[38;05;245mAA:BB` + Reset + ` [[38;05;88mfoo[0m] hello` + Newline)
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
t.Errorf("Wrong screen output:\n Got: `%q`;\nWant: `%q`", actual, expected)
|
||||
}
|
||||
}
|
||||
|
101
chat/room.go
101
chat/room.go
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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, "")
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
13
docker-compose.yml
Normal 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
14
go.mod
Normal 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
50
go.sum
Normal 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=
|
2
godoc.go
2
godoc.go
@ -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
492
host.go
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
521
host_test.go
521
host_test.go
@ -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
|
||||
}
|
||||
|
62
identity.go
62
identity.go
@ -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()
|
||||
}
|
||||
|
21
internal/humantime/humantime.go
Normal file
21
internal/humantime/humantime.go
Normal 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)
|
||||
}
|
41
internal/humantime/humantime_test.go
Normal file
41
internal/humantime/humantime_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
29
internal/sanitize/sanitize.go
Normal file
29
internal/sanitize/sanitize.go
Normal 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, "")
|
||||
}
|
@ -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))
|
||||
}
|
||||
|
5
motd.txt
5
motd.txt
@ -1 +1,4 @@
|
||||
[39;91mWelcome to chat.shazow.net, enter /help for more. [0m
|
||||
[31;1mWelcome to ssh-chat, enter [0m/help[31;1m for more.
|
||||
[32;1m🐛 Please enjoy our selection of bugs, but run your own server if you want to crash it:[0m https://ssh.chat/issues
|
||||
[33;1m🍮 Sponsors get an emoji prefix:[0m https://ssh.chat/sponsor
|
||||
[34;1m😌 Be nice and follow our Code of Conduct:[0m https://ssh.chat/conduct
|
||||
|
49
set/set.go
49
set/set.go
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
|
51
sshd/auth.go
51
sshd/auth.go
@ -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
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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] "
|
||||
|
14
sshd/net.go
14
sshd/net.go
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
101
sshd/terminal.go
101
sshd/terminal.go
@ -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
27
sshd/terminal/LICENSE
Normal 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
1031
sshd/terminal/terminal.go
Normal file
File diff suppressed because it is too large
Load Diff
435
sshd/terminal/terminal_test.go
Normal file
435
sshd/terminal/terminal_test.go
Normal 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
114
sshd/terminal/util.go
Normal 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
12
sshd/terminal/util_aix.go
Normal 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
12
sshd/terminal/util_bsd.go
Normal 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
|
10
sshd/terminal/util_linux.go
Normal file
10
sshd/terminal/util_linux.go
Normal 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
|
58
sshd/terminal/util_plan9.go
Normal file
58
sshd/terminal/util_plan9.go
Normal 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)
|
||||
}
|
125
sshd/terminal/util_solaris.go
Normal file
125
sshd/terminal/util_solaris.go
Normal 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
|
||||
}
|
103
sshd/terminal/util_windows.go
Normal file
103
sshd/terminal/util_windows.go
Normal 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
vendor/github.com/alexcesaro/log
generated
vendored
@ -1 +0,0 @@
|
||||
Subproject commit 61e686294e58a8698a9e1091268bb4ac1116bd5e
|
1
vendor/github.com/dustin/go-humanize
generated
vendored
1
vendor/github.com/dustin/go-humanize
generated
vendored
@ -1 +0,0 @@
|
||||
Subproject commit 2fcb5204cdc65b4bec9fd0a87606bb0d0e3c54e8
|
1
vendor/github.com/howeyc/gopass
generated
vendored
1
vendor/github.com/howeyc/gopass
generated
vendored
@ -1 +0,0 @@
|
||||
Subproject commit 26c6e1184fd5255fa5f5289d0b789a4819c203a4
|
1
vendor/github.com/jessevdk/go-flags
generated
vendored
1
vendor/github.com/jessevdk/go-flags
generated
vendored
@ -1 +0,0 @@
|
||||
Subproject commit f2785f5820ec967043de79c8be97edfc464ca745
|
1
vendor/github.com/shazow/rateio
generated
vendored
1
vendor/github.com/shazow/rateio
generated
vendored
@ -1 +0,0 @@
|
||||
Subproject commit e8e00881e5c12090412414be41c04ca9c8a71106
|
Loading…
x
Reference in New Issue
Block a user