Compare commits

..

No commits in common. "master" and "v1.11-rc3" have entirely different histories.

23 changed files with 336 additions and 1061 deletions

View File

@ -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.

27
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,27 @@
---
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

@ -29,4 +29,4 @@ jobs:
run: go build -v .
- name: Test
run: go test -race -vet "all" -v ./...
run: go test -race -vet "all" -v .

View File

@ -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=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
CGO_ENABLED=0 GOOS=windows GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
deploy: build/ssh-chat-linux_amd64.tgz
ssh -p 2022 ssh.chat tar xvz < build/ssh-chat-linux_amd64.tgz
@echo " --- Ready to deploy ---"
@echo "Run: ssh -t -p 2022 ssh.chat sudo systemctl restart ssh-chat"

View File

@ -12,15 +12,13 @@ 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 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.
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.
(Apologies if the server is down, try again shortly.)
@ -52,7 +50,7 @@ Additionally, `make debug` runs the server with an http `pprof` server. This all
## Quick Start
``` console
```
Usage:
ssh-chat [OPTIONS]
@ -62,7 +60,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.
--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.
--log= Write chat log to this file.
--pprof= Enable pprof http server for profiling.
@ -74,7 +72,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
```

164
auth.go
View File

@ -1,14 +1,11 @@
package sshchat
import (
"crypto/sha256"
"crypto/subtle"
"encoding/csv"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/shazow/ssh-chat/set"
@ -16,20 +13,13 @@ import (
"golang.org/x/crypto/ssh"
)
// 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)
// ErrNotWhitelisted Is the error returned when a key is checked that is not whitelisted,
// when whitelisting is enabled.
var ErrNotWhitelisted = errors.New("not whitelisted")
// 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.
// ErrBanned is the error returned when a key is checked that is banned.
var ErrBanned = errors.New("banned")
// ErrIncorrectPassphrase is the error returned when a provided passphrase is incorrect.
var ErrIncorrectPassphrase = errors.New("incorrect passphrase")
// newAuthKey returns string from an ssh.PublicKey used to index the key in our lookup.
func newAuthKey(key ssh.PublicKey) string {
if key == nil {
@ -52,20 +42,13 @@ func newAuthAddr(addr net.Addr) string {
return host
}
// 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.
// Auth stores lookups for bans, whitelists, and ops. It implements the sshd.Auth interface.
type Auth struct {
passphraseHash []byte
bannedAddr *set.Set
bannedClient *set.Set
banned *set.Set
allowlist *set.Set
ops *set.Set
settingsMu sync.RWMutex
allowlistMode bool
opLoader KeyLoader
allowlistLoader KeyLoader
bannedAddr *set.Set
bannedClient *set.Set
banned *set.Set
whitelist *set.Set
ops *set.Set
}
// NewAuth creates a new empty Auth.
@ -74,48 +57,29 @@ func NewAuth() *Auth {
bannedAddr: set.New(),
bannedClient: set.New(),
banned: set.New(),
allowlist: set.New(),
whitelist: set.New(),
ops: set.New(),
}
}
func (a *Auth) AllowlistMode() bool {
a.settingsMu.RLock()
defer a.settingsMu.RUnlock()
return a.allowlistMode
}
func (a *Auth) SetAllowlistMode(value bool) {
a.settingsMu.Lock()
defer a.settingsMu.Unlock()
a.allowlistMode = value
}
// SetPassphrase enables passphrase authentication with the given passphrase.
// If an empty passphrase is given, disable passphrase authentication.
func (a *Auth) SetPassphrase(passphrase string) {
if passphrase == "" {
a.passphraseHash = nil
} else {
hashArray := sha256.Sum256([]byte(passphrase))
a.passphraseHash = hashArray[:]
}
}
// AllowAnonymous determines if anonymous users are permitted.
func (a *Auth) AllowAnonymous() bool {
return !a.AllowlistMode() && a.passphraseHash == nil
return a.whitelist.Len() == 0
}
// AcceptPassphrase determines if passphrase authentication is accepted.
func (a *Auth) AcceptPassphrase() bool {
return a.passphraseHash != nil
}
// CheckBans checks IP, key and client bans.
func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
// Check determines if a pubkey fingerprint is permitted.
func (a *Auth) Check(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
authkey := newAuthKey(key)
if a.whitelist.Len() != 0 {
// Only check whitelist if there is something in it, otherwise it's disabled.
whitelisted := a.whitelist.In(authkey)
if !whitelisted {
return ErrNotWhitelisted
}
return nil
}
var banned bool
if authkey != "" {
banned = a.banned.In(authkey)
@ -134,29 +98,6 @@ func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string)
return nil
}
// CheckPubkey determines if a pubkey fingerprint is permitted.
func (a *Auth) CheckPublicKey(key ssh.PublicKey) error {
authkey := newAuthKey(key)
allowlisted := a.allowlist.In(authkey)
if a.AllowAnonymous() || allowlisted || a.IsOp(key) {
return nil
} else {
return ErrNotAllowed
}
}
// CheckPassphrase determines if a passphrase is permitted.
func (a *Auth) CheckPassphrase(passphrase string) error {
if !a.AcceptPassphrase() {
return errors.New("passphrases not accepted") // this should never happen
}
passedPassphraseHash := sha256.Sum256([]byte(passphrase))
if subtle.ConstantTimeCompare(passedPassphraseHash[:], a.passphraseHash) == 0 {
return ErrIncorrectPassphrase
}
return nil
}
// Op sets a public key as a known operator.
func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
if key == nil {
@ -173,68 +114,25 @@ 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)
}
// 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) {
// Whitelist will set a public key as a whitelisted user.
func (a *Auth) Whitelist(key ssh.PublicKey, d time.Duration) {
if key == nil {
return
}
var err error
authItem := newAuthItem(key)
if d != 0 {
err = a.allowlist.Set(set.Expire(authItem, d))
a.whitelist.Set(set.Expire(authItem, d))
} else {
err = a.allowlist.Set(authItem)
a.whitelist.Set(authItem)
}
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
logger.Debugf("Added to whitelist: %q (for %s)", authItem.Key(), d)
}
// Ban will set a public key as banned.

View File

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

View File

@ -469,27 +469,12 @@ func InitCommands(c *Commands) {
msg.From().SetAway(awayMsg)
if awayMsg != "" {
room.Send(message.NewEmoteMsg("has gone away: "+awayMsg, msg.From()))
return nil
}
if isAway {
} else if !isAway {
room.Send(message.NewSystemMsg("Not away. Append a reason message to set away.", msg.From()))
} else {
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")
return nil
},
})

View File

@ -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)
}
}
})
}
}

View File

@ -191,11 +191,10 @@ func (m PrivateMsg) From() *User {
}
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 {
return fmt.Sprintf(format, m.from.ID(), m.body)
return s
}
s := fmt.Sprintf(format, m.from.Name(), m.body)
return t.ColorPM(s)
}

View File

@ -2,6 +2,7 @@ package main
import (
"bufio"
"errors"
"fmt"
"io/ioutil"
"net/http"
@ -9,6 +10,7 @@ import (
"os/signal"
"os/user"
"strings"
"time"
"github.com/alexcesaro/log"
"github.com/alexcesaro/log/golog"
@ -28,17 +30,16 @@ var Version string = "dev"
// Options contains the flag options
type Options struct {
Admin string `long:"admin" description:"File of public keys who are admins."`
Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"`
Identity []string `short:"i" long:"identity" description:"Private key to identify server with." default:"~/.ssh/id_rsa"`
Log string `long:"log" description:"Write chat log to this file."`
Motd string `long:"motd" description:"Optional Message of the Day file."`
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
Version bool `long:"version" description:"Print version and exit."`
Allowlist string `long:"allowlist" description:"Optional file of public keys who are allowed to connect."`
Whitelist string `long:"whitelist" dexcription:"Old name for allowlist option"`
Passphrase string `long:"unsafe-passphrase" description:"Require an interactive passphrase to connect. Allowlist feature is more secure."`
Admin string `long:"admin" description:"File of public keys who are admins."`
Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"`
Identity string `short:"i" long:"identity" description:"Private key to identify server with." default:"~/.ssh/id_rsa"`
Log string `long:"log" description:"Write chat log to this file."`
Motd string `long:"motd" description:"Optional Message of the Day file."`
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
Version bool `long:"version" description:"Print version and exit."`
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."`
}
const extraHelp = `There are hidden options and easter eggs in ssh-chat. The source code is a good
@ -88,7 +89,7 @@ func main() {
// Figure out the log level
numVerbose := len(options.Verbose)
if numVerbose >= len(logLevels) {
if numVerbose > len(logLevels) {
numVerbose = len(logLevels) - 1
}
@ -103,26 +104,61 @@ func main() {
message.SetLogger(os.Stderr)
}
privateKeyPath := options.Identity
if strings.HasPrefix(privateKeyPath, "~/") {
user, err := user.Current()
if err == nil {
privateKeyPath = strings.Replace(privateKeyPath, "~", user.HomeDir, 1)
}
}
signer, err := ReadPrivateKey(privateKeyPath)
if err != nil {
fail(3, "Failed to read identity private key: %v\n", err)
}
auth := sshchat.NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(signer)
config.ServerVersion = "SSH-2.0-Go ssh-chat"
// FIXME: Should we be using config.NoClientAuth = true by default?
for _, privateKeyPath := range options.Identity {
if strings.HasPrefix(privateKeyPath, "~/") {
user, err := user.Current()
if err == nil {
privateKeyPath = strings.Replace(privateKeyPath, "~", user.HomeDir, 1)
if options.Passphrase != "" {
if options.Whitelist != "" {
logger.Warning("Passphrase is disabled while whitelist is enabled.")
}
if config.KeyboardInteractiveCallback != nil {
fail(1, "Passphrase authentication conflicts with existing KeyboardInteractive setup.") // This should not happen
}
// We use KeyboardInteractiveCallback instead of PasswordCallback to
// avoid preventing the client from including a pubkey in the user
// identification.
config.KeyboardInteractiveCallback = func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
answers, err := challenge("", "", []string{"Passphrase required to connect: "}, []bool{true})
if err != nil {
return nil, err
}
if len(answers) == 1 && answers[0] == options.Passphrase {
// Success
return nil, nil
}
// It's not gonna do much but may as well throttle brute force attempts a little
time.Sleep(2 * time.Second)
return nil, errors.New("incorrect passphrase")
}
signer, err := ReadPrivateKey(privateKeyPath)
if err != nil {
fail(3, "Failed to read identity private key: %v\n", err)
// We also need to override the PublicKeyCallback to prevent rando pubkeys from bypassing
cb := config.PublicKeyCallback
config.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
perms, err := cb(conn, key)
if err == nil {
err = errors.New("passphrase authentication required")
}
return perms, err
}
config.AddHostKey(signer)
fmt.Printf("Added server identity: %s\n", sshd.Fingerprint(signer.PublicKey()))
}
s, err := sshd.ListenSSH(options.Bind, config)
@ -138,24 +174,35 @@ func main() {
host.SetTheme(message.Themes[0])
host.Version = Version
if options.Passphrase != "" {
auth.SetPassphrase(options.Passphrase)
}
err = auth.LoadOps(loaderFromFile(options.Admin, logger))
err = fromFile(options.Admin, func(line []byte) error {
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
if err != nil {
if err.Error() == "ssh: no key found" {
return nil // Skip line
}
return err
}
auth.Op(key, 0)
return nil
})
if err != nil {
fail(5, "Failed to load admins: %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))
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 allowlist: %v\n", err)
fail(6, "Failed to load whitelist: %v\n", err)
}
auth.SetAllowlistMode(options.Allowlist != "")
if options.Motd != "" {
host.GetMOTD = func() (string, error) {
@ -196,32 +243,24 @@ func main() {
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 == "" {
// Skip
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
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
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
err := handler(scanner.Bytes())
if err != nil {
return err
}
}
return nil
}

View File

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

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.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
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
)
go 1.13

48
go.sum
View File

@ -4,47 +4,19 @@ 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=
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/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
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/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/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/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/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/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/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/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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=

262
host.go
View File

@ -9,14 +9,11 @@ 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"
)
@ -52,8 +49,6 @@ type Host struct {
// 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.
@ -119,33 +114,6 @@ func (h *Host) Connect(term *sshd.Terminal) {
}
user.SetConfig(cfg)
go user.Consume()
// Close term once user is closed.
defer user.Close()
defer term.Close()
h.mu.Lock()
motd := h.motd
count := h.count
h.count++
h.mu.Unlock()
// Send MOTD
if motd != "" && !apiMode {
user.Send(message.NewAnnounceMsg(motd))
}
member, err := h.Join(user)
if err != nil {
// Try again...
id.SetName(fmt.Sprintf("Guest%d", count))
member, err = h.Join(user)
}
if err != nil {
logger.Errorf("[%s] Failed to join: %s", term.Conn.RemoteAddr(), err)
return
}
// Load user config overrides from ENV
// TODO: Would be nice to skip the command parsing pipeline just to load
@ -172,6 +140,34 @@ func (h *Host) Connect(term *sshd.Terminal) {
}
}
go user.Consume()
// Close term once user is closed.
defer user.Close()
defer term.Close()
h.mu.Lock()
motd := h.motd
count := h.count
h.count++
h.mu.Unlock()
// Send MOTD
if motd != "" {
user.Send(message.NewAnnounceMsg(motd))
}
member, err := h.Join(user)
if err != nil {
// Try again...
id.SetName(fmt.Sprintf("Guest%d", count))
member, err = h.Join(user)
}
if err != nil {
logger.Errorf("[%s] Failed to join: %s", term.Conn.RemoteAddr(), err)
return
}
// Successfully joined.
if !apiMode {
term.SetPrompt(GetPrompt(user))
@ -187,10 +183,6 @@ func (h *Host) Connect(term *sshd.Terminal) {
logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name())
if h.OnUserJoined != nil {
h.OnUserJoined(user)
}
for {
line, err := term.ReadLine()
if err == io.EOF {
@ -698,202 +690,4 @@ 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,9 @@ package sshchat
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"io"
mathRand "math/rand"
"strings"
@ -23,15 +24,9 @@ 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
}
@ -48,14 +43,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",
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 {
@ -89,58 +76,77 @@ func TestHostGetPrompt(t *testing.T) {
}
}
func getHost(t *testing.T, auth *Auth) (*sshd.SSHListener, *Host) {
key, err := sshd.NewRandomSigner(1024)
func TestHostNameCollision(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
var config *ssh.ServerConfig
if auth == nil {
config = sshd.MakeNoAuth()
} else {
config = sshd.MakeAuth(auth)
}
config := sshd.MakeNoAuth()
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()
newUsers := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
newUsers <- u
}
host := NewHost(s, nil)
go host.Serve()
ready := make(chan struct{})
g := errgroup.Group{}
// First client
g.Go(func() error {
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
// second client
name := (<-newUsers).Name()
if name != "Guest1" {
t.Errorf("Second client did not get Guest1 name: %q", name)
scanner := bufio.NewScanner(r)
// Consume the initial buffer
scanner.Scan()
actual := stripPrompt(scanner.Text())
expected := " * foo joined. (Connected: 1)\r"
if actual != expected {
t.Errorf("Got %q; expected %q", actual, expected)
}
// Ready for second client
ready <- 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(ready)
return nil
})
})
// Wait for first client
<-ready
// Second client
g.Go(func() error {
// first client
name := (<-newUsers).Name()
if name != "foo" {
t.Errorf("First client did not get foo name: %q", name)
}
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
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
})
})
@ -150,193 +156,62 @@ func TestHostNameCollision(t *testing.T) {
}
}
func TestHostAllowlist(t *testing.T) {
func TestHostWhitelist(t *testing.T) {
key, err := sshd.NewRandomSigner(512)
if err != nil {
t.Fatal(err)
}
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()
host := NewHost(s, auth)
go host.Serve()
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 {
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)
}
auth.SetAllowlistMode(true)
clientpubkey, _ := ssh.NewPublicKey(clientkey.Public())
auth.Whitelist(clientpubkey, 0)
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return 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) {
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()
addr := s.Addr().String()
host := NewHost(s, nil)
go host.Serve()
g := errgroup.Group{}
@ -345,7 +220,7 @@ func TestHostKick(t *testing.T) {
g.Go(func() error {
// First client
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
return sshd.ConnectShell(addr, "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
@ -382,7 +257,7 @@ func TestHostKick(t *testing.T) {
g.Go(func() error {
// Second client
return sshd.ConnectShell(s.Addr().String(), "bar", func(r io.Reader, w io.WriteCloser) error {
return sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
<-connected
scanner.Scan()
@ -407,72 +282,3 @@ func TestHostKick(t *testing.T) {
t.Error(err)
}
}
func TestTimestampEnvConfig(t *testing.T) {
cases := []struct {
input string
timeformat *string
}{
{"", strptr("15:04")},
{"1", strptr("15:04")},
{"0", nil},
{"time +8h", strptr("15:04")},
{"datetime +8h", strptr("2006-01-02 15:04:05")},
}
for _, tc := range cases {
u := connectUserWithConfig(t, "dingus", map[string]string{
"SSHCHAT_TIMESTAMP": tc.input,
})
userConfig := u.Config()
if userConfig.Timeformat != nil && tc.timeformat != nil {
if *userConfig.Timeformat != *tc.timeformat {
t.Fatal("unexpected timeformat:", *userConfig.Timeformat, "expected:", *tc.timeformat)
}
}
}
}
func strptr(s string) *string {
return &s
}
func connectUserWithConfig(t *testing.T, name string, envConfig map[string]string) *message.User {
s, host := getHost(t, nil)
defer s.Close()
newUsers := make(chan *message.User)
host.OnUserJoined = func(u *message.User) {
newUsers <- u
}
go host.Serve()
clientConfig := sshd.NewClientConfig(name)
conn, err := ssh.Dial("tcp", s.Addr().String(), clientConfig)
if err != nil {
t.Fatal("unable to connect to test ssh-chat server:", err)
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
t.Fatal("unable to open session:", err)
}
defer session.Close()
for key := range envConfig {
session.Setenv(key, envConfig[key])
}
err = session.Shell()
if err != nil {
t.Fatal("unable to open shell:", err)
}
for u := range newUsers {
if u.Name() == name {
return u
}
}
t.Fatalf("user %s not found in the host", name)
return nil
}

View File

@ -12,6 +12,9 @@ var ErrCollision = errors.New("key already exists")
// Returned when a requested item does not exist in the set.
var ErrMissing = errors.New("item does not exist")
// Returned when a nil item is added. Nil values are considered expired and invalid.
var ErrNil = errors.New("item value must not be nil")
// ZeroValue can be used when we only care about the key, not about the value.
var ZeroValue = struct{}{}
@ -97,7 +100,7 @@ func (s *Set) Get(key string) (Item, error) {
func (s *Set) cleanup(key string) {
s.Lock()
item, ok := s.lookup[key]
if ok && item.Value() == nil {
if ok && item == nil {
delete(s.lookup, key)
}
s.Unlock()
@ -105,6 +108,9 @@ func (s *Set) cleanup(key string) {
// Add item to this set if it does not exist already.
func (s *Set) Add(item Item) error {
if item.Value() == nil {
return ErrNil
}
key := s.normalize(item.Key())
s.Lock()
@ -121,6 +127,9 @@ func (s *Set) Add(item Item) error {
// Set item to this set, even if it already exists.
func (s *Set) Set(item Item) error {
if item.Value() == nil {
return ErrNil
}
key := s.normalize(item.Key())
s.Lock()
@ -147,6 +156,9 @@ func (s *Set) Remove(key string) error {
// Replace oldKey with a new item, which might be a new key.
// Can be used to rename items.
func (s *Set) Replace(oldKey string, item Item) error {
if item.Value() == nil {
return ErrNil
}
newKey := s.normalize(item.Key())
oldKey = s.normalize(oldKey)

View File

@ -21,23 +21,14 @@ func TestSetExpiring(t *testing.T) {
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() {
t.Errorf("ExpiringItem a nanosec ago is not expiring")
}
if err := s.Add(item); err != nil {
t.Error("Error adding expired item to set: ", err)
}
if s.In("asdf") {
t.Error("Expired item in set")
}
if s.Len() != 1 {
t.Error("not len 1 after expired item")
}
item = &ExpiringItem{nil, time.Now().Add(time.Minute * 5)}
if item.Expired() {
t.Errorf("ExpiringItem in 5 minutes is expiring now")
t.Errorf("ExpiringItem in 2 minutes is expiring now")
}
item = Expire(StringItem("bar"), time.Minute*5).(*ExpiringItem)
@ -51,13 +42,11 @@ func TestSetExpiring(t *testing.T) {
if err := s.Add(item); err != nil {
t.Fatalf("failed to add item: %s", err)
}
itemInLookup, ok := s.lookup["bar"]
_, ok := s.lookup["bar"]
if !ok {
t.Fatalf("bar not present in lookup even though it's not expired")
}
if itemInLookup != item {
t.Fatalf("present item %#v != %#v original item", itemInLookup, item)
t.Fatalf("expired bar added to lookup")
}
s.lookup["bar"] = item
if !s.In("bar") {
t.Errorf("not matched after timed set")

View File

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

View File

@ -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.
func ConnectShell(host string, name string, handler func(r io.Reader, w io.WriteCloser) error) error {
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 {
config := NewClientConfig(name)
conn, err := ssh.Dial("tcp", host, config)
if err != nil {
return err

View File

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

View File

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

View File

@ -264,10 +264,6 @@ func (t *Terminal) moveCursorToPos(pos int) {
return
}
if pos > len(t.line) {
pos = len(t.line)
}
x := visualLength(t.prompt) + visualLength(t.line[:pos])
y := x / t.termWidth
x = x % t.termWidth