mirror of
https://github.com/shazow/ssh-chat.git
synced 2025-07-31 01:44:31 +02:00
Compare commits
No commits in common. "master" and "v1.8.2" have entirely different histories.
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@ -1 +0,0 @@
|
|||||||
github: shazow
|
|
63
.github/ISSUE_TEMPLATE/BUG.yml
vendored
63
.github/ISSUE_TEMPLATE/BUG.yml
vendored
@ -1,63 +0,0 @@
|
|||||||
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
32
.github/workflows/go.yml
vendored
@ -1,32 +0,0 @@
|
|||||||
name: Go
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ master ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ master ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- name: Set up Go 1.x
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: ^1
|
|
||||||
id: go
|
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Get dependencies
|
|
||||||
run: go get -v -t -d ./...
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: go build -v .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: go test -race -vet "all" -v ./...
|
|
@ -1,78 +0,0 @@
|
|||||||
# 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
21
Dockerfile
@ -1,21 +0,0 @@
|
|||||||
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)
|
all: $(BINARY)
|
||||||
|
|
||||||
$(BINARY): **/**/*.go **/*.go *.go
|
$(BINARY): **/**/*.go **/*.go *.go
|
||||||
go build -ldflags "$(LDFLAGS)" ./cmd/ssh-chat
|
go build $(BUILDFLAGS) ./cmd/ssh-chat
|
||||||
|
|
||||||
build: $(BINARY)
|
build: $(BINARY)
|
||||||
|
|
||||||
@ -35,13 +35,6 @@ 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=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=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=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=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=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
|
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,15 +12,11 @@ Custom SSH server written in Go. Instead of a shell, you get a chat prompt.
|
|||||||
|
|
||||||
Join the party:
|
Join the party:
|
||||||
|
|
||||||
``` console
|
```
|
||||||
$ ssh ssh.chat
|
$ ssh chat.shazow.net
|
||||||
```
|
```
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
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.)
|
(Apologies if the server is down, try again shortly.)
|
||||||
|
|
||||||
@ -52,7 +48,7 @@ Additionally, `make debug` runs the server with an http `pprof` server. This all
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
``` console
|
```
|
||||||
Usage:
|
Usage:
|
||||||
ssh-chat [OPTIONS]
|
ssh-chat [OPTIONS]
|
||||||
|
|
||||||
@ -62,7 +58,7 @@ Application Options:
|
|||||||
-i, --identity= Private key to identify server with. (default: ~/.ssh/id_rsa)
|
-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)
|
--bind= Host and port to listen on. (default: 0.0.0.0:2022)
|
||||||
--admin= File of public keys who are admins.
|
--admin= File of public keys who are admins.
|
||||||
--allowlist= Optional file of public keys who are allowed to connect.
|
--whitelist= Optional file of public keys who are allowed to connect.
|
||||||
--motd= Optional Message of the Day file.
|
--motd= Optional Message of the Day file.
|
||||||
--log= Write chat log to this file.
|
--log= Write chat log to this file.
|
||||||
--pprof= Enable pprof http server for profiling.
|
--pprof= Enable pprof http server for profiling.
|
||||||
@ -74,7 +70,7 @@ Help Options:
|
|||||||
After doing `go get github.com/shazow/ssh-chat/...` on this repo, you should be able
|
After doing `go get github.com/shazow/ssh-chat/...` on this repo, you should be able
|
||||||
to run a command like:
|
to run a command like:
|
||||||
|
|
||||||
``` console
|
```
|
||||||
$ ssh-chat --verbose --bind ":22" --identity ~/.ssh/id_dsa
|
$ ssh-chat --verbose --bind ":22" --identity ~/.ssh/id_dsa
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -88,4 +84,4 @@ Feel free to submit more questions to be answered and added to the page.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
This project is licensed under the MIT open source license.
|
||||||
|
164
auth.go
164
auth.go
@ -1,14 +1,11 @@
|
|||||||
package sshchat
|
package sshchat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shazow/ssh-chat/set"
|
"github.com/shazow/ssh-chat/set"
|
||||||
@ -16,20 +13,13 @@ import (
|
|||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyLoader loads public keys, e.g. from an authorized_keys file.
|
// ErrNotWhitelisted Is the error returned when a key is checked that is not whitelisted,
|
||||||
// It must return a nil slice on error.
|
// when whitelisting is enabled.
|
||||||
type KeyLoader func() ([]ssh.PublicKey, error)
|
var ErrNotWhitelisted = errors.New("not whitelisted")
|
||||||
|
|
||||||
// ErrNotAllowed Is the error returned when a key is checked that is not allowlisted,
|
// ErrBanned is the error returned when a key is checked that is banned.
|
||||||
// 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")
|
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.
|
// newAuthKey returns string from an ssh.PublicKey used to index the key in our lookup.
|
||||||
func newAuthKey(key ssh.PublicKey) string {
|
func newAuthKey(key ssh.PublicKey) string {
|
||||||
if key == nil {
|
if key == nil {
|
||||||
@ -52,20 +42,13 @@ func newAuthAddr(addr net.Addr) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth stores lookups for bans, allowlists, and ops. It implements the sshd.Auth interface.
|
// Auth stores lookups for bans, whitelists, and ops. It implements the sshd.Auth interface.
|
||||||
// If the contained passphrase is not empty, it complements a allowlist.
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
passphraseHash []byte
|
bannedAddr *set.Set
|
||||||
bannedAddr *set.Set
|
bannedClient *set.Set
|
||||||
bannedClient *set.Set
|
banned *set.Set
|
||||||
banned *set.Set
|
whitelist *set.Set
|
||||||
allowlist *set.Set
|
ops *set.Set
|
||||||
ops *set.Set
|
|
||||||
|
|
||||||
settingsMu sync.RWMutex
|
|
||||||
allowlistMode bool
|
|
||||||
opLoader KeyLoader
|
|
||||||
allowlistLoader KeyLoader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuth creates a new empty Auth.
|
// NewAuth creates a new empty Auth.
|
||||||
@ -74,48 +57,29 @@ func NewAuth() *Auth {
|
|||||||
bannedAddr: set.New(),
|
bannedAddr: set.New(),
|
||||||
bannedClient: set.New(),
|
bannedClient: set.New(),
|
||||||
banned: set.New(),
|
banned: set.New(),
|
||||||
allowlist: set.New(),
|
whitelist: set.New(),
|
||||||
ops: 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.
|
// AllowAnonymous determines if anonymous users are permitted.
|
||||||
func (a *Auth) AllowAnonymous() bool {
|
func (a *Auth) AllowAnonymous() bool {
|
||||||
return !a.AllowlistMode() && a.passphraseHash == nil
|
return a.whitelist.Len() == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptPassphrase determines if passphrase authentication is accepted.
|
// Check determines if a pubkey fingerprint is permitted.
|
||||||
func (a *Auth) AcceptPassphrase() bool {
|
func (a *Auth) Check(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
|
||||||
return a.passphraseHash != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckBans checks IP, key and client bans.
|
|
||||||
func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
|
|
||||||
authkey := newAuthKey(key)
|
authkey := newAuthKey(key)
|
||||||
|
|
||||||
|
if a.whitelist.Len() != 0 {
|
||||||
|
// Only check whitelist if there is something in it, otherwise it's disabled.
|
||||||
|
whitelisted := a.whitelist.In(authkey)
|
||||||
|
if !whitelisted {
|
||||||
|
return ErrNotWhitelisted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var banned bool
|
var banned bool
|
||||||
if authkey != "" {
|
if authkey != "" {
|
||||||
banned = a.banned.In(authkey)
|
banned = a.banned.In(authkey)
|
||||||
@ -134,29 +98,6 @@ func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckPubkey determines if a pubkey fingerprint is permitted.
|
|
||||||
func (a *Auth) CheckPublicKey(key ssh.PublicKey) error {
|
|
||||||
authkey := newAuthKey(key)
|
|
||||||
allowlisted := a.allowlist.In(authkey)
|
|
||||||
if a.AllowAnonymous() || allowlisted || a.IsOp(key) {
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return ErrNotAllowed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckPassphrase determines if a passphrase is permitted.
|
|
||||||
func (a *Auth) CheckPassphrase(passphrase string) error {
|
|
||||||
if !a.AcceptPassphrase() {
|
|
||||||
return errors.New("passphrases not accepted") // this should never happen
|
|
||||||
}
|
|
||||||
passedPassphraseHash := sha256.Sum256([]byte(passphrase))
|
|
||||||
if subtle.ConstantTimeCompare(passedPassphraseHash[:], a.passphraseHash) == 0 {
|
|
||||||
return ErrIncorrectPassphrase
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Op sets a public key as a known operator.
|
// Op sets a public key as a known operator.
|
||||||
func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
||||||
if key == nil {
|
if key == nil {
|
||||||
@ -173,68 +114,25 @@ func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
|||||||
|
|
||||||
// IsOp checks if a public key is an op.
|
// IsOp checks if a public key is an op.
|
||||||
func (a *Auth) IsOp(key ssh.PublicKey) bool {
|
func (a *Auth) IsOp(key ssh.PublicKey) bool {
|
||||||
|
if key == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
authkey := newAuthKey(key)
|
authkey := newAuthKey(key)
|
||||||
return a.ops.In(authkey)
|
return a.ops.In(authkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadOps sets the public keys form loader to operators and saves the loader for later use
|
// Whitelist will set a public key as a whitelisted user.
|
||||||
func (a *Auth) LoadOps(loader KeyLoader) error {
|
func (a *Auth) Whitelist(key ssh.PublicKey, d time.Duration) {
|
||||||
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 {
|
if key == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
authItem := newAuthItem(key)
|
authItem := newAuthItem(key)
|
||||||
if d != 0 {
|
if d != 0 {
|
||||||
err = a.allowlist.Set(set.Expire(authItem, d))
|
a.whitelist.Set(set.Expire(authItem, d))
|
||||||
} else {
|
} else {
|
||||||
err = a.allowlist.Set(authItem)
|
a.whitelist.Set(authItem)
|
||||||
}
|
}
|
||||||
if err == nil {
|
logger.Debugf("Added to whitelist: %q (for %s)", authItem.Key(), d)
|
||||||
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.
|
// Ban will set a public key as banned.
|
||||||
|
49
auth_test.go
49
auth_test.go
@ -21,20 +21,19 @@ func ClonePublicKey(key ssh.PublicKey) (ssh.PublicKey, error) {
|
|||||||
return ssh.ParsePublicKey(key.Marshal())
|
return ssh.ParsePublicKey(key.Marshal())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthAllowlist(t *testing.T) {
|
func TestAuthWhitelist(t *testing.T) {
|
||||||
key, err := NewRandomPublicKey(512)
|
key, err := NewRandomPublicKey(512)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := NewAuth()
|
auth := NewAuth()
|
||||||
err = auth.CheckPublicKey(key)
|
err = auth.Check(nil, key, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("Failed to permit in default state:", err)
|
t.Error("Failed to permit in default state:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.Allowlist(key, 0)
|
auth.Whitelist(key, 0)
|
||||||
auth.SetAllowlistMode(true)
|
|
||||||
|
|
||||||
keyClone, err := ClonePublicKey(key)
|
keyClone, err := ClonePublicKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -45,9 +44,9 @@ func TestAuthAllowlist(t *testing.T) {
|
|||||||
t.Error("Clone key does not match.")
|
t.Error("Clone key does not match.")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = auth.CheckPublicKey(keyClone)
|
err = auth.Check(nil, keyClone, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("Failed to permit allowlisted:", err)
|
t.Error("Failed to permit whitelisted:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := NewRandomPublicKey(512)
|
key2, err := NewRandomPublicKey(512)
|
||||||
@ -55,42 +54,8 @@ func TestAuthAllowlist(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = auth.CheckPublicKey(key2)
|
err = auth.Check(nil, key2, "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Failed to restrict not allowlisted:", err)
|
t.Error("Failed to restrict not whitelisted:", 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,7 +5,6 @@ package chat
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -190,7 +189,6 @@ func InitCommands(c *Commands) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
names := room.Members.ListPrefix("")
|
names := room.Members.ListPrefix("")
|
||||||
sort.Slice(names, func(i, j int) bool { return names[i].Key() < names[j].Key() })
|
|
||||||
colNames := make([]string, len(names))
|
colNames := make([]string, len(names))
|
||||||
for i, uname := range names {
|
for i, uname := range names {
|
||||||
colNames[i] = colorize(uname.Value().(*Member).User)
|
colNames[i] = colorize(uname.Value().(*Member).User)
|
||||||
@ -411,119 +409,4 @@ func InitCommands(c *Commands) {
|
|||||||
return nil
|
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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
@ -1,70 +0,0 @@
|
|||||||
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,9 +1,7 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import "io"
|
||||||
"io"
|
import stdlog "log"
|
||||||
stdlog "log"
|
|
||||||
)
|
|
||||||
|
|
||||||
var logger *stdlog.Logger
|
var logger *stdlog.Logger
|
||||||
|
|
||||||
|
@ -131,9 +131,6 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string {
|
|||||||
|
|
||||||
// RenderSelf renders the message for when it's echoing your own message.
|
// RenderSelf renders the message for when it's echoing your own message.
|
||||||
func (m PublicMsg) RenderSelf(cfg UserConfig) string {
|
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)
|
return fmt.Sprintf("[%s] %s", cfg.Theme.ColorName(m.from), m.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +138,9 @@ func (m PublicMsg) String() string {
|
|||||||
return fmt.Sprintf("%s: %s", m.from.Name(), m.body)
|
return fmt.Sprintf("%s: %s", m.from.Name(), m.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmoteMsg is a /me message sent to the room.
|
// 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.
|
||||||
type EmoteMsg struct {
|
type EmoteMsg struct {
|
||||||
Msg
|
Msg
|
||||||
from *User
|
from *User
|
||||||
@ -157,10 +156,6 @@ func NewEmoteMsg(body string, from *User) *EmoteMsg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m EmoteMsg) From() *User {
|
|
||||||
return m.from
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m EmoteMsg) Render(t *Theme) string {
|
func (m EmoteMsg) Render(t *Theme) string {
|
||||||
return fmt.Sprintf("** %s %s", m.from.Name(), m.body)
|
return fmt.Sprintf("** %s %s", m.from.Name(), m.body)
|
||||||
}
|
}
|
||||||
@ -186,16 +181,11 @@ func (m PrivateMsg) To() *User {
|
|||||||
return m.to
|
return m.to
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m PrivateMsg) From() *User {
|
|
||||||
return m.from
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m PrivateMsg) Render(t *Theme) string {
|
func (m PrivateMsg) Render(t *Theme) string {
|
||||||
format := "[PM from %s] %s"
|
s := fmt.Sprintf("[PM from %s] %s", m.from.Name(), m.body)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return fmt.Sprintf(format, m.from.ID(), m.body)
|
return s
|
||||||
}
|
}
|
||||||
s := fmt.Sprintf(format, m.from.Name(), m.body)
|
|
||||||
return t.ColorPM(s)
|
return t.ColorPM(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,7 +122,6 @@ type Theme struct {
|
|||||||
pm Style
|
pm Style
|
||||||
highlight Style
|
highlight Style
|
||||||
names *Palette
|
names *Palette
|
||||||
useID bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (theme Theme) ID() string {
|
func (theme Theme) ID() string {
|
||||||
@ -131,17 +130,11 @@ func (theme Theme) ID() string {
|
|||||||
|
|
||||||
// Colorize name string given some index
|
// Colorize name string given some index
|
||||||
func (theme Theme) ColorName(u *User) string {
|
func (theme Theme) ColorName(u *User) string {
|
||||||
var name string
|
|
||||||
if theme.useID {
|
|
||||||
name = u.ID()
|
|
||||||
} else {
|
|
||||||
name = u.Name()
|
|
||||||
}
|
|
||||||
if theme.names == nil {
|
if theme.names == nil {
|
||||||
return name
|
return u.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
return theme.names.Get(u.colorIdx).Format(name)
|
return theme.names.Get(u.colorIdx).Format(u.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colorize the PM string
|
// Colorize the PM string
|
||||||
@ -233,8 +226,7 @@ func init() {
|
|||||||
highlight: style(Bold + "\033[48;5;22m\033[38;5;46m"), // Green on dark green
|
highlight: style(Bold + "\033[48;5;22m\033[38;5;46m"), // Green on dark green
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mono",
|
id: "mono",
|
||||||
useID: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,9 +22,7 @@ var ErrUserClosed = errors.New("user closed")
|
|||||||
// User definition, implemented set Item interface and io.Writer
|
// User definition, implemented set Item interface and io.Writer
|
||||||
type User struct {
|
type User struct {
|
||||||
Identifier
|
Identifier
|
||||||
OnChange func()
|
Ignored *set.Set
|
||||||
Ignored set.Interface
|
|
||||||
Focused set.Interface
|
|
||||||
colorIdx int
|
colorIdx int
|
||||||
joined time.Time
|
joined time.Time
|
||||||
msg chan Message
|
msg chan Message
|
||||||
@ -33,12 +31,10 @@ type User struct {
|
|||||||
screen io.WriteCloser
|
screen io.WriteCloser
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
config UserConfig
|
config UserConfig
|
||||||
replyTo *User // Set when user gets a /msg, for replying.
|
replyTo *User // Set when user gets a /msg, for replying.
|
||||||
lastMsg time.Time // When the last message was rendered.
|
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 {
|
func NewUser(identity Identifier) *User {
|
||||||
@ -49,7 +45,6 @@ func NewUser(identity Identifier) *User {
|
|||||||
msg: make(chan Message, messageBuffer),
|
msg: make(chan Message, messageBuffer),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
Ignored: set.New(),
|
Ignored: set.New(),
|
||||||
Focused: set.New(),
|
|
||||||
}
|
}
|
||||||
u.setColorIdx(rand.Int())
|
u.setColorIdx(rand.Int())
|
||||||
|
|
||||||
@ -67,32 +62,6 @@ func (u *User) Joined() time.Time {
|
|||||||
return u.joined
|
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 {
|
func (u *User) Config() UserConfig {
|
||||||
u.mu.Lock()
|
u.mu.Lock()
|
||||||
defer u.mu.Unlock()
|
defer u.mu.Unlock()
|
||||||
@ -103,20 +72,12 @@ func (u *User) SetConfig(cfg UserConfig) {
|
|||||||
u.mu.Lock()
|
u.mu.Lock()
|
||||||
u.config = cfg
|
u.config = cfg
|
||||||
u.mu.Unlock()
|
u.mu.Unlock()
|
||||||
|
|
||||||
if u.OnChange != nil {
|
|
||||||
u.OnChange()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename the user with a new Identifier.
|
// Rename the user with a new Identifier.
|
||||||
func (u *User) SetID(id string) {
|
func (u *User) SetID(id string) {
|
||||||
u.Identifier.SetID(id)
|
u.Identifier.SetID(id)
|
||||||
u.setColorIdx(rand.Int())
|
u.setColorIdx(rand.Int())
|
||||||
|
|
||||||
if u.OnChange != nil {
|
|
||||||
u.OnChange()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplyTo returns the last user that messaged this user.
|
// ReplyTo returns the last user that messaged this user.
|
||||||
@ -143,9 +104,7 @@ func (u *User) setColorIdx(idx int) {
|
|||||||
func (u *User) Close() {
|
func (u *User) Close() {
|
||||||
u.closeOnce.Do(func() {
|
u.closeOnce.Do(func() {
|
||||||
if u.screen != nil {
|
if u.screen != nil {
|
||||||
if err := u.screen.Close(); err != nil {
|
u.screen.Close()
|
||||||
logger.Printf("Failed to close user %q screen: %s", u.ID(), err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// close(u.msg) TODO: Close?
|
// close(u.msg) TODO: Close?
|
||||||
close(u.done)
|
close(u.done)
|
||||||
@ -202,17 +161,7 @@ func (u *User) render(m Message) string {
|
|||||||
switch m := m.(type) {
|
switch m := m.(type) {
|
||||||
case PublicMsg:
|
case PublicMsg:
|
||||||
if u == m.From() {
|
if u == m.From() {
|
||||||
u.mu.Lock()
|
|
||||||
u.lastMsg = m.Timestamp()
|
|
||||||
u.mu.Unlock()
|
|
||||||
|
|
||||||
if !cfg.Echo {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
out += m.RenderSelf(cfg)
|
out += m.RenderSelf(cfg)
|
||||||
} else if u.Focused.Len() > 0 && !u.Focused.In(m.From().ID()) {
|
|
||||||
// Skip message during focus
|
|
||||||
return ""
|
|
||||||
} else {
|
} else {
|
||||||
out += m.RenderFor(cfg)
|
out += m.RenderFor(cfg)
|
||||||
}
|
}
|
||||||
@ -244,7 +193,7 @@ func (u *User) writeMsg(m Message) error {
|
|||||||
r := u.render(m)
|
r := u.render(m)
|
||||||
_, err := u.screen.Write([]byte(r))
|
_, err := u.screen.Write([]byte(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Write failed to %s, closing: %s", u.ID(), err)
|
logger.Printf("Write failed to %s, closing: %s", u.Name(), err)
|
||||||
u.Close()
|
u.Close()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@ -252,6 +201,9 @@ func (u *User) writeMsg(m Message) error {
|
|||||||
|
|
||||||
// HandleMsg will render the message to the screen, blocking.
|
// HandleMsg will render the message to the screen, blocking.
|
||||||
func (u *User) HandleMsg(m Message) error {
|
func (u *User) HandleMsg(m Message) error {
|
||||||
|
u.mu.Lock()
|
||||||
|
u.lastMsg = m.Timestamp()
|
||||||
|
u.mu.Unlock()
|
||||||
return u.writeMsg(m)
|
return u.writeMsg(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,7 +214,7 @@ func (u *User) Send(m Message) error {
|
|||||||
return ErrUserClosed
|
return ErrUserClosed
|
||||||
case u.msg <- m:
|
case u.msg <- m:
|
||||||
case <-time.After(messageTimeout):
|
case <-time.After(messageTimeout):
|
||||||
logger.Printf("Message buffer full, closing: %s", u.ID())
|
logger.Printf("Message buffer full, closing: %s", u.Name())
|
||||||
u.Close()
|
u.Close()
|
||||||
return ErrUserClosed
|
return ErrUserClosed
|
||||||
}
|
}
|
||||||
@ -274,7 +226,6 @@ type UserConfig struct {
|
|||||||
Highlight *regexp.Regexp
|
Highlight *regexp.Regexp
|
||||||
Bell bool
|
Bell bool
|
||||||
Quiet bool
|
Quiet bool
|
||||||
Echo bool // Echo shows your own messages after sending, disabled for bots
|
|
||||||
Timeformat *string
|
Timeformat *string
|
||||||
Timezone *time.Location
|
Timezone *time.Location
|
||||||
Theme *Theme
|
Theme *Theme
|
||||||
@ -286,16 +237,13 @@ var DefaultUserConfig UserConfig
|
|||||||
func init() {
|
func init() {
|
||||||
DefaultUserConfig = UserConfig{
|
DefaultUserConfig = UserConfig{
|
||||||
Bell: true,
|
Bell: true,
|
||||||
Echo: true,
|
|
||||||
Quiet: false,
|
Quiet: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Seed random?
|
// TODO: Seed random?
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentActiveUsers is a slice of *Users that knows how to be sorted by the
|
// RecentActiveUsers is a slice of *Users that knows how to be sorted by the time of the last message.
|
||||||
// time of the last message. If no message has been sent, then fall back to the
|
|
||||||
// time joined instead.
|
|
||||||
type RecentActiveUsers []*User
|
type RecentActiveUsers []*User
|
||||||
|
|
||||||
func (a RecentActiveUsers) Len() int { return len(a) }
|
func (a RecentActiveUsers) Len() int { return len(a) }
|
||||||
@ -305,16 +253,5 @@ func (a RecentActiveUsers) Less(i, j int) bool {
|
|||||||
defer a[i].mu.Unlock()
|
defer a[i].mu.Unlock()
|
||||||
a[j].mu.Lock()
|
a[j].mu.Lock()
|
||||||
defer a[j].mu.Unlock()
|
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,23 +27,6 @@ var ErrInvalidName = errors.New("invalid name")
|
|||||||
type Member struct {
|
type Member struct {
|
||||||
*message.User
|
*message.User
|
||||||
IsOp bool
|
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
|
// Room definition, also a Set of User Items
|
||||||
@ -96,25 +79,6 @@ func (r *Room) SetLogging(out io.Writer) {
|
|||||||
|
|
||||||
// HandleMsg reacts to a message, will block until done.
|
// HandleMsg reacts to a message, will block until done.
|
||||||
func (r *Room) HandleMsg(m message.Message) {
|
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) {
|
switch m := m.(type) {
|
||||||
case *message.CommandMsg:
|
case *message.CommandMsg:
|
||||||
cmd := *m
|
cmd := *m
|
||||||
@ -125,23 +89,22 @@ func (r *Room) HandleMsg(m message.Message) {
|
|||||||
}
|
}
|
||||||
case message.MessageTo:
|
case message.MessageTo:
|
||||||
user := m.To()
|
user := m.To()
|
||||||
if user.Ignored.In(fromID) {
|
|
||||||
return // Skip ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Send(m)
|
user.Send(m)
|
||||||
default:
|
default:
|
||||||
|
fromMsg, _ := m.(message.MessageFrom)
|
||||||
r.history.Add(m)
|
r.history.Add(m)
|
||||||
r.Members.Each(func(_ string, item set.Item) (err error) {
|
r.Members.Each(func(_ string, item set.Item) (err error) {
|
||||||
user := item.Value().(*Member).User
|
user := item.Value().(*Member).User
|
||||||
|
|
||||||
if user.Ignored.In(fromID) {
|
if fromMsg != nil && user.Ignored.In(fromMsg.From().ID()) {
|
||||||
return // Skip ignored
|
// Skip because ignored
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := m.(*message.AnnounceMsg); ok {
|
if _, ok := m.(*message.AnnounceMsg); ok {
|
||||||
if user.Config().Quiet {
|
if user.Config().Quiet {
|
||||||
return // Skip announcements
|
// Skip announcements
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
user.Send(m)
|
user.Send(m)
|
||||||
@ -181,7 +144,6 @@ func (r *Room) Join(u *message.User) (*Member, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// TODO: Remove user ID from sets, probably referring to a prior user.
|
|
||||||
r.History(u)
|
r.History(u)
|
||||||
s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), r.Members.Len())
|
s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), r.Members.Len())
|
||||||
r.Send(message.NewAnnounceMsg(s))
|
r.Send(message.NewAnnounceMsg(s))
|
||||||
@ -271,7 +233,7 @@ func (r *Room) NamesPrefix(prefix string) []string {
|
|||||||
// Pull out names
|
// Pull out names
|
||||||
names := make([]string, 0, len(items))
|
names := make([]string, 0, len(items))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
names = append(names, user.ID())
|
names = append(names, user.Name())
|
||||||
}
|
}
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shazow/ssh-chat/chat/message"
|
"github.com/shazow/ssh-chat/chat/message"
|
||||||
"github.com/shazow/ssh-chat/set"
|
"github.com/shazow/ssh-chat/set"
|
||||||
@ -104,18 +105,8 @@ func TestIgnore(t *testing.T) {
|
|||||||
t.Fatalf("should have %d ignored users, has %d", 1, len(ignoredList))
|
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
|
// when a message is sent from the ignored user, it is delivered to non-ignoring users
|
||||||
ch.HandleMsg(message.NewPublicMsg("hello", ignored.user))
|
ch.Send(message.NewPublicMsg("hello", ignored.user))
|
||||||
other.user.HandleMsg(other.user.ConsumeOne())
|
other.user.HandleMsg(other.user.ConsumeOne())
|
||||||
other.screen.Read(&buffer)
|
other.screen.Read(&buffer)
|
||||||
expectOutput(t, buffer, ignored.user.Name()+": hello"+message.Newline)
|
expectOutput(t, buffer, ignored.user.Name()+": hello"+message.Newline)
|
||||||
@ -147,10 +138,14 @@ func TestIgnore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// after unignoring a user, its messages can be received again
|
// after unignoring a user, its messages can be received again
|
||||||
ch.HandleMsg(message.NewPublicMsg("hello again!", ignored.user))
|
ch.Send(message.NewPublicMsg("hello again!", ignored.user))
|
||||||
|
|
||||||
|
// give some time for the channel to get the message
|
||||||
|
time.Sleep(100)
|
||||||
|
|
||||||
// ensure ignorer has received the message
|
// ensure ignorer has received the message
|
||||||
if !ignorer.user.HasMessages() {
|
if !ignorer.user.HasMessages() {
|
||||||
|
// FIXME: This is flaky :/
|
||||||
t.Fatal("should have messages")
|
t.Fatal("should have messages")
|
||||||
}
|
}
|
||||||
ignorer.user.HandleMsg(ignorer.user.ConsumeOne())
|
ignorer.user.HandleMsg(ignorer.user.ConsumeOne())
|
||||||
@ -158,97 +153,7 @@ func TestIgnore(t *testing.T) {
|
|||||||
expectOutput(t, buffer, ignored.user.Name()+": hello again!"+message.Newline)
|
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) {
|
func expectOutput(t *testing.T, buffer []byte, expected string) {
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
bytes := []byte(expected)
|
bytes := []byte(expected)
|
||||||
if !reflect.DeepEqual(buffer, bytes) {
|
if !reflect.DeepEqual(buffer, bytes) {
|
||||||
t.Errorf("Got: %q; Expected: %q", buffer, expected)
|
t.Errorf("Got: %q; Expected: %q", buffer, expected)
|
||||||
@ -476,22 +381,17 @@ 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
|
// Inject some activity
|
||||||
sendMsg(members[2], "hi") // aac
|
members[2].HandleMsg(message.NewMsg("hi")) // aac
|
||||||
sendMsg(members[0], "hi") // aaa
|
members[0].HandleMsg(message.NewMsg("hi")) // aaa
|
||||||
sendMsg(members[3], "hi") // foo
|
members[3].HandleMsg(message.NewMsg("hi")) // foo
|
||||||
sendMsg(members[1], "hi") // aab
|
members[1].HandleMsg(message.NewMsg("hi")) // aab
|
||||||
|
|
||||||
if got, want := r.NamesPrefix("a"), []string{"aab", "aaa", "aac"}; !reflect.DeepEqual(got, want) {
|
if got, want := r.NamesPrefix("a"), []string{"aab", "aaa", "aac"}; !reflect.DeepEqual(got, want) {
|
||||||
t.Errorf("got: %q; want: %q", got, want)
|
t.Errorf("got: %q; want: %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMsg(members[2], "hi") // aac
|
members[2].HandleMsg(message.NewMsg("hi")) // aac
|
||||||
if got, want := r.NamesPrefix("a"), []string{"aac", "aab", "aaa"}; !reflect.DeepEqual(got, want) {
|
if got, want := r.NamesPrefix("a"), []string{"aac", "aab", "aaa"}; !reflect.DeepEqual(got, want) {
|
||||||
t.Errorf("got: %q; want: %q", got, want)
|
t.Errorf("got: %q; want: %q", got, want)
|
||||||
}
|
}
|
||||||
|
@ -19,37 +19,25 @@ import (
|
|||||||
"github.com/shazow/ssh-chat/chat"
|
"github.com/shazow/ssh-chat/chat"
|
||||||
"github.com/shazow/ssh-chat/chat/message"
|
"github.com/shazow/ssh-chat/chat/message"
|
||||||
"github.com/shazow/ssh-chat/sshd"
|
"github.com/shazow/ssh-chat/sshd"
|
||||||
|
|
||||||
_ "net/http/pprof"
|
|
||||||
)
|
)
|
||||||
|
import _ "net/http/pprof"
|
||||||
|
|
||||||
// Version of the binary, assigned during build.
|
// Version of the binary, assigned during build.
|
||||||
var Version string = "dev"
|
var Version string = "dev"
|
||||||
|
|
||||||
// Options contains the flag options
|
// Options contains the flag options
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Admin string `long:"admin" description:"File of public keys who are admins."`
|
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
|
||||||
Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"`
|
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"`
|
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."`
|
Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"`
|
||||||
Motd string `long:"motd" description:"Optional Message of the Day file."`
|
Admin string `long:"admin" description:"File of public keys who are admins."`
|
||||||
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
|
Whitelist string `long:"whitelist" description:"Optional file of public keys who are allowed to connect."`
|
||||||
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
|
Motd string `long:"motd" description:"Optional Message of the Day file."`
|
||||||
Version bool `long:"version" description:"Print version and exit."`
|
Log string `long:"log" description:"Write chat log to this file."`
|
||||||
Allowlist string `long:"allowlist" description:"Optional file of public keys who are allowed to connect."`
|
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
|
||||||
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{
|
var logLevels = []log.Level{
|
||||||
log.Warning,
|
log.Warning,
|
||||||
log.Info,
|
log.Info,
|
||||||
@ -69,9 +57,6 @@ func main() {
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
fmt.Print(err)
|
fmt.Print(err)
|
||||||
}
|
}
|
||||||
if flagErr, ok := err.(*flags.Error); ok && flagErr.Type == flags.ErrHelp {
|
|
||||||
fmt.Print(extraHelp)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,13 +73,12 @@ func main() {
|
|||||||
|
|
||||||
// Figure out the log level
|
// Figure out the log level
|
||||||
numVerbose := len(options.Verbose)
|
numVerbose := len(options.Verbose)
|
||||||
if numVerbose >= len(logLevels) {
|
if numVerbose > len(logLevels) {
|
||||||
numVerbose = len(logLevels) - 1
|
numVerbose = len(logLevels) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
logLevel := logLevels[numVerbose]
|
logLevel := logLevels[numVerbose]
|
||||||
logger := golog.New(os.Stderr, logLevel)
|
sshchat.SetLogger(golog.New(os.Stderr, logLevel))
|
||||||
sshchat.SetLogger(logger)
|
|
||||||
|
|
||||||
if logLevel == log.Debug {
|
if logLevel == log.Debug {
|
||||||
// Enable logging from submodules
|
// Enable logging from submodules
|
||||||
@ -103,27 +87,28 @@ func main() {
|
|||||||
message.SetLogger(os.Stderr)
|
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()
|
auth := sshchat.NewAuth()
|
||||||
config := sshd.MakeAuth(auth)
|
config := sshd.MakeAuth(auth)
|
||||||
|
config.AddHostKey(signer)
|
||||||
config.ServerVersion = "SSH-2.0-Go ssh-chat"
|
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)
|
s, err := sshd.ListenSSH(options.Bind, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -138,42 +123,46 @@ func main() {
|
|||||||
host.SetTheme(message.Themes[0])
|
host.SetTheme(message.Themes[0])
|
||||||
host.Version = Version
|
host.Version = Version
|
||||||
|
|
||||||
if options.Passphrase != "" {
|
err = fromFile(options.Admin, func(line []byte) error {
|
||||||
auth.SetPassphrase(options.Passphrase)
|
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
|
||||||
}
|
if err != nil {
|
||||||
|
if err.Error() == "ssh: no key found" {
|
||||||
err = auth.LoadOps(loaderFromFile(options.Admin, logger))
|
return nil // Skip line
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth.Op(key, 0)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(5, "Failed to load admins: %v\n", err)
|
fail(5, "Failed to load admins: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Allowlist == "" && options.Whitelist != "" {
|
err = fromFile(options.Whitelist, func(line []byte) error {
|
||||||
fmt.Println("--whitelist was renamed to --allowlist.")
|
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
|
||||||
options.Allowlist = options.Whitelist
|
if err != nil {
|
||||||
}
|
if err.Error() == "ssh: no key found" {
|
||||||
err = auth.LoadAllowlist(loaderFromFile(options.Allowlist, logger))
|
return nil // Skip line
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth.Whitelist(key, 0)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(6, "Failed to load allowlist: %v\n", err)
|
fail(6, "Failed to load whitelist: %v\n", err)
|
||||||
}
|
}
|
||||||
auth.SetAllowlistMode(options.Allowlist != "")
|
|
||||||
|
|
||||||
if options.Motd != "" {
|
if options.Motd != "" {
|
||||||
host.GetMOTD = func() (string, error) {
|
motd, err := ioutil.ReadFile(options.Motd)
|
||||||
motd, err := ioutil.ReadFile(options.Motd)
|
if err != nil {
|
||||||
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)
|
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 == "-" {
|
if options.Log == "-" {
|
||||||
@ -196,32 +185,24 @@ func main() {
|
|||||||
fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.")
|
fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loaderFromFile(path string, logger *golog.Logger) sshchat.KeyLoader {
|
func fromFile(path string, handler func(line []byte) error) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
// Skip
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return func() ([]ssh.PublicKey, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var keys []ssh.PublicKey
|
file, err := os.Open(path)
|
||||||
scanner := bufio.NewScanner(file)
|
if err != nil {
|
||||||
for scanner.Scan() {
|
return err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
err := handler(scanner.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -1,38 +1,50 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"github.com/howeyc/gopass"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReadPrivateKey attempts to read your private key and possibly decrypt it if it
|
// ReadPrivateKey attempts to read your private key and possibly decrypt it if it
|
||||||
// requires a passphrase.
|
// requires a passphrase.
|
||||||
// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`),
|
// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`),
|
||||||
// is not set.
|
// is not set.
|
||||||
func ReadPrivateKey(path string) (ssh.Signer, error) {
|
func ReadPrivateKey(path string) ([]byte, error) {
|
||||||
privateKey, err := ioutil.ReadFile(path)
|
privateKey, err := ioutil.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load identity: %v", err)
|
return nil, fmt.Errorf("failed to load identity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pk, err := ssh.ParsePrivateKey(privateKey)
|
block, rest := pem.Decode(privateKey)
|
||||||
if err == nil {
|
if len(rest) > 0 {
|
||||||
} else if _, ok := err.(*ssh.PassphraseMissingError); ok {
|
return nil, fmt.Errorf("extra data when decoding private key")
|
||||||
passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE"))
|
}
|
||||||
if len(passphrase) == 0 {
|
if !x509.IsEncryptedPEMBlock(block) {
|
||||||
fmt.Println("Enter passphrase to unlock identity private key:", path)
|
return privateKey, nil
|
||||||
passphrase, err = term.ReadPassword(int(syscall.Stdin))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("couldn't read passphrase: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ssh.ParsePrivateKeyWithPassphrase(privateKey, passphrase)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return pk, err
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
der, err := x509.DecryptPEMBlock(block, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey = pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: block.Type,
|
||||||
|
Bytes: der,
|
||||||
|
})
|
||||||
|
|
||||||
|
return privateKey, nil
|
||||||
}
|
}
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
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,13 +2,9 @@ module github.com/shazow/ssh-chat
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58
|
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58
|
||||||
github.com/jessevdk/go-flags v1.5.0
|
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c
|
||||||
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4
|
github.com/jessevdk/go-flags v1.4.0
|
||||||
golang.org/x/crypto v0.17.0
|
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1
|
||||||
golang.org/x/sync v0.1.0
|
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576
|
||||||
golang.org/x/sys v0.15.0
|
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54
|
||||||
golang.org/x/term v0.15.0
|
|
||||||
golang.org/x/text v0.14.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
go 1.13
|
|
||||||
|
57
go.sum
57
go.sum
@ -1,50 +1,13 @@
|
|||||||
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58 h1:MkpmYfld/S8kXqTYI68DfL8/hHXjHogL120Dy00TIxc=
|
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/alexcesaro/log v0.0.0-20150915221235-61e686294e58/go.mod h1:YNfsMyWSs+h+PaYkxGeMVmVCX75Zj/pqdjbu12ciCYE=
|
||||||
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
|
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c h1:kQWxfPIHVLbgLzphqk3QUflDy9QdksZR4ygR807bpy0=
|
||||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
|
||||||
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4 h1:zwQ1HBo5FYwn1ksMd19qBCKO8JAWE9wmHivEpkw/DvE=
|
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
|
||||||
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1 h1:Lx3BlDGFElJt4u/zKc9A3BuGYbQAGlEFyPuUA3jeMD0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576 h1:aUX/1G2gFSs4AsJJg2cL3HuoRhCSCz733FE5GUSuaT4=
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54 h1:xe1/2UUJRmA9iDglQSlkx8c5n3twv58+K0mPpC2zmhA=
|
||||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/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,14 +9,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
|
|
||||||
"github.com/shazow/rateio"
|
"github.com/shazow/rateio"
|
||||||
"github.com/shazow/ssh-chat/chat"
|
"github.com/shazow/ssh-chat/chat"
|
||||||
"github.com/shazow/ssh-chat/chat/message"
|
"github.com/shazow/ssh-chat/chat/message"
|
||||||
"github.com/shazow/ssh-chat/internal/humantime"
|
"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"
|
"github.com/shazow/ssh-chat/sshd"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -49,11 +45,6 @@ type Host struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
motd string
|
motd string
|
||||||
count int
|
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.
|
// NewHost creates a Host on top of an existing listener.
|
||||||
@ -83,7 +74,6 @@ func (h *Host) SetTheme(theme message.Theme) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetMotd sets the host's message of the day.
|
// SetMotd sets the host's message of the day.
|
||||||
// TODO: Change to SetMOTD
|
|
||||||
func (h *Host) SetMotd(motd string) {
|
func (h *Host) SetMotd(motd string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.motd = motd
|
h.motd = motd
|
||||||
@ -100,24 +90,11 @@ func (h *Host) isOp(conn sshd.Connection) bool {
|
|||||||
|
|
||||||
// Connect a specific Terminal to this host and its room.
|
// Connect a specific Terminal to this host and its room.
|
||||||
func (h *Host) Connect(term *sshd.Terminal) {
|
func (h *Host) Connect(term *sshd.Terminal) {
|
||||||
|
term.SetEnterClear(true) // We provide our own echo rendering
|
||||||
id := NewIdentity(term.Conn)
|
id := NewIdentity(term.Conn)
|
||||||
user := message.NewUserScreen(id, term)
|
user := message.NewUserScreen(id, term)
|
||||||
user.OnChange = func() {
|
|
||||||
term.SetPrompt(GetPrompt(user))
|
|
||||||
user.SetHighlight(user.ID())
|
|
||||||
}
|
|
||||||
cfg := user.Config()
|
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)
|
user.SetConfig(cfg)
|
||||||
go user.Consume()
|
go user.Consume()
|
||||||
|
|
||||||
@ -132,7 +109,7 @@ func (h *Host) Connect(term *sshd.Terminal) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// Send MOTD
|
// Send MOTD
|
||||||
if motd != "" && !apiMode {
|
if motd != "" {
|
||||||
user.Send(message.NewAnnounceMsg(motd))
|
user.Send(message.NewAnnounceMsg(motd))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,37 +124,10 @@ func (h *Host) Connect(term *sshd.Terminal) {
|
|||||||
return
|
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.
|
// Successfully joined.
|
||||||
if !apiMode {
|
term.SetPrompt(GetPrompt(user))
|
||||||
term.SetPrompt(GetPrompt(user))
|
term.AutoCompleteCallback = h.AutoCompleteFunction(user)
|
||||||
term.AutoCompleteCallback = h.AutoCompleteFunction(user)
|
user.SetHighlight(user.Name())
|
||||||
user.SetHighlight(user.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should the user be op'd on join?
|
// Should the user be op'd on join?
|
||||||
if h.isOp(term.Conn) {
|
if h.isOp(term.Conn) {
|
||||||
@ -187,10 +137,6 @@ func (h *Host) Connect(term *sshd.Terminal) {
|
|||||||
|
|
||||||
logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name())
|
logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name())
|
||||||
|
|
||||||
if h.OnUserJoined != nil {
|
|
||||||
h.OnUserJoined(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
line, err := term.ReadLine()
|
line, err := term.ReadLine()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
@ -218,20 +164,24 @@ func (h *Host) Connect(term *sshd.Terminal) {
|
|||||||
|
|
||||||
m := message.ParseInput(line, user)
|
m := message.ParseInput(line, user)
|
||||||
|
|
||||||
if !apiMode {
|
if m, ok := m.(*message.CommandMsg); ok {
|
||||||
if m, ok := m.(*message.CommandMsg); ok {
|
// Other messages render themselves by the room, commands we'll
|
||||||
// Other messages render themselves by the room, commands we'll
|
// have to re-echo ourselves manually.
|
||||||
// have to re-echo ourselves manually.
|
user.HandleMsg(m)
|
||||||
user.HandleMsg(m)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Any reason to use h.room.Send(m) instead?
|
// FIXME: Any reason to use h.room.Send(m) instead?
|
||||||
h.HandleMsg(m)
|
h.HandleMsg(m)
|
||||||
|
|
||||||
if apiMode {
|
cmd := m.Command()
|
||||||
// Skip the remaining rendering workarounds
|
if cmd == "/nick" || cmd == "/theme" {
|
||||||
continue
|
// 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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,19 +199,14 @@ func (h *Host) Serve() {
|
|||||||
h.listener.Serve()
|
h.listener.Serve()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Host) completeName(partial string, skipName string) string {
|
func (h *Host) completeName(partial string) string {
|
||||||
names := h.NamesPrefix(partial)
|
names := h.NamesPrefix(partial)
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
// Didn't find anything
|
// Didn't find anything
|
||||||
return ""
|
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 ""
|
|
||||||
|
return names[len(names)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Host) completeCommand(partial string) string {
|
func (h *Host) completeCommand(partial string) string {
|
||||||
@ -300,7 +245,7 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int,
|
|||||||
if completed == "/reply" {
|
if completed == "/reply" {
|
||||||
replyTo := u.ReplyTo()
|
replyTo := u.ReplyTo()
|
||||||
if replyTo != nil {
|
if replyTo != nil {
|
||||||
name := replyTo.ID()
|
name := replyTo.Name()
|
||||||
_, found := h.GetUser(name)
|
_, found := h.GetUser(name)
|
||||||
if found {
|
if found {
|
||||||
completed = "/msg " + name
|
completed = "/msg " + name
|
||||||
@ -311,7 +256,7 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int,
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Name
|
// Name
|
||||||
completed = h.completeName(partial, u.Name())
|
completed = h.completeName(partial)
|
||||||
if completed == "" {
|
if completed == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -342,20 +287,6 @@ func (h *Host) GetUser(name string) (*message.User, bool) {
|
|||||||
// InitCommands adds host-specific commands to a Commands container. These will
|
// InitCommands adds host-specific commands to a Commands container. These will
|
||||||
// override any existing commands.
|
// override any existing commands.
|
||||||
func (h *Host) InitCommands(c *chat.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{
|
c.Add(chat.Command{
|
||||||
Prefix: "/msg",
|
Prefix: "/msg",
|
||||||
PrefixHelp: "USER MESSAGE",
|
PrefixHelp: "USER MESSAGE",
|
||||||
@ -374,7 +305,14 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
return errors.New("user not found")
|
return errors.New("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendPM(room, strings.Join(args[1:], " "), msg.From(), target)
|
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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -394,12 +332,20 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
return errors.New("no message to reply to")
|
return errors.New("no message to reply to")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, found := h.GetUser(target.ID())
|
name := target.Name()
|
||||||
|
_, found := h.GetUser(name)
|
||||||
if !found {
|
if !found {
|
||||||
return errors.New("user not found")
|
return errors.New("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendPM(room, strings.Join(args, " "), msg.From(), target)
|
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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -417,13 +363,14 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("user not found")
|
return errors.New("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
id := target.Identifier.(*Identity)
|
id := target.Identifier.(*Identity)
|
||||||
var whois string
|
var whois string
|
||||||
switch room.IsOp(msg.From()) {
|
switch room.IsOp(msg.From()) {
|
||||||
case true:
|
case true:
|
||||||
whois = id.WhoisAdmin(room)
|
whois = id.WhoisAdmin()
|
||||||
case false:
|
case false:
|
||||||
whois = id.Whois(room)
|
whois = id.Whois()
|
||||||
}
|
}
|
||||||
room.Send(message.NewSystemMsg(whois, msg.From()))
|
room.Send(message.NewSystemMsg(whois, msg.From()))
|
||||||
|
|
||||||
@ -516,7 +463,7 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
room.Send(message.NewAnnounceMsg(body))
|
room.Send(message.NewAnnounceMsg(body))
|
||||||
target.Close()
|
target.Close()
|
||||||
|
|
||||||
logger.Debugf("Banned: \n-> %s", id.Whois(room))
|
logger.Debugf("Banned: \n-> %s", id.Whois())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@ -555,7 +502,7 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
Op: true,
|
Op: true,
|
||||||
Prefix: "/motd",
|
Prefix: "/motd",
|
||||||
PrefixHelp: "[MESSAGE]",
|
PrefixHelp: "[MESSAGE]",
|
||||||
Help: "Set a new MESSAGE of the day, or print the motd if no MESSAGE.",
|
Help: "Set a new MESSAGE of the day, print the current motd without parameters.",
|
||||||
Handler: func(room *chat.Room, msg message.CommandMsg) error {
|
Handler: func(room *chat.Room, msg message.CommandMsg) error {
|
||||||
args := msg.Args()
|
args := msg.Args()
|
||||||
user := msg.From()
|
user := msg.From()
|
||||||
@ -572,21 +519,10 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
return errors.New("must be OP to modify the MOTD")
|
return errors.New("must be OP to modify the MOTD")
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
motd = strings.Join(args, " ")
|
||||||
var s string = strings.Join(args, " ")
|
h.SetMotd(motd)
|
||||||
|
|
||||||
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())
|
fromMsg := fmt.Sprintf("New message of the day set by %s:", msg.From().Name())
|
||||||
room.Send(message.NewAnnounceMsg(fromMsg + message.Newline + "-> " + s))
|
room.Send(message.NewAnnounceMsg(fromMsg + message.Newline + "-> " + motd))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@ -639,261 +575,4 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
return nil
|
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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
436
host_test.go
436
host_test.go
@ -2,8 +2,9 @@ package sshchat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
mathRand "math/rand"
|
mathRand "math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
@ -12,7 +13,6 @@ import (
|
|||||||
"github.com/shazow/ssh-chat/chat/message"
|
"github.com/shazow/ssh-chat/chat/message"
|
||||||
"github.com/shazow/ssh-chat/sshd"
|
"github.com/shazow/ssh-chat/sshd"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func stripPrompt(s string) string {
|
func stripPrompt(s string) string {
|
||||||
@ -23,15 +23,9 @@ func stripPrompt(s string) string {
|
|||||||
if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 {
|
if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 {
|
||||||
return s[endPos+4:]
|
return s[endPos+4:]
|
||||||
}
|
}
|
||||||
if endPos := strings.Index(s, "\x1b[K-> "); endPos > 0 {
|
|
||||||
return s[endPos+6:]
|
|
||||||
}
|
|
||||||
if endPos := strings.Index(s, "] "); endPos > 0 {
|
if endPos := strings.Index(s, "] "); endPos > 0 {
|
||||||
return s[endPos+2:]
|
return s[endPos+2:]
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(s, "-> ") {
|
|
||||||
return s[3:]
|
|
||||||
}
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,14 +42,6 @@ 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",
|
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",
|
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 {
|
for i, tc := range tests {
|
||||||
@ -89,263 +75,153 @@ func TestHostGetPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHost(t *testing.T, auth *Auth) (*sshd.SSHListener, *Host) {
|
func TestHostNameCollision(t *testing.T) {
|
||||||
key, err := sshd.NewRandomSigner(1024)
|
key, err := sshd.NewRandomSigner(512)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var config *ssh.ServerConfig
|
config := sshd.MakeNoAuth()
|
||||||
if auth == nil {
|
|
||||||
config = sshd.MakeNoAuth()
|
|
||||||
} else {
|
|
||||||
config = sshd.MakeAuth(auth)
|
|
||||||
}
|
|
||||||
config.AddHostKey(key)
|
config.AddHostKey(key)
|
||||||
|
|
||||||
s, err := sshd.ListenSSH("localhost:0", config)
|
s, err := sshd.ListenSSH("localhost:0", config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return s, NewHost(s, auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostNameCollision(t *testing.T) {
|
|
||||||
s, host := getHost(t, nil)
|
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
host := NewHost(s, nil)
|
||||||
newUsers := make(chan *message.User)
|
|
||||||
host.OnUserJoined = func(u *message.User) {
|
|
||||||
newUsers <- u
|
|
||||||
}
|
|
||||||
go host.Serve()
|
go host.Serve()
|
||||||
|
|
||||||
g := errgroup.Group{}
|
done := make(chan struct{}, 1)
|
||||||
|
|
||||||
// First client
|
// First client
|
||||||
g.Go(func() error {
|
go func() {
|
||||||
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
err := sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
||||||
// second client
|
scanner := bufio.NewScanner(r)
|
||||||
name := (<-newUsers).Name()
|
|
||||||
if name != "Guest1" {
|
// Consume the initial buffer
|
||||||
t.Errorf("Second client did not get Guest1 name: %q", name)
|
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
|
return nil
|
||||||
})
|
})
|
||||||
})
|
if err != nil {
|
||||||
|
done <- struct{}{}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for first client
|
||||||
|
<-done
|
||||||
|
|
||||||
// Second client
|
// Second client
|
||||||
g.Go(func() error {
|
err = sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
||||||
// first client
|
scanner := bufio.NewScanner(r)
|
||||||
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 {
|
// Consume the initial buffer
|
||||||
t.Error(err)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostAllowlist(t *testing.T) {
|
func TestHostWhitelist(t *testing.T) {
|
||||||
|
key, err := sshd.NewRandomSigner(512)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
auth := NewAuth()
|
auth := NewAuth()
|
||||||
s, host := getHost(t, auth)
|
config := sshd.MakeAuth(auth)
|
||||||
|
config.AddHostKey(key)
|
||||||
|
|
||||||
|
s, err := sshd.ListenSSH("localhost:0", config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
host := NewHost(s, auth)
|
||||||
go host.Serve()
|
go host.Serve()
|
||||||
|
|
||||||
target := s.Addr().String()
|
target := s.Addr().String()
|
||||||
|
|
||||||
clientPrivateKey, err := sshd.NewRandomSigner(512)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 })
|
clientpubkey, _ := ssh.NewPublicKey(clientkey.Public())
|
||||||
if err != nil {
|
auth.Whitelist(clientpubkey, 0)
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.SetAllowlistMode(true)
|
|
||||||
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
|
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error(err)
|
t.Error("Failed to block unwhitelisted connection.")
|
||||||
}
|
}
|
||||||
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 TestHostAllowlistCommand(t *testing.T) {
|
|
||||||
s, host := getHost(t, NewAuth())
|
|
||||||
defer s.Close()
|
|
||||||
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) {
|
func TestHostKick(t *testing.T) {
|
||||||
s, host := getHost(t, NewAuth())
|
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)
|
||||||
|
}
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
addr := s.Addr().String()
|
||||||
|
host := NewHost(s, nil)
|
||||||
go host.Serve()
|
go host.Serve()
|
||||||
|
|
||||||
g := errgroup.Group{}
|
|
||||||
connected := make(chan struct{})
|
connected := make(chan struct{})
|
||||||
kicked := make(chan struct{})
|
kicked := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
g.Go(func() error {
|
go func() {
|
||||||
// First client
|
// First client
|
||||||
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
err := sshd.ConnectShell(addr, "foo", func(r io.Reader, w io.WriteCloser) error {
|
||||||
scanner := bufio.NewScanner(r)
|
scanner := bufio.NewScanner(r)
|
||||||
|
|
||||||
// Consume the initial buffer
|
// Consume the initial buffer
|
||||||
@ -376,103 +252,41 @@ func TestHostKick(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kicked <- struct{}{}
|
kicked <- struct{}{}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
})
|
if err != nil {
|
||||||
|
connected <- struct{}{}
|
||||||
|
close(connected)
|
||||||
|
t.Fatal(err)
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
g.Go(func() error {
|
go func() {
|
||||||
// Second client
|
// Second client
|
||||||
return sshd.ConnectShell(s.Addr().String(), "bar", func(r io.Reader, w io.WriteCloser) error {
|
err := sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error {
|
||||||
scanner := bufio.NewScanner(r)
|
scanner := bufio.NewScanner(r)
|
||||||
<-connected
|
<-connected
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
|
|
||||||
<-kicked
|
<-kicked
|
||||||
|
|
||||||
if _, err := w.Write([]byte("am I still here?\r\n")); err != io.EOF {
|
if _, err := w.Write([]byte("am I still here?\r\n")); err != nil {
|
||||||
return errors.New("expected to be kicked")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
if err := scanner.Err(); err == io.EOF {
|
return scanner.Err()
|
||||||
// All good, we got kicked.
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
if err == io.EOF {
|
||||||
|
// All good, we got kicked.
|
||||||
if err := g.Wait(); err != nil {
|
} else if err != nil {
|
||||||
t.Error(err)
|
close(done)
|
||||||
}
|
t.Fatal(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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
close(done)
|
||||||
}
|
}()
|
||||||
|
|
||||||
func strptr(s string) *string {
|
<-done
|
||||||
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,12 +1,9 @@
|
|||||||
package sshchat
|
package sshchat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shazow/ssh-chat/chat"
|
|
||||||
"github.com/shazow/ssh-chat/chat/message"
|
"github.com/shazow/ssh-chat/chat/message"
|
||||||
"github.com/shazow/ssh-chat/internal/humantime"
|
"github.com/shazow/ssh-chat/internal/humantime"
|
||||||
"github.com/shazow/ssh-chat/internal/sanitize"
|
"github.com/shazow/ssh-chat/internal/sanitize"
|
||||||
@ -17,7 +14,6 @@ import (
|
|||||||
type Identity struct {
|
type Identity struct {
|
||||||
sshd.Connection
|
sshd.Connection
|
||||||
id string
|
id string
|
||||||
symbol string // symbol is displayed as a prefix to the name
|
|
||||||
created time.Time
|
created time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,69 +41,33 @@ func (i *Identity) SetName(name string) {
|
|||||||
i.SetID(name)
|
i.SetID(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Identity) SetSymbol(symbol string) {
|
|
||||||
i.symbol = symbol
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns the name for the Identity
|
// Name returns the name for the Identity
|
||||||
func (i Identity) Name() string {
|
func (i Identity) Name() string {
|
||||||
if i.symbol != "" {
|
|
||||||
return i.symbol + " " + i.id
|
|
||||||
}
|
|
||||||
return i.id
|
return i.id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whois returns a whois description for non-admin users.
|
// Whois returns a whois description for non-admin users.
|
||||||
func (i Identity) Whois(room *chat.Room) string {
|
func (i Identity) Whois() string {
|
||||||
fingerprint := "(no public key)"
|
fingerprint := "(no public key)"
|
||||||
if i.PublicKey() != nil {
|
if i.PublicKey() != nil {
|
||||||
fingerprint = sshd.Fingerprint(i.PublicKey())
|
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 +
|
return "name: " + i.Name() + message.Newline +
|
||||||
" > fingerprint: " + fingerprint + message.Newline +
|
" > fingerprint: " + fingerprint + message.Newline +
|
||||||
" > client: " + sanitize.Data(string(i.ClientVersion()), 64) + 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.
|
// WhoisAdmin returns a whois description for admin users.
|
||||||
func (i Identity) WhoisAdmin(room *chat.Room) string {
|
func (i Identity) WhoisAdmin() string {
|
||||||
ip, _, _ := net.SplitHostPort(i.RemoteAddr().String())
|
ip, _, _ := net.SplitHostPort(i.RemoteAddr().String())
|
||||||
fingerprint := "(no public key)"
|
fingerprint := "(no public key)"
|
||||||
if i.PublicKey() != nil {
|
if i.PublicKey() != nil {
|
||||||
fingerprint = sshd.Fingerprint(i.PublicKey())
|
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 +
|
" > ip: " + ip + message.Newline +
|
||||||
" > fingerprint: " + fingerprint + message.Newline +
|
" > fingerprint: " + fingerprint + message.Newline +
|
||||||
" > client: " + sanitize.Data(string(i.ClientVersion()), 64) + 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()
|
|
||||||
}
|
}
|
||||||
|
8
issue_template.md
Normal file
8
issue_template.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
### Expected Behavior
|
||||||
|
|
||||||
|
### Actual Behavior
|
||||||
|
|
||||||
|
### Steps to reproduce behavior
|
||||||
|
|
||||||
|
|
||||||
|
### Additional Comments
|
5
motd.txt
5
motd.txt
@ -1,4 +1 @@
|
|||||||
[31;1mWelcome to ssh-chat, enter [0m/help[31;1m for more.
|
[39;91mWelcome to chat.shazow.net, enter /help for more. [0m
|
||||||
[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,24 +12,8 @@ var ErrCollision = errors.New("key already exists")
|
|||||||
// Returned when a requested item does not exist in the set.
|
// Returned when a requested item does not exist in the set.
|
||||||
var ErrMissing = errors.New("item does not exist")
|
var ErrMissing = errors.New("item does not exist")
|
||||||
|
|
||||||
// ZeroValue can be used when we only care about the key, not about the value.
|
// Returned when a nil item is added. Nil values are considered expired and invalid.
|
||||||
var ZeroValue = struct{}{}
|
var ErrNil = errors.New("item value must not be nil")
|
||||||
|
|
||||||
// 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
|
type IterFunc func(key string, item Item) error
|
||||||
|
|
||||||
@ -97,7 +81,7 @@ func (s *Set) Get(key string) (Item, error) {
|
|||||||
func (s *Set) cleanup(key string) {
|
func (s *Set) cleanup(key string) {
|
||||||
s.Lock()
|
s.Lock()
|
||||||
item, ok := s.lookup[key]
|
item, ok := s.lookup[key]
|
||||||
if ok && item.Value() == nil {
|
if ok && item == nil {
|
||||||
delete(s.lookup, key)
|
delete(s.lookup, key)
|
||||||
}
|
}
|
||||||
s.Unlock()
|
s.Unlock()
|
||||||
@ -105,6 +89,9 @@ func (s *Set) cleanup(key string) {
|
|||||||
|
|
||||||
// Add item to this set if it does not exist already.
|
// Add item to this set if it does not exist already.
|
||||||
func (s *Set) Add(item Item) error {
|
func (s *Set) Add(item Item) error {
|
||||||
|
if item.Value() == nil {
|
||||||
|
return ErrNil
|
||||||
|
}
|
||||||
key := s.normalize(item.Key())
|
key := s.normalize(item.Key())
|
||||||
|
|
||||||
s.Lock()
|
s.Lock()
|
||||||
@ -121,6 +108,9 @@ func (s *Set) Add(item Item) error {
|
|||||||
|
|
||||||
// Set item to this set, even if it already exists.
|
// Set item to this set, even if it already exists.
|
||||||
func (s *Set) Set(item Item) error {
|
func (s *Set) Set(item Item) error {
|
||||||
|
if item.Value() == nil {
|
||||||
|
return ErrNil
|
||||||
|
}
|
||||||
key := s.normalize(item.Key())
|
key := s.normalize(item.Key())
|
||||||
|
|
||||||
s.Lock()
|
s.Lock()
|
||||||
@ -147,6 +137,9 @@ func (s *Set) Remove(key string) error {
|
|||||||
// Replace oldKey with a new item, which might be a new key.
|
// Replace oldKey with a new item, which might be a new key.
|
||||||
// Can be used to rename items.
|
// Can be used to rename items.
|
||||||
func (s *Set) Replace(oldKey string, item Item) error {
|
func (s *Set) Replace(oldKey string, item Item) error {
|
||||||
|
if item.Value() == nil {
|
||||||
|
return ErrNil
|
||||||
|
}
|
||||||
newKey := s.normalize(item.Key())
|
newKey := s.normalize(item.Key())
|
||||||
oldKey = s.normalize(oldKey)
|
oldKey = s.normalize(oldKey)
|
||||||
|
|
||||||
|
@ -21,23 +21,14 @@ func TestSetExpiring(t *testing.T) {
|
|||||||
t.Error("not len 1 after set")
|
t.Error("not len 1 after set")
|
||||||
}
|
}
|
||||||
|
|
||||||
item := Expire(StringItem("asdf"), -time.Nanosecond).(*ExpiringItem)
|
item := &ExpiringItem{nil, time.Now().Add(-time.Nanosecond * 1)}
|
||||||
if !item.Expired() {
|
if !item.Expired() {
|
||||||
t.Errorf("ExpiringItem a nanosec ago is not expiring")
|
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)}
|
item = &ExpiringItem{nil, time.Now().Add(time.Minute * 5)}
|
||||||
if item.Expired() {
|
if item.Expired() {
|
||||||
t.Errorf("ExpiringItem in 5 minutes is expiring now")
|
t.Errorf("ExpiringItem in 2 minutes is expiring now")
|
||||||
}
|
}
|
||||||
|
|
||||||
item = Expire(StringItem("bar"), time.Minute*5).(*ExpiringItem)
|
item = Expire(StringItem("bar"), time.Minute*5).(*ExpiringItem)
|
||||||
@ -51,13 +42,11 @@ func TestSetExpiring(t *testing.T) {
|
|||||||
if err := s.Add(item); err != nil {
|
if err := s.Add(item); err != nil {
|
||||||
t.Fatalf("failed to add item: %s", err)
|
t.Fatalf("failed to add item: %s", err)
|
||||||
}
|
}
|
||||||
itemInLookup, ok := s.lookup["bar"]
|
_, ok := s.lookup["bar"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("bar not present in lookup even though it's not expired")
|
t.Fatalf("expired bar added to lookup")
|
||||||
}
|
|
||||||
if itemInLookup != item {
|
|
||||||
t.Fatalf("present item %#v != %#v original item", itemInLookup, item)
|
|
||||||
}
|
}
|
||||||
|
s.lookup["bar"] = item
|
||||||
|
|
||||||
if !s.In("bar") {
|
if !s.In("bar") {
|
||||||
t.Errorf("not matched after timed set")
|
t.Errorf("not matched after timed set")
|
||||||
|
48
sshd/auth.go
48
sshd/auth.go
@ -5,40 +5,26 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shazow/ssh-chat/internal/sanitize"
|
"github.com/shazow/ssh-chat/internal/sanitize"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auth is used to authenticate connections.
|
// Auth is used to authenticate connections based on public keys.
|
||||||
type Auth interface {
|
type Auth interface {
|
||||||
// Whether to allow connections without a public key.
|
// Whether to allow connections without a public key.
|
||||||
AllowAnonymous() bool
|
AllowAnonymous() bool
|
||||||
// If passphrase authentication is accepted
|
// Given address and public key and client agent string, returns nil if the connection should be allowed.
|
||||||
AcceptPassphrase() bool
|
Check(net.Addr, ssh.PublicKey, string) error
|
||||||
// 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.
|
// 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 {
|
func MakeAuth(auth Auth) *ssh.ServerConfig {
|
||||||
config := ssh.ServerConfig{
|
config := ssh.ServerConfig{
|
||||||
NoClientAuth: false,
|
NoClientAuth: false,
|
||||||
// Auth-related things should be constant-time to avoid timing attacks.
|
// Auth-related things should be constant-time to avoid timing attacks.
|
||||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||||
err := auth.CheckBans(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
|
err := auth.Check(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = auth.CheckPublicKey(key)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -47,31 +33,11 @@ func MakeAuth(auth Auth) *ssh.ServerConfig {
|
|||||||
}}
|
}}
|
||||||
return perm, nil
|
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) {
|
KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
|
||||||
err := auth.CheckBans(conn.RemoteAddr(), nil, sanitize.Data(string(conn.ClientVersion()), 64))
|
if !auth.AllowAnonymous() {
|
||||||
if err != nil {
|
return nil, errors.New("public key authentication required")
|
||||||
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
|
return nil, err
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -30,24 +30,9 @@ 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.
|
// 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 {
|
func ConnectShell(host string, name string, handler func(r io.Reader, w io.WriteCloser) error) error {
|
||||||
return connectShell(host, NewClientConfig(name), handler)
|
config := NewClientConfig(name)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
conn, err := ssh.Dial("tcp", host, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@ -16,19 +15,9 @@ type RejectAuth struct{}
|
|||||||
func (a RejectAuth) AllowAnonymous() bool {
|
func (a RejectAuth) AllowAnonymous() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
func (a RejectAuth) AcceptPassphrase() bool {
|
func (a RejectAuth) Check(net.Addr, ssh.PublicKey, string) error {
|
||||||
return false
|
|
||||||
}
|
|
||||||
func (a RejectAuth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
|
|
||||||
return errRejectAuth
|
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) {
|
func TestClientReject(t *testing.T) {
|
||||||
signer, err := NewRandomSigner(512)
|
signer, err := NewRandomSigner(512)
|
||||||
|
@ -2,7 +2,6 @@ package sshd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shazow/rateio"
|
"github.com/shazow/rateio"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@ -33,12 +32,6 @@ func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) {
|
|||||||
conn = ReadLimitConn(conn, l.RateLimit())
|
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
|
// Upgrade TCP connection to SSH connection
|
||||||
sshConn, channels, requests, err := ssh.NewServerConn(conn, l.config)
|
sshConn, channels, requests, err := ssh.NewServerConn(conn, l.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -25,7 +25,7 @@ func TestServerInit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServeTerminals(t *testing.T) {
|
func TestServeTerminals(t *testing.T) {
|
||||||
signer, err := NewRandomSigner(1024)
|
signer, err := NewRandomSigner(512)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,8 @@ import "encoding/binary"
|
|||||||
|
|
||||||
// parsePtyRequest parses the payload of the pty-req message and extracts the
|
// parsePtyRequest parses the payload of the pty-req message and extracts the
|
||||||
// dimensions of the terminal. See RFC 4254, section 6.2.
|
// dimensions of the terminal. See RFC 4254, section 6.2.
|
||||||
func parsePtyRequest(s []byte) (term string, width, height int, ok bool) {
|
func parsePtyRequest(s []byte) (width, height int, ok bool) {
|
||||||
term, s, ok = parseString(s)
|
_, s, ok = parseString(s)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -55,29 +55,6 @@ func (c sshConn) Name() string {
|
|||||||
return c.User()
|
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
|
// Terminal extends ssh/terminal to include a close method
|
||||||
type Terminal struct {
|
type Terminal struct {
|
||||||
terminal.Terminal
|
terminal.Terminal
|
||||||
@ -86,14 +63,9 @@ type Terminal struct {
|
|||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
env []EnvVar
|
|
||||||
term string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make new terminal from a session channel
|
// 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) {
|
func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) {
|
||||||
if ch.ChannelType() != "session" {
|
if ch.ChannelType() != "session" {
|
||||||
return nil, ErrNotSessionChannel
|
return nil, ErrNotSessionChannel
|
||||||
@ -103,15 +75,14 @@ func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
term := Terminal{
|
term := Terminal{
|
||||||
Terminal: *terminal.NewTerminal(channel, ""),
|
Terminal: *terminal.NewTerminal(channel, "Connecting..."),
|
||||||
Conn: sshConn{conn},
|
Conn: sshConn{conn},
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
|
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
ready := make(chan struct{})
|
go term.listen(requests)
|
||||||
go term.listen(requests, ready)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// Keep-Alive Ticker
|
// Keep-Alive Ticker
|
||||||
@ -132,18 +103,7 @@ func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// We need to wait for term.ready to acquire a shell before we return, this
|
return &term, nil
|
||||||
// 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
|
// NewSession Finds a session channel and make a Terminal from it
|
||||||
@ -167,17 +127,14 @@ func (t *Terminal) Close() error {
|
|||||||
var err error
|
var err error
|
||||||
t.closeOnce.Do(func() {
|
t.closeOnce.Do(func() {
|
||||||
close(t.done)
|
close(t.done)
|
||||||
if err := t.Channel.Close(); err != nil {
|
t.Channel.Close()
|
||||||
logger.Printf("[%s] Failed to close terminal channel: %s", t.Conn.RemoteAddr(), err)
|
|
||||||
}
|
|
||||||
err = t.Conn.Close()
|
err = t.Conn.Close()
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// listen negotiates the terminal type and state
|
// Negotiate terminal type and settings
|
||||||
// ready is closed when the terminal is ready.
|
func (t *Terminal) listen(requests <-chan *ssh.Request) {
|
||||||
func (t *Terminal) listen(requests <-chan *ssh.Request, ready chan<- struct{}) {
|
|
||||||
hasShell := false
|
hasShell := false
|
||||||
|
|
||||||
for req := range requests {
|
for req := range requests {
|
||||||
@ -189,19 +146,13 @@ func (t *Terminal) listen(requests <-chan *ssh.Request, ready chan<- struct{}) {
|
|||||||
if !hasShell {
|
if !hasShell {
|
||||||
ok = true
|
ok = true
|
||||||
hasShell = true
|
hasShell = true
|
||||||
close(ready)
|
|
||||||
}
|
}
|
||||||
case "pty-req":
|
case "pty-req":
|
||||||
var term string
|
width, height, ok = parsePtyRequest(req.Payload)
|
||||||
term, width, height, ok = parsePtyRequest(req.Payload)
|
|
||||||
if ok {
|
if ok {
|
||||||
// TODO: Hardcode width to 100000?
|
// TODO: Hardcode width to 100000?
|
||||||
err := t.SetSize(width, height)
|
err := t.SetSize(width, height)
|
||||||
ok = err == nil
|
ok = err == nil
|
||||||
// Save the term:
|
|
||||||
t.mu.Lock()
|
|
||||||
t.term = term
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
case "window-change":
|
case "window-change":
|
||||||
width, height, ok = parseWinchRequest(req.Payload)
|
width, height, ok = parseWinchRequest(req.Payload)
|
||||||
@ -210,14 +161,6 @@ func (t *Terminal) listen(requests <-chan *ssh.Request, ready chan<- struct{}) {
|
|||||||
err := t.SetSize(width, height)
|
err := t.SetSize(width, height)
|
||||||
ok = err == nil
|
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 {
|
if req.WantReply {
|
||||||
@ -225,24 +168,3 @@ func (t *Terminal) listen(requests <-chan *ssh.Request, ready chan<- struct{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,8 +10,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/text/width"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// EscapeCodes contains escape sequences that can be written to the terminal in
|
// EscapeCodes contains escape sequences that can be written to the terminal in
|
||||||
@ -131,8 +129,6 @@ const (
|
|||||||
keyRight
|
keyRight
|
||||||
keyAltLeft
|
keyAltLeft
|
||||||
keyAltRight
|
keyAltRight
|
||||||
keyAltF
|
|
||||||
keyAltB
|
|
||||||
keyHome
|
keyHome
|
||||||
keyEnd
|
keyEnd
|
||||||
keyDeleteWord
|
keyDeleteWord
|
||||||
@ -159,12 +155,8 @@ func bytesToKey(b []byte, pasteActive bool) (rune, []byte) {
|
|||||||
switch b[0] {
|
switch b[0] {
|
||||||
case 1: // ^A
|
case 1: // ^A
|
||||||
return keyHome, b[1:]
|
return keyHome, b[1:]
|
||||||
case 2: // ^B
|
|
||||||
return keyLeft, b[1:]
|
|
||||||
case 5: // ^E
|
case 5: // ^E
|
||||||
return keyEnd, b[1:]
|
return keyEnd, b[1:]
|
||||||
case 6: // ^F
|
|
||||||
return keyRight, b[1:]
|
|
||||||
case 8: // ^H
|
case 8: // ^H
|
||||||
return keyBackspace, b[1:]
|
return keyBackspace, b[1:]
|
||||||
case 11: // ^K
|
case 11: // ^K
|
||||||
@ -214,15 +206,6 @@ 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) {
|
if !pasteActive && len(b) >= 6 && bytes.Equal(b[:6], pasteStart) {
|
||||||
return keyPasteStart, b[6:]
|
return keyPasteStart, b[6:]
|
||||||
}
|
}
|
||||||
@ -264,11 +247,7 @@ func (t *Terminal) moveCursorToPos(pos int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos > len(t.line) {
|
x := visualLength(t.prompt) + pos
|
||||||
pos = len(t.line)
|
|
||||||
}
|
|
||||||
|
|
||||||
x := visualLength(t.prompt) + visualLength(t.line[:pos])
|
|
||||||
y := x / t.termWidth
|
y := x / t.termWidth
|
||||||
x = x % t.termWidth
|
x = x % t.termWidth
|
||||||
|
|
||||||
@ -357,7 +336,6 @@ func (t *Terminal) setLine(newLine []rune, newPos int) {
|
|||||||
for i := len(newLine); i < len(t.line); i++ {
|
for i := len(newLine); i < len(t.line); i++ {
|
||||||
t.writeLine(space)
|
t.writeLine(space)
|
||||||
}
|
}
|
||||||
t.line = newLine
|
|
||||||
t.moveCursorToPos(newPos)
|
t.moveCursorToPos(newPos)
|
||||||
}
|
}
|
||||||
t.line = newLine
|
t.line = newLine
|
||||||
@ -469,10 +447,6 @@ func visualLength(runes []rune) int {
|
|||||||
inEscapeSeq = true
|
inEscapeSeq = true
|
||||||
default:
|
default:
|
||||||
length++
|
length++
|
||||||
kind := width.LookupRune(r).Kind()
|
|
||||||
if kind == width.EastAsianFullwidth || kind == width.EastAsianWide {
|
|
||||||
length++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,14 +467,10 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.eraseNPreviousChars(1)
|
t.eraseNPreviousChars(1)
|
||||||
case keyAltB:
|
|
||||||
fallthrough
|
|
||||||
case keyAltLeft:
|
case keyAltLeft:
|
||||||
// move left by a word.
|
// move left by a word.
|
||||||
t.pos -= t.countToLeftWord()
|
t.pos -= t.countToLeftWord()
|
||||||
t.moveCursorToPos(t.pos)
|
t.moveCursorToPos(t.pos)
|
||||||
case keyAltF:
|
|
||||||
fallthrough
|
|
||||||
case keyAltRight:
|
case keyAltRight:
|
||||||
// move right by a word.
|
// move right by a word.
|
||||||
t.pos += t.countToRightWord()
|
t.pos += t.countToRightWord()
|
||||||
|
@ -12,7 +12,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MockTerminal struct {
|
type MockTerminal struct {
|
||||||
@ -407,29 +406,3 @@ func TestOutputNewlines(t *testing.T) {
|
|||||||
t.Errorf("incorrect output: was %q, expected %q", output, expected)
|
t.Errorf("incorrect output: was %q, expected %q", output, expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTerminalvisualLength(t *testing.T) {
|
|
||||||
var tests = []struct {
|
|
||||||
input string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"hello world", 11},
|
|
||||||
{"babalala", 8},
|
|
||||||
{"端子", 4},
|
|
||||||
{"を搭載", 6},
|
|
||||||
{"baba端子lalaを搭載", 18},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
var runes []rune
|
|
||||||
for i, w := 0, 0; i < len(test.input); i += w {
|
|
||||||
runeValue, width := utf8.DecodeRuneInString(test.input[i:])
|
|
||||||
runes = append(runes, runeValue)
|
|
||||||
w = width
|
|
||||||
}
|
|
||||||
output := visualLength(runes)
|
|
||||||
if output != test.want {
|
|
||||||
t.Errorf("incorrect [%s] output: was %d, expected %d",
|
|
||||||
test.input, output, test.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
// panic(err)
|
// panic(err)
|
||||||
// }
|
// }
|
||||||
// defer terminal.Restore(0, oldState)
|
// defer terminal.Restore(0, oldState)
|
||||||
package terminal
|
package terminal // import "golang.org/x/crypto/ssh/terminal"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
|
@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
// +build solaris
|
// +build solaris
|
||||||
|
|
||||||
package terminal
|
package terminal // import "golang.org/x/crypto/ssh/terminal"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
"io"
|
"io"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// State contains the state of a terminal.
|
// State contains the state of a terminal.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user