diff --git a/Gopkg.toml b/Gopkg.toml index f3da934..df5da3d 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -25,10 +25,6 @@ branch = "master" name = "github.com/alexcesaro/log" -[[constraint]] - branch = "master" - name = "github.com/dustin/go-humanize" - [[constraint]] branch = "master" name = "github.com/howeyc/gopass" diff --git a/chat/command.go b/chat/command.go index 0570215..6462b78 100644 --- a/chat/command.go +++ b/chat/command.go @@ -6,6 +6,7 @@ import ( "bytes" "errors" "fmt" + "io" "strings" "github.com/shazow/ssh-chat/chat/message" @@ -25,6 +26,10 @@ var ErrMissingArg = errors.New("missing argument") // The error returned when a command is added without a prefix. var ErrMissingPrefix = errors.New("command missing prefix") +// The error returned when we fail to find a corresponding userMember struct +// for an ID. This should not happen, probably a bug somewhere if encountered. +var ErrMissingMember = errors.New("failed to find member") + // Command is a definition of a handler for a command. type Command struct { // The command's key, such as /foo @@ -132,8 +137,7 @@ func InitCommands(c *Commands) { Prefix: "/exit", Help: "Exit the chat.", Handler: func(room *Room, msg message.CommandMsg) error { - msg.From().Close() - return nil + return msg.From().(io.Closer).Close() }, }) c.Alias("/exit", "/quit") @@ -173,14 +177,14 @@ func InitCommands(c *Commands) { Prefix: "/names", Help: "List users who are connected.", Handler: func(room *Room, msg message.CommandMsg) error { - theme := msg.From().Config().Theme + theme := msg.From().(Member).Config().Theme - colorize := func(u *message.User) string { + colorize := func(u Member) string { return theme.ColorName(u) } if theme == nil { - colorize = func(u *message.User) string { + colorize = func(u Member) string { return u.Name() } } @@ -188,7 +192,7 @@ func InitCommands(c *Commands) { names := room.Members.ListPrefix("") colNames := make([]string, len(names)) for i, uname := range names { - colNames[i] = colorize(uname.Value().(*Member).User) + colNames[i] = colorize(uname.Value().(Member)) } body := fmt.Sprintf("%d connected: %s", len(colNames), strings.Join(colNames, ", ")) @@ -203,7 +207,7 @@ func InitCommands(c *Commands) { PrefixHelp: "[colors|...]", Help: "Set your color theme.", Handler: func(room *Room, msg message.CommandMsg) error { - user := msg.From() + user := msg.From().(Member) args := msg.Args() cfg := user.Config() if len(args) == 0 { @@ -242,7 +246,7 @@ func InitCommands(c *Commands) { Prefix: "/quiet", Help: "Silence room announcements.", Handler: func(room *Room, msg message.CommandMsg) error { - u := msg.From() + u := msg.From().(Member) cfg := u.Config() cfg.Quiet = !cfg.Quiet u.SetConfig(cfg) @@ -293,7 +297,7 @@ func InitCommands(c *Commands) { Prefix: "/timestamp", Help: "Timestamps after 30min of inactivity.", Handler: func(room *Room, msg message.CommandMsg) error { - u := msg.From() + u := msg.From().(Member) cfg := u.Config() cfg.Timestamp = !cfg.Timestamp u.SetConfig(cfg) @@ -314,11 +318,15 @@ func InitCommands(c *Commands) { PrefixHelp: "[USER]", Help: "Hide messages from USER, /unignore USER to stop hiding.", Handler: func(room *Room, msg message.CommandMsg) error { + from, ok := room.Member(msg.From()) + if !ok { + return ErrMissingMember + } id := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/ignore")) if id == "" { // Print ignored names, if any. var names []string - msg.From().Ignored.Each(func(_ string, item set.Item) error { + from.Ignored.Each(func(_ string, item set.Item) error { names = append(names, item.Key()) return nil }) @@ -342,7 +350,7 @@ func InitCommands(c *Commands) { return fmt.Errorf("user not found: %s", id) } - err := msg.From().Ignored.Add(set.Itemize(id, target)) + err := from.Ignored.Add(set.Itemize(id, target)) if err == set.ErrCollision { return fmt.Errorf("user already ignored: %s", id) } else if err != nil { @@ -363,7 +371,12 @@ func InitCommands(c *Commands) { return errors.New("must specify user") } - if err := msg.From().Ignored.Remove(id); err != nil { + from, ok := room.Member(msg.From()) + if !ok { + return ErrMissingMember + } + + if err := from.Ignored.Remove(id); err != nil { return err } diff --git a/chat/member.go b/chat/member.go new file mode 100644 index 0000000..2b1b691 --- /dev/null +++ b/chat/member.go @@ -0,0 +1,24 @@ +package chat + +import ( + "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/set" +) + +// Member is a User with per-Room metadata attached to it. +type roomMember struct { + Member + Ignored *set.Set +} + +type Member interface { + message.Author + + Config() message.UserConfig + SetConfig(message.UserConfig) + + Send(message.Message) error + + SetID(string) + Close() +} diff --git a/chat/message/message.go b/chat/message/message.go index 5b4f76a..e7cac39 100644 --- a/chat/message/message.go +++ b/chat/message/message.go @@ -6,6 +6,12 @@ import ( "time" ) +type Author interface { + ID() string + Name() string + Color() int +} + // Message is an interface for messages. type Message interface { Render(*Theme) string @@ -16,15 +22,15 @@ type Message interface { type MessageTo interface { Message - To() *User + To() Author } type MessageFrom interface { Message - From() *User + From() Author } -func ParseInput(body string, from *User) Message { +func ParseInput(body string, from Author) Message { m := NewPublicMsg(body, from) cmd, isCmd := m.ParseCommand() if isCmd { @@ -69,10 +75,10 @@ func (m Msg) Timestamp() time.Time { // PublicMsg is any message from a user sent to the room. type PublicMsg struct { Msg - from *User + from Author } -func NewPublicMsg(body string, from *User) PublicMsg { +func NewPublicMsg(body string, from Author) PublicMsg { return PublicMsg{ Msg: Msg{ body: body, @@ -82,7 +88,7 @@ func NewPublicMsg(body string, from *User) PublicMsg { } } -func (m PublicMsg) From() *User { +func (m PublicMsg) From() Author { return m.from } @@ -137,10 +143,10 @@ func (m PublicMsg) String() string { // sender to see the emote. type EmoteMsg struct { Msg - from *User + from Author } -func NewEmoteMsg(body string, from *User) *EmoteMsg { +func NewEmoteMsg(body string, from Author) *EmoteMsg { return &EmoteMsg{ Msg: Msg{ body: body, @@ -161,17 +167,17 @@ func (m EmoteMsg) String() string { // PrivateMsg is a message sent to another user, not shown to anyone else. type PrivateMsg struct { PublicMsg - to *User + to Author } -func NewPrivateMsg(body string, from *User, to *User) PrivateMsg { +func NewPrivateMsg(body string, from Author, to Author) PrivateMsg { return PrivateMsg{ PublicMsg: NewPublicMsg(body, from), to: to, } } -func (m PrivateMsg) To() *User { +func (m PrivateMsg) To() Author { return m.to } @@ -191,10 +197,10 @@ func (m PrivateMsg) String() string { // to anyone else. Usually in response to something, like /help. type SystemMsg struct { Msg - to *User + to Author } -func NewSystemMsg(body string, to *User) *SystemMsg { +func NewSystemMsg(body string, to Author) *SystemMsg { return &SystemMsg{ Msg: Msg{ body: body, @@ -215,7 +221,7 @@ func (m *SystemMsg) String() string { return fmt.Sprintf("-> %s", m.body) } -func (m *SystemMsg) To() *User { +func (m *SystemMsg) To() Author { return m.to } diff --git a/chat/message/theme.go b/chat/message/theme.go index d2585b7..56d36bf 100644 --- a/chat/message/theme.go +++ b/chat/message/theme.go @@ -126,13 +126,18 @@ func (t Theme) ID() string { return t.id } +type coloredName interface { + Name() string + Color() int +} + // Colorize name string given some index -func (t Theme) ColorName(u *User) string { +func (t Theme) ColorName(n coloredName) string { if t.names == nil { - return u.Name() + return n.Name() } - return t.names.Get(u.colorIdx).Format(u.Name()) + return t.names.Get(n.Color()).Format(n.Name()) } // Colorize the PM string diff --git a/chat/message/user.go b/chat/message/user.go index cc12ebc..ecdf81b 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -71,6 +71,10 @@ func (u *User) SetConfig(cfg UserConfig) { u.mu.Unlock() } +func (u *User) Color() int { + return u.colorIdx +} + // Rename the user with a new Identifier. func (u *User) SetID(id string) { u.Identifier.SetID(id) diff --git a/chat/room.go b/chat/room.go index 7ce6de1..13319a3 100644 --- a/chat/room.go +++ b/chat/room.go @@ -21,11 +21,6 @@ var ErrRoomClosed = errors.New("room closed") // as empty string. var ErrInvalidName = errors.New("invalid name") -// Member is a User with per-Room metadata attached to it. -type Member struct { - *message.User -} - // Room definition, also a Set of User Items type Room struct { topic string @@ -63,7 +58,7 @@ func (r *Room) Close() { r.closeOnce.Do(func() { r.closed = true r.Members.Each(func(_ string, item set.Item) error { - item.Value().(*Member).Close() + item.Value().(Member).Close() return nil }) r.Members.Clear() @@ -87,25 +82,28 @@ func (r *Room) HandleMsg(m message.Message) { go r.HandleMsg(m) } case message.MessageTo: - user := m.To() - user.Send(m) + if user, ok := r.Member(m.To()); ok { + user.Send(m) + } else { + // Todo: Handle error? + } default: fromMsg, skip := m.(message.MessageFrom) - var skipUser *message.User + var skipAuthor message.Author if skip { - skipUser = fromMsg.From() + skipAuthor = fromMsg.From() } r.history.Add(m) r.Members.Each(func(_ string, item set.Item) (err error) { - user := item.Value().(*Member).User + user := item.Value().(*roomMember) if fromMsg != nil && user.Ignored.In(fromMsg.From().ID()) { // Skip because ignored return } - if skip && skipUser == user { + if skip && skipAuthor == user.Member.(message.Author) { // Skip self return } @@ -135,19 +133,22 @@ func (r *Room) Send(m message.Message) { } // History feeds the room's recent message history to the user's handler. -func (r *Room) History(u *message.User) { +func (r *Room) History(u Member) { for _, m := range r.history.Get(historyLen) { u.Send(m) } } // Join the room as a user, will announce. -func (r *Room) Join(u *message.User) (*Member, error) { +func (r *Room) Join(u Member) (*roomMember, error) { // TODO: Check if closed if u.ID() == "" { return nil, ErrInvalidName } - member := &Member{u} + member := &roomMember{ + Member: u, + Ignored: set.New(), + } err := r.Members.Add(set.Itemize(u.ID(), member)) if err != nil { return nil, err @@ -187,28 +188,29 @@ func (r *Room) Rename(oldID string, u message.Identifier) error { // Member returns a corresponding Member object to a User if the Member is // present in this room. -func (r *Room) Member(u *message.User) (*Member, bool) { +func (r *Room) Member(u message.Author) (*roomMember, bool) { m, ok := r.MemberByID(u.ID()) if !ok { return nil, false } // Check that it's the same user - if m.User != u { + if m.Member != u { return nil, false } return m, true } -func (r *Room) MemberByID(id string) (*Member, bool) { +func (r *Room) MemberByID(id string) (*roomMember, bool) { m, err := r.Members.Get(id) if err != nil { return nil, false } - return m.Value().(*Member), true + rm, ok := m.Value().(*roomMember) + return rm, ok } // IsOp returns whether a user is an operator in this room. -func (r *Room) IsOp(u *message.User) bool { +func (r *Room) IsOp(u message.Author) bool { return r.Ops.In(u.ID()) } @@ -228,7 +230,7 @@ func (r *Room) NamesPrefix(prefix string) []string { items := r.Members.ListPrefix(prefix) names := make([]string, len(items)) for i, item := range items { - names[i] = item.Value().(*Member).User.Name() + names[i] = item.Value().(*roomMember).Name() } return names } diff --git a/cmd/ssh-chat/cmd.go b/cmd/ssh-chat/cmd.go index 7c7d000..a1fceb4 100644 --- a/cmd/ssh-chat/cmd.go +++ b/cmd/ssh-chat/cmd.go @@ -120,7 +120,6 @@ func main() { fmt.Printf("Listening for connections on %v\n", s.Addr().String()) host := sshchat.NewHost(s, auth) - host.SetTheme(message.Themes[0]) host.Version = Version err = fromFile(options.Admin, func(line []byte) error { diff --git a/host.go b/host.go index 34a19df..cc70ff0 100644 --- a/host.go +++ b/host.go @@ -38,12 +38,11 @@ type Host struct { // Version string to print on /version Version string - // Default theme - theme message.Theme - mu sync.Mutex motd string count int + + oneScreen *multiScreen } // NewHost creates a Host on top of an existing listener. @@ -65,13 +64,6 @@ func NewHost(listener *sshd.SSHListener, auth *Auth) *Host { 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() @@ -87,54 +79,31 @@ func (h *Host) isOp(conn sshd.Connection) bool { return h.auth.IsOp(key) } -// Connect a specific Terminal to this host and its room. -func (h *Host) Connect(term *sshd.Terminal) { +func (h *Host) getScreen(term *sshd.Terminal) (screen *multiScreen, isNew bool) { + if h.oneScreen != nil { + h.oneScreen.add(term) + return h.oneScreen, false + } id := NewIdentity(term.Conn) user := message.NewUserScreen(id, term) + + // TODO: Skip this if TERM is set to something dumb? cfg := user.Config() - cfg.Theme = &h.theme + cfg.Theme = message.DefaultTheme 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)) + h.oneScreen = &multiScreen{ + User: user, } - 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 - } + return h.oneScreen, true +} - // Successfully joined. +func (h *Host) consumeScreen(term *sshd.Terminal, user *message.User) { 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) { - h.Room.Ops.Add(set.Itemize(member.ID(), member)) - } 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 { @@ -175,7 +144,57 @@ func (h *Host) Connect(term *sshd.Terminal) { user.SetHighlight(user.Name()) } } +} +// Connect a specific Terminal to this host and its room. +func (h *Host) Connect(term *sshd.Terminal) { + screen, isNew := h.getScreen(term) + user := screen.User + go user.Consume() + + defer term.Close() + + if !isNew { + h.consumeScreen(term, user) + return + } + + // XXX: defer user.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... + user.SetID(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. + user.SetHighlight(user.Name()) + + // Should the user be op'd on join? + if h.isOp(term.Conn) { + h.Room.Ops.Add(set.Itemize(member.ID(), member)) + } + logger.Debugf("[%s] Joined: %s", term.Conn.RemoteAddr(), user.Name()) + + h.consumeScreen(term, user) + + // XXX: Move this into a user close thing err = h.Leave(user) if err != nil { logger.Errorf("[%s] Failed to leave: %s", term.Conn.RemoteAddr(), err) @@ -267,12 +286,13 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int, } // GetUser returns a message.User based on a name. -func (h *Host) GetUser(name string) (*message.User, bool) { +func (h *Host) GetUser(name string) (User, bool) { m, ok := h.MemberByID(name) if !ok { return nil, false } - return m.User, true + u, ok := m.Member.(User) + return u, ok } // InitCommands adds host-specific commands to a Commands container. These will @@ -318,7 +338,7 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("must specify message") } - target := msg.From().ReplyTo() + target := msg.From().(Member).ReplyTo() if target == nil { return errors.New("no message to reply to") } @@ -354,13 +374,12 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("user not found") } - id := target.Identifier.(*Identity) var whois string - switch room.IsOp(msg.From()) { + switch h.IsOp(msg.From()) { case true: - whois = id.WhoisAdmin() + whois = whoisAdmin(target) case false: - whois = id.Whois() + whois = whoisPublic(target) } room.Send(message.NewSystemMsg(whois, msg.From())) @@ -440,15 +459,16 @@ func (h *Host) InitCommands(c *chat.Commands) { until, _ = time.ParseDuration(args[1]) } - id := target.Identifier.(*Identity) - h.auth.Ban(id.PublicKey(), until) - h.auth.BanAddr(id.RemoteAddr(), until) + for _, conn := range target.Connections() { + h.auth.Ban(conn.PublicKey(), until) + h.auth.BanAddr(conn.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()) + logger.Debugf("Banned: \n-> %s", whoisAdmin(target)) return nil }, @@ -504,17 +524,18 @@ func (h *Host) InitCommands(c *chat.Commands) { until, _ = time.ParseDuration(args[1]) } - member, ok := room.MemberByID(args[0]) + user, ok := h.GetUser(args[0]) if !ok { return errors.New("user not found") } - room.Ops.Add(set.Itemize(member.ID(), member)) + room.Ops.Add(set.Itemize(user.ID(), user)) - id := member.Identifier.(*Identity) - h.auth.Op(id.PublicKey(), until) + for _, conn := range user.Connections() { + h.auth.Op(conn.PublicKey(), until) + } body := fmt.Sprintf("Made op by %s.", msg.From().Name()) - room.Send(message.NewSystemMsg(body, member.User)) + room.Send(message.NewSystemMsg(body, user)) return nil }, diff --git a/member.go b/member.go new file mode 100644 index 0000000..5a1b4e0 --- /dev/null +++ b/member.go @@ -0,0 +1,43 @@ +package sshchat + +import ( + "sync" + "time" + + "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/sshd" +) + +type client struct { + Member + sync.Mutex + conns []sshd.Connection +} + +func (cl *client) Connections() []sshd.Connection { + return cl.conns +} + +func (cl *client) Close() { + // TODO: Stack errors? + for _, conn := range cl.conns { + conn.Close() + } +} + +type Member interface { + chat.Member + + Joined() time.Time + Prompt() string + ReplyTo() message.Author + SetHighlight(string) error + SetReplyTo(message.Author) +} + +type User interface { + Member + + Connections() []sshd.Connection +} diff --git a/multiscreen.go b/multiscreen.go new file mode 100644 index 0000000..add3e71 --- /dev/null +++ b/multiscreen.go @@ -0,0 +1,48 @@ +package sshchat + +import ( + "io" + "sync" + + "github.com/shazow/ssh-chat/chat/message" +) + +type multiScreen struct { + *message.User + + mu sync.Mutex + writers []io.WriteCloser +} + +func (s *multiScreen) add(w io.WriteCloser) { + s.mu.Lock() + s.writers = append(s.writers, w) + s.mu.Unlock() +} + +func (s *multiScreen) Write(p []byte) (n int, err error) { + s.mu.Lock() + defer s.mu.Unlock() + for i, w := range s.writers { + n, err = w.Write(p) + if err == nil && n != len(p) { + err = io.ErrShortWrite + } + if err == nil { + continue + } + if err != nil && len(s.writers) == 1 { + // Once we're out of writers, fail. + return len(p), err + } + + // Remove faulty writer + w.Close() + s.writers[i] = s.writers[len(s.writers)-1] + s.writers[len(s.writers)-1] = nil + s.writers = s.writers[:len(s.writers)-1] + + // TODO: Emit error to a callback or something? + } + return len(p), nil +} diff --git a/whois.go b/whois.go new file mode 100644 index 0000000..4d42260 --- /dev/null +++ b/whois.go @@ -0,0 +1,41 @@ +package sshchat + +import ( + "net" + "time" + + "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/chat/message" + "github.com/shazow/ssh-chat/sshd" +) + +// Helpers for printing whois messages + +func whoisPublic(u User) string { + fingerprint := "(no public key)" + // FIXME: Use all connections? + conn := u.Connections()[0] + if conn.PublicKey() != nil { + fingerprint = sshd.Fingerprint(conn.PublicKey()) + } + + return "name: " + u.Name() + message.Newline + + " > fingerprint: " + fingerprint + message.Newline + + " > client: " + chat.SanitizeData(string(conn.ClientVersion())) + message.Newline + + " > joined: " + humanSince(time.Since(u.Joined())) + " ago" +} + +func whoisAdmin(u User) string { + // FIXME: Use all connections? + conn := u.Connections()[0] + ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) + fingerprint := "(no public key)" + if conn.PublicKey() != nil { + fingerprint = sshd.Fingerprint(conn.PublicKey()) + } + return "name: " + u.Name() + message.Newline + + " > ip: " + ip + message.Newline + + " > fingerprint: " + fingerprint + message.Newline + + " > client: " + chat.SanitizeData(string(conn.ClientVersion())) + message.Newline + + " > joined: " + humanSince(time.Since(u.Joined())) +}