package sshchat import ( "bytes" "errors" "fmt" "io" "strings" "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" ) 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 != "" && !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 // 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 }, }) 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 }, }) }