Compare commits

..

No commits in common. "master" and "v1.9" have entirely different histories.
master ... v1.9

34 changed files with 479 additions and 1803 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.

View File

@ -1,32 +0,0 @@
name: Go
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get -v -t -d ./...
- name: Build
run: go build -v .
- name: Test
run: go test -race -vet "all" -v ./...

View File

@ -1,78 +0,0 @@
# Code of Conduct
This code of conduct applies to both: The `ssh.chat` code participants and the `ssh-chat` code contributors.
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces (such as inside the chat) and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at andrey.petrov@shazow.net. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

View File

@ -1,21 +0,0 @@
FROM golang:alpine AS builder
WORKDIR /usr/src/app
COPY . .
RUN apk add make openssh
RUN make build
FROM alpine
RUN apk add openssh
RUN mkdir /root/.ssh
WORKDIR /root/.ssh
RUN ssh-keygen -t rsa -C "chatkey" -f id_rsa
WORKDIR /usr/local/bin
COPY --from=builder /usr/src/app/ssh-chat .
RUN chmod +x ssh-chat
CMD ["/usr/local/bin/ssh-chat"]

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,11 @@ 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 +48,7 @@ Additionally, `make debug` runs the server with an http `pprof` server. This all
## Quick Start
``` console
```
Usage:
ssh-chat [OPTIONS]
@ -62,7 +58,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 +70,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
```
@ -88,4 +84,4 @@ Feel free to submit more questions to be answered and added to the page.
## License
MIT
This project is licensed under the MIT open source license.

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

@ -5,7 +5,6 @@ package chat
import (
"errors"
"fmt"
"sort"
"strings"
"time"
@ -190,7 +189,6 @@ func InitCommands(c *Commands) {
}
names := room.Members.ListPrefix("")
sort.Slice(names, func(i, j int) bool { return names[i].Key() < names[j].Key() })
colNames := make([]string, len(names))
for i, uname := range names {
colNames[i] = colorize(uname.Value().(*Member).User)
@ -411,119 +409,4 @@ func InitCommands(c *Commands) {
return nil
},
})
c.Add(Command{
Prefix: "/focus",
PrefixHelp: "[USER ...]",
Help: "Only show messages from focused users, or $ to reset.",
Handler: func(room *Room, msg message.CommandMsg) error {
ids := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/focus"))
if ids == "" {
// Print focused names, if any.
var names []string
msg.From().Focused.Each(func(_ string, item set.Item) error {
names = append(names, item.Key())
return nil
})
var systemMsg string
if len(names) == 0 {
systemMsg = "Unfocused."
} else {
systemMsg = fmt.Sprintf("Focusing on %d users: %s", len(names), strings.Join(names, ", "))
}
room.Send(message.NewSystemMsg(systemMsg, msg.From()))
return nil
}
n := msg.From().Focused.Clear()
if ids == "$" {
room.Send(message.NewSystemMsg(fmt.Sprintf("Removed focus from %d users.", n), msg.From()))
return nil
}
var focused []string
for _, name := range strings.Split(ids, " ") {
id := sanitize.Name(name)
if id == "" {
continue // Skip
}
focused = append(focused, id)
if err := msg.From().Focused.Set(set.Itemize(id, set.ZeroValue)); err != nil {
return err
}
}
room.Send(message.NewSystemMsg(fmt.Sprintf("Focusing: %s", strings.Join(focused, ", ")), msg.From()))
return nil
},
})
c.Add(Command{
Prefix: "/away",
PrefixHelp: "[REASON]",
Help: "Set away reason, or empty to unset.",
Handler: func(room *Room, msg message.CommandMsg) error {
awayMsg := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/away"))
isAway, _, _ := msg.From().GetAway()
msg.From().SetAway(awayMsg)
if awayMsg != "" {
room.Send(message.NewEmoteMsg("has gone away: "+awayMsg, msg.From()))
return nil
}
if isAway {
room.Send(message.NewEmoteMsg("is back.", msg.From()))
return nil
}
return errors.New("not away. Append a reason message to set away")
},
})
c.Add(Command{
Prefix: "/back",
Help: "Clear away status.",
Handler: func(room *Room, msg message.CommandMsg) error {
isAway, _, _ := msg.From().GetAway()
if isAway {
msg.From().SetAway("")
room.Send(message.NewEmoteMsg("is back.", msg.From()))
return nil
}
return errors.New("must be away to be back")
},
})
c.Add(Command{
Op: true,
Prefix: "/mute",
PrefixHelp: "USER",
Help: "Toggle muting USER, preventing messages from broadcasting.",
Handler: func(room *Room, msg message.CommandMsg) error {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) == 0 {
return errors.New("must specify user")
}
member, ok := room.MemberByID(args[0])
if !ok {
return errors.New("user not found")
}
setMute := !member.IsMuted()
member.SetMute(setMute)
id := member.ID()
if setMute {
room.Send(message.NewSystemMsg("Muted: "+id, msg.From()))
} else {
room.Send(message.NewSystemMsg("Unmuted: "+id, msg.From()))
}
return nil
},
})
}

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

@ -1,9 +1,7 @@
package chat
import (
"io"
stdlog "log"
)
import "io"
import stdlog "log"
var logger *stdlog.Logger

View File

@ -131,9 +131,6 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string {
// RenderSelf renders the message for when it's echoing your own message.
func (m PublicMsg) RenderSelf(cfg UserConfig) string {
if cfg.Theme == nil {
return fmt.Sprintf("[%s] %s", m.from.Name(), m.body)
}
return fmt.Sprintf("[%s] %s", cfg.Theme.ColorName(m.from), m.body)
}
@ -141,7 +138,9 @@ func (m PublicMsg) String() string {
return fmt.Sprintf("%s: %s", m.from.Name(), m.body)
}
// EmoteMsg is a /me message sent to the room.
// EmoteMsg is a /me message sent to the room. It specifically does not
// extend PublicMsg because it doesn't implement MessageFrom to allow the
// sender to see the emote.
type EmoteMsg struct {
Msg
from *User
@ -157,10 +156,6 @@ func NewEmoteMsg(body string, from *User) *EmoteMsg {
}
}
func (m EmoteMsg) From() *User {
return m.from
}
func (m EmoteMsg) Render(t *Theme) string {
return fmt.Sprintf("** %s %s", m.from.Name(), m.body)
}
@ -186,16 +181,11 @@ func (m PrivateMsg) To() *User {
return m.to
}
func (m PrivateMsg) From() *User {
return m.from
}
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

@ -122,7 +122,6 @@ type Theme struct {
pm Style
highlight Style
names *Palette
useID bool
}
func (theme Theme) ID() string {
@ -131,17 +130,11 @@ func (theme Theme) ID() string {
// Colorize name string given some index
func (theme Theme) ColorName(u *User) string {
var name string
if theme.useID {
name = u.ID()
} else {
name = u.Name()
}
if theme.names == nil {
return name
return u.Name()
}
return theme.names.Get(u.colorIdx).Format(name)
return theme.names.Get(u.colorIdx).Format(u.Name())
}
// Colorize the PM string
@ -233,8 +226,7 @@ func init() {
highlight: style(Bold + "\033[48;5;22m\033[38;5;46m"), // Green on dark green
},
{
id: "mono",
useID: true,
id: "mono",
},
}

View File

@ -22,9 +22,7 @@ var ErrUserClosed = errors.New("user closed")
// User definition, implemented set Item interface and io.Writer
type User struct {
Identifier
OnChange func()
Ignored set.Interface
Focused set.Interface
Ignored *set.Set
colorIdx int
joined time.Time
msg chan Message
@ -33,12 +31,10 @@ type User struct {
screen io.WriteCloser
closeOnce sync.Once
mu sync.Mutex
config UserConfig
replyTo *User // Set when user gets a /msg, for replying.
lastMsg time.Time // When the last message was rendered.
awayReason string // Away reason, "" when not away.
awaySince time.Time // When away was set, 0 when not away.
mu sync.Mutex
config UserConfig
replyTo *User // Set when user gets a /msg, for replying.
lastMsg time.Time // When the last message was rendered
}
func NewUser(identity Identifier) *User {
@ -49,7 +45,6 @@ func NewUser(identity Identifier) *User {
msg: make(chan Message, messageBuffer),
done: make(chan struct{}),
Ignored: set.New(),
Focused: set.New(),
}
u.setColorIdx(rand.Int())
@ -67,32 +62,6 @@ func (u *User) Joined() time.Time {
return u.joined
}
func (u *User) LastMsg() time.Time {
u.mu.Lock()
defer u.mu.Unlock()
return u.lastMsg
}
// SetAway sets the users away reason and state.
func (u *User) SetAway(msg string) {
u.mu.Lock()
defer u.mu.Unlock()
u.awayReason = msg
if msg == "" {
u.awaySince = time.Time{}
} else {
// Reset away timer even if already away
u.awaySince = time.Now()
}
}
// GetAway returns if the user is away, when they went away, and the reason.
func (u *User) GetAway() (bool, time.Time, string) {
u.mu.Lock()
defer u.mu.Unlock()
return u.awayReason != "", u.awaySince, u.awayReason
}
func (u *User) Config() UserConfig {
u.mu.Lock()
defer u.mu.Unlock()
@ -103,20 +72,12 @@ func (u *User) SetConfig(cfg UserConfig) {
u.mu.Lock()
u.config = cfg
u.mu.Unlock()
if u.OnChange != nil {
u.OnChange()
}
}
// Rename the user with a new Identifier.
func (u *User) SetID(id string) {
u.Identifier.SetID(id)
u.setColorIdx(rand.Int())
if u.OnChange != nil {
u.OnChange()
}
}
// ReplyTo returns the last user that messaged this user.
@ -143,9 +104,7 @@ func (u *User) setColorIdx(idx int) {
func (u *User) Close() {
u.closeOnce.Do(func() {
if u.screen != nil {
if err := u.screen.Close(); err != nil {
logger.Printf("Failed to close user %q screen: %s", u.ID(), err)
}
u.screen.Close()
}
// close(u.msg) TODO: Close?
close(u.done)
@ -202,17 +161,10 @@ func (u *User) render(m Message) string {
switch m := m.(type) {
case PublicMsg:
if u == m.From() {
u.mu.Lock()
u.lastMsg = m.Timestamp()
u.mu.Unlock()
if !cfg.Echo {
return ""
}
out += m.RenderSelf(cfg)
} else if u.Focused.Len() > 0 && !u.Focused.In(m.From().ID()) {
// Skip message during focus
return ""
} else {
out += m.RenderFor(cfg)
}
@ -244,7 +196,7 @@ func (u *User) writeMsg(m Message) error {
r := u.render(m)
_, err := u.screen.Write([]byte(r))
if err != nil {
logger.Printf("Write failed to %s, closing: %s", u.ID(), err)
logger.Printf("Write failed to %s, closing: %s", u.Name(), err)
u.Close()
}
return err
@ -252,6 +204,9 @@ func (u *User) writeMsg(m Message) error {
// HandleMsg will render the message to the screen, blocking.
func (u *User) HandleMsg(m Message) error {
u.mu.Lock()
u.lastMsg = m.Timestamp()
u.mu.Unlock()
return u.writeMsg(m)
}
@ -262,7 +217,7 @@ func (u *User) Send(m Message) error {
return ErrUserClosed
case u.msg <- m:
case <-time.After(messageTimeout):
logger.Printf("Message buffer full, closing: %s", u.ID())
logger.Printf("Message buffer full, closing: %s", u.Name())
u.Close()
return ErrUserClosed
}
@ -293,9 +248,7 @@ func init() {
// TODO: Seed random?
}
// RecentActiveUsers is a slice of *Users that knows how to be sorted by the
// time of the last message. If no message has been sent, then fall back to the
// time joined instead.
// RecentActiveUsers is a slice of *Users that knows how to be sorted by the time of the last message.
type RecentActiveUsers []*User
func (a RecentActiveUsers) Len() int { return len(a) }
@ -305,16 +258,5 @@ func (a RecentActiveUsers) Less(i, j int) bool {
defer a[i].mu.Unlock()
a[j].mu.Lock()
defer a[j].mu.Unlock()
ai := a[i].lastMsg
if ai.IsZero() {
ai = a[i].joined
}
aj := a[j].lastMsg
if aj.IsZero() {
aj = a[j].joined
}
return ai.After(aj)
return a[i].lastMsg.After(a[j].lastMsg)
}

View File

@ -27,23 +27,6 @@ var ErrInvalidName = errors.New("invalid name")
type Member struct {
*message.User
IsOp bool
// TODO: Move IsOp under mu?
mu sync.Mutex
isMuted bool // When true, messages should not be broadcasted.
}
func (m *Member) IsMuted() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.isMuted
}
func (m *Member) SetMute(muted bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.isMuted = muted
}
// Room definition, also a Set of User Items
@ -96,25 +79,6 @@ func (r *Room) SetLogging(out io.Writer) {
// HandleMsg reacts to a message, will block until done.
func (r *Room) HandleMsg(m message.Message) {
var fromID string
if fromMsg, ok := m.(message.MessageFrom); ok {
fromID = fromMsg.From().ID()
}
if fromID != "" {
if item, err := r.Members.Get(fromID); err != nil {
// Message from a member who is not in the room, this should not happen.
logger.Printf("Room received unexpected message from a non-member: %v", m)
return
} else if member, ok := item.Value().(*Member); ok && member.IsMuted() {
// Short circuit message handling for muted users
if _, ok = m.(*message.CommandMsg); !ok {
member.User.Send(m)
}
return
}
}
switch m := m.(type) {
case *message.CommandMsg:
cmd := *m
@ -125,23 +89,22 @@ func (r *Room) HandleMsg(m message.Message) {
}
case message.MessageTo:
user := m.To()
if user.Ignored.In(fromID) {
return // Skip ignored
}
user.Send(m)
default:
fromMsg, _ := m.(message.MessageFrom)
r.history.Add(m)
r.Members.Each(func(_ string, item set.Item) (err error) {
user := item.Value().(*Member).User
if user.Ignored.In(fromID) {
return // Skip ignored
if fromMsg != nil && user.Ignored.In(fromMsg.From().ID()) {
// Skip because ignored
return
}
if _, ok := m.(*message.AnnounceMsg); ok {
if user.Config().Quiet {
return // Skip announcements
// Skip announcements
return
}
}
user.Send(m)
@ -181,7 +144,6 @@ func (r *Room) Join(u *message.User) (*Member, error) {
if err != nil {
return nil, err
}
// TODO: Remove user ID from sets, probably referring to a prior user.
r.History(u)
s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), r.Members.Len())
r.Send(message.NewAnnounceMsg(s))
@ -271,7 +233,7 @@ func (r *Room) NamesPrefix(prefix string) []string {
// Pull out names
names := make([]string, 0, len(items))
for _, user := range users {
names = append(names, user.ID())
names = append(names, user.Name())
}
return names
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"testing"
"time"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/set"
@ -104,18 +105,8 @@ func TestIgnore(t *testing.T) {
t.Fatalf("should have %d ignored users, has %d", 1, len(ignoredList))
}
// when an emote is sent by an ignored user, it should not be displayed for ignorer
ch.HandleMsg(message.NewEmoteMsg("is crying", ignored.user))
if ignorer.user.HasMessages() {
t.Fatal("should not have emote messages")
}
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
expectOutput(t, buffer, "** "+ignored.user.Name()+" is crying"+message.Newline)
// when a message is sent from the ignored user, it is delivered to non-ignoring users
ch.HandleMsg(message.NewPublicMsg("hello", ignored.user))
ch.Send(message.NewPublicMsg("hello", ignored.user))
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
expectOutput(t, buffer, ignored.user.Name()+": hello"+message.Newline)
@ -147,10 +138,15 @@ func TestIgnore(t *testing.T) {
}
// after unignoring a user, its messages can be received again
ch.HandleMsg(message.NewPublicMsg("hello again!", ignored.user))
ch.Send(message.NewPublicMsg("hello again!", ignored.user))
// give some time for the channel to get the message
time.Sleep(100)
// ensure ignorer has received the message
if !ignorer.user.HasMessages() {
// FIXME: This is flaky :/
t.Skip("test is broken")
t.Fatal("should have messages")
}
ignorer.user.HandleMsg(ignorer.user.ConsumeOne())
@ -158,97 +154,7 @@ func TestIgnore(t *testing.T) {
expectOutput(t, buffer, ignored.user.Name()+": hello again!"+message.Newline)
}
func TestMute(t *testing.T) {
var buffer []byte
ch := NewRoom()
go ch.Serve()
defer ch.Close()
// Create 3 users, join the room and clear their screen buffers
users := make([]ScreenedUser, 3)
members := make([]*Member, 3)
for i := 0; i < 3; i++ {
screen := &MockScreen{}
user := message.NewUserScreen(message.SimpleID(fmt.Sprintf("user%d", i)), screen)
users[i] = ScreenedUser{
user: user,
screen: screen,
}
member, err := ch.Join(user)
if err != nil {
t.Fatal(err)
}
members[i] = member
}
for _, u := range users {
for i := 0; i < 3; i++ {
u.user.HandleMsg(u.user.ConsumeOne())
u.screen.Read(&buffer)
}
}
// Use some handy variable names for distinguish between roles
muter := users[0]
muted := users[1]
other := users[2]
members[0].IsOp = true
// test muting unexisting user
if err := sendCommand("/mute test", muter, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Err: user not found"+message.Newline)
// test muting by non-op
if err := sendCommand("/mute "+muted.user.Name(), other, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Err: must be op"+message.Newline)
// test muting existing user
if err := sendCommand("/mute "+muted.user.Name(), muter, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Muted: "+muted.user.Name()+message.Newline)
if got, want := members[1].IsMuted(), true; got != want {
t.Error("muted user failed to set mute flag")
}
// when an emote is sent by a muted user, it should not be displayed for anyone
ch.HandleMsg(message.NewPublicMsg("hello!", muted.user))
ch.HandleMsg(message.NewEmoteMsg("is crying", muted.user))
if muter.user.HasMessages() {
muter.user.HandleMsg(muter.user.ConsumeOne())
muter.screen.Read(&buffer)
t.Errorf("muter should not have messages: %s", buffer)
}
if other.user.HasMessages() {
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
t.Errorf("other should not have messages: %s", buffer)
}
// test unmuting
if err := sendCommand("/mute "+muted.user.Name(), muter, ch, &buffer); err != nil {
t.Fatal(err)
}
expectOutput(t, buffer, "-> Unmuted: "+muted.user.Name()+message.Newline)
ch.HandleMsg(message.NewPublicMsg("hello again!", muted.user))
other.user.HandleMsg(other.user.ConsumeOne())
other.screen.Read(&buffer)
expectOutput(t, buffer, muted.user.Name()+": hello again!"+message.Newline)
}
func expectOutput(t *testing.T, buffer []byte, expected string) {
t.Helper()
bytes := []byte(expected)
if !reflect.DeepEqual(buffer, bytes) {
t.Errorf("Got: %q; Expected: %q", buffer, expected)
@ -476,22 +382,17 @@ func TestRoomNamesPrefix(t *testing.T) {
}
}
sendMsg := func(from *Member, body string) {
// lastMsg is set during render of self messags, so we can't use NewMsg
from.HandleMsg(message.NewPublicMsg(body, from.User))
}
// Inject some activity
sendMsg(members[2], "hi") // aac
sendMsg(members[0], "hi") // aaa
sendMsg(members[3], "hi") // foo
sendMsg(members[1], "hi") // aab
members[2].HandleMsg(message.NewMsg("hi")) // aac
members[0].HandleMsg(message.NewMsg("hi")) // aaa
members[3].HandleMsg(message.NewMsg("hi")) // foo
members[1].HandleMsg(message.NewMsg("hi")) // aab
if got, want := r.NamesPrefix("a"), []string{"aab", "aaa", "aac"}; !reflect.DeepEqual(got, want) {
t.Errorf("got: %q; want: %q", got, want)
}
sendMsg(members[2], "hi") // aac
members[2].HandleMsg(message.NewMsg("hi")) // aac
if got, want := r.NamesPrefix("a"), []string{"aac", "aab", "aaa"}; !reflect.DeepEqual(got, want) {
t.Errorf("got: %q; want: %q", got, want)
}

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,66 @@ func main() {
message.SetLogger(os.Stderr)
}
privateKeyPath := options.Identity
if strings.HasPrefix(privateKeyPath, "~/") {
user, err := user.Current()
if err == nil {
privateKeyPath = strings.Replace(privateKeyPath, "~", user.HomeDir, 1)
}
}
privateKey, err := ReadPrivateKey(privateKeyPath)
if err != nil {
fail(2, "Couldn't read private key: %v\n", err)
}
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
fail(3, "Failed to parse key: %v\n", err)
}
auth := sshchat.NewAuth()
config := sshd.MakeAuth(auth)
config.AddHostKey(signer)
config.ServerVersion = "SSH-2.0-Go ssh-chat"
// FIXME: Should we be using config.NoClientAuth = true by default?
for _, privateKeyPath := range options.Identity {
if strings.HasPrefix(privateKeyPath, "~/") {
user, err := user.Current()
if err == nil {
privateKeyPath = strings.Replace(privateKeyPath, "~", user.HomeDir, 1)
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,42 +179,46 @@ 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) {
motd, err := ioutil.ReadFile(options.Motd)
if err != nil {
return "", err
}
motdString := string(motd)
// hack to normalize line endings into \r\n
motdString = strings.Replace(motdString, "\r\n", "\n", -1)
motdString = strings.Replace(motdString, "\n", "\r\n", -1)
return motdString, nil
}
if motdString, err := host.GetMOTD(); err != nil {
motd, err := ioutil.ReadFile(options.Motd)
if err != nil {
fail(7, "Failed to load MOTD file: %v\n", err)
} else {
host.SetMotd(motdString)
}
motdString := string(motd)
// hack to normalize line endings into \r\n
motdString = strings.Replace(motdString, "\r\n", "\n", -1)
motdString = strings.Replace(motdString, "\n", "\r\n", -1)
host.SetMotd(motdString)
}
if options.Log == "-" {
@ -196,32 +241,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,38 +1,50 @@
package main
import (
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"os"
"syscall"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
"github.com/howeyc/gopass"
)
// ReadPrivateKey attempts to read your private key and possibly decrypt it if it
// requires a passphrase.
// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`),
// is not set.
func ReadPrivateKey(path string) (ssh.Signer, error) {
func ReadPrivateKey(path string) ([]byte, error) {
privateKey, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to load identity: %v", err)
}
pk, err := ssh.ParsePrivateKey(privateKey)
if err == nil {
} else if _, ok := err.(*ssh.PassphraseMissingError); ok {
passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE"))
if len(passphrase) == 0 {
fmt.Println("Enter passphrase to unlock identity private key:", path)
passphrase, err = term.ReadPassword(int(syscall.Stdin))
if err != nil {
return nil, fmt.Errorf("couldn't read passphrase: %v", err)
}
}
return ssh.ParsePrivateKeyWithPassphrase(privateKey, passphrase)
block, rest := pem.Decode(privateKey)
if len(rest) > 0 {
return nil, fmt.Errorf("extra data when decoding private key")
}
if !x509.IsEncryptedPEMBlock(block) {
return privateKey, nil
}
return pk, err
passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE"))
if len(passphrase) == 0 {
fmt.Print("Enter passphrase: ")
passphrase, err = gopass.GetPasswd()
if err != nil {
return nil, fmt.Errorf("couldn't read passphrase: %v", err)
}
}
der, err := x509.DecryptPEMBlock(block, passphrase)
if err != nil {
return nil, fmt.Errorf("decrypt failed: %v", err)
}
privateKey = pem.EncodeToMemory(&pem.Block{
Type: block.Type,
Bytes: der,
})
return privateKey, nil
}

View File

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

13
go.mod
View File

@ -2,13 +2,12 @@ module github.com/shazow/ssh-chat
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
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c
github.com/jessevdk/go-flags v1.4.0
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54
golang.org/x/text v0.3.2
)
go 1.13

58
go.sum
View File

@ -1,50 +1,16 @@
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58 h1:MkpmYfld/S8kXqTYI68DfL8/hHXjHogL120Dy00TIxc=
github.com/alexcesaro/log v0.0.0-20150915221235-61e686294e58/go.mod h1:YNfsMyWSs+h+PaYkxGeMVmVCX75Zj/pqdjbu12ciCYE=
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4 h1:zwQ1HBo5FYwn1ksMd19qBCKO8JAWE9wmHivEpkw/DvE=
github.com/shazow/rateio v0.0.0-20200113175441-4461efc8bdc4/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c h1:kQWxfPIHVLbgLzphqk3QUflDy9QdksZR4ygR807bpy0=
github.com/howeyc/gopass v0.0.0-20170109162249-bf9dde6d0d2c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1 h1:Lx3BlDGFElJt4u/zKc9A3BuGYbQAGlEFyPuUA3jeMD0=
github.com/shazow/rateio v0.0.0-20150116013248-e8e00881e5c1/go.mod h1:vt2jWY/3Qw1bIzle5thrJWucsLuuX9iUNnp20CqCciI=
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576 h1:aUX/1G2gFSs4AsJJg2cL3HuoRhCSCz733FE5GUSuaT4=
golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54 h1:xe1/2UUJRmA9iDglQSlkx8c5n3twv58+K0mPpC2zmhA=
golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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=

417
host.go
View File

@ -9,14 +9,10 @@ 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"
)
@ -49,11 +45,6 @@ type Host struct {
mu sync.Mutex
motd string
count int
// GetMOTD is used to reload the motd from an external source
GetMOTD func() (string, error)
// OnUserJoined is used to notify when a user joins a host
OnUserJoined func(*message.User)
}
// NewHost creates a Host on top of an existing listener.
@ -83,7 +74,6 @@ func (h *Host) SetTheme(theme message.Theme) {
}
// SetMotd sets the host's message of the day.
// TODO: Change to SetMOTD
func (h *Host) SetMotd(motd string) {
h.mu.Lock()
h.motd = motd
@ -102,10 +92,6 @@ func (h *Host) isOp(conn sshd.Connection) bool {
func (h *Host) Connect(term *sshd.Terminal) {
id := NewIdentity(term.Conn)
user := message.NewUserScreen(id, term)
user.OnChange = func() {
term.SetPrompt(GetPrompt(user))
user.SetHighlight(user.ID())
}
cfg := user.Config()
apiMode := strings.ToLower(term.Term()) == "bot"
@ -119,33 +105,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 +131,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 +174,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 {
@ -233,6 +216,17 @@ func (h *Host) Connect(term *sshd.Terminal) {
// Skip the remaining rendering workarounds
continue
}
cmd := m.Command()
if cmd == "/nick" || cmd == "/theme" {
// Hijack /nick command to update terminal synchronously. Wouldn't
// work if we use h.room.Send(m) above.
//
// FIXME: This is hacky, how do we improve the API to allow for
// this? Chat module shouldn't know about terminals.
term.SetPrompt(GetPrompt(user))
user.SetHighlight(user.Name())
}
}
err = h.Leave(user)
@ -249,19 +243,14 @@ func (h *Host) Serve() {
h.listener.Serve()
}
func (h *Host) completeName(partial string, skipName string) string {
func (h *Host) completeName(partial string) string {
names := h.NamesPrefix(partial)
if len(names) == 0 {
// Didn't find anything
return ""
} else if name := names[0]; name != skipName {
// First name is not the skipName, great
return name
} else if len(names) > 1 {
// Next candidate
return names[1]
}
return ""
return names[len(names)-1]
}
func (h *Host) completeCommand(partial string) string {
@ -300,7 +289,7 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int,
if completed == "/reply" {
replyTo := u.ReplyTo()
if replyTo != nil {
name := replyTo.ID()
name := replyTo.Name()
_, found := h.GetUser(name)
if found {
completed = "/msg " + name
@ -311,7 +300,7 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int,
}
} else {
// Name
completed = h.completeName(partial, u.Name())
completed = h.completeName(partial)
if completed == "" {
return
}
@ -342,20 +331,6 @@ func (h *Host) GetUser(name string) (*message.User, bool) {
// InitCommands adds host-specific commands to a Commands container. These will
// override any existing commands.
func (h *Host) InitCommands(c *chat.Commands) {
sendPM := func(room *chat.Room, msg string, from *message.User, target *message.User) error {
m := message.NewPrivateMsg(msg, from, target)
room.Send(&m)
txt := fmt.Sprintf("[Sent PM to %s]", target.Name())
if isAway, _, awayReason := target.GetAway(); isAway {
txt += " Away: " + awayReason
}
sysMsg := message.NewSystemMsg(txt, from)
room.Send(sysMsg)
target.SetReplyTo(from)
return nil
}
c.Add(chat.Command{
Prefix: "/msg",
PrefixHelp: "USER MESSAGE",
@ -374,7 +349,14 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("user not found")
}
return sendPM(room, strings.Join(args[1:], " "), msg.From(), target)
m := message.NewPrivateMsg(strings.Join(args[1:], " "), msg.From(), target)
room.Send(&m)
txt := fmt.Sprintf("[Sent PM to %s]", target.Name())
ms := message.NewSystemMsg(txt, msg.From())
room.Send(ms)
target.SetReplyTo(msg.From())
return nil
},
})
@ -394,12 +376,20 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("no message to reply to")
}
_, found := h.GetUser(target.ID())
name := target.Name()
_, found := h.GetUser(name)
if !found {
return errors.New("user not found")
}
return sendPM(room, strings.Join(args, " "), msg.From(), target)
m := message.NewPrivateMsg(strings.Join(args, " "), msg.From(), target)
room.Send(&m)
txt := fmt.Sprintf("[Sent PM to %s]", name)
ms := message.NewSystemMsg(txt, msg.From())
room.Send(ms)
target.SetReplyTo(msg.From())
return nil
},
})
@ -417,13 +407,14 @@ func (h *Host) InitCommands(c *chat.Commands) {
if !ok {
return errors.New("user not found")
}
id := target.Identifier.(*Identity)
var whois string
switch room.IsOp(msg.From()) {
case true:
whois = id.WhoisAdmin(room)
whois = id.WhoisAdmin()
case false:
whois = id.Whois(room)
whois = id.Whois()
}
room.Send(message.NewSystemMsg(whois, msg.From()))
@ -516,7 +507,7 @@ func (h *Host) InitCommands(c *chat.Commands) {
room.Send(message.NewAnnounceMsg(body))
target.Close()
logger.Debugf("Banned: \n-> %s", id.Whois(room))
logger.Debugf("Banned: \n-> %s", id.Whois())
return nil
},
@ -555,7 +546,7 @@ func (h *Host) InitCommands(c *chat.Commands) {
Op: true,
Prefix: "/motd",
PrefixHelp: "[MESSAGE]",
Help: "Set a new MESSAGE of the day, or print the motd if no MESSAGE.",
Help: "Set a new MESSAGE of the day, print the current motd without parameters.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
args := msg.Args()
user := msg.From()
@ -572,21 +563,10 @@ func (h *Host) InitCommands(c *chat.Commands) {
return errors.New("must be OP to modify the MOTD")
}
var err error
var s string = strings.Join(args, " ")
if s == "@" {
if h.GetMOTD == nil {
return errors.New("motd reload not set")
}
if s, err = h.GetMOTD(); err != nil {
return err
}
}
h.SetMotd(s)
motd = strings.Join(args, " ")
h.SetMotd(motd)
fromMsg := fmt.Sprintf("New message of the day set by %s:", msg.From().Name())
room.Send(message.NewAnnounceMsg(fromMsg + message.Newline + "-> " + s))
room.Send(message.NewAnnounceMsg(fromMsg + message.Newline + "-> " + motd))
return nil
},
@ -639,261 +619,4 @@ func (h *Host) InitCommands(c *chat.Commands) {
return nil
},
})
c.Add(chat.Command{
Op: true,
Prefix: "/rename",
PrefixHelp: "USER NEW_NAME [SYMBOL]",
Help: "Rename USER to NEW_NAME, add optional SYMBOL prefix",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) < 2 {
return errors.New("must specify user and new name")
}
member, ok := room.MemberByID(args[0])
if !ok {
return errors.New("user not found")
}
symbolSet := false
if len(args) == 3 {
s := args[2]
if id, ok := member.Identifier.(*Identity); ok {
id.SetSymbol(s)
} else {
return errors.New("user does not support setting symbol")
}
body := fmt.Sprintf("Assigned symbol %q by %s.", s, msg.From().Name())
room.Send(message.NewSystemMsg(body, member.User))
symbolSet = true
}
oldID := member.ID()
newID := sanitize.Name(args[1])
if newID == oldID && !symbolSet {
return errors.New("new name is the same as the original")
} else if (newID == "" || newID == oldID) && symbolSet {
if member.User.OnChange != nil {
member.User.OnChange()
}
return nil
}
member.SetID(newID)
err := room.Rename(oldID, member)
if err != nil {
member.SetID(oldID)
return err
}
body := fmt.Sprintf("%s was renamed by %s.", oldID, msg.From().Name())
room.Send(message.NewAnnounceMsg(body))
return nil
},
})
forConnectedUsers := func(cmd func(*chat.Member, ssh.PublicKey) error) error {
return h.Members.Each(func(key string, item set.Item) error {
v := item.Value()
if v == nil { // expired between Each and here
return nil
}
user := v.(*chat.Member)
pk := user.Identifier.(*Identity).PublicKey()
return cmd(user, pk)
})
}
forPubkeyUser := func(args []string, cmd func(ssh.PublicKey)) (errors []string) {
invalidUsers := []string{}
invalidKeys := []string{}
noKeyUsers := []string{}
var keyType string
for _, v := range args {
switch {
case keyType != "":
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyType + " " + v))
if err == nil {
cmd(pk)
} else {
invalidKeys = append(invalidKeys, keyType+" "+v)
}
keyType = ""
case strings.HasPrefix(v, "ssh-"):
keyType = v
default:
user, ok := h.GetUser(v)
if ok {
pk := user.Identifier.(*Identity).PublicKey()
if pk == nil {
noKeyUsers = append(noKeyUsers, user.Identifier.Name())
} else {
cmd(pk)
}
} else {
invalidUsers = append(invalidUsers, v)
}
}
}
if len(noKeyUsers) != 0 {
errors = append(errors, fmt.Sprintf("users without a public key: %v", noKeyUsers))
}
if len(invalidUsers) != 0 {
errors = append(errors, fmt.Sprintf("invalid users: %v", invalidUsers))
}
if len(invalidKeys) != 0 {
errors = append(errors, fmt.Sprintf("invalid keys: %v", invalidKeys))
}
return
}
allowlistHelptext := []string{
"Usage: /allowlist help | on | off | add {PUBKEY|USER}... | remove {PUBKEY|USER}... | import [AGE] | reload {keep|flush} | reverify | status",
"help: this help message",
"on, off: set allowlist mode (applies to new connections)",
"add, remove: add or remove keys from the allowlist",
"import: add all keys of users connected since AGE (default 0) ago to the allowlist",
"reload: re-read the allowlist file and keep or discard entries in the current allowlist but not in the file",
"reverify: kick all users not in the allowlist if allowlisting is enabled",
"status: show status information",
}
allowlistImport := func(args []string) (msgs []string, err error) {
var since time.Duration
if len(args) > 0 {
since, err = time.ParseDuration(args[0])
if err != nil {
return
}
}
cutoff := time.Now().Add(-since)
noKeyUsers := []string{}
forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if user.Joined().Before(cutoff) {
if pk == nil {
noKeyUsers = append(noKeyUsers, user.Identifier.Name())
} else {
h.auth.Allowlist(pk, 0)
}
}
return nil
})
if len(noKeyUsers) != 0 {
msgs = []string{fmt.Sprintf("users without a public key: %v", noKeyUsers)}
}
return
}
allowlistReload := func(args []string) error {
if !(len(args) > 0 && (args[0] == "keep" || args[0] == "flush")) {
return errors.New("must specify whether to keep or flush current entries")
}
if args[0] == "flush" {
h.auth.allowlist.Clear()
}
return h.auth.ReloadAllowlist()
}
allowlistReverify := func(room *chat.Room) []string {
if !h.auth.AllowlistMode() {
return []string{"allowlist is disabled, so nobody will be kicked"}
}
var kicked []string
forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if h.auth.CheckPublicKey(pk) != nil && !user.IsOp { // we do this check here as well for ops without keys
kicked = append(kicked, user.Name())
user.Close()
}
return nil
})
if kicked != nil {
room.Send(message.NewAnnounceMsg("Kicked during pubkey reverification: " + strings.Join(kicked, ", ")))
}
return nil
}
allowlistStatus := func() (msgs []string) {
if h.auth.AllowlistMode() {
msgs = []string{"allowlist enabled"}
} else {
msgs = []string{"allowlist disabled"}
}
allowlistedUsers := []string{}
allowlistedKeys := []string{}
h.auth.allowlist.Each(func(key string, item set.Item) error {
keyFP := item.Key()
if forConnectedUsers(func(user *chat.Member, pk ssh.PublicKey) error {
if pk != nil && sshd.Fingerprint(pk) == keyFP {
allowlistedUsers = append(allowlistedUsers, user.Name())
return io.EOF
}
return nil
}) == nil {
// if we land here, the key matches no users
allowlistedKeys = append(allowlistedKeys, keyFP)
}
return nil
})
if len(allowlistedUsers) != 0 {
msgs = append(msgs, "Connected users on the allowlist: "+strings.Join(allowlistedUsers, ", "))
}
if len(allowlistedKeys) != 0 {
msgs = append(msgs, "Keys on the allowlist without connected user: "+strings.Join(allowlistedKeys, ", "))
}
return
}
c.Add(chat.Command{
Op: true,
Prefix: "/allowlist",
PrefixHelp: "COMMAND [ARGS...]",
Help: "Modify the allowlist or allowlist state. See /allowlist help for subcommands",
Handler: func(room *chat.Room, msg message.CommandMsg) (err error) {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) == 0 {
args = []string{"help"}
}
// send exactly one message to preserve order
var replyLines []string
switch args[0] {
case "help":
replyLines = allowlistHelptext
case "on":
h.auth.SetAllowlistMode(true)
case "off":
h.auth.SetAllowlistMode(false)
case "add":
replyLines = forPubkeyUser(args[1:], func(pk ssh.PublicKey) { h.auth.Allowlist(pk, 0) })
case "remove":
replyLines = forPubkeyUser(args[1:], func(pk ssh.PublicKey) { h.auth.Allowlist(pk, 1) })
case "import":
replyLines, err = allowlistImport(args[1:])
case "reload":
err = allowlistReload(args[1:])
case "reverify":
replyLines = allowlistReverify(room)
case "status":
replyLines = allowlistStatus()
default:
err = errors.New("invalid subcommand: " + args[0])
}
if err == nil && replyLines != nil {
room.Send(message.NewSystemMsg(strings.Join(replyLines, "\r\n"), msg.From()))
}
return
},
})
}

View File

@ -2,8 +2,9 @@ package sshchat
import (
"bufio"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"io"
mathRand "math/rand"
"strings"
@ -12,7 +13,6 @@ import (
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/sshd"
"golang.org/x/crypto/ssh"
"golang.org/x/sync/errgroup"
)
func stripPrompt(s string) string {
@ -23,15 +23,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 +42,6 @@ func TestStripPrompt(t *testing.T) {
Input: "[foo] \x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[K * Guest1 joined. (Connected: 2)\r",
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,263 +75,153 @@ 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()
g := errgroup.Group{}
done := make(chan struct{}, 1)
// 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)
go func() {
err := sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
scanner.Scan()
actual := stripPrompt(scanner.Text())
expected := " * foo joined. (Connected: 1)\r"
if actual != expected {
t.Errorf("Got %q; expected %q", actual, expected)
}
// Ready for second client
done <- struct{}{}
scanner.Scan()
actual = scanner.Text()
// This check has to happen second because prompt doesn't always
// get set before the first message.
if !strings.HasPrefix(actual, "[foo] ") {
t.Errorf("First client failed to get 'foo' name: %q", actual)
}
actual = stripPrompt(actual)
expected = " * Guest1 joined. (Connected: 2)\r"
if actual != expected {
t.Errorf("Got %q; expected %q", actual, expected)
}
// Wrap it up.
close(done)
return nil
})
})
if err != nil {
done <- struct{}{}
t.Fatal(err)
}
}()
// Wait for first client
<-done
// Second client
g.Go(func() error {
// first client
name := (<-newUsers).Name()
if name != "foo" {
t.Errorf("First client did not get foo name: %q", name)
}
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
return nil
})
})
err = sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
if err := g.Wait(); err != nil {
t.Error(err)
// Consume the initial buffer
scanner.Scan()
scanner.Scan()
scanner.Scan()
actual := scanner.Text()
if !strings.HasPrefix(actual, "[Guest1] ") {
t.Errorf("Second client did not get Guest1 name: %q", actual)
}
return nil
})
if err != nil {
t.Fatal(err)
}
<-done
}
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{}
connected := make(chan struct{})
kicked := make(chan struct{})
done := make(chan struct{})
g.Go(func() error {
go func() {
// First client
return sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) error {
err := sshd.ConnectShell(addr, "foo", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
// Consume the initial buffer
@ -376,103 +252,41 @@ func TestHostKick(t *testing.T) {
}
kicked <- struct{}{}
return nil
})
})
if err != nil {
connected <- struct{}{}
close(connected)
t.Fatal(err)
close(done)
}
}()
g.Go(func() error {
go func() {
// Second client
return sshd.ConnectShell(s.Addr().String(), "bar", func(r io.Reader, w io.WriteCloser) error {
err := sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error {
scanner := bufio.NewScanner(r)
<-connected
scanner.Scan()
<-kicked
if _, err := w.Write([]byte("am I still here?\r\n")); err != io.EOF {
return errors.New("expected to be kicked")
if _, err := w.Write([]byte("am I still here?\r\n")); err != nil {
return err
}
scanner.Scan()
if err := scanner.Err(); err == io.EOF {
// All good, we got kicked.
return nil
} else {
return err
}
return scanner.Err()
})
})
if err := g.Wait(); err != nil {
t.Error(err)
}
}
func TestTimestampEnvConfig(t *testing.T) {
cases := []struct {
input string
timeformat *string
}{
{"", strptr("15:04")},
{"1", strptr("15:04")},
{"0", nil},
{"time +8h", strptr("15:04")},
{"datetime +8h", strptr("2006-01-02 15:04:05")},
}
for _, tc := range cases {
u := connectUserWithConfig(t, "dingus", map[string]string{
"SSHCHAT_TIMESTAMP": tc.input,
})
userConfig := u.Config()
if userConfig.Timeformat != nil && tc.timeformat != nil {
if *userConfig.Timeformat != *tc.timeformat {
t.Fatal("unexpected timeformat:", *userConfig.Timeformat, "expected:", *tc.timeformat)
}
if err == io.EOF {
// All good, we got kicked.
} else if err != nil {
close(done)
t.Fatal(err)
}
}
}
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
close(done)
}()
<-done
}

View File

@ -1,12 +1,9 @@
package sshchat
import (
"fmt"
"net"
"strings"
"time"
"github.com/shazow/ssh-chat/chat"
"github.com/shazow/ssh-chat/chat/message"
"github.com/shazow/ssh-chat/internal/humantime"
"github.com/shazow/ssh-chat/internal/sanitize"
@ -17,7 +14,6 @@ import (
type Identity struct {
sshd.Connection
id string
symbol string // symbol is displayed as a prefix to the name
created time.Time
}
@ -45,69 +41,33 @@ func (i *Identity) SetName(name string) {
i.SetID(name)
}
func (i *Identity) SetSymbol(symbol string) {
i.symbol = symbol
}
// Name returns the name for the Identity
func (i Identity) Name() string {
if i.symbol != "" {
return i.symbol + " " + i.id
}
return i.id
}
// Whois returns a whois description for non-admin users.
func (i Identity) Whois(room *chat.Room) string {
func (i Identity) Whois() string {
fingerprint := "(no public key)"
if i.PublicKey() != nil {
fingerprint = sshd.Fingerprint(i.PublicKey())
}
// TODO: Rewrite this using strings.Builder like WhoisAdmin
awayMsg := ""
if m, ok := room.MemberByID(i.ID()); ok {
isAway, awaySince, awayMessage := m.GetAway()
if isAway {
awayMsg = fmt.Sprintf("%s > away: (%s ago) %s", message.Newline, humantime.Since(awaySince), awayMessage)
}
}
return "name: " + i.Name() + message.Newline +
" > fingerprint: " + fingerprint + message.Newline +
" > client: " + sanitize.Data(string(i.ClientVersion()), 64) + message.Newline +
" > joined: " + humantime.Since(i.created) + " ago" +
awayMsg
" > joined: " + humantime.Since(i.created) + " ago"
}
// WhoisAdmin returns a whois description for admin users.
func (i Identity) WhoisAdmin(room *chat.Room) string {
func (i Identity) WhoisAdmin() string {
ip, _, _ := net.SplitHostPort(i.RemoteAddr().String())
fingerprint := "(no public key)"
if i.PublicKey() != nil {
fingerprint = sshd.Fingerprint(i.PublicKey())
}
out := strings.Builder{}
out.WriteString("name: " + i.Name() + message.Newline +
return "name: " + i.Name() + message.Newline +
" > ip: " + ip + message.Newline +
" > fingerprint: " + fingerprint + message.Newline +
" > client: " + sanitize.Data(string(i.ClientVersion()), 64) + message.Newline +
" > joined: " + humantime.Since(i.created) + " ago")
if member, ok := room.MemberByID(i.ID()); ok {
// Add room-specific whois
if isAway, awaySince, awayMessage := member.GetAway(); isAway {
fmt.Fprintf(&out, message.Newline+" > away: (%s ago) %s", humantime.Since(awaySince), awayMessage)
}
// FIXME: Should these always be present, even if they're false? Maybe
// change that once we add room context to Whois() above.
if !member.LastMsg().IsZero() {
out.WriteString(message.Newline + " > room/messaged: " + humantime.Since(member.LastMsg()) + " ago")
}
if room.IsOp(member.User) {
out.WriteString(message.Newline + " > room/op: true")
}
}
return out.String()
" > joined: " + humantime.Since(i.created) + " ago"
}

8
issue_template.md Normal file
View File

@ -0,0 +1,8 @@
### Expected Behavior
### Actual Behavior
### Steps to reproduce behavior
### Additional Comments

View File

@ -1,4 +1 @@
Welcome to ssh-chat, enter /help for more.
🐛 Please enjoy our selection of bugs, but run your own server if you want to crash it: https://ssh.chat/issues
🍮 Sponsors get an emoji prefix: https://ssh.chat/sponsor
😌 Be nice and follow our Code of Conduct: https://ssh.chat/conduct
Welcome to chat.shazow.net, enter /help for more. 

View File

@ -12,24 +12,8 @@ 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")
// ZeroValue can be used when we only care about the key, not about the value.
var ZeroValue = struct{}{}
// Interface is the Set interface
type Interface interface {
Clear() int
Each(fn IterFunc) error
// Add only if the item does not already exist
Add(item Item) error
// Set item, override if it already exists
Set(item Item) error
Get(key string) (Item, error)
In(key string) bool
Len() int
ListPrefix(prefix string) []Item
Remove(key string) error
Replace(oldKey string, item Item) error
}
// Returned when a nil item is added. Nil values are considered expired and invalid.
var ErrNil = errors.New("item value must not be nil")
type IterFunc func(key string, item Item) error
@ -97,7 +81,7 @@ func (s *Set) Get(key string) (Item, error) {
func (s *Set) cleanup(key string) {
s.Lock()
item, ok := s.lookup[key]
if ok && item.Value() == nil {
if ok && item == nil {
delete(s.lookup, key)
}
s.Unlock()
@ -105,6 +89,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 +108,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 +137,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

@ -167,9 +167,7 @@ func (t *Terminal) Close() error {
var err error
t.closeOnce.Do(func() {
close(t.done)
if err := t.Channel.Close(); err != nil {
logger.Printf("[%s] Failed to close terminal channel: %s", t.Conn.RemoteAddr(), err)
}
t.Channel.Close()
err = t.Conn.Close()
})
return 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