mirror of
https://github.com/shazow/ssh-chat.git
synced 2025-06-07 19:03:17 +03:00
Compare commits
No commits in common. "master" and "v1.11-rc3" have entirely different histories.
63
.github/ISSUE_TEMPLATE/BUG.yml
vendored
63
.github/ISSUE_TEMPLATE/BUG.yml
vendored
@ -1,63 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Create a report to fix something that is broken
|
|
||||||
title: "[Bug]: "
|
|
||||||
labels: ["bug", "needs-triage"]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: summary
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
attributes:
|
|
||||||
label: Summary
|
|
||||||
description: A clear and concise description of what the bug is.
|
|
||||||
- type: input
|
|
||||||
id: client-version
|
|
||||||
attributes:
|
|
||||||
label: Client version
|
|
||||||
description: Paste output of `ssh -V`
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: server-version
|
|
||||||
attributes:
|
|
||||||
label: Server version
|
|
||||||
description: Paste output of `ssh-chat --version`
|
|
||||||
placeholder: e.g., ssh-chat v0.1.0
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: latest-server-version
|
|
||||||
attributes:
|
|
||||||
label: Latest server version available (at time of report)
|
|
||||||
description: Check https://github.com/shazow/ssh-chat/releases and paste the latest version
|
|
||||||
placeholder: e.g., v0.2.0
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: reproduce
|
|
||||||
attributes:
|
|
||||||
label: To Reproduce
|
|
||||||
description: Steps to reproduce the behavior
|
|
||||||
placeholder: |
|
|
||||||
1. Full command to run...
|
|
||||||
2. Resulting output...
|
|
||||||
render: markdown
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
description: A clear and concise description of what you expected to happen.
|
|
||||||
placeholder: Describe the expected behavior
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context about the problem here.
|
|
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
@ -29,4 +29,4 @@ jobs:
|
|||||||
run: go build -v .
|
run: go build -v .
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -race -vet "all" -v ./...
|
run: go test -race -vet "all" -v .
|
||||||
|
7
Makefile
7
Makefile
@ -35,13 +35,6 @@ release:
|
|||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
CGO_ENABLED=0 GOOS=linux GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
|
||||||
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
CGO_ENABLED=0 GOOS=windows GOARCH=386 LDFLAGS='$(LDFLAGS)' ./build_release "github.com/shazow/ssh-chat/cmd/ssh-chat" README.md LICENSE
|
||||||
|
|
||||||
deploy: build/ssh-chat-linux_amd64.tgz
|
|
||||||
ssh -p 2022 ssh.chat tar xvz < build/ssh-chat-linux_amd64.tgz
|
|
||||||
@echo " --- Ready to deploy ---"
|
|
||||||
@echo "Run: ssh -t -p 2022 ssh.chat sudo systemctl restart ssh-chat"
|
|
||||||
|
12
README.md
12
README.md
@ -12,15 +12,13 @@ Custom SSH server written in Go. Instead of a shell, you get a chat prompt.
|
|||||||
|
|
||||||
Join the party:
|
Join the party:
|
||||||
|
|
||||||
``` console
|
```
|
||||||
$ ssh ssh.chat
|
$ ssh 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.
|
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).
|
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.
|
||||||
|
|
||||||
If you see something different, you might be [MITM](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)'d.
|
|
||||||
|
|
||||||
(Apologies if the server is down, try again shortly.)
|
(Apologies if the server is down, try again shortly.)
|
||||||
|
|
||||||
@ -52,7 +50,7 @@ Additionally, `make debug` runs the server with an http `pprof` server. This all
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
``` console
|
```
|
||||||
Usage:
|
Usage:
|
||||||
ssh-chat [OPTIONS]
|
ssh-chat [OPTIONS]
|
||||||
|
|
||||||
@ -62,7 +60,7 @@ Application Options:
|
|||||||
-i, --identity= Private key to identify server with. (default: ~/.ssh/id_rsa)
|
-i, --identity= Private key to identify server with. (default: ~/.ssh/id_rsa)
|
||||||
--bind= Host and port to listen on. (default: 0.0.0.0:2022)
|
--bind= Host and port to listen on. (default: 0.0.0.0:2022)
|
||||||
--admin= File of public keys who are admins.
|
--admin= File of public keys who are admins.
|
||||||
--allowlist= Optional file of public keys who are allowed to connect.
|
--whitelist= Optional file of public keys who are allowed to connect.
|
||||||
--motd= Optional Message of the Day file.
|
--motd= Optional Message of the Day file.
|
||||||
--log= Write chat log to this file.
|
--log= Write chat log to this file.
|
||||||
--pprof= Enable pprof http server for profiling.
|
--pprof= Enable pprof http server for profiling.
|
||||||
@ -74,7 +72,7 @@ Help Options:
|
|||||||
After doing `go get github.com/shazow/ssh-chat/...` on this repo, you should be able
|
After doing `go get github.com/shazow/ssh-chat/...` on this repo, you should be able
|
||||||
to run a command like:
|
to run a command like:
|
||||||
|
|
||||||
``` console
|
```
|
||||||
$ ssh-chat --verbose --bind ":22" --identity ~/.ssh/id_dsa
|
$ ssh-chat --verbose --bind ":22" --identity ~/.ssh/id_dsa
|
||||||
```
|
```
|
||||||
|
|
||||||
|
156
auth.go
156
auth.go
@ -1,14 +1,11 @@
|
|||||||
package sshchat
|
package sshchat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shazow/ssh-chat/set"
|
"github.com/shazow/ssh-chat/set"
|
||||||
@ -16,20 +13,13 @@ import (
|
|||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyLoader loads public keys, e.g. from an authorized_keys file.
|
// ErrNotWhitelisted Is the error returned when a key is checked that is not whitelisted,
|
||||||
// It must return a nil slice on error.
|
// when whitelisting is enabled.
|
||||||
type KeyLoader func() ([]ssh.PublicKey, error)
|
var ErrNotWhitelisted = errors.New("not whitelisted")
|
||||||
|
|
||||||
// ErrNotAllowed Is the error returned when a key is checked that is not allowlisted,
|
// ErrBanned is the error returned when a key is checked that is banned.
|
||||||
// when allowlisting is enabled.
|
|
||||||
var ErrNotAllowed = errors.New("not allowed")
|
|
||||||
|
|
||||||
// ErrBanned is the error returned when a client is banned.
|
|
||||||
var ErrBanned = errors.New("banned")
|
var ErrBanned = errors.New("banned")
|
||||||
|
|
||||||
// ErrIncorrectPassphrase is the error returned when a provided passphrase is incorrect.
|
|
||||||
var ErrIncorrectPassphrase = errors.New("incorrect passphrase")
|
|
||||||
|
|
||||||
// newAuthKey returns string from an ssh.PublicKey used to index the key in our lookup.
|
// newAuthKey returns string from an ssh.PublicKey used to index the key in our lookup.
|
||||||
func newAuthKey(key ssh.PublicKey) string {
|
func newAuthKey(key ssh.PublicKey) string {
|
||||||
if key == nil {
|
if key == nil {
|
||||||
@ -52,20 +42,13 @@ func newAuthAddr(addr net.Addr) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth stores lookups for bans, allowlists, and ops. It implements the sshd.Auth interface.
|
// Auth stores lookups for bans, whitelists, and ops. It implements the sshd.Auth interface.
|
||||||
// If the contained passphrase is not empty, it complements a allowlist.
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
passphraseHash []byte
|
|
||||||
bannedAddr *set.Set
|
bannedAddr *set.Set
|
||||||
bannedClient *set.Set
|
bannedClient *set.Set
|
||||||
banned *set.Set
|
banned *set.Set
|
||||||
allowlist *set.Set
|
whitelist *set.Set
|
||||||
ops *set.Set
|
ops *set.Set
|
||||||
|
|
||||||
settingsMu sync.RWMutex
|
|
||||||
allowlistMode bool
|
|
||||||
opLoader KeyLoader
|
|
||||||
allowlistLoader KeyLoader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuth creates a new empty Auth.
|
// NewAuth creates a new empty Auth.
|
||||||
@ -74,48 +57,29 @@ func NewAuth() *Auth {
|
|||||||
bannedAddr: set.New(),
|
bannedAddr: set.New(),
|
||||||
bannedClient: set.New(),
|
bannedClient: set.New(),
|
||||||
banned: set.New(),
|
banned: set.New(),
|
||||||
allowlist: set.New(),
|
whitelist: set.New(),
|
||||||
ops: set.New(),
|
ops: set.New(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) AllowlistMode() bool {
|
|
||||||
a.settingsMu.RLock()
|
|
||||||
defer a.settingsMu.RUnlock()
|
|
||||||
return a.allowlistMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Auth) SetAllowlistMode(value bool) {
|
|
||||||
a.settingsMu.Lock()
|
|
||||||
defer a.settingsMu.Unlock()
|
|
||||||
a.allowlistMode = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPassphrase enables passphrase authentication with the given passphrase.
|
|
||||||
// If an empty passphrase is given, disable passphrase authentication.
|
|
||||||
func (a *Auth) SetPassphrase(passphrase string) {
|
|
||||||
if passphrase == "" {
|
|
||||||
a.passphraseHash = nil
|
|
||||||
} else {
|
|
||||||
hashArray := sha256.Sum256([]byte(passphrase))
|
|
||||||
a.passphraseHash = hashArray[:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowAnonymous determines if anonymous users are permitted.
|
// AllowAnonymous determines if anonymous users are permitted.
|
||||||
func (a *Auth) AllowAnonymous() bool {
|
func (a *Auth) AllowAnonymous() bool {
|
||||||
return !a.AllowlistMode() && a.passphraseHash == nil
|
return a.whitelist.Len() == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptPassphrase determines if passphrase authentication is accepted.
|
// Check determines if a pubkey fingerprint is permitted.
|
||||||
func (a *Auth) AcceptPassphrase() bool {
|
func (a *Auth) Check(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
|
||||||
return a.passphraseHash != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckBans checks IP, key and client bans.
|
|
||||||
func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
|
|
||||||
authkey := newAuthKey(key)
|
authkey := newAuthKey(key)
|
||||||
|
|
||||||
|
if a.whitelist.Len() != 0 {
|
||||||
|
// Only check whitelist if there is something in it, otherwise it's disabled.
|
||||||
|
whitelisted := a.whitelist.In(authkey)
|
||||||
|
if !whitelisted {
|
||||||
|
return ErrNotWhitelisted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var banned bool
|
var banned bool
|
||||||
if authkey != "" {
|
if authkey != "" {
|
||||||
banned = a.banned.In(authkey)
|
banned = a.banned.In(authkey)
|
||||||
@ -134,29 +98,6 @@ func (a *Auth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckPubkey determines if a pubkey fingerprint is permitted.
|
|
||||||
func (a *Auth) CheckPublicKey(key ssh.PublicKey) error {
|
|
||||||
authkey := newAuthKey(key)
|
|
||||||
allowlisted := a.allowlist.In(authkey)
|
|
||||||
if a.AllowAnonymous() || allowlisted || a.IsOp(key) {
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return ErrNotAllowed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckPassphrase determines if a passphrase is permitted.
|
|
||||||
func (a *Auth) CheckPassphrase(passphrase string) error {
|
|
||||||
if !a.AcceptPassphrase() {
|
|
||||||
return errors.New("passphrases not accepted") // this should never happen
|
|
||||||
}
|
|
||||||
passedPassphraseHash := sha256.Sum256([]byte(passphrase))
|
|
||||||
if subtle.ConstantTimeCompare(passedPassphraseHash[:], a.passphraseHash) == 0 {
|
|
||||||
return ErrIncorrectPassphrase
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Op sets a public key as a known operator.
|
// Op sets a public key as a known operator.
|
||||||
func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
||||||
if key == nil {
|
if key == nil {
|
||||||
@ -173,68 +114,25 @@ func (a *Auth) Op(key ssh.PublicKey, d time.Duration) {
|
|||||||
|
|
||||||
// IsOp checks if a public key is an op.
|
// IsOp checks if a public key is an op.
|
||||||
func (a *Auth) IsOp(key ssh.PublicKey) bool {
|
func (a *Auth) IsOp(key ssh.PublicKey) bool {
|
||||||
|
if key == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
authkey := newAuthKey(key)
|
authkey := newAuthKey(key)
|
||||||
return a.ops.In(authkey)
|
return a.ops.In(authkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadOps sets the public keys form loader to operators and saves the loader for later use
|
// Whitelist will set a public key as a whitelisted user.
|
||||||
func (a *Auth) LoadOps(loader KeyLoader) error {
|
func (a *Auth) Whitelist(key ssh.PublicKey, d time.Duration) {
|
||||||
a.settingsMu.Lock()
|
|
||||||
a.opLoader = loader
|
|
||||||
a.settingsMu.Unlock()
|
|
||||||
return a.ReloadOps()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReloadOps sets the public keys from a loader saved in the last call to operators
|
|
||||||
func (a *Auth) ReloadOps() error {
|
|
||||||
a.settingsMu.RLock()
|
|
||||||
defer a.settingsMu.RUnlock()
|
|
||||||
return addFromLoader(a.opLoader, a.Op)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowlist will set a public key as a allowlisted user.
|
|
||||||
func (a *Auth) Allowlist(key ssh.PublicKey, d time.Duration) {
|
|
||||||
if key == nil {
|
if key == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
authItem := newAuthItem(key)
|
authItem := newAuthItem(key)
|
||||||
if d != 0 {
|
if d != 0 {
|
||||||
err = a.allowlist.Set(set.Expire(authItem, d))
|
a.whitelist.Set(set.Expire(authItem, d))
|
||||||
} else {
|
} else {
|
||||||
err = a.allowlist.Set(authItem)
|
a.whitelist.Set(authItem)
|
||||||
}
|
}
|
||||||
if err == nil {
|
logger.Debugf("Added to whitelist: %q (for %s)", authItem.Key(), d)
|
||||||
logger.Debugf("Added to allowlist: %q (for %s)", authItem.Key(), d)
|
|
||||||
} else {
|
|
||||||
logger.Errorf("Error adding %q to allowlist for %s: %s", authItem.Key(), d, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAllowlist adds the public keys from the loader to the allowlist and saves the loader for later use
|
|
||||||
func (a *Auth) LoadAllowlist(loader KeyLoader) error {
|
|
||||||
a.settingsMu.Lock()
|
|
||||||
a.allowlistLoader = loader
|
|
||||||
a.settingsMu.Unlock()
|
|
||||||
return a.ReloadAllowlist()
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadAllowlist adds the public keys from a loader saved in a previous call to the allowlist
|
|
||||||
func (a *Auth) ReloadAllowlist() error {
|
|
||||||
a.settingsMu.RLock()
|
|
||||||
defer a.settingsMu.RUnlock()
|
|
||||||
return addFromLoader(a.allowlistLoader, a.Allowlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addFromLoader(loader KeyLoader, adder func(ssh.PublicKey, time.Duration)) error {
|
|
||||||
if loader == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
keys, err := loader()
|
|
||||||
for _, key := range keys {
|
|
||||||
adder(key, 0)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ban will set a public key as banned.
|
// Ban will set a public key as banned.
|
||||||
|
49
auth_test.go
49
auth_test.go
@ -21,20 +21,19 @@ func ClonePublicKey(key ssh.PublicKey) (ssh.PublicKey, error) {
|
|||||||
return ssh.ParsePublicKey(key.Marshal())
|
return ssh.ParsePublicKey(key.Marshal())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthAllowlist(t *testing.T) {
|
func TestAuthWhitelist(t *testing.T) {
|
||||||
key, err := NewRandomPublicKey(512)
|
key, err := NewRandomPublicKey(512)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := NewAuth()
|
auth := NewAuth()
|
||||||
err = auth.CheckPublicKey(key)
|
err = auth.Check(nil, key, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("Failed to permit in default state:", err)
|
t.Error("Failed to permit in default state:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.Allowlist(key, 0)
|
auth.Whitelist(key, 0)
|
||||||
auth.SetAllowlistMode(true)
|
|
||||||
|
|
||||||
keyClone, err := ClonePublicKey(key)
|
keyClone, err := ClonePublicKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -45,9 +44,9 @@ func TestAuthAllowlist(t *testing.T) {
|
|||||||
t.Error("Clone key does not match.")
|
t.Error("Clone key does not match.")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = auth.CheckPublicKey(keyClone)
|
err = auth.Check(nil, keyClone, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("Failed to permit allowlisted:", err)
|
t.Error("Failed to permit whitelisted:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
key2, err := NewRandomPublicKey(512)
|
key2, err := NewRandomPublicKey(512)
|
||||||
@ -55,42 +54,8 @@ func TestAuthAllowlist(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = auth.CheckPublicKey(key2)
|
err = auth.Check(nil, key2, "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Failed to restrict not allowlisted:", err)
|
t.Error("Failed to restrict not whitelisted:", err)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthPassphrases(t *testing.T) {
|
|
||||||
auth := NewAuth()
|
|
||||||
|
|
||||||
if auth.AcceptPassphrase() {
|
|
||||||
t.Error("Doesn't known it won't accept passphrases.")
|
|
||||||
}
|
|
||||||
auth.SetPassphrase("")
|
|
||||||
if auth.AcceptPassphrase() {
|
|
||||||
t.Error("Doesn't known it won't accept passphrases.")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := auth.CheckPassphrase("Pa$$w0rd")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Failed to deny without passphrase:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.SetPassphrase("Pa$$w0rd")
|
|
||||||
|
|
||||||
err = auth.CheckPassphrase("Pa$$w0rd")
|
|
||||||
if err != nil {
|
|
||||||
t.Error("Failed to allow vaild passphrase:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = auth.CheckPassphrase("something else")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Failed to restrict wrong passphrase:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.SetPassphrase("")
|
|
||||||
if auth.AcceptPassphrase() {
|
|
||||||
t.Error("Didn't clear passphrase.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -469,27 +469,12 @@ func InitCommands(c *Commands) {
|
|||||||
msg.From().SetAway(awayMsg)
|
msg.From().SetAway(awayMsg)
|
||||||
if awayMsg != "" {
|
if awayMsg != "" {
|
||||||
room.Send(message.NewEmoteMsg("has gone away: "+awayMsg, msg.From()))
|
room.Send(message.NewEmoteMsg("has gone away: "+awayMsg, msg.From()))
|
||||||
return nil
|
} else if !isAway {
|
||||||
}
|
room.Send(message.NewSystemMsg("Not away. Append a reason message to set away.", msg.From()))
|
||||||
if isAway {
|
} else {
|
||||||
room.Send(message.NewEmoteMsg("is back.", msg.From()))
|
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 nil
|
||||||
}
|
|
||||||
return errors.New("must be away to be back")
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -191,11 +191,10 @@ func (m PrivateMsg) From() *User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m PrivateMsg) Render(t *Theme) string {
|
func (m PrivateMsg) Render(t *Theme) string {
|
||||||
format := "[PM from %s] %s"
|
s := fmt.Sprintf("[PM from %s] %s", m.from.Name(), m.body)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return fmt.Sprintf(format, m.from.ID(), m.body)
|
return s
|
||||||
}
|
}
|
||||||
s := fmt.Sprintf(format, m.from.Name(), m.body)
|
|
||||||
return t.ColorPM(s)
|
return t.ColorPM(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -9,6 +10,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"os/user"
|
"os/user"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/alexcesaro/log"
|
"github.com/alexcesaro/log"
|
||||||
"github.com/alexcesaro/log/golog"
|
"github.com/alexcesaro/log/golog"
|
||||||
@ -30,15 +32,14 @@ var Version string = "dev"
|
|||||||
type Options struct {
|
type Options struct {
|
||||||
Admin string `long:"admin" description:"File of public keys who are admins."`
|
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"`
|
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"`
|
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."`
|
Log string `long:"log" description:"Write chat log to this file."`
|
||||||
Motd string `long:"motd" description:"Optional Message of the Day file."`
|
Motd string `long:"motd" description:"Optional Message of the Day file."`
|
||||||
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
|
Pprof int `long:"pprof" description:"Enable pprof http server for profiling."`
|
||||||
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
|
Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."`
|
||||||
Version bool `long:"version" description:"Print version and exit."`
|
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" 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. Whitelist feature is more secure."`
|
||||||
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
|
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
|
// Figure out the log level
|
||||||
numVerbose := len(options.Verbose)
|
numVerbose := len(options.Verbose)
|
||||||
if numVerbose >= len(logLevels) {
|
if numVerbose > len(logLevels) {
|
||||||
numVerbose = len(logLevels) - 1
|
numVerbose = len(logLevels) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,12 +104,7 @@ func main() {
|
|||||||
message.SetLogger(os.Stderr)
|
message.SetLogger(os.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := sshchat.NewAuth()
|
privateKeyPath := options.Identity
|
||||||
config := sshd.MakeAuth(auth)
|
|
||||||
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, "~/") {
|
if strings.HasPrefix(privateKeyPath, "~/") {
|
||||||
user, err := user.Current()
|
user, err := user.Current()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -121,8 +117,48 @@ func main() {
|
|||||||
fail(3, "Failed to read identity private key: %v\n", err)
|
fail(3, "Failed to read identity private key: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auth := sshchat.NewAuth()
|
||||||
|
config := sshd.MakeAuth(auth)
|
||||||
config.AddHostKey(signer)
|
config.AddHostKey(signer)
|
||||||
fmt.Printf("Added server identity: %s\n", sshd.Fingerprint(signer.PublicKey()))
|
config.ServerVersion = "SSH-2.0-Go ssh-chat"
|
||||||
|
// FIXME: Should we be using config.NoClientAuth = true by default?
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := sshd.ListenSSH(options.Bind, config)
|
s, err := sshd.ListenSSH(options.Bind, config)
|
||||||
@ -138,24 +174,35 @@ func main() {
|
|||||||
host.SetTheme(message.Themes[0])
|
host.SetTheme(message.Themes[0])
|
||||||
host.Version = Version
|
host.Version = Version
|
||||||
|
|
||||||
if options.Passphrase != "" {
|
err = fromFile(options.Admin, func(line []byte) error {
|
||||||
auth.SetPassphrase(options.Passphrase)
|
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "ssh: no key found" {
|
||||||
|
return nil // Skip line
|
||||||
}
|
}
|
||||||
|
return err
|
||||||
err = auth.LoadOps(loaderFromFile(options.Admin, logger))
|
}
|
||||||
|
auth.Op(key, 0)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(5, "Failed to load admins: %v\n", err)
|
fail(5, "Failed to load admins: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Allowlist == "" && options.Whitelist != "" {
|
err = fromFile(options.Whitelist, func(line []byte) error {
|
||||||
fmt.Println("--whitelist was renamed to --allowlist.")
|
key, _, _, _, err := ssh.ParseAuthorizedKey(line)
|
||||||
options.Allowlist = options.Whitelist
|
|
||||||
}
|
|
||||||
err = auth.LoadAllowlist(loaderFromFile(options.Allowlist, logger))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(6, "Failed to load allowlist: %v\n", err)
|
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)
|
||||||
}
|
}
|
||||||
auth.SetAllowlistMode(options.Allowlist != "")
|
|
||||||
|
|
||||||
if options.Motd != "" {
|
if options.Motd != "" {
|
||||||
host.GetMOTD = func() (string, error) {
|
host.GetMOTD = func() (string, error) {
|
||||||
@ -196,32 +243,24 @@ func main() {
|
|||||||
fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.")
|
fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loaderFromFile(path string, logger *golog.Logger) sshchat.KeyLoader {
|
func fromFile(path string, handler func(line []byte) error) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
// Skip
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return func() ([]ssh.PublicKey, error) {
|
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var keys []ssh.PublicKey
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
key, _, _, _, err := ssh.ParseAuthorizedKey(scanner.Bytes())
|
err := handler(scanner.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "ssh: no key found" {
|
return err
|
||||||
continue // Skip line
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
if keys == nil {
|
|
||||||
logger.Warning("file", path, "contained no keys")
|
|
||||||
}
|
|
||||||
return keys, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -1,13 +1,7 @@
|
|||||||
version: '3.2'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
container_name: ssh-chat
|
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- 2022:2022
|
- 2022:2022
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: ~/.ssh/
|
|
||||||
target: /root/.ssh/
|
|
||||||
read_only: true
|
|
||||||
|
10
go.mod
10
go.mod
@ -4,11 +4,11 @@ require (
|
|||||||
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58
|
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58
|
||||||
github.com/jessevdk/go-flags v1.5.0
|
github.com/jessevdk/go-flags v1.5.0
|
||||||
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4
|
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4
|
||||||
golang.org/x/crypto v0.17.0
|
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||||
golang.org/x/sync v0.1.0
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||||
golang.org/x/sys v0.15.0
|
golang.org/x/sys v0.0.0-20210324051608-47abb6519492
|
||||||
golang.org/x/term v0.15.0
|
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6
|
||||||
golang.org/x/text v0.14.0
|
golang.org/x/text v0.3.5
|
||||||
)
|
)
|
||||||
|
|
||||||
go 1.13
|
go 1.13
|
||||||
|
48
go.sum
48
go.sum
@ -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/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 h1:zwQ1HBo5FYwn1ksMd19qBCKO8JAWE9wmHivEpkw/DvE=
|
||||||
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
|
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-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
|
||||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
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-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-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-20210324051608-47abb6519492 h1:Paq34FxTluEPvVyayQqMPgHm+vTOrIifmcYxFBx9TLg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
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-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.0.0-20210317153231-de623e64d2a6 h1:EC6+IGYTjPpRfv9a2b/6Puw0W+hLtAhkV1tPsXhutqs=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
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.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.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
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-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
262
host.go
@ -9,14 +9,11 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
|
|
||||||
"github.com/shazow/rateio"
|
"github.com/shazow/rateio"
|
||||||
"github.com/shazow/ssh-chat/chat"
|
"github.com/shazow/ssh-chat/chat"
|
||||||
"github.com/shazow/ssh-chat/chat/message"
|
"github.com/shazow/ssh-chat/chat/message"
|
||||||
"github.com/shazow/ssh-chat/internal/humantime"
|
"github.com/shazow/ssh-chat/internal/humantime"
|
||||||
"github.com/shazow/ssh-chat/internal/sanitize"
|
"github.com/shazow/ssh-chat/internal/sanitize"
|
||||||
"github.com/shazow/ssh-chat/set"
|
|
||||||
"github.com/shazow/ssh-chat/sshd"
|
"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 is used to reload the motd from an external source
|
||||||
GetMOTD func() (string, error)
|
GetMOTD func() (string, error)
|
||||||
// OnUserJoined is used to notify when a user joins a host
|
|
||||||
OnUserJoined func(*message.User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHost creates a Host on top of an existing listener.
|
// NewHost creates a Host on top of an existing listener.
|
||||||
@ -119,33 +114,6 @@ func (h *Host) Connect(term *sshd.Terminal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.SetConfig(cfg)
|
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
|
// Load user config overrides from ENV
|
||||||
// TODO: Would be nice to skip the command parsing pipeline just to load
|
// 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.
|
// Successfully joined.
|
||||||
if !apiMode {
|
if !apiMode {
|
||||||
term.SetPrompt(GetPrompt(user))
|
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())
|
logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name())
|
||||||
|
|
||||||
if h.OnUserJoined != nil {
|
|
||||||
h.OnUserJoined(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
line, err := term.ReadLine()
|
line, err := term.ReadLine()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
@ -698,202 +690,4 @@ func (h *Host) InitCommands(c *chat.Commands) {
|
|||||||
return nil
|
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
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
372
host_test.go
372
host_test.go
@ -2,8 +2,9 @@ package sshchat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
mathRand "math/rand"
|
mathRand "math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
@ -23,15 +24,9 @@ func stripPrompt(s string) string {
|
|||||||
if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 {
|
if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 {
|
||||||
return s[endPos+4:]
|
return s[endPos+4:]
|
||||||
}
|
}
|
||||||
if endPos := strings.Index(s, "\x1b[K-> "); endPos > 0 {
|
|
||||||
return s[endPos+6:]
|
|
||||||
}
|
|
||||||
if endPos := strings.Index(s, "] "); endPos > 0 {
|
if endPos := strings.Index(s, "] "); endPos > 0 {
|
||||||
return s[endPos+2:]
|
return s[endPos+2:]
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(s, "-> ") {
|
|
||||||
return s[3:]
|
|
||||||
}
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,14 +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",
|
Input: "[foo] \x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[K * Guest1 joined. (Connected: 2)\r",
|
||||||
Want: " * Guest1 joined. (Connected: 2)\r",
|
Want: " * Guest1 joined. (Connected: 2)\r",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Input: "[foo] \x1b[6D\x1b[K-> From your friendly system.\r",
|
|
||||||
Want: "From your friendly system.\r",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Input: "-> Err: must be op.\r",
|
|
||||||
Want: "Err: must be op.\r",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, tc := range tests {
|
for i, tc := range tests {
|
||||||
@ -89,58 +76,77 @@ func TestHostGetPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHost(t *testing.T, auth *Auth) (*sshd.SSHListener, *Host) {
|
func TestHostNameCollision(t *testing.T) {
|
||||||
key, err := sshd.NewRandomSigner(1024)
|
key, err := sshd.NewRandomSigner(512)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var config *ssh.ServerConfig
|
config := sshd.MakeNoAuth()
|
||||||
if auth == nil {
|
|
||||||
config = sshd.MakeNoAuth()
|
|
||||||
} else {
|
|
||||||
config = sshd.MakeAuth(auth)
|
|
||||||
}
|
|
||||||
config.AddHostKey(key)
|
config.AddHostKey(key)
|
||||||
|
|
||||||
s, err := sshd.ListenSSH("localhost:0", config)
|
s, err := sshd.ListenSSH("localhost:0", config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return s, NewHost(s, auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostNameCollision(t *testing.T) {
|
|
||||||
s, host := getHost(t, nil)
|
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
host := NewHost(s, nil)
|
||||||
newUsers := make(chan *message.User)
|
|
||||||
host.OnUserJoined = func(u *message.User) {
|
|
||||||
newUsers <- u
|
|
||||||
}
|
|
||||||
go host.Serve()
|
go host.Serve()
|
||||||
|
|
||||||
|
ready := make(chan struct{})
|
||||||
g := errgroup.Group{}
|
g := errgroup.Group{}
|
||||||
|
|
||||||
// First client
|
// First client
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
||||||
// second client
|
scanner := bufio.NewScanner(r)
|
||||||
name := (<-newUsers).Name()
|
|
||||||
if name != "Guest1" {
|
// Consume the initial buffer
|
||||||
t.Errorf("Second client did not get Guest1 name: %q", name)
|
scanner.Scan()
|
||||||
|
actual := stripPrompt(scanner.Text())
|
||||||
|
expected := " * foo joined. (Connected: 1)\r"
|
||||||
|
if actual != expected {
|
||||||
|
t.Errorf("Got %q; expected %q", actual, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ready for second client
|
||||||
|
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
|
return nil
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Wait for first client
|
||||||
|
<-ready
|
||||||
|
|
||||||
// Second client
|
// Second client
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
// first client
|
|
||||||
name := (<-newUsers).Name()
|
|
||||||
if name != "foo" {
|
|
||||||
t.Errorf("First client did not get foo name: %q", name)
|
|
||||||
}
|
|
||||||
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
return 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
|
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()
|
auth := NewAuth()
|
||||||
s, host := getHost(t, auth)
|
config := sshd.MakeAuth(auth)
|
||||||
|
config.AddHostKey(key)
|
||||||
|
|
||||||
|
s, err := sshd.ListenSSH("localhost:0", config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
host := NewHost(s, auth)
|
||||||
go host.Serve()
|
go host.Serve()
|
||||||
|
|
||||||
target := s.Addr().String()
|
target := s.Addr().String()
|
||||||
|
|
||||||
clientPrivateKey, err := sshd.NewRandomSigner(512)
|
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientkey, err := rsa.GenerateKey(rand.Reader, 512)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
clientKey := clientPrivateKey.PublicKey()
|
|
||||||
loadCount := -1
|
|
||||||
loader := func() ([]ssh.PublicKey, error) {
|
|
||||||
loadCount++
|
|
||||||
return [][]ssh.PublicKey{
|
|
||||||
{},
|
|
||||||
{clientKey},
|
|
||||||
}[loadCount], nil
|
|
||||||
}
|
|
||||||
auth.LoadAllowlist(loader)
|
|
||||||
|
|
||||||
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
|
clientpubkey, _ := ssh.NewPublicKey(clientkey.Public())
|
||||||
if err != nil {
|
auth.Whitelist(clientpubkey, 0)
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.SetAllowlistMode(true)
|
|
||||||
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
|
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error(err)
|
t.Error("Failed to block unwhitelisted connection.")
|
||||||
}
|
}
|
||||||
err = sshd.ConnectShellWithKey(target, "foo", clientPrivateKey, func(r io.Reader, w io.WriteCloser) error { return nil })
|
|
||||||
if err == nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth.ReloadAllowlist()
|
|
||||||
err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) error { return nil })
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Failed to block unallowlisted connection.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostAllowlistCommand(t *testing.T) {
|
|
||||||
s, host := getHost(t, NewAuth())
|
|
||||||
defer s.Close()
|
|
||||||
go host.Serve()
|
|
||||||
|
|
||||||
users := make(chan *message.User)
|
|
||||||
host.OnUserJoined = func(u *message.User) {
|
|
||||||
users <- u
|
|
||||||
}
|
|
||||||
|
|
||||||
kickSignal := make(chan struct{})
|
|
||||||
clientKey, err := sshd.NewRandomSigner(1024)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
clientKeyFP := sshd.Fingerprint(clientKey.PublicKey())
|
|
||||||
go sshd.ConnectShellWithKey(s.Addr().String(), "bar", clientKey, func(r io.Reader, w io.WriteCloser) error {
|
|
||||||
<-kickSignal
|
|
||||||
n, err := w.Write([]byte("alive and well"))
|
|
||||||
if n != 0 || err == nil {
|
|
||||||
t.Error("could write after being kicked")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
|
|
||||||
<-users
|
|
||||||
<-users
|
|
||||||
m, ok := host.MemberByID("foo")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("can't get member foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(r)
|
|
||||||
scanner.Scan() // Joined
|
|
||||||
scanner.Scan()
|
|
||||||
|
|
||||||
assertLineEq := func(expected ...string) {
|
|
||||||
if !scanner.Scan() {
|
|
||||||
t.Error("no line available")
|
|
||||||
}
|
|
||||||
actual := stripPrompt(scanner.Text())
|
|
||||||
for _, exp := range expected {
|
|
||||||
if exp == actual {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Errorf("expected %#v, got %q", expected, actual)
|
|
||||||
}
|
|
||||||
sendCmd := func(cmd string, formatting ...interface{}) {
|
|
||||||
host.HandleMsg(message.ParseInput(fmt.Sprintf(cmd, formatting...), m.User))
|
|
||||||
}
|
|
||||||
|
|
||||||
sendCmd("/allowlist")
|
|
||||||
assertLineEq("Err: must be op\r")
|
|
||||||
m.IsOp = true
|
|
||||||
sendCmd("/allowlist")
|
|
||||||
for _, expected := range [...]string{"Usage", "help", "on, off", "add, remove", "import", "reload", "reverify", "status"} {
|
|
||||||
if !scanner.Scan() {
|
|
||||||
t.Error("no line available")
|
|
||||||
}
|
|
||||||
if actual := stripPrompt(scanner.Text()); !strings.HasPrefix(actual, expected) {
|
|
||||||
t.Errorf("Unexpected help message order: have %q, want prefix %q", actual, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendCmd("/allowlist on")
|
|
||||||
if !host.auth.AllowlistMode() {
|
|
||||||
t.Error("allowlist not enabled after /allowlist on")
|
|
||||||
}
|
|
||||||
sendCmd("/allowlist off")
|
|
||||||
if host.auth.AllowlistMode() {
|
|
||||||
t.Error("allowlist not disabled after /allowlist off")
|
|
||||||
}
|
|
||||||
|
|
||||||
testKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPUiNw0nQku4pcUCbZcJlIEAIf5bXJYTy/DKI1vh5b+P"
|
|
||||||
testKeyFP := "SHA256:GJNSl9NUcOS2pZYALn0C5Qgfh5deT+R+FfqNIUvpM9s="
|
|
||||||
|
|
||||||
if host.auth.allowlist.Len() != 0 {
|
|
||||||
t.Error("allowlist not empty before adding anyone")
|
|
||||||
}
|
|
||||||
sendCmd("/allowlist add ssh-invalid blah ssh-rsa wrongAsWell invalid foo bar %s", testKey)
|
|
||||||
assertLineEq("users without a public key: [foo]\r")
|
|
||||||
assertLineEq("invalid users: [invalid]\r")
|
|
||||||
assertLineEq("invalid keys: [ssh-invalid blah ssh-rsa wrongAsWell]\r")
|
|
||||||
if !host.auth.allowlist.In(testKeyFP) || !host.auth.allowlist.In(clientKeyFP) {
|
|
||||||
t.Error("failed to add keys to allowlist")
|
|
||||||
}
|
|
||||||
sendCmd("/allowlist remove invalid bar")
|
|
||||||
assertLineEq("invalid users: [invalid]\r")
|
|
||||||
if host.auth.allowlist.In(clientKeyFP) {
|
|
||||||
t.Error("failed to remove key from allowlist")
|
|
||||||
}
|
|
||||||
if !host.auth.allowlist.In(testKeyFP) {
|
|
||||||
t.Error("removed wrong key")
|
|
||||||
}
|
|
||||||
|
|
||||||
sendCmd("/allowlist import 5h")
|
|
||||||
if host.auth.allowlist.In(clientKeyFP) {
|
|
||||||
t.Error("imporrted key not seen long enough")
|
|
||||||
}
|
|
||||||
sendCmd("/allowlist import")
|
|
||||||
assertLineEq("users without a public key: [foo]\r")
|
|
||||||
if !host.auth.allowlist.In(clientKeyFP) {
|
|
||||||
t.Error("failed to import key")
|
|
||||||
}
|
|
||||||
|
|
||||||
sendCmd("/allowlist reload keep")
|
|
||||||
if !host.auth.allowlist.In(testKeyFP) {
|
|
||||||
t.Error("cleared allowlist to be kept")
|
|
||||||
}
|
|
||||||
sendCmd("/allowlist reload flush")
|
|
||||||
if host.auth.allowlist.In(testKeyFP) {
|
|
||||||
t.Error("kept allowlist to be cleared")
|
|
||||||
}
|
|
||||||
sendCmd("/allowlist reload thisIsWrong")
|
|
||||||
assertLineEq("Err: must specify whether to keep or flush current entries\r")
|
|
||||||
sendCmd("/allowlist reload")
|
|
||||||
assertLineEq("Err: must specify whether to keep or flush current entries\r")
|
|
||||||
|
|
||||||
sendCmd("/allowlist reverify")
|
|
||||||
assertLineEq("allowlist is disabled, so nobody will be kicked\r")
|
|
||||||
sendCmd("/allowlist on")
|
|
||||||
sendCmd("/allowlist reverify")
|
|
||||||
assertLineEq(" * Kicked during pubkey reverification: bar\r", " * bar left. (After 0 seconds)\r")
|
|
||||||
assertLineEq(" * Kicked during pubkey reverification: bar\r", " * bar left. (After 0 seconds)\r")
|
|
||||||
kickSignal <- struct{}{}
|
|
||||||
|
|
||||||
sendCmd("/allowlist add " + testKey)
|
|
||||||
sendCmd("/allowlist status")
|
|
||||||
assertLineEq("allowlist enabled\r")
|
|
||||||
assertLineEq(fmt.Sprintf("Keys on the allowlist without connected user: %s\r", testKeyFP))
|
|
||||||
|
|
||||||
sendCmd("/allowlist invalidSubcommand")
|
|
||||||
assertLineEq("Err: invalid subcommand: invalidSubcommand\r")
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostKick(t *testing.T) {
|
func TestHostKick(t *testing.T) {
|
||||||
s, host := getHost(t, NewAuth())
|
key, err := sshd.NewRandomSigner(512)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := NewAuth()
|
||||||
|
config := sshd.MakeAuth(auth)
|
||||||
|
config.AddHostKey(key)
|
||||||
|
|
||||||
|
s, err := sshd.ListenSSH("localhost:0", config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
|
addr := s.Addr().String()
|
||||||
|
host := NewHost(s, nil)
|
||||||
go host.Serve()
|
go host.Serve()
|
||||||
|
|
||||||
g := errgroup.Group{}
|
g := errgroup.Group{}
|
||||||
@ -345,7 +220,7 @@ func TestHostKick(t *testing.T) {
|
|||||||
|
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
// First client
|
// 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)
|
scanner := bufio.NewScanner(r)
|
||||||
|
|
||||||
// Consume the initial buffer
|
// Consume the initial buffer
|
||||||
@ -382,7 +257,7 @@ func TestHostKick(t *testing.T) {
|
|||||||
|
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
// Second client
|
// 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)
|
scanner := bufio.NewScanner(r)
|
||||||
<-connected
|
<-connected
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
@ -407,72 +282,3 @@ func TestHostKick(t *testing.T) {
|
|||||||
t.Error(err)
|
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
|
|
||||||
}
|
|
||||||
|
14
set/set.go
14
set/set.go
@ -12,6 +12,9 @@ var ErrCollision = errors.New("key already exists")
|
|||||||
// Returned when a requested item does not exist in the set.
|
// Returned when a requested item does not exist in the set.
|
||||||
var ErrMissing = errors.New("item does not exist")
|
var ErrMissing = errors.New("item does not exist")
|
||||||
|
|
||||||
|
// 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.
|
// ZeroValue can be used when we only care about the key, not about the value.
|
||||||
var ZeroValue = struct{}{}
|
var ZeroValue = struct{}{}
|
||||||
|
|
||||||
@ -97,7 +100,7 @@ func (s *Set) Get(key string) (Item, error) {
|
|||||||
func (s *Set) cleanup(key string) {
|
func (s *Set) cleanup(key string) {
|
||||||
s.Lock()
|
s.Lock()
|
||||||
item, ok := s.lookup[key]
|
item, ok := s.lookup[key]
|
||||||
if ok && item.Value() == nil {
|
if ok && item == nil {
|
||||||
delete(s.lookup, key)
|
delete(s.lookup, key)
|
||||||
}
|
}
|
||||||
s.Unlock()
|
s.Unlock()
|
||||||
@ -105,6 +108,9 @@ func (s *Set) cleanup(key string) {
|
|||||||
|
|
||||||
// Add item to this set if it does not exist already.
|
// Add item to this set if it does not exist already.
|
||||||
func (s *Set) Add(item Item) error {
|
func (s *Set) Add(item Item) error {
|
||||||
|
if item.Value() == nil {
|
||||||
|
return ErrNil
|
||||||
|
}
|
||||||
key := s.normalize(item.Key())
|
key := s.normalize(item.Key())
|
||||||
|
|
||||||
s.Lock()
|
s.Lock()
|
||||||
@ -121,6 +127,9 @@ func (s *Set) Add(item Item) error {
|
|||||||
|
|
||||||
// Set item to this set, even if it already exists.
|
// Set item to this set, even if it already exists.
|
||||||
func (s *Set) Set(item Item) error {
|
func (s *Set) Set(item Item) error {
|
||||||
|
if item.Value() == nil {
|
||||||
|
return ErrNil
|
||||||
|
}
|
||||||
key := s.normalize(item.Key())
|
key := s.normalize(item.Key())
|
||||||
|
|
||||||
s.Lock()
|
s.Lock()
|
||||||
@ -147,6 +156,9 @@ func (s *Set) Remove(key string) error {
|
|||||||
// Replace oldKey with a new item, which might be a new key.
|
// Replace oldKey with a new item, which might be a new key.
|
||||||
// Can be used to rename items.
|
// Can be used to rename items.
|
||||||
func (s *Set) Replace(oldKey string, item Item) error {
|
func (s *Set) Replace(oldKey string, item Item) error {
|
||||||
|
if item.Value() == nil {
|
||||||
|
return ErrNil
|
||||||
|
}
|
||||||
newKey := s.normalize(item.Key())
|
newKey := s.normalize(item.Key())
|
||||||
oldKey = s.normalize(oldKey)
|
oldKey = s.normalize(oldKey)
|
||||||
|
|
||||||
|
@ -21,23 +21,14 @@ func TestSetExpiring(t *testing.T) {
|
|||||||
t.Error("not len 1 after set")
|
t.Error("not len 1 after set")
|
||||||
}
|
}
|
||||||
|
|
||||||
item := Expire(StringItem("asdf"), -time.Nanosecond).(*ExpiringItem)
|
item := &ExpiringItem{nil, time.Now().Add(-time.Nanosecond * 1)}
|
||||||
if !item.Expired() {
|
if !item.Expired() {
|
||||||
t.Errorf("ExpiringItem a nanosec ago is not expiring")
|
t.Errorf("ExpiringItem a nanosec ago is not expiring")
|
||||||
}
|
}
|
||||||
if err := s.Add(item); err != nil {
|
|
||||||
t.Error("Error adding expired item to set: ", err)
|
|
||||||
}
|
|
||||||
if s.In("asdf") {
|
|
||||||
t.Error("Expired item in set")
|
|
||||||
}
|
|
||||||
if s.Len() != 1 {
|
|
||||||
t.Error("not len 1 after expired item")
|
|
||||||
}
|
|
||||||
|
|
||||||
item = &ExpiringItem{nil, time.Now().Add(time.Minute * 5)}
|
item = &ExpiringItem{nil, time.Now().Add(time.Minute * 5)}
|
||||||
if item.Expired() {
|
if item.Expired() {
|
||||||
t.Errorf("ExpiringItem in 5 minutes is expiring now")
|
t.Errorf("ExpiringItem in 2 minutes is expiring now")
|
||||||
}
|
}
|
||||||
|
|
||||||
item = Expire(StringItem("bar"), time.Minute*5).(*ExpiringItem)
|
item = Expire(StringItem("bar"), time.Minute*5).(*ExpiringItem)
|
||||||
@ -51,13 +42,11 @@ func TestSetExpiring(t *testing.T) {
|
|||||||
if err := s.Add(item); err != nil {
|
if err := s.Add(item); err != nil {
|
||||||
t.Fatalf("failed to add item: %s", err)
|
t.Fatalf("failed to add item: %s", err)
|
||||||
}
|
}
|
||||||
itemInLookup, ok := s.lookup["bar"]
|
_, ok := s.lookup["bar"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("bar not present in lookup even though it's not expired")
|
t.Fatalf("expired bar added to lookup")
|
||||||
}
|
|
||||||
if itemInLookup != item {
|
|
||||||
t.Fatalf("present item %#v != %#v original item", itemInLookup, item)
|
|
||||||
}
|
}
|
||||||
|
s.lookup["bar"] = item
|
||||||
|
|
||||||
if !s.In("bar") {
|
if !s.In("bar") {
|
||||||
t.Errorf("not matched after timed set")
|
t.Errorf("not matched after timed set")
|
||||||
|
47
sshd/auth.go
47
sshd/auth.go
@ -5,26 +5,17 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/shazow/ssh-chat/internal/sanitize"
|
"github.com/shazow/ssh-chat/internal/sanitize"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auth is used to authenticate connections.
|
// Auth is used to authenticate connections based on public keys.
|
||||||
type Auth interface {
|
type Auth interface {
|
||||||
// Whether to allow connections without a public key.
|
// Whether to allow connections without a public key.
|
||||||
AllowAnonymous() bool
|
AllowAnonymous() bool
|
||||||
// If passphrase authentication is accepted
|
// Given address and public key and client agent string, returns nil if the connection should be allowed.
|
||||||
AcceptPassphrase() bool
|
Check(net.Addr, ssh.PublicKey, string) error
|
||||||
// Given address and public key and client agent string, returns nil if the connection is not banned.
|
|
||||||
CheckBans(net.Addr, ssh.PublicKey, string) error
|
|
||||||
// Given a public key, returns nil if the connection should be allowed.
|
|
||||||
CheckPublicKey(ssh.PublicKey) error
|
|
||||||
// Given a passphrase, returns nil if the connection should be allowed.
|
|
||||||
CheckPassphrase(string) error
|
|
||||||
// BanAddr bans an IP address for the specified amount of time.
|
|
||||||
BanAddr(net.Addr, time.Duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation.
|
// MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation.
|
||||||
@ -34,11 +25,7 @@ func MakeAuth(auth Auth) *ssh.ServerConfig {
|
|||||||
NoClientAuth: false,
|
NoClientAuth: false,
|
||||||
// Auth-related things should be constant-time to avoid timing attacks.
|
// Auth-related things should be constant-time to avoid timing attacks.
|
||||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||||
err := auth.CheckBans(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
|
err := auth.Check(conn.RemoteAddr(), key, sanitize.Data(string(conn.ClientVersion()), 64))
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = auth.CheckPublicKey(key)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -47,31 +34,11 @@ func MakeAuth(auth Auth) *ssh.ServerConfig {
|
|||||||
}}
|
}}
|
||||||
return perm, nil
|
return perm, nil
|
||||||
},
|
},
|
||||||
|
|
||||||
// We use KeyboardInteractiveCallback instead of PasswordCallback to
|
|
||||||
// avoid preventing the client from including a pubkey in the user
|
|
||||||
// identification.
|
|
||||||
KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
|
KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
|
||||||
err := auth.CheckBans(conn.RemoteAddr(), nil, sanitize.Data(string(conn.ClientVersion()), 64))
|
if !auth.AllowAnonymous() {
|
||||||
if err != nil {
|
return nil, errors.New("public key authentication required")
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if auth.AcceptPassphrase() {
|
|
||||||
var answers []string
|
|
||||||
answers, err = challenge("", "", []string{"Passphrase required to connect: "}, []bool{true})
|
|
||||||
if err == nil {
|
|
||||||
if len(answers) != 1 {
|
|
||||||
err = errors.New("didn't get passphrase")
|
|
||||||
} else {
|
|
||||||
err = auth.CheckPassphrase(answers[0])
|
|
||||||
if err != nil {
|
|
||||||
auth.BanAddr(conn.RemoteAddr(), time.Second*2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !auth.AllowAnonymous() {
|
|
||||||
err = errors.New("public key authentication required")
|
|
||||||
}
|
}
|
||||||
|
err := auth.Check(conn.RemoteAddr(), nil, sanitize.Data(string(conn.ClientVersion()), 64))
|
||||||
return nil, err
|
return nil, err
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -30,24 +30,9 @@ func NewClientConfig(name string) *ssh.ClientConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClientConfigWithKey(name string, key ssh.Signer) *ssh.ClientConfig {
|
|
||||||
return &ssh.ClientConfig{
|
|
||||||
User: name,
|
|
||||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(key)},
|
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConnectShell makes a barebones SSH client session, used for testing.
|
// ConnectShell makes a barebones SSH client session, used for testing.
|
||||||
func ConnectShell(host string, name string, handler func(r io.Reader, w io.WriteCloser) error) error {
|
func ConnectShell(host string, name string, handler func(r io.Reader, w io.WriteCloser) error) error {
|
||||||
return connectShell(host, NewClientConfig(name), handler)
|
config := NewClientConfig(name)
|
||||||
}
|
|
||||||
|
|
||||||
func ConnectShellWithKey(host string, name string, key ssh.Signer, handler func(r io.Reader, w io.WriteCloser) error) error {
|
|
||||||
return connectShell(host, NewClientConfigWithKey(name, key), handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectShell(host string, config *ssh.ClientConfig, handler func(r io.Reader, w io.WriteCloser) error) error {
|
|
||||||
conn, err := ssh.Dial("tcp", host, config)
|
conn, err := ssh.Dial("tcp", host, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@ -16,19 +15,9 @@ type RejectAuth struct{}
|
|||||||
func (a RejectAuth) AllowAnonymous() bool {
|
func (a RejectAuth) AllowAnonymous() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
func (a RejectAuth) AcceptPassphrase() bool {
|
func (a RejectAuth) Check(net.Addr, ssh.PublicKey, string) error {
|
||||||
return false
|
|
||||||
}
|
|
||||||
func (a RejectAuth) CheckBans(addr net.Addr, key ssh.PublicKey, clientVersion string) error {
|
|
||||||
return errRejectAuth
|
return errRejectAuth
|
||||||
}
|
}
|
||||||
func (a RejectAuth) CheckPublicKey(ssh.PublicKey) error {
|
|
||||||
return errRejectAuth
|
|
||||||
}
|
|
||||||
func (a RejectAuth) CheckPassphrase(string) error {
|
|
||||||
return errRejectAuth
|
|
||||||
}
|
|
||||||
func (a RejectAuth) BanAddr(net.Addr, time.Duration) {}
|
|
||||||
|
|
||||||
func TestClientReject(t *testing.T) {
|
func TestClientReject(t *testing.T) {
|
||||||
signer, err := NewRandomSigner(512)
|
signer, err := NewRandomSigner(512)
|
||||||
|
@ -25,7 +25,7 @@ func TestServerInit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServeTerminals(t *testing.T) {
|
func TestServeTerminals(t *testing.T) {
|
||||||
signer, err := NewRandomSigner(1024)
|
signer, err := NewRandomSigner(512)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -264,10 +264,6 @@ func (t *Terminal) moveCursorToPos(pos int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos > len(t.line) {
|
|
||||||
pos = len(t.line)
|
|
||||||
}
|
|
||||||
|
|
||||||
x := visualLength(t.prompt) + visualLength(t.line[:pos])
|
x := visualLength(t.prompt) + visualLength(t.line[:pos])
|
||||||
y := x / t.termWidth
|
y := x / t.termWidth
|
||||||
x = x % t.termWidth
|
x = x % t.termWidth
|
||||||
|
Loading…
x
Reference in New Issue
Block a user