Compare commits

...

20 Commits

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

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

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

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

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

* add /whitelist command with lots of open questions

* add test for /whitelist

* gofmt

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

* mutex whitelistMode and remove some deferred TODOs

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

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

* possibly fix the test timeout in HostNameCollision

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

This reverts commit 664dbb0976f8f10ea7a673950a879591c2e7c320.

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

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

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

* hopefully make test less racy

* s/whitelist/allowlist/

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

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

* add loader to allowlist test

* minor message changes

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

* apparently, we have another prefix

* check names directly on the User objects in TestHostNameCollision

* not allowlisted -> not allowed

* small message change

* update test
2022-01-06 09:09:51 -05:00
Andrey Petrov
84bc5c76dd go mod update for golang.org/x/crypto/ssh 2021-12-03 11:03:08 -05:00
Andrey Petrov
82526e9123
Update ssh.chat pubkey 2021-10-13 11:30:58 -04:00
15 changed files with 686 additions and 225 deletions

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

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

View File

@ -1,27 +0,0 @@
---
name: Bug report
about: Create a report to fix something that is broken
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Versions**
- Client version: (Paste output of `ssh -V`)
- Server version: (Paste output of `ssh-chat --version`)
- Latest server version available: (Check https://github.com/shazow/ssh-chat/releases and paste the latest version)
**To Reproduce**
Steps to reproduce the behavior:
1. Full command to run...
2. Resulting output...
**Expected behavior**
A clear and concise description of what you expected to happen.
**Additional context**
Add any other context about the problem here.

View File

@ -35,6 +35,7 @@ release:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE

View File

@ -12,13 +12,15 @@ Custom SSH server written in Go. Instead of a shell, you get a chat prompt.
Join the party:
```
``` console
$ ssh ssh.chat
```
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.)
@ -50,7 +52,7 @@ Additionally, `make debug` runs the server with an http `pprof` server. This all
## Quick Start
```
``` console
Usage:
ssh-chat [OPTIONS]
@ -60,7 +62,7 @@ Application Options:
-i, --identity= Private key to identify server with. (default: ~/.ssh/id_rsa)
--bind= Host and port to listen on. (default: 0.0.0.0:2022)
--admin= File of public keys who are admins.
--whitelist= Optional file of public keys who are allowed to connect.
--allowlist= Optional file of public keys who are allowed to connect.
--motd= Optional Message of the Day file.
--log= Write chat log to this file.
--pprof= Enable pprof http server for profiling.
@ -72,7 +74,7 @@ Help Options:
After doing `go get github.com/shazow/ssh-chat/...` on this repo, you should be able
to run a command like:
```
``` console
$ ssh-chat --verbose --bind ":22" --identity ~/.ssh/id_dsa
```

103
auth.go
View File

@ -8,6 +8,7 @@ import (
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/shazow/ssh-chat/set"
@ -15,9 +16,13 @@ import (
"golang.org/x/crypto/ssh"
)
// ErrNotWhitelisted Is the error returned when a key is checked that is not whitelisted,
// when whitelisting is enabled.
var ErrNotWhitelisted = errors.New("not whitelisted")
// KeyLoader loads public keys, e.g. from an authorized_keys file.
// It must return a nil slice on error.
type KeyLoader func() ([]ssh.PublicKey, error)
// ErrNotAllowed Is the error returned when a key is checked that is not allowlisted,
// when allowlisting is enabled.
var ErrNotAllowed = errors.New("not allowed")
// ErrBanned is the error returned when a client is banned.
var ErrBanned = errors.New("banned")
@ -47,15 +52,20 @@ func newAuthAddr(addr net.Addr) string {
return host
}
// Auth stores lookups for bans, whitelists, and ops. It implements the sshd.Auth interface.
// If the contained passphrase is not empty, it complements a whitelist.
// Auth stores lookups for bans, allowlists, and ops. It implements the sshd.Auth interface.
// If the contained passphrase is not empty, it complements a allowlist.
type Auth struct {
passphraseHash []byte
bannedAddr *set.Set
bannedClient *set.Set
banned *set.Set
whitelist *set.Set
allowlist *set.Set
ops *set.Set
settingsMu sync.RWMutex
allowlistMode bool
opLoader KeyLoader
allowlistLoader KeyLoader
}
// NewAuth creates a new empty Auth.
@ -64,11 +74,23 @@ func NewAuth() *Auth {
bannedAddr: set.New(),
bannedClient: set.New(),
banned: set.New(),
whitelist: set.New(),
allowlist: set.New(),
ops: set.New(),
}
}
func (a *Auth) AllowlistMode() bool {
a.settingsMu.RLock()
defer a.settingsMu.RUnlock()
return a.allowlistMode
}
func (a *Auth) SetAllowlistMode(value bool) {
a.settingsMu.Lock()
defer a.settingsMu.Unlock()
a.allowlistMode = value
}
// SetPassphrase enables passphrase authentication with the given passphrase.
// If an empty passphrase is given, disable passphrase authentication.
func (a *Auth) SetPassphrase(passphrase string) {
@ -82,7 +104,7 @@ func (a *Auth) SetPassphrase(passphrase string) {
// AllowAnonymous determines if anonymous users are permitted.
func (a *Auth) AllowAnonymous() bool {
return a.whitelist.Len() == 0 && a.passphraseHash == nil
return !a.AllowlistMode() && a.passphraseHash == nil
}
// AcceptPassphrase determines if passphrase authentication is accepted.
@ -115,11 +137,11 @@ func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string)
// CheckPubkey determines if a pubkey fingerprint is permitted.
func (a *Auth) CheckPublicKey(key ssh.PublicKey) error {
authkey := newAuthKey(key)
whitelisted := a.whitelist.In(authkey)
if a.AllowAnonymous() || whitelisted {
allowlisted := a.allowlist.In(authkey)
if a.AllowAnonymous() || allowlisted || a.IsOp(key) {
return nil
} else {
return ErrNotWhitelisted
return ErrNotAllowed
}
}
@ -151,25 +173,68 @@ func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
// IsOp checks if a public key is an op.
func (a *Auth) IsOp(key ssh.PublicKey) bool {
if key == nil {
return false
}
authkey := newAuthKey(key)
return a.ops.In(authkey)
}
// Whitelist will set a public key as a whitelisted user.
func (a *Auth) Whitelist(key ssh.PublicKey, d time.Duration) {
// LoadOps sets the public keys form loader to operators and saves the loader for later use
func (a *Auth) LoadOps(loader KeyLoader) error {
a.settingsMu.Lock()
a.opLoader = loader
a.settingsMu.Unlock()
return a.ReloadOps()
}
// ReloadOps sets the public keys from a loader saved in the last call to operators
func (a *Auth) ReloadOps() error {
a.settingsMu.RLock()
defer a.settingsMu.RUnlock()
return addFromLoader(a.opLoader, a.Op)
}
// Allowlist will set a public key as a allowlisted user.
func (a *Auth) Allowlist(key ssh.PublicKey, d time.Duration) {
if key == nil {
return
}
var err error
authItem := newAuthItem(key)
if d != 0 {
a.whitelist.Set(set.Expire(authItem, d))
err = a.allowlist.Set(set.Expire(authItem, d))
} else {
a.whitelist.Set(authItem)
err = a.allowlist.Set(authItem)
}
logger.Debugf("Added to whitelist: %q (for %s)", authItem.Key(), d)
if err == nil {
logger.Debugf("Added to allowlist: %q (for %s)", authItem.Key(), d)
} else {
logger.Errorf("Error adding %q to allowlist for %s: %s", authItem.Key(), d, err)
}
}
// LoadAllowlist adds the public keys from the loader to the allowlist and saves the loader for later use
func (a *Auth) LoadAllowlist(loader KeyLoader) error {
a.settingsMu.Lock()
a.allowlistLoader = loader
a.settingsMu.Unlock()
return a.ReloadAllowlist()
}
// LoadAllowlist adds the public keys from a loader saved in a previous call to the allowlist
func (a *Auth) ReloadAllowlist() error {
a.settingsMu.RLock()
defer a.settingsMu.RUnlock()
return addFromLoader(a.allowlistLoader, a.Allowlist)
}
func addFromLoader(loader KeyLoader, adder func(ssh.PublicKey, time.Duration)) error {
if loader == nil {
return nil
}
keys, err := loader()
for _, key := range keys {
adder(key, 0)
}
return err
}
// Ban will set a public key as banned.

View File

@ -21,7 +21,7 @@ func ClonePublicKey(key ssh.PublicKey) (ssh.PublicKey, error) {
return ssh.ParsePublicKey(key.Marshal())
}
func TestAuthWhitelist(t *testing.T) {
func TestAuthAllowlist(t *testing.T) {
key, err := NewRandomPublicKey(512)
if err != nil {
t.Fatal(err)
@ -33,7 +33,8 @@ func TestAuthWhitelist(t *testing.T) {
t.Error("Failed to permit in default state:", err)
}
auth.Whitelist(key, 0)
auth.Allowlist(key, 0)
auth.SetAllowlistMode(true)
keyClone, err := ClonePublicKey(key)
if err != nil {
@ -46,7 +47,7 @@ func TestAuthWhitelist(t *testing.T) {
err = auth.CheckPublicKey(keyClone)
if err != nil {
t.Error("Failed to permit whitelisted:", err)
t.Error("Failed to permit allowlisted:", err)
}
key2, err := NewRandomPublicKey(512)
@ -56,7 +57,7 @@ func TestAuthWhitelist(t *testing.T) {
err = auth.CheckPublicKey(key2)
if err == nil {
t.Error("Failed to restrict not whitelisted:", err)
t.Error("Failed to restrict not allowlisted:", err)
}
}

View File

@ -191,10 +191,11 @@ func (m PrivateMsg) From() *User {
}
func (m PrivateMsg) Render(t *Theme) string {
s := fmt.Sprintf("[PM from %s] %s", m.from.Name(), m.body)
format := "[PM from %s] %s"
if t == nil {
return s
return fmt.Sprintf(format, m.from.ID(), m.body)
}
s := fmt.Sprintf(format, m.from.Name(), m.body)
return t.ColorPM(s)
}

View File

@ -36,8 +36,9 @@ type Options struct {
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
Version bool `long:"version" description:"Print version and exit."`
Whitelist string `long:"whitelist" description:"Optional file of public keys who are allowed to connect."`
Passphrase string `long:"unsafe-passphrase" description:"Require an interactive passphrase to connect. Whitelist feature is more secure."`
Allowlist string `long:"allowlist" description:"Optional file of public keys who are allowed to connect."`
Whitelist string `long:"whitelist" dexcription:"Old name for allowlist option"`
Passphrase string `long:"unsafe-passphrase" description:"Require an interactive passphrase to connect. Allowlist feature is more secure."`
}
const extraHelp = `There are hidden options and easter eggs in ssh-chat. The source code is a good
@ -87,7 +88,7 @@ func main() {
// Figure out the log level
numVerbose := len(options.Verbose)
if numVerbose > len(logLevels) {
if numVerbose >= len(logLevels) {
numVerbose = len(logLevels) - 1
}
@ -141,35 +142,20 @@ func main() {
auth.SetPassphrase(options.Passphrase)
}
err = fromFile(options.Admin, func(line []byte) error {
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
if err.Error() == "ssh: no key found" {
return nil // Skip line
}
return err
}
auth.Op(key, 0)
return nil
})
err = auth.LoadOps(loaderFromFile(options.Admin, logger))
if err != nil {
fail(5, "Failed to load admins: %v\n", err)
}
err = fromFile(options.Whitelist, func(line []byte) error {
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
if err.Error() == "ssh: no key found" {
return nil // Skip line
}
return err
}
auth.Whitelist(key, 0)
return nil
})
if err != nil {
fail(6, "Failed to load whitelist: %v\n", err)
if options.Allowlist == "" && options.Whitelist != "" {
fmt.Println("--whitelist was renamed to --allowlist.")
options.Allowlist = options.Whitelist
}
err = auth.LoadAllowlist(loaderFromFile(options.Allowlist, logger))
if err != nil {
fail(6, "Failed to load allowlist: %v\n", err)
}
auth.SetAllowlistMode(options.Allowlist != "")
if options.Motd != "" {
host.GetMOTD = func() (string, error) {
@ -210,24 +196,32 @@ func main() {
fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.")
}
func fromFile(path string, handler func(line []byte) error) error {
func loaderFromFile(path string, logger *golog.Logger) sshchat.KeyLoader {
if path == "" {
// Skip
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
err := handler(scanner.Bytes())
return func() ([]ssh.PublicKey, error) {
file, err := os.Open(path)
if err != nil {
return err
return nil, err
}
defer file.Close()
var keys []ssh.PublicKey
scanner := bufio.NewScanner(file)
for scanner.Scan() {
key, _, _, _, err := ssh.ParseAuthorizedKey(scanner.Bytes())
if err != nil {
if err.Error() == "ssh: no key found" {
continue // Skip line
}
return nil, err
}
keys = append(keys, key)
}
if keys == nil {
logger.Warning("file", path, "contained no keys")
}
return keys, nil
}
return nil
}

View File

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

10
go.mod
View File

@ -4,11 +4,11 @@ require (
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58
github.com/jessevdk/go-flags v1.5.0
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210324051608-47abb6519492
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6
golang.org/x/text v0.3.5
golang.org/x/crypto v0.17.0
golang.org/x/sync v0.1.0
golang.org/x/sys v0.15.0
golang.org/x/term v0.15.0
golang.org/x/text v0.14.0
)
go 1.13

48
go.sum
View File

@ -4,19 +4,47 @@ github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LF
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4 h1:zwQ1HBo5FYwn1ksMd19qBCKO8JAWE9wmHivEpkw/DvE=
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492 h1:Paq34FxTluEPvVyayQqMPgHm+vTOrIifmcYxFBx9TLg=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/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-20210317153231-de623e64d2a6 h1:EC6+IGYTjPpRfv9a2b/6Puw0W+hLtAhkV1tPsXhutqs=
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/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.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/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=

203
host.go
View File

@ -9,11 +9,14 @@ import (
"sync"
"time"
"golang.org/x/crypto/ssh"
"github.com/shazow/rateio"
"github.com/shazow/ssh-chat/chat"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/internal/humantime"
"github.com/shazow/ssh-chat/internal/sanitize"
"github.com/shazow/ssh-chat/set"
"github.com/shazow/ssh-chat/sshd"
)
@ -129,7 +132,7 @@ func (h *Host) Connect(term *sshd.Terminal) {
h.mu.Unlock()
// Send MOTD
if motd != "" {
if motd != "" && !apiMode {
user.Send(message.NewAnnounceMsg(motd))
}
@ -695,4 +698,202 @@ func (h *Host) InitCommands(c *chat.Commands) {
return nil
},
})
forConnectedUsers := func(cmd func(*chat.Member, ssh.PublicKey) error) error {
return h.Members.Each(func(key string, item set.Item) error {
v := item.Value()
if v == nil { // expired between Each and here
return nil
}
user := v.(*chat.Member)
pk := user.Identifier.(*Identity).PublicKey()
return cmd(user, pk)
})
}
forPubkeyUser := func(args []string, cmd func(ssh.PublicKey)) (errors []string) {
invalidUsers := []string{}
invalidKeys := []string{}
noKeyUsers := []string{}
var keyType string
for _, v := range args {
switch {
case keyType != "":
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyType + " " + v))
if err == nil {
cmd(pk)
} else {
invalidKeys = append(invalidKeys, keyType+" "+v)
}
keyType = ""
case strings.HasPrefix(v, "ssh-"):
keyType = v
default:
user, ok := h.GetUser(v)
if ok {
pk := user.Identifier.(*Identity).PublicKey()
if pk == nil {
noKeyUsers = append(noKeyUsers, user.Identifier.Name())
} else {
cmd(pk)
}
} else {
invalidUsers = append(invalidUsers, v)
}
}
}
if len(noKeyUsers) != 0 {
errors = append(errors, fmt.Sprintf("users without a public key: %v", noKeyUsers))
}
if len(invalidUsers) != 0 {
errors = append(errors, fmt.Sprintf("invalid users: %v", invalidUsers))
}
if len(invalidKeys) != 0 {
errors = append(errors, fmt.Sprintf("invalid keys: %v", invalidKeys))
}
return
}
allowlistHelptext := []string{
"Usage: /allowlist help | on | off | add {PUBKEY|USER}... | remove {PUBKEY|USER}... | import [AGE] | reload {keep|flush} | reverify | status",
"help: this help message",
"on, off: set allowlist mode (applies to new connections)",
"add, remove: add or remove keys from the allowlist",
"import: add all keys of users connected since AGE (default 0) ago to the allowlist",
"reload: re-read the allowlist file and keep or discard entries in the current allowlist but not in the file",
"reverify: kick all users not in the allowlist if allowlisting is enabled",
"status: show status information",
}
allowlistImport := func(args []string) (msgs []string, err error) {
var since time.Duration
if len(args) > 0 {
since, err = time.ParseDuration(args[0])
if err != nil {
return
}
}
cutoff := time.Now().Add(-since)
noKeyUsers := []string{}
forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if user.Joined().Before(cutoff) {
if pk == nil {
noKeyUsers = append(noKeyUsers, user.Identifier.Name())
} else {
h.auth.Allowlist(pk, 0)
}
}
return nil
})
if len(noKeyUsers) != 0 {
msgs = []string{fmt.Sprintf("users without a public key: %v", noKeyUsers)}
}
return
}
allowlistReload := func(args []string) error {
if !(len(args) > 0 && (args[0] == "keep" || args[0] == "flush")) {
return errors.New("must specify whether to keep or flush current entries")
}
if args[0] == "flush" {
h.auth.allowlist.Clear()
}
return h.auth.ReloadAllowlist()
}
allowlistReverify := func(room *chat.Room) []string {
if !h.auth.AllowlistMode() {
return []string{"allowlist is disabled, so nobody will be kicked"}
}
var kicked []string
forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if h.auth.CheckPublicKey(pk) != nil && !user.IsOp { // we do this check here as well for ops without keys
kicked = append(kicked, user.Name())
user.Close()
}
return nil
})
if kicked != nil {
room.Send(message.NewAnnounceMsg("Kicked during pubkey reverification: " + strings.Join(kicked, ", ")))
}
return nil
}
allowlistStatus := func() (msgs []string) {
if h.auth.AllowlistMode() {
msgs = []string{"allowlist enabled"}
} else {
msgs = []string{"allowlist disabled"}
}
allowlistedUsers := []string{}
allowlistedKeys := []string{}
h.auth.allowlist.Each(func(key string, item set.Item) error {
keyFP := item.Key()
if forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if pk != nil && sshd.Fingerprint(pk) == keyFP {
allowlistedUsers = append(allowlistedUsers, user.Name())
return io.EOF
}
return nil
}) == nil {
// if we land here, the key matches no users
allowlistedKeys = append(allowlistedKeys, keyFP)
}
return nil
})
if len(allowlistedUsers) != 0 {
msgs = append(msgs, "Connected users on the allowlist: "+strings.Join(allowlistedUsers, ", "))
}
if len(allowlistedKeys) != 0 {
msgs = append(msgs, "Keys on the allowlist without connected user: "+strings.Join(allowlistedKeys, ", "))
}
return
}
c.Add(chat.Command{
Op: true,
Prefix: "/allowlist",
PrefixHelp: "COMMAND [ARGS...]",
Help: "Modify the allowlist or allowlist state. See /allowlist help for subcommands",
Handler: func(room *chat.Room, msg message.CommandMsg) (err error) {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) == 0 {
args = []string{"help"}
}
// send exactly one message to preserve order
var replyLines []string
switch args[0] {
case "help":
replyLines = allowlistHelptext
case "on":
h.auth.SetAllowlistMode(true)
case "off":
h.auth.SetAllowlistMode(false)
case "add":
replyLines = forPubkeyUser(args[1:], func(pk ssh.PublicKey) { h.auth.Allowlist(pk, 0) })
case "remove":
replyLines = forPubkeyUser(args[1:], func(pk ssh.PublicKey) { h.auth.Allowlist(pk, 1) })
case "import":
replyLines, err = allowlistImport(args[1:])
case "reload":
err = allowlistReload(args[1:])
case "reverify":
replyLines = allowlistReverify(room)
case "status":
replyLines = allowlistStatus()
default:
err = errors.New("invalid subcommand: " + args[0])
}
if err == nil && replyLines != nil {
room.Send(message.NewSystemMsg(strings.Join(replyLines, "\r\n"), msg.From()))
}
return
},
})
}

View File

@ -2,8 +2,6 @@ package sshchat
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"io"
@ -25,9 +23,15 @@ func stripPrompt(s string) string {
if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 {
return s[endPos+4:]
}
if endPos := strings.Index(s, "\x1b[K-> "); endPos > 0 {
return s[endPos+6:]
}
if endPos := strings.Index(s, "] "); endPos > 0 {
return s[endPos+2:]
}
if strings.HasPrefix(s, "-> ") {
return s[3:]
}
return s
}
@ -44,6 +48,14 @@ func TestStripPrompt(t *testing.T) {
Input: "[foo] \x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[K * Guest1 joined. (Connected: 2)\r",
Want: " * Guest1 joined. (Connected: 2)\r",
},
{
Input: "[foo] \x1b[6D\x1b[K-> From your friendly system.\r",
Want: "From your friendly system.\r",
},
{
Input: "-> Err: must be op.\r",
Want: "Err: must be op.\r",
},
}
for i, tc := range tests {
@ -77,20 +89,29 @@ func TestHostGetPrompt(t *testing.T) {
}
}
func TestHostNameCollision(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
func getHost(t *testing.T, auth *Auth) (*sshd.SSHListener, *Host) {
key, err := sshd.NewRandomSigner(1024)
if err != nil {
t.Fatal(err)
}
config := sshd.MakeNoAuth()
var config *ssh.ServerConfig
if auth == nil {
config = sshd.MakeNoAuth()
} else {
config = sshd.MakeAuth(auth)
}
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
t.Fatal(err)
}
return s, NewHost(s, auth)
}
func TestHostNameCollision(t *testing.T) {
s, host := getHost(t, nil)
defer s.Close()
host := NewHost(s, nil)
newUsers := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
@ -103,51 +124,23 @@ func TestHostNameCollision(t *testing.T) {
// First client
g.Go(func() error {
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
scanner.Scan()
actual := stripPrompt(scanner.Text())
expected := " * foo joined. (Connected: 1)\r"
if actual != expected {
t.Errorf("Got %q; expected %q", actual, expected)
// second client
name := (<-newUsers).Name()
if name != "Guest1" {
t.Errorf("Second client did not get Guest1 name: %q", name)
}
// wait for the second client
<-newUsers
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)
}
return nil
})
})
// Second client
g.Go(func() error {
// wait for the first client
<-newUsers
// first client
name := (<-newUsers).Name()
if name != "foo" {
t.Errorf("First client did not get foo name: %q", name)
}
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
scanner.Scan()
scanner.Scan()
scanner.Scan()
actual := scanner.Text()
if !strings.HasPrefix(actual, "[Guest1] ") {
t.Errorf("Second client did not get Guest1 name: %q", actual)
}
return nil
})
})
@ -157,62 +150,193 @@ func TestHostNameCollision(t *testing.T) {
}
}
func TestHostWhitelist(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
func TestHostAllowlist(t *testing.T) {
auth := NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
t.Fatal(err)
}
s, host := getHost(t, auth)
defer s.Close()
host := NewHost(s, auth)
go host.Serve()
target := s.Addr().String()
clientPrivateKey, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
clientKey := clientPrivateKey.PublicKey()
loadCount := -1
loader := func() ([]ssh.PublicKey, error) {
loadCount++
return [][]ssh.PublicKey{
{},
{clientKey},
}[loadCount], nil
}
auth.LoadAllowlist(loader)
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
if err != nil {
t.Error(err)
}
clientkey, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatal(err)
}
clientpubkey, _ := ssh.NewPublicKey(clientkey.Public())
auth.Whitelist(clientpubkey, 0)
auth.SetAllowlistMode(true)
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
if err == nil {
t.Error("Failed to block unwhitelisted connection.")
t.Error(err)
}
err = sshd.ConnectShellWithKey(target, "foo", clientPrivateKey, func(r io.Reader, w io.WriteCloser) error { return nil })
if err == nil {
t.Error(err)
}
auth.ReloadAllowlist()
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
if err == nil {
t.Error("Failed to block unallowlisted connection.")
}
}
func TestHostKick(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
auth := NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
t.Fatal(err)
}
func TestHostAllowlistCommand(t *testing.T) {
s, host := getHost(t, NewAuth())
defer s.Close()
go host.Serve()
users := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
users <- u
}
kickSignal := make(chan struct{})
clientKey, err := sshd.NewRandomSigner(1024)
if err != nil {
t.Fatal(err)
}
clientKeyFP := sshd.Fingerprint(clientKey.PublicKey())
go sshd.ConnectShellWithKey(s.Addr().String(), "bar", clientKey, func(r io.Reader, w io.WriteCloser) error {
<-kickSignal
n, err := w.Write([]byte("alive and well"))
if n != 0 || err == nil {
t.Error("could write after being kicked")
}
return nil
})
sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
<-users
<-users
m, ok := host.MemberByID("foo")
if !ok {
t.Fatal("can't get member foo")
}
scanner := bufio.NewScanner(r)
scanner.Scan() // Joined
scanner.Scan()
assertLineEq := func(expected ...string) {
if !scanner.Scan() {
t.Error("no line available")
}
actual := stripPrompt(scanner.Text())
for _, exp := range expected {
if exp == actual {
return
}
}
t.Errorf("expected %#v, got %q", expected, actual)
}
sendCmd := func(cmd string, formatting ...interface{}) {
host.HandleMsg(message.ParseInput(fmt.Sprintf(cmd, formatting...), m.User))
}
sendCmd("/allowlist")
assertLineEq("Err: must be op\r")
m.IsOp = true
sendCmd("/allowlist")
for _, expected := range [...]string{"Usage", "help", "on, off", "add, remove", "import", "reload", "reverify", "status"} {
if !scanner.Scan() {
t.Error("no line available")
}
if actual := stripPrompt(scanner.Text()); !strings.HasPrefix(actual, expected) {
t.Errorf("Unexpected help message order: have %q, want prefix %q", actual, expected)
}
}
sendCmd("/allowlist on")
if !host.auth.AllowlistMode() {
t.Error("allowlist not enabled after /allowlist on")
}
sendCmd("/allowlist off")
if host.auth.AllowlistMode() {
t.Error("allowlist not disabled after /allowlist off")
}
testKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPUiNw0nQku4pcUCbZcJlIEAIf5bXJYTy/DKI1vh5b+P"
testKeyFP := "SHA256:GJNSl9NUcOS2pZYALn0C5Qgfh5deT+R+FfqNIUvpM9s="
if host.auth.allowlist.Len() != 0 {
t.Error("allowlist not empty before adding anyone")
}
sendCmd("/allowlist add ssh-invalid blah ssh-rsa wrongAsWell invalid foo bar %s", testKey)
assertLineEq("users without a public key: [foo]\r")
assertLineEq("invalid users: [invalid]\r")
assertLineEq("invalid keys: [ssh-invalid blah ssh-rsa wrongAsWell]\r")
if !host.auth.allowlist.In(testKeyFP) || !host.auth.allowlist.In(clientKeyFP) {
t.Error("failed to add keys to allowlist")
}
sendCmd("/allowlist remove invalid bar")
assertLineEq("invalid users: [invalid]\r")
if host.auth.allowlist.In(clientKeyFP) {
t.Error("failed to remove key from allowlist")
}
if !host.auth.allowlist.In(testKeyFP) {
t.Error("removed wrong key")
}
sendCmd("/allowlist import 5h")
if host.auth.allowlist.In(clientKeyFP) {
t.Error("imporrted key not seen long enough")
}
sendCmd("/allowlist import")
assertLineEq("users without a public key: [foo]\r")
if !host.auth.allowlist.In(clientKeyFP) {
t.Error("failed to import key")
}
sendCmd("/allowlist reload keep")
if !host.auth.allowlist.In(testKeyFP) {
t.Error("cleared allowlist to be kept")
}
sendCmd("/allowlist reload flush")
if host.auth.allowlist.In(testKeyFP) {
t.Error("kept allowlist to be cleared")
}
sendCmd("/allowlist reload thisIsWrong")
assertLineEq("Err: must specify whether to keep or flush current entries\r")
sendCmd("/allowlist reload")
assertLineEq("Err: must specify whether to keep or flush current entries\r")
sendCmd("/allowlist reverify")
assertLineEq("allowlist is disabled, so nobody will be kicked\r")
sendCmd("/allowlist on")
sendCmd("/allowlist reverify")
assertLineEq(" * Kicked during pubkey reverification: bar\r", " * bar left. (After 0 seconds)\r")
assertLineEq(" * Kicked during pubkey reverification: bar\r", " * bar left. (After 0 seconds)\r")
kickSignal <- struct{}{}
sendCmd("/allowlist add " + testKey)
sendCmd("/allowlist status")
assertLineEq("allowlist enabled\r")
assertLineEq(fmt.Sprintf("Keys on the allowlist without connected user: %s\r", testKeyFP))
sendCmd("/allowlist invalidSubcommand")
assertLineEq("Err: invalid subcommand: invalidSubcommand\r")
return nil
})
}
func TestHostKick(t *testing.T) {
s, host := getHost(t, NewAuth())
defer s.Close()
addr := s.Addr().String()
host := NewHost(s, nil)
go host.Serve()
g := errgroup.Group{}
@ -221,7 +345,7 @@ func TestHostKick(t *testing.T) {
g.Go(func() error {
// First client
return sshd.ConnectShell(addr, "foo", func(r io.Reader, w io.WriteCloser) error {
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
@ -258,7 +382,7 @@ func TestHostKick(t *testing.T) {
g.Go(func() error {
// Second client
return sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error {
return sshd.ConnectShell(s.Addr().String(), "bar", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
<-connected
scanner.Scan()
@ -296,12 +420,9 @@ func TestTimestampEnvConfig(t *testing.T) {
{"datetime +8h", strptr("2006-01-02 15:04:05")},
}
for _, tc := range cases {
u, err := connectUserWithConfig("dingus", map[string]string{
u := connectUserWithConfig(t, "dingus", map[string]string{
"SSHCHAT_TIMESTAMP": tc.input,
})
if err != nil {
t.Fatal(err)
}
userConfig := u.Config()
if userConfig.Timeformat != nil && tc.timeformat != nil {
if *userConfig.Timeformat != *tc.timeformat {
@ -315,20 +436,9 @@ func strptr(s string) *string {
return &s
}
func connectUserWithConfig(name string, envConfig map[string]string) (*message.User, error) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
return nil, fmt.Errorf("unable to create signer: %w", err)
}
config := sshd.MakeNoAuth()
config.AddHostKey(key)
s, err := sshd.ListenSSH("localhost:0", config)
if err != nil {
return nil, fmt.Errorf("unable to create a test server: %w", err)
}
func connectUserWithConfig(t *testing.T, name string, envConfig map[string]string) *message.User {
s, host := getHost(t, nil)
defer s.Close()
host := NewHost(s, nil)
newUsers := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
@ -339,13 +449,13 @@ func connectUserWithConfig(name string, envConfig map[string]string) (*message.U
clientConfig := sshd.NewClientConfig(name)
conn, err := ssh.Dial("tcp", s.Addr().String(), clientConfig)
if err != nil {
return nil, fmt.Errorf("unable to connect to test ssh-chat server: %w", err)
t.Fatal("unable to connect to test ssh-chat server:", err)
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
return nil, fmt.Errorf("unable to open session: %w", err)
t.Fatal("unable to open session:", err)
}
defer session.Close()
@ -355,13 +465,14 @@ func connectUserWithConfig(name string, envConfig map[string]string) (*message.U
err = session.Shell()
if err != nil {
return nil, fmt.Errorf("unable to open shell: %w", err)
t.Fatal("unable to open shell:", err)
}
for u := range newUsers {
if u.Name() == name {
return u, nil
return u
}
}
return nil, fmt.Errorf("user %s not found in the host", name)
t.Fatalf("user %s not found in the host", name)
return nil
}

View File

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

View File

@ -25,7 +25,7 @@ func TestServerInit(t *testing.T) {
}
func TestServeTerminals(t *testing.T) {
signer, err := NewRandomSigner(512)
signer, err := NewRandomSigner(1024)
if err != nil {
t.Fatal(err)
}