ssh-chat/host.go

579 lines
13 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/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
}
// 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.
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) {
term.SetEnterClear(true) // We provide our own echo rendering
id := NewIdentity(term.Conn)
user := message.NewUserScreen(id, term)
cfg := user.Config()
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
}
// Successfully joined.
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())
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 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)
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)
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) string {
names := h.NamesPrefix(partial)
if len(names) == 0 {
// Didn't find anything
return ""
}
return names[len(names)-1]
}
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.Name()
_, found := h.GetUser(name)
if found {
completed = "/msg " + name
} else {
u.SetReplyTo(nil)
}
}
}
} else {
// Name
completed = h.completeName(partial)
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) {
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")
}
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
},
})
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")
}
name := target.Name()
_, found := h.GetUser(name)
if !found {
return errors.New("user not found")
}
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
},
})
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()
case false:
whois = id.Whois()
}
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())
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, print the current motd without parameters.",
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")
}
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 + "-> " + motd))
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
},
})
}