ssh-chat/host.go
Akshay Shekher e1e534344e
Fix SSHCHAT_TIMESTAMP env variables (#392)
* Fixes Env Vars to pass config to ssh-chat.

The env vars were beign parsed and set to the host
before the user was even added to the host and
hence ignored. This change moves the env var parsing
to after initializing the user.

TODO: tests, completeness+reliability

* cleaned up the test

* reduced test flakyness by adding wait instead of being optimistic

Co-authored-by: Akshay <akshay.shekher@gmail.com>
2021-05-02 12:18:31 -04:00

699 lines
16 KiB
Go

package sshchat
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"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/sshd"
)
const maxInputLength int = 1024
// GetPrompt will render the terminal prompt string based on the user.
func GetPrompt(user *message.User) string {
name := user.Name()
cfg := user.Config()
if cfg.Theme != nil {
name = cfg.Theme.ColorName(user)
}
return fmt.Sprintf("[%s] ", name)
}
// Host is the bridge between sshd and chat modules
// TODO: Should be easy to add support for multiple rooms, if we want.
type Host struct {
*chat.Room
listener *sshd.SSHListener
commands chat.Commands
auth *Auth
// Version string to print on /version
Version string
// Default theme
theme message.Theme
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.
func NewHost(listener *sshd.SSHListener, auth *Auth) *Host {
room := chat.NewRoom()
h := Host{
Room: room,
listener: listener,
commands: chat.Commands{},
auth: auth,
}
// Make our own commands registry instance.
chat.InitCommands(&h.commands)
h.InitCommands(&h.commands)
room.SetCommands(h.commands)
go room.Serve()
return &h
}
// SetTheme sets the default theme for the host.
func (h *Host) SetTheme(theme message.Theme) {
h.mu.Lock()
h.theme = theme
h.mu.Unlock()
}
// 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
h.mu.Unlock()
}
func (h *Host) isOp(conn sshd.Connection) bool {
key := conn.PublicKey()
if key == nil {
return false
}
return h.auth.IsOp(key)
}
// Connect a specific Terminal to this host and its room.
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"
if apiMode {
cfg.Theme = message.MonoTheme
cfg.Echo = false
} else {
term.SetEnterClear(true) // We provide our own echo rendering
cfg.Theme = &h.theme
}
user.SetConfig(cfg)
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
}
// Load user config overrides from ENV
// TODO: Would be nice to skip the command parsing pipeline just to load
// config values. Would need to factor out some command handler logic into
// accessible helpers.
env := term.Env()
for _, e := range env {
switch e.Key {
case "SSHCHAT_TIMESTAMP":
if e.Value != "" && e.Value != "0" {
cmd := "/timestamp"
if e.Value != "1" {
cmd += " " + e.Value
}
if msg, ok := message.NewPublicMsg(cmd, user).ParseCommand(); ok {
h.Room.HandleMsg(msg)
}
}
case "SSHCHAT_THEME":
cmd := "/theme " + e.Value
if msg, ok := message.NewPublicMsg(cmd, user).ParseCommand(); ok {
h.Room.HandleMsg(msg)
}
}
}
// Successfully joined.
if !apiMode {
term.SetPrompt(GetPrompt(user))
term.AutoCompleteCallback = h.AutoCompleteFunction(user)
user.SetHighlight(user.Name())
}
// Should the user be op'd on join?
if h.isOp(term.Conn) {
member.IsOp = true
}
ratelimit := rateio.NewSimpleLimiter(3, time.Second*3)
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 {
// Closed
break
} else if err != nil {
logger.Errorf("[%s] Terminal reading error: %s", term.Conn.RemoteAddr(), err)
break
}
err = ratelimit.Count(1)
if err != nil {
user.Send(message.NewSystemMsg("Message rejected: Rate limiting is in effect.", user))
continue
}
if len(line) > maxInputLength {
user.Send(message.NewSystemMsg("Message rejected: Input too long.", user))
continue
}
if line == "" {
// Silently ignore empty lines.
term.Write([]byte{})
continue
}
m := message.ParseInput(line, user)
if !apiMode {
if m, ok := m.(*message.CommandMsg); ok {
// Other messages render themselves by the room, commands we'll
// have to re-echo ourselves manually.
user.HandleMsg(m)
}
}
// FIXME: Any reason to use h.room.Send(m) instead?
h.HandleMsg(m)
if apiMode {
// Skip the remaining rendering workarounds
continue
}
}
err = h.Leave(user)
if err != nil {
logger.Errorf("[%s] Failed to leave: %s", term.Conn.RemoteAddr(), err)
return
}
logger.Debugf("[%s] Leaving: %s", term.Conn.RemoteAddr(), user.Name())
}
// Serve our chat room onto the listener
func (h *Host) Serve() {
h.listener.HandlerFunc = h.Connect
h.listener.Serve()
}
func (h *Host) completeName(partial string, skipName 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 ""
}
func (h *Host) completeCommand(partial string) string {
for cmd := range h.commands {
if strings.HasPrefix(cmd, partial) {
return cmd
}
}
return ""
}
// AutoCompleteFunction returns a callback for terminal autocompletion
func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int, key rune) (newLine string, newPos int, ok bool) {
return func(line string, pos int, key rune) (newLine string, newPos int, ok bool) {
if key != 9 {
return
}
if line == "" || strings.HasSuffix(line[:pos], " ") {
// Don't autocomplete spaces.
return
}
fields := strings.Fields(line[:pos])
isFirst := len(fields) < 2
partial := ""
if len(fields) > 0 {
partial = fields[len(fields)-1]
}
posPartial := pos - len(partial)
var completed string
if isFirst && strings.HasPrefix(line, "/") {
// Command
completed = h.completeCommand(partial)
if completed == "/reply" {
replyTo := u.ReplyTo()
if replyTo != nil {
name := replyTo.ID()
_, found := h.GetUser(name)
if found {
completed = "/msg " + name
} else {
u.SetReplyTo(nil)
}
}
}
} else {
// Name
completed = h.completeName(partial, u.Name())
if completed == "" {
return
}
if isFirst {
completed += ":"
}
}
completed += " "
// Reposition the cursor
newLine = strings.Replace(line[posPartial:], partial, completed, 1)
newLine = line[:posPartial] + newLine
newPos = pos + (len(completed) - len(partial))
ok = true
return
}
}
// GetUser returns a message.User based on a name.
func (h *Host) GetUser(name string) (*message.User, bool) {
m, ok := h.MemberByID(name)
if !ok {
return nil, false
}
return m.User, true
}
// 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",
Help: "Send MESSAGE to USER.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
args := msg.Args()
switch len(args) {
case 0:
return errors.New("must specify user")
case 1:
return errors.New("must specify message")
}
target, ok := h.GetUser(args[0])
if !ok {
return errors.New("user not found")
}
return sendPM(room, strings.Join(args[1:], " "), msg.From(), target)
},
})
c.Add(chat.Command{
Prefix: "/reply",
PrefixHelp: "MESSAGE",
Help: "Reply with MESSAGE to the previous private message.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
args := msg.Args()
switch len(args) {
case 0:
return errors.New("must specify message")
}
target := msg.From().ReplyTo()
if target == nil {
return errors.New("no message to reply to")
}
_, found := h.GetUser(target.ID())
if !found {
return errors.New("user not found")
}
return sendPM(room, strings.Join(args, " "), msg.From(), target)
},
})
c.Add(chat.Command{
Prefix: "/whois",
PrefixHelp: "USER",
Help: "Information about USER.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
args := msg.Args()
if len(args) == 0 {
return errors.New("must specify user")
}
target, ok := h.GetUser(args[0])
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)
case false:
whois = id.Whois(room)
}
room.Send(message.NewSystemMsg(whois, msg.From()))
return nil
},
})
// Hidden commands
c.Add(chat.Command{
Prefix: "/version",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
room.Send(message.NewSystemMsg(h.Version, msg.From()))
return nil
},
})
timeStarted := time.Now()
c.Add(chat.Command{
Prefix: "/uptime",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
room.Send(message.NewSystemMsg(humantime.Since(timeStarted), msg.From()))
return nil
},
})
// Op commands
c.Add(chat.Command{
Op: true,
Prefix: "/kick",
PrefixHelp: "USER",
Help: "Kick USER from the server.",
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) == 0 {
return errors.New("must specify user")
}
target, ok := h.GetUser(args[0])
if !ok {
return errors.New("user not found")
}
body := fmt.Sprintf("%s was kicked by %s.", target.Name(), msg.From().Name())
room.Send(message.NewAnnounceMsg(body))
target.Close()
return nil
},
})
c.Add(chat.Command{
Op: true,
Prefix: "/ban",
PrefixHelp: "QUERY [DURATION]",
Help: "Ban from the server. QUERY can be a username to ban the fingerprint and ip, or quoted \"key=value\" pairs with keys like ip, fingerprint, client.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
// TODO: Would be nice to specify what to ban. Key? Ip? etc.
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
args := msg.Args()
if len(args) == 0 {
return errors.New("must specify user")
}
query := args[0]
target, ok := h.GetUser(query)
if !ok {
query = strings.Join(args, " ")
if strings.Contains(query, "=") {
return h.auth.BanQuery(query)
}
return errors.New("user not found")
}
var until time.Duration
if len(args) > 1 {
until, _ = time.ParseDuration(args[1])
}
id := target.Identifier.(*Identity)
h.auth.Ban(id.PublicKey(), until)
h.auth.BanAddr(id.RemoteAddr(), until)
body := fmt.Sprintf("%s was banned by %s.", target.Name(), msg.From().Name())
room.Send(message.NewAnnounceMsg(body))
target.Close()
logger.Debugf("Banned: \n-> %s", id.Whois(room))
return nil
},
})
c.Add(chat.Command{
Op: true,
Prefix: "/banned",
Help: "List the current ban conditions.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
if !room.IsOp(msg.From()) {
return errors.New("must be op")
}
bannedIPs, bannedFingerprints, bannedClients := h.auth.Banned()
buf := bytes.Buffer{}
fmt.Fprintf(&buf, "Banned:")
for _, key := range bannedIPs {
fmt.Fprintf(&buf, "\n \"ip=%s\"", key)
}
for _, key := range bannedFingerprints {
fmt.Fprintf(&buf, "\n \"fingerprint=%s\"", key)
}
for _, key := range bannedClients {
fmt.Fprintf(&buf, "\n \"client=%s\"", key)
}
room.Send(message.NewSystemMsg(buf.String(), msg.From()))
return nil
},
})
c.Add(chat.Command{
Op: true,
Prefix: "/motd",
PrefixHelp: "[MESSAGE]",
Help: "Set a new MESSAGE of the day, or print the motd if no MESSAGE.",
Handler: func(room *chat.Room, msg message.CommandMsg) error {
args := msg.Args()
user := msg.From()
h.mu.Lock()
motd := h.motd
h.mu.Unlock()
if len(args) == 0 {
room.Send(message.NewSystemMsg(motd, user))
return nil
}
if !room.IsOp(user) {
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)
fromMsg := fmt.Sprintf("New message of the day set by %s:", msg.From().Name())
room.Send(message.NewAnnounceMsg(fromMsg + message.Newline + "-> " + s))
return nil
},
})
c.Add(chat.Command{
Op: true,
Prefix: "/op",
PrefixHelp: "USER [DURATION|remove]",
Help: "Set USER as admin. Duration only applies to pubkey reconnects.",
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) == 0 {
return errors.New("must specify user")
}
opValue := true
var until time.Duration
if len(args) > 1 {
if args[1] == "remove" {
// Expire instantly
until = time.Duration(1)
opValue = false
} else {
until, _ = time.ParseDuration(args[1])
}
}
member, ok := room.MemberByID(args[0])
if !ok {
return errors.New("user not found")
}
member.IsOp = opValue
id := member.Identifier.(*Identity)
h.auth.Op(id.PublicKey(), until)
var body string
if opValue {
body = fmt.Sprintf("Made op by %s.", msg.From().Name())
} else {
body = fmt.Sprintf("Removed op by %s.", msg.From().Name())
}
room.Send(message.NewSystemMsg(body, member.User))
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
},
})
}