diff --git a/auth.go b/auth.go index ced008b..7b22a01 100644 --- a/auth.go +++ b/auth.go @@ -44,6 +44,7 @@ type Auth struct { banned *set.Set whitelist *set.Set ops *set.Set + masters *set.Set } // NewAuth creates a new empty Auth. @@ -53,6 +54,7 @@ func NewAuth() *Auth { banned: set.New(), whitelist: set.New(), ops: set.New(), + masters: set.New(), } } @@ -99,6 +101,20 @@ func (a *Auth) Op(key ssh.PublicKey, d time.Duration) { logger.Debugf("Added to ops: %s (for %s)", authItem.Key(), d) } +// Master sets a public key as a known admin master. +func (a *Auth) Master(key ssh.PublicKey, d time.Duration) { + if key == nil { + return + } + authItem := newAuthItem(key) + if d != 0 { + a.masters.Add(set.Expire(authItem, d)) + } else { + a.masters.Add(authItem) + } + logger.Debugf("Added to masters: %s (for %s)", authItem.Key(), d) +} + // IsOp checks if a public key is an op. func (a *Auth) IsOp(key ssh.PublicKey) bool { if key == nil { @@ -108,6 +124,14 @@ func (a *Auth) IsOp(key ssh.PublicKey) bool { return a.ops.In(authkey) } +func (a *Auth) IsMaster(key ssh.PublicKey) bool { + if key == nil { + return false + } + authkey := newAuthKey(key) + return a.masters.In(authkey) +} + // Whitelist will set a public key as a whitelisted user. func (a *Auth) Whitelist(key ssh.PublicKey, d time.Duration) { if key == nil { diff --git a/chat/command.go b/chat/command.go index e84350b..afeec11 100644 --- a/chat/command.go +++ b/chat/command.go @@ -36,6 +36,8 @@ type Command struct { Handler func(*Room, message.CommandMsg) error // Command requires Op permissions Op bool + // command requires Admin permissions + Admin bool } // Commands is a registry of available commands. @@ -68,7 +70,7 @@ func (c Commands) Run(room *Room, msg message.CommandMsg) error { return ErrNoOwner } - cmd, ok := c[msg.Command()] + cmd, ok := c[msg.Command()[1:]] if !ok { return ErrInvalidCommand } @@ -77,14 +79,19 @@ func (c Commands) Run(room *Room, msg message.CommandMsg) error { } // Help will return collated help text as one string. -func (c Commands) Help(showOp bool) string { +func (c Commands) Help(showOp, showAdmin bool) string { // Filter by op op := []*Command{} + admin := []*Command{} normal := []*Command{} for _, cmd := range c { if cmd.Op { op = append(op, cmd) - } else { + } + if cmd.Admin { + admin = append(admin, cmd) + } + if !cmd.Op && !cmd.Admin { normal = append(normal, cmd) } } @@ -92,6 +99,9 @@ func (c Commands) Help(showOp bool) string { if showOp { help += message.Newline + "-> Operator commands:" + message.Newline + NewCommandsHelp(op).String() } + if showAdmin { + help += message.Newline + "-> Admin commands:" + message.Newline + NewCommandsHelp(admin).String() + } return help } @@ -105,16 +115,17 @@ func init() { // InitCommands injects default commands into a Commands registry. func InitCommands(c *Commands) { c.Add(Command{ - Prefix: "/help", + Prefix: "help", Handler: func(room *Room, msg message.CommandMsg) error { op := room.IsOp(msg.From()) - room.Send(message.NewSystemMsg(room.commands.Help(op), msg.From())) + admin := room.IsMaster(msg.From()) + room.Send(message.NewSystemMsg(room.commands.Help(op, admin), msg.From())) return nil }, }) c.Add(Command{ - Prefix: "/me", + Prefix: "me", Handler: func(room *Room, msg message.CommandMsg) error { me := strings.TrimLeft(msg.Body(), "/me") if me == "" { @@ -129,17 +140,18 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/exit", + Prefix: "exit", Help: "Exit the chat.", Handler: func(room *Room, msg message.CommandMsg) error { msg.From().Close() return nil }, }) - c.Alias("/exit", "/quit") + c.Alias("exit", "quit") + c.Alias("q", "quit") c.Add(Command{ - Prefix: "/nick", + Prefix: "nick", PrefixHelp: "NAME", Help: "Rename yourself.", Handler: func(room *Room, msg message.CommandMsg) error { @@ -170,7 +182,38 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/names", + Admin: true, + Prefix: "setnick", + PrefixHelp: "NAME ANOTHER", + Help: "Rename NAME to ANOTHER username", + Handler: func(room *Room, msg message.CommandMsg) error { + args := msg.Args() + if len(args) < 2 { + return errors.New("invalid arguments") + } + + member, ok := room.MemberByID(args[0]) + if !ok { + return errors.New("failed to find member") + } + + oldID := member.ID() + newID := SanitizeName(args[1]) + if newID == oldID { + return errors.New("new name is the same as the original") + } + member.SetID(newID) + err := room.Rename(oldID, member) + if err != nil { + member.SetID(oldID) + return err + } + return nil + }, + }) + + c.Add(Command{ + Prefix: "names", Help: "List users who are connected.", Handler: func(room *Room, msg message.CommandMsg) error { theme := msg.From().Config().Theme @@ -196,10 +239,10 @@ func InitCommands(c *Commands) { return nil }, }) - c.Alias("/names", "/list") + c.Alias("names", "list") c.Add(Command{ - Prefix: "/theme", + Prefix: "theme", PrefixHelp: "[colors|...]", Help: "Set your color theme.", Handler: func(room *Room, msg message.CommandMsg) error { @@ -239,7 +282,7 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/quiet", + Prefix: "quiet", Help: "Silence room announcements.", Handler: func(room *Room, msg message.CommandMsg) error { u := msg.From() @@ -259,7 +302,7 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/slap", + Prefix: "slap", PrefixHelp: "NAME", Handler: func(room *Room, msg message.CommandMsg) error { var me string @@ -276,7 +319,7 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/ignore", + Prefix: "ignore", PrefixHelp: "[USER]", Help: "Hide messages from USER, /unignore USER to stop hiding.", Handler: func(room *Room, msg message.CommandMsg) error { @@ -321,7 +364,7 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/unignore", + Prefix: "unignore", PrefixHelp: "USER", Handler: func(room *Room, msg message.CommandMsg) error { id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/unignore")) diff --git a/chat/message/identity.go b/chat/message/identity.go index 8edef77..ca0ece3 100644 --- a/chat/message/identity.go +++ b/chat/message/identity.go @@ -5,6 +5,8 @@ type Identifier interface { ID() string SetID(string) Name() string + Chat() string + SetChat(string) } // SimpleID is a simple Identifier implementation used for testing. @@ -24,3 +26,12 @@ func (i SimpleID) SetID(s string) { func (i SimpleID) Name() string { return i.ID() } + +// Chat returns the chat name +func (i SimpleID) Chat() string { + return i.Chat() +} + +func (i SimpleID) SetChat(s string) { + +} diff --git a/chat/message/message.go b/chat/message/message.go index 5b4f76a..832979d 100644 --- a/chat/message/message.go +++ b/chat/message/message.go @@ -88,7 +88,7 @@ func (m PublicMsg) From() *User { func (m PublicMsg) ParseCommand() (*CommandMsg, bool) { // Check if the message is a command - if !strings.HasPrefix(m.body, "/") { + if !strings.HasPrefix(m.body, "/") && !strings.HasPrefix(m.body, ":") { return nil, false } diff --git a/chat/room.go b/chat/room.go index 7ce6de1..3186c42 100644 --- a/chat/room.go +++ b/chat/room.go @@ -10,8 +10,8 @@ import ( "github.com/shazow/ssh-chat/set" ) -const historyLen = 20 -const roomBuffer = 10 +const historyLen = 5 +const roomBuffer = 250 // The error returned when a message is sent to a room that is already // closed. @@ -37,6 +37,7 @@ type Room struct { Members *set.Set Ops *set.Set + Masters *set.Set } // NewRoom creates a new room. @@ -50,6 +51,7 @@ func NewRoom() *Room { Members: set.New(), Ops: set.New(), + Masters: set.New(), } } @@ -165,6 +167,7 @@ func (r *Room) Leave(u message.Identifier) error { return err } r.Ops.Remove(u.ID()) + r.Masters.Remove(u.ID()) s := fmt.Sprintf("%s left.", u.Name()) r.Send(message.NewAnnounceMsg(s)) return nil @@ -212,6 +215,11 @@ func (r *Room) IsOp(u *message.User) bool { return r.Ops.In(u.ID()) } +// IsMaster returns whether a user is an admin master in this room. +func (r *Room) IsMaster(u *message.User) bool { + return r.Masters.In(u.ID()) +} + // Topic of the room. func (r *Room) Topic() string { return r.topic diff --git a/cmd/ssh-chat/cmd.go b/cmd/ssh-chat/cmd.go index 7c7d000..c3eeb83 100644 --- a/cmd/ssh-chat/cmd.go +++ b/cmd/ssh-chat/cmd.go @@ -12,6 +12,7 @@ import ( "github.com/alexcesaro/log" "github.com/alexcesaro/log/golog" + "github.com/fsnotify/fsnotify" "github.com/jessevdk/go-flags" "golang.org/x/crypto/ssh" @@ -31,7 +32,8 @@ type Options struct { Version bool `long:"version" description:"Print version and exit."` Identity string `short:"i" long:"identity" description:"Private key to identify server with." default:"~/.ssh/id_rsa"` Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"` - Admin string `long:"admin" description:"File of public keys who are admins."` + Mods string `long:"moderators" description:"File of public keys who are moderators."` + Admins string `long:"admins" description:"File of public keys who are admins."` Whitelist string `long:"whitelist" description:"Optional file of public keys who are allowed to connect."` Motd string `long:"motd" description:"Optional Message of the Day file."` Log string `long:"log" description:"Write chat log to this file."` @@ -123,7 +125,18 @@ func main() { host.SetTheme(message.Themes[0]) host.Version = Version - err = fromFile(options.Admin, func(line []byte) error { + err = fromFile(options.Admins, func(line []byte) error { + key, _, _, _, err := ssh.ParseAuthorizedKey(line) + if err != nil { + return err + } + auth.Master(key, 0) + return nil + }) + if err != nil { + fail(5, "Failed to load admins: %v\n", err) + } + err = fromFile(options.Mods, func(line []byte) error { key, _, _, _, err := ssh.ParseAuthorizedKey(line) if err != nil { return err @@ -132,7 +145,7 @@ func main() { return nil }) if err != nil { - fail(5, "Failed to load admins: %v\n", err) + fail(5, "Failed to load mods: %v\n", err) } err = fromFile(options.Whitelist, func(line []byte) error { @@ -169,7 +182,45 @@ func main() { host.SetLogging(fp) } + watcher, err := fsnotify.NewWatcher() + if err != nil { + panic(err) + } + defer watcher.Close() + go host.Serve() + go func() { + for event := range watcher.Events { + if event.Op&fsnotify.Write == fsnotify.Write { + if event.Name == options.Admins { + err = fromFile(options.Admins, func(line []byte) error { + key, _, _, _, err := ssh.ParseAuthorizedKey(line) + if err != nil { + return err + } + auth.Master(key, 0) + return nil + }) + } + if event.Name == options.Mods { + err = fromFile(options.Mods, func(line []byte) error { + key, _, _, _, err := ssh.ParseAuthorizedKey(line) + if err != nil { + return err + } + auth.Op(key, 0) + return nil + }) + } + } + } + }() + if len(options.Admins) > 0 { + watcher.Add(options.Admins) + } + if len(options.Mods) > 0 { + watcher.Add(options.Mods) + } // Construct interrupt handler sig := make(chan os.Signal, 1) @@ -177,7 +228,6 @@ func main() { <-sig // Wait for ^C signal fmt.Fprintln(os.Stderr, "Interrupt signal detected, shutting down.") - os.Exit(0) } func fromFile(path string, handler func(line []byte) error) error { diff --git a/host.go b/host.go index 752cf2f..e9430e2 100644 --- a/host.go +++ b/host.go @@ -25,7 +25,7 @@ func GetPrompt(user *message.User) string { if cfg.Theme != nil { name = cfg.Theme.ColorName(user) } - return fmt.Sprintf("[%s] ", name) + return fmt.Sprintf("[%s:%s] ", name, user.Chat()) } // Host is the bridge between sshd and chat modules @@ -42,8 +42,11 @@ type Host struct { // Default theme theme message.Theme - mu sync.Mutex - motd string + mu sync.Mutex + motd string + // start private mode + private map[string]*message.User + count int } @@ -88,6 +91,14 @@ func (h *Host) isOp(conn sshd.Connection) bool { return h.auth.IsOp(key) } +func (h *Host) isMaster(conn sshd.Connection) bool { + key := conn.PublicKey() + if key == nil { + return false + } + return h.auth.IsMaster(key) +} + // Connect a specific Terminal to this host and its room. func (h *Host) Connect(term *sshd.Terminal) { id := NewIdentity(term.Conn) @@ -124,6 +135,7 @@ func (h *Host) Connect(term *sshd.Terminal) { } // Successfully joined. + user.SetChat("general") term.SetPrompt(GetPrompt(user)) term.AutoCompleteCallback = h.AutoCompleteFunction(user) user.SetHighlight(user.Name()) @@ -132,10 +144,15 @@ func (h *Host) Connect(term *sshd.Terminal) { if h.isOp(term.Conn) { h.Room.Ops.Add(set.Itemize(member.ID(), member)) } + if h.isMaster(term.Conn) { + h.Room.Masters.Add(set.Itemize(member.ID(), member)) + } ratelimit := rateio.NewSimpleLimiter(3, time.Second*3) logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name()) + var args []string + h.private = make(map[string]*message.User) for { line, err := term.ReadLine() if err == io.EOF { @@ -162,19 +179,35 @@ func (h *Host) Connect(term *sshd.Terminal) { m := message.ParseInput(line, user) - // FIXME: Any reason to use h.room.Send(m) instead? - h.HandleMsg(m) + switch c := m.(type) { + case *message.CommandMsg: + args = c.Args() + h.HandleMsg(m) + default: + to, ok := h.private[user.Name()] + if ok { + m = message.NewPrivateMsg( + m.String(), user, to, + ) + } + 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. + if cmd := m.Command(); len(cmd) > 0 { + switch cmd[1:] { + case "private": + if len(args) > 0 { + user.SetChat(args[0]) + } + case "endprivate": + user.SetChat("general") + case "nick": + case "theme": + } term.SetPrompt(GetPrompt(user)) user.SetHighlight(user.Name()) } + args = nil } err = h.Leave(user) @@ -280,7 +313,7 @@ func (h *Host) GetUser(name string) (*message.User, bool) { // override any existing commands. func (h *Host) InitCommands(c *chat.Commands) { c.Add(chat.Command{ - Prefix: "/msg", + Prefix: "msg", PrefixHelp: "USER MESSAGE", Help: "Send MESSAGE to USER.", Handler: func(room *chat.Room, msg message.CommandMsg) error { @@ -309,7 +342,59 @@ func (h *Host) InitCommands(c *chat.Commands) { }) c.Add(chat.Command{ - Prefix: "/reply", + Prefix: "private", + PrefixHelp: "USER", + Help: "Start private chat with 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") + } + + h.private[msg.From().Name()] = target + + txt := fmt.Sprintf("[Private mode started with %s]", target.Name()) + ms := message.NewSystemMsg(txt, msg.From()) + room.Send(ms) + target.SetReplyTo(msg.From()) + return nil + }, + }) + c.Alias("p", "private") + + c.Add(chat.Command{ + Admin: true, + Prefix: "welcome", + Handler: func(room *chat.Room, msg message.CommandMsg) error { + if !room.IsMaster(msg.From()) { + return errors.New("must be admin") + } + + room.Send(message.NewMsg(h.motd)) + return nil + }, + }) + + c.Add(chat.Command{ + Prefix: "endprivate", + Help: "Stop private chat", + Handler: func(room *chat.Room, msg message.CommandMsg) error { + delete(h.private, msg.From().Name()) + + txt := "[Private mode ended]" + ms := message.NewSystemMsg(txt, msg.From()) + room.Send(ms) + 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 { @@ -341,7 +426,7 @@ func (h *Host) InitCommands(c *chat.Commands) { }) c.Add(chat.Command{ - Prefix: "/whois", + Prefix: "whois", PrefixHelp: "USER", Help: "Information about USER.", Handler: func(room *chat.Room, msg message.CommandMsg) error { @@ -357,11 +442,15 @@ func (h *Host) InitCommands(c *chat.Commands) { id := target.Identifier.(*Identity) var whois string - switch room.IsOp(msg.From()) { + switch room.IsMaster(msg.From()) { case true: - whois = id.WhoisAdmin() + whois = id.WhoisMaster() case false: - whois = id.Whois() + if room.IsOp(msg.From()) { + whois = id.WhoisAdmin() + } else { + whois = id.Whois() + } } room.Send(message.NewSystemMsg(whois, msg.From())) @@ -371,7 +460,7 @@ func (h *Host) InitCommands(c *chat.Commands) { // Hidden commands c.Add(chat.Command{ - Prefix: "/version", + Prefix: "version", Handler: func(room *chat.Room, msg message.CommandMsg) error { room.Send(message.NewSystemMsg(h.Version, msg.From())) return nil @@ -380,7 +469,7 @@ func (h *Host) InitCommands(c *chat.Commands) { timeStarted := time.Now() c.Add(chat.Command{ - Prefix: "/uptime", + Prefix: "uptime", Handler: func(room *chat.Room, msg message.CommandMsg) error { room.Send(message.NewSystemMsg(humanize.Time(timeStarted), msg.From())) return nil @@ -390,11 +479,12 @@ func (h *Host) InitCommands(c *chat.Commands) { // Op commands c.Add(chat.Command{ Op: true, - Prefix: "/kick", + Admin: 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()) { + if !room.IsOp(msg.From()) && !room.IsMaster(msg.From()) { return errors.New("must be op") } @@ -408,6 +498,10 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("user not found") } + if room.IsMaster(target) { + return errors.New("you cannot kick master") + } + body := fmt.Sprintf("%s was kicked by %s.", target.Name(), msg.From().Name()) room.Send(message.NewAnnounceMsg(body)) target.Close() @@ -416,13 +510,14 @@ func (h *Host) InitCommands(c *chat.Commands) { }) c.Add(chat.Command{ + Admin: true, Op: true, - Prefix: "/ban", + Prefix: "ban", PrefixHelp: "USER [DURATION]", Help: "Ban USER from the server.", 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()) { + if !room.IsMaster(msg.From()) { return errors.New("must be op") } @@ -436,6 +531,10 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("user not found") } + if room.IsMaster(target) { + return errors.New("you cannot ban master.") + } + var until time.Duration = 0 if len(args) > 1 { until, _ = time.ParseDuration(args[1]) @@ -456,13 +555,18 @@ func (h *Host) InitCommands(c *chat.Commands) { }) c.Add(chat.Command{ + Admin: true, Op: true, - Prefix: "/motd", + 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() + if !room.IsMaster(user) && !room.IsOp(user) { + return errors.New("must be OP to modify the MOTD") + } + + args := msg.Args() h.mu.Lock() motd := h.motd @@ -472,9 +576,6 @@ func (h *Host) InitCommands(c *chat.Commands) { 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) @@ -486,13 +587,13 @@ func (h *Host) InitCommands(c *chat.Commands) { }) c.Add(chat.Command{ - Op: true, - Prefix: "/op", + Admin: true, + Prefix: "op", PrefixHelp: "USER [DURATION]", Help: "Set USER as admin.", Handler: func(room *chat.Room, msg message.CommandMsg) error { - if !room.IsOp(msg.From()) { - return errors.New("must be op") + if !room.IsMaster(msg.From()) { + return errors.New("must be admin") } args := msg.Args() @@ -520,4 +621,35 @@ func (h *Host) InitCommands(c *chat.Commands) { return nil }, }) + + c.Add(chat.Command{ + Admin: true, + Prefix: "delop", + PrefixHelp: "USER", + Help: "Remove USER as admin.", + Handler: func(room *chat.Room, msg message.CommandMsg) error { + if !room.IsMaster(msg.From()) { + return errors.New("must be master") + } + + 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") + } + err := room.Ops.Remove(args[0]) + if err != nil { + return err + } + + body := fmt.Sprintf("Deleted op by %s.", msg.From().Name()) + room.Send(message.NewSystemMsg(body, member.User)) + + return nil + }, + }) } diff --git a/identity.go b/identity.go index 50cee7e..e947e31 100644 --- a/identity.go +++ b/identity.go @@ -14,6 +14,7 @@ import ( type Identity struct { sshd.Connection id string + chat string created time.Time } @@ -42,8 +43,22 @@ func (i Identity) Name() string { return i.id } +func (i Identity) Chat() string { + return i.chat +} + +func (i *Identity) SetChat(c string) { + i.chat = c +} + // Whois returns a whois description for non-admin users. func (i Identity) Whois() string { + return "name: " + i.Name() + message.Newline + + " > joined: " + humanize.Time(i.created) +} + +// WhoisAdmin returns a whois description for admin users. +func (i Identity) WhoisAdmin() string { fingerprint := "(no public key)" if i.PublicKey() != nil { fingerprint = sshd.Fingerprint(i.PublicKey()) @@ -54,8 +69,7 @@ func (i Identity) Whois() string { " > joined: " + humanize.Time(i.created) } -// WhoisAdmin returns a whois description for admin users. -func (i Identity) WhoisAdmin() string { +func (i Identity) WhoisMaster() string { ip, _, _ := net.SplitHostPort(i.RemoteAddr().String()) fingerprint := "(no public key)" if i.PublicKey() != nil {