mirror of
https://github.com/shazow/ssh-chat.git
synced 2025-04-19 18:27:41 +03:00
Compare commits
130 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 | ||
|
4aa2460d82 |
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 ./...
|
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
|
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"]
|
9
Makefile
9
Makefile
@ -9,7 +9,7 @@ LDFLAGS = -X main.Version=$(VERSION) -extldflags "-static"
|
||||
all: $(BINARY)
|
||||
|
||||
$(BINARY): **/**/*.go **/*.go *.go
|
||||
go build $(BUILDFLAGS) ./cmd/ssh-chat
|
||||
go build -ldflags "$(LDFLAGS)" ./cmd/ssh-chat
|
||||
|
||||
build: $(BINARY)
|
||||
|
||||
@ -35,6 +35,13 @@ release:
|
||||
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"
|
||||
|
18
README.md
18
README.md
@ -12,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.)
|
||||
|
||||
@ -48,7 +52,7 @@ Additionally, `make debug` runs the server with an http `pprof` server. This all
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
``` console
|
||||
Usage:
|
||||
ssh-chat [OPTIONS]
|
||||
|
||||
@ -58,7 +62,7 @@ Application Options:
|
||||
-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.
|
||||
--whitelist= Optional file of public keys who are allowed to connect.
|
||||
--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 pprof http server for profiling.
|
||||
@ -70,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
|
||||
```
|
||||
|
||||
@ -84,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
|
||||
|
164
auth.go
164
auth.go
@ -1,11 +1,14 @@
|
||||
package sshchat
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shazow/ssh-chat/set"
|
||||
@ -13,13 +16,20 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ErrNotWhitelisted Is the error returned when a key is checked that is not whitelisted,
|
||||
// when whitelisting is enabled.
|
||||
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)
|
||||
|
||||
// ErrBanned is the error returned when 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 {
|
||||
@ -42,13 +52,20 @@ 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
|
||||
bannedClient *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.
|
||||
@ -57,28 +74,47 @@ func NewAuth() *Auth {
|
||||
bannedAddr: set.New(),
|
||||
bannedClient: set.New(),
|
||||
banned: set.New(),
|
||||
whitelist: 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, clientVersion string) error {
|
||||
authkey := newAuthKey(key)
|
||||
// AcceptPassphrase determines if passphrase authentication is accepted.
|
||||
func (a *Auth) AcceptPassphrase() bool {
|
||||
return a.passphraseHash != nil
|
||||
}
|
||||
|
||||
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 ErrNotWhitelisted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// CheckBans checks IP, key and client bans.
|
||||
func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
|
||||
authkey := newAuthKey(key)
|
||||
|
||||
var banned bool
|
||||
if authkey != "" {
|
||||
@ -98,6 +134,29 @@ func (a *Auth) Check(addr net.Addr, key ssh.PublicKey, clientVersion string) err
|
||||
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.
|
||||
func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
||||
if key == nil {
|
||||
@ -114,25 +173,68 @@ func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
||||
|
||||
// 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.Set(set.Expire(authItem, d))
|
||||
err = a.allowlist.Set(set.Expire(authItem, d))
|
||||
} else {
|
||||
a.whitelist.Set(authItem)
|
||||
err = a.allowlist.Set(authItem)
|
||||
}
|
||||
logger.Debugf("Added to whitelist: %q (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.
|
||||
|
49
auth_test.go
49
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()
|
||||
err = auth.Check(nil, key, "")
|
||||
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.")
|
||||
}
|
||||
|
||||
err = auth.Check(nil, keyClone, "")
|
||||
err = auth.CheckPublicKey(keyClone)
|
||||
if err != nil {
|
||||
t.Error("Failed to permit whitelisted:", err)
|
||||
t.Error("Failed to permit allowlisted:", err)
|
||||
}
|
||||
|
||||
key2, err := NewRandomPublicKey(512)
|
||||
@ -54,8 +55,42 @@ func TestAuthWhitelist(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = auth.Check(nil, key2, "")
|
||||
err = auth.CheckPublicKey(key2)
|
||||
if err == nil {
|
||||
t.Error("Failed to restrict not whitelisted:", err)
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
117
chat/command.go
117
chat/command.go
@ -5,6 +5,7 @@ package chat
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -189,6 +190,7 @@ func InitCommands(c *Commands) {
|
||||
}
|
||||
|
||||
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)
|
||||
@ -409,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,9 @@
|
||||
package chat
|
||||
|
||||
import "io"
|
||||
import stdlog "log"
|
||||
import (
|
||||
"io"
|
||||
stdlog "log"
|
||||
)
|
||||
|
||||
var logger *stdlog.Logger
|
||||
|
||||
|
@ -131,6 +131,9 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string {
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@ -138,9 +141,7 @@ 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
|
||||
@ -156,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)
|
||||
}
|
||||
@ -181,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)
|
||||
}
|
||||
|
||||
|
@ -122,6 +122,7 @@ type Theme struct {
|
||||
pm Style
|
||||
highlight Style
|
||||
names *Palette
|
||||
useID bool
|
||||
}
|
||||
|
||||
func (theme Theme) ID() string {
|
||||
@ -130,11 +131,17 @@ func (theme Theme) ID() string {
|
||||
|
||||
// Colorize name string given some index
|
||||
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 u.Name()
|
||||
return name
|
||||
}
|
||||
|
||||
return theme.names.Get(u.colorIdx).Format(u.Name())
|
||||
return theme.names.Get(u.colorIdx).Format(name)
|
||||
}
|
||||
|
||||
// Colorize the PM string
|
||||
@ -226,7 +233,8 @@ func init() {
|
||||
highlight: style(Bold + "\033[48;5;22m\033[38;5;46m"), // Green on dark green
|
||||
},
|
||||
{
|
||||
id: "mono",
|
||||
id: "mono",
|
||||
useID: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,9 @@ 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
|
||||
@ -31,10 +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.
|
||||
lastMsg time.Time // When the last message was rendered
|
||||
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 {
|
||||
@ -45,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())
|
||||
|
||||
@ -62,6 +67,32 @@ 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()
|
||||
@ -72,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.
|
||||
@ -104,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)
|
||||
@ -161,7 +202,17 @@ func (u *User) render(m Message) string {
|
||||
switch m := m.(type) {
|
||||
case PublicMsg:
|
||||
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)
|
||||
}
|
||||
@ -193,7 +244,7 @@ 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.Name(), err)
|
||||
logger.Printf("Write failed to %s, closing: %s", u.ID(), err)
|
||||
u.Close()
|
||||
}
|
||||
return err
|
||||
@ -201,9 +252,6 @@ func (u *User) writeMsg(m Message) error {
|
||||
|
||||
// HandleMsg will render the message to the screen, blocking.
|
||||
func (u *User) HandleMsg(m Message) error {
|
||||
u.mu.Lock()
|
||||
u.lastMsg = m.Timestamp()
|
||||
u.mu.Unlock()
|
||||
return u.writeMsg(m)
|
||||
}
|
||||
|
||||
@ -214,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
|
||||
}
|
||||
@ -226,6 +274,7 @@ type UserConfig struct {
|
||||
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
|
||||
@ -237,13 +286,16 @@ 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.
|
||||
// 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) }
|
||||
@ -253,5 +305,16 @@ func (a RecentActiveUsers) Less(i, j int) bool {
|
||||
defer a[i].mu.Unlock()
|
||||
a[j].mu.Lock()
|
||||
defer a[j].mu.Unlock()
|
||||
return a[i].lastMsg.After(a[j].lastMsg)
|
||||
|
||||
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)
|
||||
}
|
||||
|
52
chat/room.go
52
chat/room.go
@ -27,6 +27,23 @@ var ErrInvalidName = errors.New("invalid name")
|
||||
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
|
||||
@ -79,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
|
||||
@ -89,22 +125,23 @@ func (r *Room) HandleMsg(m message.Message) {
|
||||
}
|
||||
case message.MessageTo:
|
||||
user := m.To()
|
||||
if user.Ignored.In(fromID) {
|
||||
return // Skip ignored
|
||||
}
|
||||
|
||||
user.Send(m)
|
||||
default:
|
||||
fromMsg, _ := m.(message.MessageFrom)
|
||||
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 _, ok := m.(*message.AnnounceMsg); ok {
|
||||
if user.Config().Quiet {
|
||||
// Skip announcements
|
||||
return
|
||||
return // Skip announcements
|
||||
}
|
||||
}
|
||||
user.Send(m)
|
||||
@ -144,6 +181,7 @@ func (r *Room) Join(u *message.User) (*Member, error) {
|
||||
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))
|
||||
@ -233,7 +271,7 @@ func (r *Room) NamesPrefix(prefix string) []string {
|
||||
// Pull out names
|
||||
names := make([]string, 0, len(items))
|
||||
for _, user := range users {
|
||||
names = append(names, user.Name())
|
||||
names = append(names, user.ID())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shazow/ssh-chat/chat/message"
|
||||
"github.com/shazow/ssh-chat/set"
|
||||
@ -105,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)
|
||||
@ -138,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())
|
||||
@ -153,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)
|
||||
@ -381,17 +476,22 @@ func TestRoomNamesPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
members[2].HandleMsg(message.NewMsg("hi")) // aac
|
||||
members[0].HandleMsg(message.NewMsg("hi")) // aaa
|
||||
members[3].HandleMsg(message.NewMsg("hi")) // foo
|
||||
members[1].HandleMsg(message.NewMsg("hi")) // aab
|
||||
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)
|
||||
}
|
||||
|
||||
members[2].HandleMsg(message.NewMsg("hi")) // aac
|
||||
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)
|
||||
}
|
||||
|
@ -19,25 +19,37 @@ import (
|
||||
"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,6 +69,9 @@ func main() {
|
||||
if p == nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
if flagErr, ok := err.(*flags.Error); ok && flagErr.Type == flags.ErrHelp {
|
||||
fmt.Print(extraHelp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -73,12 +88,13 @@ func main() {
|
||||
|
||||
// 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
|
||||
@ -87,28 +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,46 +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 {
|
||||
if err.Error() == "ssh: no key found" {
|
||||
return nil // Skip line
|
||||
}
|
||||
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 {
|
||||
if err.Error() == "ssh: no key found" {
|
||||
return nil // Skip line
|
||||
}
|
||||
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 == "-" {
|
||||
@ -185,24 +196,32 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.")
|
||||
}
|
||||
|
||||
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
14
go.mod
@ -2,9 +2,13 @@ module github.com/shazow/ssh-chat
|
||||
|
||||
require (
|
||||
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58
|
||||
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c
|
||||
github.com/jessevdk/go-flags v1.4.0
|
||||
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1
|
||||
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576
|
||||
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54
|
||||
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
|
||||
|
57
go.sum
57
go.sum
@ -1,13 +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/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c h1:kQWxfPIHVLbgLzphqk3QUflDy9QdksZR4ygR807bpy0=
|
||||
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
|
||||
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1 h1:Lx3BlDGFElJt4u/zKc9A3BuGYbQAGlEFyPuUA3jeMD0=
|
||||
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
|
||||
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576 h1:aUX/1G2gFSs4AsJJg2cL3HuoRhCSCz733FE5GUSuaT4=
|
||||
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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-20190321052220-f7bb7a8bee54 h1:xe1/2UUJRmA9iDglQSlkx8c5n3twv58+K0mPpC2zmhA=
|
||||
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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=
|
||||
|
421
host.go
421
host.go
@ -9,10 +9,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
@ -90,11 +100,24 @@ func (h *Host) isOp(conn sshd.Connection) bool {
|
||||
|
||||
// Connect a specific Terminal to this host and its room.
|
||||
func (h *Host) Connect(term *sshd.Terminal) {
|
||||
term.SetEnterClear(true) // We provide our own echo rendering
|
||||
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()
|
||||
|
||||
@ -109,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))
|
||||
}
|
||||
|
||||
@ -124,10 +147,37 @@ 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) {
|
||||
@ -137,6 +187,10 @@ func (h *Host) Connect(term *sshd.Terminal) {
|
||||
|
||||
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 {
|
||||
@ -164,24 +218,20 @@ func (h *Host) Connect(term *sshd.Terminal) {
|
||||
|
||||
m := message.ParseInput(line, user)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -199,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 {
|
||||
@ -245,7 +300,7 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int,
|
||||
if completed == "/reply" {
|
||||
replyTo := u.ReplyTo()
|
||||
if replyTo != nil {
|
||||
name := replyTo.Name()
|
||||
name := replyTo.ID()
|
||||
_, found := h.GetUser(name)
|
||||
if found {
|
||||
completed = "/msg " + name
|
||||
@ -256,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
|
||||
}
|
||||
@ -287,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",
|
||||
@ -305,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)
|
||||
},
|
||||
})
|
||||
|
||||
@ -332,20 +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)
|
||||
target.SetReplyTo(msg.From())
|
||||
return nil
|
||||
return sendPM(room, strings.Join(args, " "), msg.From(), target)
|
||||
},
|
||||
})
|
||||
|
||||
@ -363,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()))
|
||||
|
||||
@ -463,7 +516,7 @@ 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
|
||||
},
|
||||
@ -502,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()
|
||||
@ -519,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
|
||||
},
|
||||
@ -575,4 +639,261 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
462
host_test.go
462
host_test.go
@ -2,9 +2,8 @@ package sshchat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
mathRand "math/rand"
|
||||
"strings"
|
||||
@ -13,6 +12,7 @@ import (
|
||||
"github.com/shazow/ssh-chat/chat/message"
|
||||
"github.com/shazow/ssh-chat/sshd"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func stripPrompt(s string) string {
|
||||
@ -23,9 +23,15 @@ func stripPrompt(s string) string {
|
||||
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
|
||||
}
|
||||
|
||||
@ -42,6 +48,14 @@ func TestStripPrompt(t *testing.T) {
|
||||
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 {
|
||||
@ -75,153 +89,263 @@ func TestHostGetPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostNameCollision(t *testing.T) {
|
||||
key, err := sshd.NewRandomSigner(512)
|
||||
func getHost(t *testing.T, auth *Auth) (*sshd.SSHListener, *Host) {
|
||||
key, err := sshd.NewRandomSigner(1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := sshd.MakeNoAuth()
|
||||
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)
|
||||
}
|
||||
defer s.Close()
|
||||
host := NewHost(s, nil)
|
||||
go host.Serve()
|
||||
|
||||
done := make(chan struct{}, 1)
|
||||
|
||||
// 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)\r"
|
||||
if actual != expected {
|
||||
t.Errorf("Got %q; expected %q", actual, expected)
|
||||
}
|
||||
|
||||
// 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)\r"
|
||||
if actual != expected {
|
||||
t.Errorf("Got %q; expected %q", actual, expected)
|
||||
}
|
||||
|
||||
// Wrap it up.
|
||||
close(done)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
done <- struct{}{}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
<-done
|
||||
return s, NewHost(s, auth)
|
||||
}
|
||||
|
||||
func TestHostWhitelist(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 TestHostNameCollision(t *testing.T) {
|
||||
s, host := getHost(t, nil)
|
||||
defer s.Close()
|
||||
|
||||
newUsers := make(chan *message.User)
|
||||
host.OnUserJoined = func(u *message.User) {
|
||||
newUsers <- u
|
||||
}
|
||||
go host.Serve()
|
||||
|
||||
g := errgroup.Group{}
|
||||
|
||||
// First client
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
// Second client
|
||||
g.Go(func() error {
|
||||
// first client
|
||||
name := (<-newUsers).Name()
|
||||
if name != "foo" {
|
||||
t.Errorf("First client did not get foo name: %q", name)
|
||||
}
|
||||
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowlist(t *testing.T) {
|
||||
auth := NewAuth()
|
||||
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()
|
||||
|
||||
users := make(chan *message.User)
|
||||
host.OnUserJoined = func(u *message.User) {
|
||||
users <- u
|
||||
}
|
||||
|
||||
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{})
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
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
|
||||
@ -252,41 +376,103 @@ func TestHostKick(t *testing.T) {
|
||||
}
|
||||
|
||||
kicked <- struct{}{}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
connected <- struct{}{}
|
||||
close(connected)
|
||||
t.Fatal(err)
|
||||
close(done)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
<-kicked
|
||||
|
||||
if _, err := w.Write([]byte("am I still here?\r\n")); err != nil {
|
||||
return err
|
||||
if _, err := w.Write([]byte("am I still here?\r\n")); err != io.EOF {
|
||||
return errors.New("expected to be kicked")
|
||||
}
|
||||
|
||||
scanner.Scan()
|
||||
return scanner.Err()
|
||||
if err := scanner.Err(); err == io.EOF {
|
||||
// All good, we got kicked.
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
})
|
||||
if err == io.EOF {
|
||||
// All good, we got kicked.
|
||||
} else if err != nil {
|
||||
close(done)
|
||||
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
|
||||
}
|
||||
|
50
identity.go
50
identity.go
@ -1,9 +1,12 @@
|
||||
package sshchat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -41,33 +45,69 @@ 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: " + sanitize.Data(string(i.ClientVersion()), 64) + message.Newline +
|
||||
" > joined: " + humantime.Since(i.created) + " ago"
|
||||
" > 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: " + sanitize.Data(string(i.ClientVersion()), 64) + message.Newline +
|
||||
" > joined: " + humantime.Since(i.created) + " ago"
|
||||
" > 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()
|
||||
}
|
||||
|
@ -1,8 +0,0 @@
|
||||
### Expected Behavior
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
### Steps to reproduce behavior
|
||||
|
||||
|
||||
### Additional Comments
|
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
|
||||
|
31
set/set.go
31
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
|
||||
|
||||
@ -81,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()
|
||||
@ -89,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()
|
||||
@ -108,9 +121,6 @@ func (s *Set) Add(item Item) error {
|
||||
|
||||
// Set item to this set, even if it already exists.
|
||||
func (s *Set) Set(item Item) error {
|
||||
if item.Value() == nil {
|
||||
return ErrNil
|
||||
}
|
||||
key := s.normalize(item.Key())
|
||||
|
||||
s.Lock()
|
||||
@ -137,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)
|
||||
|
||||
|
@ -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")
|
||||
|
48
sshd/auth.go
48
sshd/auth.go
@ -5,26 +5,40 @@ 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 and client agent string, returns nil if the connection should be allowed.
|
||||
Check(net.Addr, ssh.PublicKey, string) 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) {
|
||||
err := auth.Check(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
|
||||
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
|
||||
}
|
||||
@ -33,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, sanitize.Data(string(conn.ClientVersion()), 64))
|
||||
return nil, err
|
||||
},
|
||||
}
|
||||
|
@ -30,9 +30,24 @@ func NewClientConfig(name string) *ssh.ClientConfig {
|
||||
}
|
||||
}
|
||||
|
||||
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, string) error {
|
||||
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)
|
||||
|
@ -2,6 +2,7 @@ package sshd
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/shazow/rateio"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -55,6 +55,29 @@ func (c sshConn) Name() string {
|
||||
return c.User()
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -63,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
|
||||
@ -75,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
|
||||
@ -103,7 +132,18 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
// NewSession Finds a session channel and make a Terminal from it
|
||||
@ -127,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 {
|
||||
@ -146,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)
|
||||
@ -161,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 {
|
||||
@ -168,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")
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/width"
|
||||
)
|
||||
|
||||
// EscapeCodes contains escape sequences that can be written to the terminal in
|
||||
@ -129,6 +131,8 @@ const (
|
||||
keyRight
|
||||
keyAltLeft
|
||||
keyAltRight
|
||||
keyAltF
|
||||
keyAltB
|
||||
keyHome
|
||||
keyEnd
|
||||
keyDeleteWord
|
||||
@ -155,8 +159,12 @@ func bytesToKey(b []byte, pasteActive bool) (rune, []byte) {
|
||||
switch b[0] {
|
||||
case 1: // ^A
|
||||
return keyHome, b[1:]
|
||||
case 2: // ^B
|
||||
return keyLeft, b[1:]
|
||||
case 5: // ^E
|
||||
return keyEnd, b[1:]
|
||||
case 6: // ^F
|
||||
return keyRight, b[1:]
|
||||
case 8: // ^H
|
||||
return keyBackspace, b[1:]
|
||||
case 11: // ^K
|
||||
@ -206,6 +214,15 @@ func bytesToKey(b []byte, pasteActive bool) (rune, []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
if !pasteActive && len(b) >= 2 && b[0] == keyEscape {
|
||||
switch b[1] {
|
||||
case 'f':
|
||||
return keyAltF, b[2:]
|
||||
case 'b':
|
||||
return keyAltB, b[2:]
|
||||
}
|
||||
}
|
||||
|
||||
if !pasteActive && len(b) >= 6 && bytes.Equal(b[:6], pasteStart) {
|
||||
return keyPasteStart, b[6:]
|
||||
}
|
||||
@ -247,7 +264,11 @@ func (t *Terminal) moveCursorToPos(pos int) {
|
||||
return
|
||||
}
|
||||
|
||||
x := visualLength(t.prompt) + pos
|
||||
if pos > len(t.line) {
|
||||
pos = len(t.line)
|
||||
}
|
||||
|
||||
x := visualLength(t.prompt) + visualLength(t.line[:pos])
|
||||
y := x / t.termWidth
|
||||
x = x % t.termWidth
|
||||
|
||||
@ -336,6 +357,7 @@ func (t *Terminal) setLine(newLine []rune, newPos int) {
|
||||
for i := len(newLine); i < len(t.line); i++ {
|
||||
t.writeLine(space)
|
||||
}
|
||||
t.line = newLine
|
||||
t.moveCursorToPos(newPos)
|
||||
}
|
||||
t.line = newLine
|
||||
@ -447,6 +469,10 @@ func visualLength(runes []rune) int {
|
||||
inEscapeSeq = true
|
||||
default:
|
||||
length++
|
||||
kind := width.LookupRune(r).Kind()
|
||||
if kind == width.EastAsianFullwidth || kind == width.EastAsianWide {
|
||||
length++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -467,10 +493,14 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) {
|
||||
return
|
||||
}
|
||||
t.eraseNPreviousChars(1)
|
||||
case keyAltB:
|
||||
fallthrough
|
||||
case keyAltLeft:
|
||||
// move left by a word.
|
||||
t.pos -= t.countToLeftWord()
|
||||
t.moveCursorToPos(t.pos)
|
||||
case keyAltF:
|
||||
fallthrough
|
||||
case keyAltRight:
|
||||
// move right by a word.
|
||||
t.pos += t.countToRightWord()
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type MockTerminal struct {
|
||||
@ -406,3 +407,29 @@ func TestOutputNewlines(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
// panic(err)
|
||||
// }
|
||||
// defer terminal.Restore(0, oldState)
|
||||
package terminal // import "golang.org/x/crypto/ssh/terminal"
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
|
@ -4,12 +4,13 @@
|
||||
|
||||
// +build solaris
|
||||
|
||||
package terminal // import "golang.org/x/crypto/ssh/terminal"
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
"io"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// State contains the state of a terminal.
|
||||
|
Loading…
x
Reference in New Issue
Block a user