From 4e9bd419b03f359d71facd858e2bac8e34e578f3 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 19 Dec 2014 20:04:40 -0800 Subject: [PATCH 01/60] Close connection on channel close? --- client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/client.go b/client.go index 189afcf..1d19e76 100644 --- a/client.go +++ b/client.go @@ -190,6 +190,7 @@ func (c *Client) Emote(message string) { func (c *Client) handleShell(channel ssh.Channel) { defer channel.Close() + defer c.Conn.Close() // FIXME: This shouldn't live here, need to restructure the call chaining. c.Server.Add(c) From 4c8d73b9323e6b4fad09ebb7bd48cb34f2b5edc2 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 20 Dec 2014 16:45:10 -0800 Subject: [PATCH 02/60] Framework for server-agnostic chat. --- chat/channel.go | 44 ++++++++++++++ chat/history.go | 58 ++++++++++++++++++ chat/history_test.go | 65 ++++++++++++++++++++ chat/message.go | 55 +++++++++++++++++ chat/set.go | 106 ++++++++++++++++++++++++++++++++ chat/set_test.go | 38 ++++++++++++ chat/theme.go | 141 +++++++++++++++++++++++++++++++++++++++++++ chat/user.go | 67 ++++++++++++++++++++ 8 files changed, 574 insertions(+) create mode 100644 chat/channel.go create mode 100644 chat/history.go create mode 100644 chat/history_test.go create mode 100644 chat/message.go create mode 100644 chat/set.go create mode 100644 chat/set_test.go create mode 100644 chat/theme.go create mode 100644 chat/user.go diff --git a/chat/channel.go b/chat/channel.go new file mode 100644 index 0000000..b478c82 --- /dev/null +++ b/chat/channel.go @@ -0,0 +1,44 @@ +package chat + +const historyLen = 20 + +// Channel definition, also a Set of User Items +type Channel struct { + id string + topic string + history *History + users *Set + out chan<- Message +} + +func NewChannel(id string, out chan<- Message) *Channel { + ch := Channel{ + id: id, + out: out, + history: NewHistory(historyLen), + users: NewSet(), + } + return &ch +} + +func (ch *Channel) Send(m Message) { + ch.out <- m +} + +func (ch *Channel) Join(u *User) error { + err := ch.users.Add(u) + return err +} + +func (ch *Channel) Leave(u *User) error { + err := ch.users.Remove(u) + return err +} + +func (ch *Channel) Topic() string { + return ch.topic +} + +func (ch *Channel) SetTopic(s string) { + ch.topic = s +} diff --git a/chat/history.go b/chat/history.go new file mode 100644 index 0000000..b0f78a6 --- /dev/null +++ b/chat/history.go @@ -0,0 +1,58 @@ +package chat + +import "sync" + +// History contains the history entries +type History struct { + entries []interface{} + head int + size int + sync.RWMutex +} + +// NewHistory constructs a new history of the given size +func NewHistory(size int) *History { + return &History{ + entries: make([]interface{}, size), + } +} + +// Add adds the given entry to the entries in the history +func (h *History) Add(entry interface{}) { + h.Lock() + defer h.Unlock() + + max := cap(h.entries) + h.head = (h.head + 1) % max + h.entries[h.head] = entry + if h.size < max { + h.size++ + } +} + +// Len returns the number of entries in the history +func (h *History) Len() int { + return h.size +} + +// Get recent entries +func (h *History) Get(num int) []interface{} { + h.RLock() + defer h.RUnlock() + + max := cap(h.entries) + if num > h.size { + num = h.size + } + + r := make([]interface{}, num) + for i := 0; i < num; i++ { + idx := (h.head - i) % max + if idx < 0 { + idx += max + } + r[num-i-1] = h.entries[idx] + } + + return r +} diff --git a/chat/history_test.go b/chat/history_test.go new file mode 100644 index 0000000..fb166c4 --- /dev/null +++ b/chat/history_test.go @@ -0,0 +1,65 @@ +package chat + +import "testing" + +func equal(a []interface{}, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := 0; i < len(a); i++ { + if a[0] != b[0] { + return false + } + } + + return true +} + +func TestHistory(t *testing.T) { + var r []interface{} + var expected []string + var size int + + h := NewHistory(5) + + r = h.Get(10) + expected = []string{} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } + + h.Add("1") + + if size = h.Len(); size != 1 { + t.Errorf("Wrong size: %v", size) + } + + r = h.Get(1) + expected = []string{"1"} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } + + h.Add("2") + h.Add("3") + h.Add("4") + h.Add("5") + h.Add("6") + + if size = h.Len(); size != 5 { + t.Errorf("Wrong size: %v", size) + } + + r = h.Get(2) + expected = []string{"5", "6"} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } + + r = h.Get(10) + expected = []string{"2", "3", "4", "5", "6"} + if !equal(r, expected) { + t.Errorf("Got: %v, Expected: %v", r, expected) + } +} diff --git a/chat/message.go b/chat/message.go new file mode 100644 index 0000000..6050e83 --- /dev/null +++ b/chat/message.go @@ -0,0 +1,55 @@ +package chat + +import ( + "fmt" + "time" +) + +// Container for messages sent to chat +type Message struct { + Body string + from *User // Not set for Sys messages + to *User // Only set for PMs + timestamp time.Time + themeCache *map[*Theme]string +} + +func NewMessage(from *User, body string) *Message { + m := Message{ + Body: body, + from: from, + timestamp: time.Now(), + } + return &m +} + +// Set message recipient +func (m *Message) To(u *User) *Message { + m.to = u + return m +} + +// Set message sender +func (m *Message) From(u *User) *Message { + m.from = u + return m +} + +// Render message based on the given theme +func (m *Message) Render(*Theme) string { + // TODO: Render based on theme. + // TODO: Cache based on theme + var msg string + if m.to != nil { + msg = fmt.Sprintf("[PM from %s] %s", m.to, m.Body) + } else if m.from != nil { + msg = fmt.Sprintf("%s: %s", m.from, m.Body) + } else { + msg = fmt.Sprintf(" * %s", m.Body) + } + return msg +} + +func (m *Message) String() string { + return m.Render(nil) +} diff --git a/chat/set.go b/chat/set.go new file mode 100644 index 0000000..d4442d7 --- /dev/null +++ b/chat/set.go @@ -0,0 +1,106 @@ +package chat + +import ( + "errors" + "strings" + "sync" +) + +var ErrIdTaken error = errors.New("id already taken") +var ErrItemMissing error = errors.New("item does not exist") + +// Unique identifier for an item +type Id string + +// A prefix for a unique identifier +type IdPrefix Id + +// An interface for items to store-able in the set +type Item interface { + Id() Id +} + +// Set with string lookup +// TODO: Add trie for efficient prefix lookup? +type Set struct { + lookup map[Id]Item + sync.RWMutex +} + +// Create a new set +func NewSet() *Set { + return &Set{ + lookup: map[Id]Item{}, + } +} + +// Size of the set right now +func (s *Set) Len() int { + return len(s.lookup) +} + +// Check if user belongs in this set +func (s *Set) In(item Item) bool { + s.RLock() + _, ok := s.lookup[item.Id()] + s.RUnlock() + return ok +} + +// Get user by name +func (s *Set) Get(id Id) (Item, error) { + s.RLock() + item, ok := s.lookup[id] + s.RUnlock() + + if !ok { + return nil, ErrItemMissing + } + + return item, nil +} + +// Add user to set if user does not exist already +func (s *Set) Add(item Item) error { + s.Lock() + defer s.Unlock() + + _, found := s.lookup[item.Id()] + if found { + return ErrIdTaken + } + + s.lookup[item.Id()] = item + return nil +} + +// Remove user from set +func (s *Set) Remove(item Item) error { + s.Lock() + defer s.Unlock() + id := item.Id() + _, found := s.lookup[id] + if found { + return ErrItemMissing + } + delete(s.lookup, id) + return nil +} + +// List users by prefix, case insensitive +func (s *Set) ListPrefix(prefix string) []Item { + r := []Item{} + prefix = strings.ToLower(prefix) + + s.RLock() + defer s.RUnlock() + + for id, item := range s.lookup { + if !strings.HasPrefix(string(id), prefix) { + continue + } + r = append(r, item) + } + + return r +} diff --git a/chat/set_test.go b/chat/set_test.go new file mode 100644 index 0000000..60038c1 --- /dev/null +++ b/chat/set_test.go @@ -0,0 +1,38 @@ +package chat + +import "testing" + +func TestSet(t *testing.T) { + var err error + s := NewSet() + u := NewUser("foo") + + if s.In(u) { + t.Errorf("Set should be empty.") + } + + err = s.Add(u) + if err != nil { + t.Error(err) + } + + if !s.In(u) { + t.Errorf("Set should contain user.") + } + + u2 := NewUser("bar") + err = s.Add(u2) + if err != nil { + t.Error(err) + } + + err = s.Add(u2) + if err != ErrIdTaken { + t.Error(err) + } + + size := s.Len() + if size != 2 { + t.Errorf("Set wrong size: %d (expected %d)", size, 2) + } +} diff --git a/chat/theme.go b/chat/theme.go new file mode 100644 index 0000000..d187256 --- /dev/null +++ b/chat/theme.go @@ -0,0 +1,141 @@ +package chat + +import "fmt" + +const ( + // Reset resets the color + Reset = "\033[0m" + + // Bold makes the following text bold + Bold = "\033[1m" + + // Dim dims the following text + Dim = "\033[2m" + + // Italic makes the following text italic + Italic = "\033[3m" + + // Underline underlines the following text + Underline = "\033[4m" + + // Blink blinks the following text + Blink = "\033[5m" + + // Invert inverts the following text + Invert = "\033[7m" +) + +// Interface for Colors +type Color interface { + String() string + Format(string) string +} + +// 256 color type, for terminals who support it +type Color256 int8 + +// String version of this color +func (c Color256) String() string { + return fmt.Sprintf("38;05;%d", c) +} + +// Return formatted string with this color +func (c Color256) Format(s string) string { + return "\033[" + c.String() + "m" + s + Reset +} + +// No color, used for mono theme +type Color0 struct{} + +// No-op for Color0 +func (c Color0) String() string { + return "" +} + +// No-op for Color0 +func (c Color0) Format(s string) string { + return s +} + +// Container for a collection of colors +type Palette struct { + colors []Color + size int +} + +// Get a color by index, overflows are looped around. +func (p Palette) Get(i int) Color { + return p.colors[i%p.size] +} + +// Collection of settings for chat +type Theme struct { + id string + sys Color + pm Color + names *Palette +} + +// Colorize name string given some index +func (t Theme) ColorName(s string, i int) string { + if t.names == nil { + return s + } + + return t.names.Get(i).Format(s) +} + +// Colorize the PM string +func (t Theme) ColorPM(s string) string { + if t.pm == nil { + return s + } + + return t.pm.Format(s) +} + +// Colorize the Sys message +func (t Theme) ColorSys(s string) string { + if t.sys == nil { + return s + } + + return t.sys.Format(s) +} + +// List of initialzied themes +var Themes []Theme + +// Default theme to use +var DefaultTheme *Theme + +func readableColors256() *Palette { + p := Palette{ + colors: make([]Color, 246), + size: 246, + } + for i := 0; i < 256; i++ { + if (16 <= i && i <= 18) || (232 <= i && i <= 237) { + // Remove the ones near black, this is kinda sadpanda. + continue + } + p.colors = append(p.colors, Color256(i)) + } + return &p +} + +func init() { + palette := readableColors256() + + Themes = []Theme{ + Theme{ + id: "colors", + names: palette, + }, + Theme{ + id: "mono", + }, + } + + DefaultTheme = &Themes[0] +} diff --git a/chat/user.go b/chat/user.go new file mode 100644 index 0000000..97acb8b --- /dev/null +++ b/chat/user.go @@ -0,0 +1,67 @@ +package chat + +import ( + "math/rand" + "time" +) + +// User definition, implemented set Item interface +type User struct { + name string + op bool + colorIdx int + joined time.Time + Config UserConfig +} + +func NewUser(name string) *User { + u := User{Config: *DefaultUserConfig} + u.SetName(name) + return &u +} + +// Return unique identifier for user +func (u *User) Id() Id { + return Id(u.name) +} + +// Return user's name +func (u *User) Name() string { + return u.name +} + +// Return set user's name +func (u *User) SetName(name string) { + u.name = name + u.colorIdx = rand.Int() +} + +// Return whether user is an admin +func (u *User) Op() bool { + return u.op +} + +// Set whether user is an admin +func (u *User) SetOp(op bool) { + u.op = op +} + +// Container for per-user configurations. +type UserConfig struct { + Highlight bool + Bell bool + Theme *Theme +} + +// Default user configuration to use +var DefaultUserConfig *UserConfig + +func init() { + DefaultUserConfig = &UserConfig{ + Highlight: true, + Bell: false, + Theme: DefaultTheme, + } + + // TODO: Seed random? +} From 1652511bf239a6c8c32c1654df7b5ac2aea45711 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 20 Dec 2014 20:21:41 -0800 Subject: [PATCH 03/60] Progress, most of this probably doesnt work. --- chat/channel.go | 32 +++++++++++++++++---------- chat/channel_test.go | 27 +++++++++++++++++++++++ chat/command.go | 52 ++++++++++++++++++++++++++++++++++++++++++++ chat/host.go | 43 ++++++++++++++++++++++++++++++++++++ chat/message.go | 26 +++++++++++++++++----- chat/set_test.go | 4 ++-- chat/user.go | 17 ++++++++++++--- chat/user_test.go | 26 ++++++++++++++++++++++ 8 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 chat/channel_test.go create mode 100644 chat/command.go create mode 100644 chat/host.go create mode 100644 chat/user_test.go diff --git a/chat/channel.go b/chat/channel.go index b478c82..d6b1393 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -1,37 +1,47 @@ package chat +import "fmt" + const historyLen = 20 // Channel definition, also a Set of User Items type Channel struct { - id string - topic string - history *History - users *Set - out chan<- Message + id string + topic string + history *History + users *Set + broadcast chan<- Message } -func NewChannel(id string, out chan<- Message) *Channel { +func NewChannel(id string, broadcast chan<- Message) *Channel { ch := Channel{ - id: id, - out: out, - history: NewHistory(historyLen), - users: NewSet(), + id: id, + broadcast: broadcast, + history: NewHistory(historyLen), + users: NewSet(), } return &ch } func (ch *Channel) Send(m Message) { - ch.out <- m + ch.broadcast <- m } func (ch *Channel) Join(u *User) error { err := ch.users.Add(u) + if err != nil { + s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.users.Len()) + ch.Send(*NewMessage(s)) + } return err } func (ch *Channel) Leave(u *User) error { err := ch.users.Remove(u) + if err != nil { + s := fmt.Sprintf("%s left.", u.Name()) + ch.Send(*NewMessage(s)) + } return err } diff --git a/chat/channel_test.go b/chat/channel_test.go new file mode 100644 index 0000000..53debc9 --- /dev/null +++ b/chat/channel_test.go @@ -0,0 +1,27 @@ +package chat + +import ( + "reflect" + "testing" +) + +func TestChannel(t *testing.T) { + s := &MockScreen{} + out := make(chan Message) + defer close(out) + + go func() { + for msg := range out { + s.Write([]byte(msg.Render(nil))) + } + }() + + u := NewUser("foo", s) + ch := NewChannel("", out) + ch.Join(u) + + expected := []byte(" * foo joined. (Connected: 1)") + if !reflect.DeepEqual(s.received, expected) { + t.Errorf("Got: %s, Expected: %s", s.received, expected) + } +} diff --git a/chat/command.go b/chat/command.go new file mode 100644 index 0000000..73ffa2f --- /dev/null +++ b/chat/command.go @@ -0,0 +1,52 @@ +package chat + +import ( + "errors" + "strings" +) + +var ErrInvalidCommand error = errors.New("invalid command") +var ErrNoOwner error = errors.New("command without owner") + +type CmdHandler func(host Host, msg Message, args []string) error + +type Commands map[string]CmdHandler + +// Register command +func (c Commands) Add(cmd string, handler CmdHandler) { + c[cmd] = handler +} + +// Execute command message, assumes IsCommand was checked +func (c Commands) Run(host Host, msg Message) error { + if msg.from == nil { + return ErrNoOwner + } + + cmd, args := msg.ParseCommand() + handler, ok := c[cmd] + if !ok { + return ErrInvalidCommand + } + + return handler(host, msg, args) +} + +var defaultCmdHandlers Commands + +func init() { + c := Commands{} + + c.Add("me", func(host Host, msg Message, args []string) error { + me := strings.TrimLeft(msg.Body, "/me") + if me == "" { + me = " is at a loss for words." + } + + // XXX: Finish this. + + return nil + }) + + defaultCmdHandlers = c +} diff --git a/chat/host.go b/chat/host.go new file mode 100644 index 0000000..bb20571 --- /dev/null +++ b/chat/host.go @@ -0,0 +1,43 @@ +package chat + +const messageBuffer = 20 + +// Host knows about all the commands and channels. +type Host struct { + defaultChannel *Channel + commands Commands + done chan struct{} +} + +func NewHost() *Host { + h := Host{ + commands: defaultCmdHandlers, + } + h.defaultChannel = h.CreateChannel("") + return &h +} + +func (h *Host) handleCommand(m Message) { + // TODO: ... +} + +func (h *Host) broadcast(ch *Channel, m Message) { + // TODO: ... +} + +func (h *Host) CreateChannel(id string) *Channel { + out := make(chan Message, 20) + ch := NewChannel(id, out) + + go func() { + for msg := range out { + if msg.IsCommand() { + go h.handleCommand(msg) + continue + } + h.broadcast(ch, msg) + } + }() + + return ch +} diff --git a/chat/message.go b/chat/message.go index 6050e83..04d816f 100644 --- a/chat/message.go +++ b/chat/message.go @@ -2,6 +2,7 @@ package chat import ( "fmt" + "strings" "time" ) @@ -14,10 +15,9 @@ type Message struct { themeCache *map[*Theme]string } -func NewMessage(from *User, body string) *Message { +func NewMessage(body string) *Message { m := Message{ Body: body, - from: from, timestamp: time.Now(), } return &m @@ -37,19 +37,35 @@ func (m *Message) From(u *User) *Message { // Render message based on the given theme func (m *Message) Render(*Theme) string { - // TODO: Render based on theme. + // TODO: Render based on theme // TODO: Cache based on theme var msg string - if m.to != nil { - msg = fmt.Sprintf("[PM from %s] %s", m.to, m.Body) + if m.to != nil && m.from != nil { + msg = fmt.Sprintf("[PM from %s] %s", m.from, m.Body) } else if m.from != nil { msg = fmt.Sprintf("%s: %s", m.from, m.Body) + } else if m.to != nil { + msg = fmt.Sprintf("-> %s", m.Body) } else { msg = fmt.Sprintf(" * %s", m.Body) } return msg } +// Render message without a theme func (m *Message) String() string { return m.Render(nil) } + +// Wether message is a command (starts with /) +func (m *Message) IsCommand() bool { + return strings.HasPrefix(m.Body, "/") +} + +// Parse command (assumes IsCommand was already called) +func (m *Message) ParseCommand() (string, []string) { + // TODO: Tokenize this properly, to support quoted args? + cmd := strings.Split(m.Body, " ") + args := cmd[1:] + return cmd[0][1:], args +} diff --git a/chat/set_test.go b/chat/set_test.go index 60038c1..b55972b 100644 --- a/chat/set_test.go +++ b/chat/set_test.go @@ -5,7 +5,7 @@ import "testing" func TestSet(t *testing.T) { var err error s := NewSet() - u := NewUser("foo") + u := NewUser("foo", nil) if s.In(u) { t.Errorf("Set should be empty.") @@ -20,7 +20,7 @@ func TestSet(t *testing.T) { t.Errorf("Set should contain user.") } - u2 := NewUser("bar") + u2 := NewUser("bar", nil) err = s.Add(u2) if err != nil { t.Error(err) diff --git a/chat/user.go b/chat/user.go index 97acb8b..946c371 100644 --- a/chat/user.go +++ b/chat/user.go @@ -1,21 +1,27 @@ package chat import ( + "io" "math/rand" "time" ) -// User definition, implemented set Item interface +// User definition, implemented set Item interface and io.Writer type User struct { name string op bool colorIdx int joined time.Time + screen io.Writer Config UserConfig } -func NewUser(name string) *User { - u := User{Config: *DefaultUserConfig} +func NewUser(name string, screen io.Writer) *User { + u := User{ + screen: screen, + joined: time.Now(), + Config: *DefaultUserConfig, + } u.SetName(name) return &u } @@ -46,6 +52,11 @@ func (u *User) SetOp(op bool) { u.op = op } +// Write to user's screen +func (u *User) Write(p []byte) (n int, err error) { + return u.screen.Write(p) +} + // Container for per-user configurations. type UserConfig struct { Highlight bool diff --git a/chat/user_test.go b/chat/user_test.go new file mode 100644 index 0000000..1f0c5cd --- /dev/null +++ b/chat/user_test.go @@ -0,0 +1,26 @@ +package chat + +import ( + "reflect" + "testing" +) + +type MockScreen struct { + received []byte +} + +func (s *MockScreen) Write(data []byte) (n int, err error) { + s.received = append(s.received, data...) + return len(data), nil +} + +func TestMakeUser(t *testing.T) { + s := &MockScreen{} + u := NewUser("foo", s) + + line := []byte("hello") + u.Write(line) + if !reflect.DeepEqual(s.received, line) { + t.Errorf("Expected hello but got: %s", s.received) + } +} From bf3cc264e69eee6654e6586c85e7fba2c73f3832 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 21 Dec 2014 10:17:14 -0800 Subject: [PATCH 04/60] A bit of logging framework, and channel test. --- chat/channel.go | 14 ++++++++------ chat/channel_test.go | 10 +++++++--- chat/doc.go | 13 +++++++++++++ chat/logger.go | 22 ++++++++++++++++++++++ 4 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 chat/doc.go create mode 100644 chat/logger.go diff --git a/chat/channel.go b/chat/channel.go index d6b1393..f26dbf2 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -30,19 +30,21 @@ func (ch *Channel) Send(m Message) { func (ch *Channel) Join(u *User) error { err := ch.users.Add(u) if err != nil { - s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.users.Len()) - ch.Send(*NewMessage(s)) + return err } - return err + s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.users.Len()) + ch.Send(*NewMessage(s)) + return nil } func (ch *Channel) Leave(u *User) error { err := ch.users.Remove(u) if err != nil { - s := fmt.Sprintf("%s left.", u.Name()) - ch.Send(*NewMessage(s)) + return err } - return err + s := fmt.Sprintf("%s left.", u.Name()) + ch.Send(*NewMessage(s)) + return nil } func (ch *Channel) Topic() string { diff --git a/chat/channel_test.go b/chat/channel_test.go index 53debc9..b52577c 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -8,20 +8,24 @@ import ( func TestChannel(t *testing.T) { s := &MockScreen{} out := make(chan Message) - defer close(out) go func() { for msg := range out { + t.Logf("Broadcasted: %s", msg.String()) s.Write([]byte(msg.Render(nil))) } }() u := NewUser("foo", s) ch := NewChannel("", out) - ch.Join(u) + err := ch.Join(u) + + if err != nil { + t.Error(err) + } expected := []byte(" * foo joined. (Connected: 1)") if !reflect.DeepEqual(s.received, expected) { - t.Errorf("Got: %s, Expected: %s", s.received, expected) + t.Errorf("Got: `%s`, Expected: `%s`", s.received, expected) } } diff --git a/chat/doc.go b/chat/doc.go new file mode 100644 index 0000000..7c80e02 --- /dev/null +++ b/chat/doc.go @@ -0,0 +1,13 @@ +/* +`chat` package is a server-agnostic implementation of a chat interface, built +with the intention of using with the intention of using as the backend for +ssh-chat. + +This package should not know anything about sockets. It should expose io-style +interfaces and channels for communicating with any method of transnport. + +TODO: Add usage examples here. + +*/ + +package chat diff --git a/chat/logger.go b/chat/logger.go new file mode 100644 index 0000000..93b2761 --- /dev/null +++ b/chat/logger.go @@ -0,0 +1,22 @@ +package chat + +import "io" +import stdlog "log" + +var logger *stdlog.Logger + +func SetLogger(w io.Writer) { + flags := stdlog.Flags() + prefix := "[chat] " + logger = stdlog.New(w, prefix, flags) +} + +type nullWriter struct{} + +func (nullWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +func init() { + SetLogger(nullWriter{}) +} From 137e84db79d3c1a8f68936630fa3b4237bf5d40d Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 21 Dec 2014 12:17:01 -0800 Subject: [PATCH 05/60] Messing with the API more, tests pass. --- chat/channel_test.go | 4 +-- chat/host.go | 2 -- chat/message.go | 1 + chat/set_test.go | 4 +-- chat/user.go | 69 +++++++++++++++++++++++++++++++++++++++----- chat/user_test.go | 13 +++++---- 6 files changed, 75 insertions(+), 18 deletions(-) diff --git a/chat/channel_test.go b/chat/channel_test.go index b52577c..7bda641 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -16,7 +16,7 @@ func TestChannel(t *testing.T) { } }() - u := NewUser("foo", s) + u := NewUserScreen("foo", s) ch := NewChannel("", out) err := ch.Join(u) @@ -26,6 +26,6 @@ func TestChannel(t *testing.T) { expected := []byte(" * foo joined. (Connected: 1)") if !reflect.DeepEqual(s.received, expected) { - t.Errorf("Got: `%s`, Expected: `%s`", s.received, expected) + t.Errorf("Got: `%s`; Expected: `%s`", s.received, expected) } } diff --git a/chat/host.go b/chat/host.go index bb20571..ce8c905 100644 --- a/chat/host.go +++ b/chat/host.go @@ -1,7 +1,5 @@ package chat -const messageBuffer = 20 - // Host knows about all the commands and channels. type Host struct { defaultChannel *Channel diff --git a/chat/message.go b/chat/message.go index 04d816f..5e51b2c 100644 --- a/chat/message.go +++ b/chat/message.go @@ -37,6 +37,7 @@ func (m *Message) From(u *User) *Message { // Render message based on the given theme func (m *Message) Render(*Theme) string { + // TODO: Return []byte? // TODO: Render based on theme // TODO: Cache based on theme var msg string diff --git a/chat/set_test.go b/chat/set_test.go index b55972b..60038c1 100644 --- a/chat/set_test.go +++ b/chat/set_test.go @@ -5,7 +5,7 @@ import "testing" func TestSet(t *testing.T) { var err error s := NewSet() - u := NewUser("foo", nil) + u := NewUser("foo") if s.In(u) { t.Errorf("Set should be empty.") @@ -20,7 +20,7 @@ func TestSet(t *testing.T) { t.Errorf("Set should contain user.") } - u2 := NewUser("bar", nil) + u2 := NewUser("bar") err = s.Add(u2) if err != nil { t.Error(err) diff --git a/chat/user.go b/chat/user.go index 946c371..cf157ad 100644 --- a/chat/user.go +++ b/chat/user.go @@ -1,31 +1,46 @@ package chat import ( + "errors" "io" "math/rand" "time" ) +const messageBuffer = 20 + +var ErrUserClosed = errors.New("user closed") + // User definition, implemented set Item interface and io.Writer type User struct { name string op bool colorIdx int joined time.Time - screen io.Writer + msg chan Message + done chan struct{} Config UserConfig } -func NewUser(name string, screen io.Writer) *User { +func NewUser(name string) *User { u := User{ - screen: screen, - joined: time.Now(), Config: *DefaultUserConfig, + joined: time.Now(), + msg: make(chan Message, messageBuffer), + done: make(chan struct{}, 1), } u.SetName(name) + return &u } +func NewUserScreen(name string, screen io.Writer) *User { + u := NewUser(name) + go u.Consume(screen) + + return u +} + // Return unique identifier for user func (u *User) Id() Id { return Id(u.name) @@ -52,9 +67,49 @@ func (u *User) SetOp(op bool) { u.op = op } -// Write to user's screen -func (u *User) Write(p []byte) (n int, err error) { - return u.screen.Write(p) +// Block until user is closed +func (u *User) Wait() { + <-u.done +} + +// Disconnect user, stop accepting messages +func (u *User) Close() { + close(u.done) + close(u.msg) +} + +// Consume message buffer into an io.Writer. Will block, should be called in a +// goroutine. +func (u *User) Consume(out io.Writer) { + for m := range u.msg { + u.consumeMsg(m, out) + } +} + +// Consume one message and stop, mostly for testing +func (u *User) ConsumeOne(out io.Writer) { + u.consumeMsg(<-u.msg, out) +} + +func (u *User) consumeMsg(m Message, out io.Writer) { + s := m.Render(u.Config.Theme) + _, err := out.Write([]byte(s)) + if err != nil { + logger.Printf("Write failed to %s, closing: %s", u.Name(), err) + u.Close() + } +} + +// Add message to consume by user +func (u *User) Send(m Message) error { + select { + case u.msg <- m: + default: + logger.Printf("Msg buffer full, closing: %s", u.Name()) + u.Close() + return ErrUserClosed + } + return nil } // Container for per-user configurations. diff --git a/chat/user_test.go b/chat/user_test.go index 1f0c5cd..034b697 100644 --- a/chat/user_test.go +++ b/chat/user_test.go @@ -16,11 +16,14 @@ func (s *MockScreen) Write(data []byte) (n int, err error) { func TestMakeUser(t *testing.T) { s := &MockScreen{} - u := NewUser("foo", s) + u := NewUser("foo") + m := NewMessage("hello") - line := []byte("hello") - u.Write(line) - if !reflect.DeepEqual(s.received, line) { - t.Errorf("Expected hello but got: %s", s.received) + defer u.Close() + u.Send(*m) + u.ConsumeOne(s) + + if !reflect.DeepEqual(string(s.received), m.String()) { + t.Errorf("Got: `%s`; Expected: `%s`", s.received, m.String()) } } From bcfacb89b12ffb2c578969a791cb37ab5ebfb1d5 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 21 Dec 2014 14:24:03 -0800 Subject: [PATCH 06/60] Fix message rendering, tests pass. --- chat/channel_test.go | 31 ++++++++++++++++++++------- chat/message.go | 4 ++-- chat/screen_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++ chat/user.go | 2 +- chat/user_test.go | 17 ++++++--------- 5 files changed, 83 insertions(+), 22 deletions(-) create mode 100644 chat/screen_test.go diff --git a/chat/channel_test.go b/chat/channel_test.go index 7bda641..5ff2fb4 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -6,26 +6,41 @@ import ( ) func TestChannel(t *testing.T) { - s := &MockScreen{} + var expected, actual []byte + out := make(chan Message) + defer close(out) + + s := &MockScreen{} + u := NewUser("foo") go func() { for msg := range out { - t.Logf("Broadcasted: %s", msg.String()) - s.Write([]byte(msg.Render(nil))) + t.Logf("Broadcasted: ", msg.String()) + u.Send(msg) } }() - u := NewUserScreen("foo", s) ch := NewChannel("", out) err := ch.Join(u) - if err != nil { t.Error(err) } - expected := []byte(" * foo joined. (Connected: 1)") - if !reflect.DeepEqual(s.received, expected) { - t.Errorf("Got: `%s`; Expected: `%s`", s.received, expected) + u.ConsumeOne(s) + expected = []byte(" * foo joined. (Connected: 1)") + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + m := NewMessage("hello").From(u) + ch.Send(*m) + + u.ConsumeOne(s) + expected = []byte("foo: hello") + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } } diff --git a/chat/message.go b/chat/message.go index 5e51b2c..aee5d38 100644 --- a/chat/message.go +++ b/chat/message.go @@ -42,9 +42,9 @@ func (m *Message) Render(*Theme) string { // TODO: Cache based on theme var msg string if m.to != nil && m.from != nil { - msg = fmt.Sprintf("[PM from %s] %s", m.from, m.Body) + msg = fmt.Sprintf("[PM from %s] %s", m.from.Name(), m.Body) } else if m.from != nil { - msg = fmt.Sprintf("%s: %s", m.from, m.Body) + msg = fmt.Sprintf("%s: %s", m.from.Name(), m.Body) } else if m.to != nil { msg = fmt.Sprintf("-> %s", m.Body) } else { diff --git a/chat/screen_test.go b/chat/screen_test.go new file mode 100644 index 0000000..c530f94 --- /dev/null +++ b/chat/screen_test.go @@ -0,0 +1,51 @@ +package chat + +import ( + "reflect" + "testing" +) + +// Used for testing +type MockScreen struct { + buffer []byte +} + +func (s *MockScreen) Write(data []byte) (n int, err error) { + s.buffer = append(s.buffer, data...) + return len(data), nil +} + +func (s *MockScreen) Read(p *[]byte) (n int, err error) { + *p = s.buffer + s.buffer = []byte{} + return len(*p), nil +} + +func TestScreen(t *testing.T) { + var actual, expected []byte + + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %v; Expected: %v", actual, expected) + } + + actual = []byte("foo") + expected = []byte("foo") + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %v; Expected: %v", actual, expected) + } + + s := &MockScreen{} + + expected = nil + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %v; Expected: %v", actual, expected) + } + + expected = []byte("hello, world") + s.Write(expected) + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: %v; Expected: %v", actual, expected) + } +} diff --git a/chat/user.go b/chat/user.go index cf157ad..ffc4e61 100644 --- a/chat/user.go +++ b/chat/user.go @@ -80,6 +80,7 @@ func (u *User) Close() { // Consume message buffer into an io.Writer. Will block, should be called in a // goroutine. +// TODO: Not sure if this is a great API. func (u *User) Consume(out io.Writer) { for m := range u.msg { u.consumeMsg(m, out) @@ -126,7 +127,6 @@ func init() { DefaultUserConfig = &UserConfig{ Highlight: true, Bell: false, - Theme: DefaultTheme, } // TODO: Seed random? diff --git a/chat/user_test.go b/chat/user_test.go index 034b697..390f12d 100644 --- a/chat/user_test.go +++ b/chat/user_test.go @@ -5,16 +5,9 @@ import ( "testing" ) -type MockScreen struct { - received []byte -} - -func (s *MockScreen) Write(data []byte) (n int, err error) { - s.received = append(s.received, data...) - return len(data), nil -} - func TestMakeUser(t *testing.T) { + var actual, expected []byte + s := &MockScreen{} u := NewUser("foo") m := NewMessage("hello") @@ -23,7 +16,9 @@ func TestMakeUser(t *testing.T) { u.Send(*m) u.ConsumeOne(s) - if !reflect.DeepEqual(string(s.received), m.String()) { - t.Errorf("Got: `%s`; Expected: `%s`", s.received, m.String()) + s.Read(&actual) + expected = []byte(m.String()) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } } From 54b593ed476bae09a2926ef0d3eaf42ed36afe7c Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 21 Dec 2014 14:30:42 -0800 Subject: [PATCH 07/60] Message tests. --- chat/message_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 chat/message_test.go diff --git a/chat/message_test.go b/chat/message_test.go new file mode 100644 index 0000000..120a7ca --- /dev/null +++ b/chat/message_test.go @@ -0,0 +1,34 @@ +package chat + +import "testing" + +func TestMessage(t *testing.T) { + var expected, actual string + + expected = " * foo" + actual = NewMessage("foo").String() + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + u := NewUser("foo") + expected = "foo: hello" + actual = NewMessage("hello").From(u).String() + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + expected = "-> hello" + actual = NewMessage("hello").To(u).String() + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + expected = "[PM from foo] hello" + actual = NewMessage("hello").From(u).To(u).String() + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } +} + +// TODO: Add theme rendering tests From 59ac8bb0378706e8b12d96b415f3bcf4ac113f9a Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Mon, 22 Dec 2014 14:26:26 -0800 Subject: [PATCH 08/60] sshd abstraction might be done, untested. --- cmd.go | 1 + sshd/auth.go | 68 +++++++++++++++++++++++++++++++++ sshd/doc.go | 33 ++++++++++++++++ sshd/logger.go | 22 +++++++++++ sshd/multi.go | 42 +++++++++++++++++++++ sshd/net.go | 68 +++++++++++++++++++++++++++++++++ sshd/pty.go | 69 ++++++++++++++++++++++++++++++++++ sshd/server.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ sshd/terminal.go | 93 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 494 insertions(+) create mode 100644 sshd/auth.go create mode 100644 sshd/doc.go create mode 100644 sshd/logger.go create mode 100644 sshd/multi.go create mode 100644 sshd/net.go create mode 100644 sshd/pty.go create mode 100644 sshd/server.go create mode 100644 sshd/terminal.go diff --git a/cmd.go b/cmd.go index 34840ad..89ad01a 100644 --- a/cmd.go +++ b/cmd.go @@ -34,6 +34,7 @@ var logLevels = []log.Level{ } var buildCommit string + func main() { options := Options{} parser := flags.NewParser(&options, flags.Default) diff --git a/sshd/auth.go b/sshd/auth.go new file mode 100644 index 0000000..d271a85 --- /dev/null +++ b/sshd/auth.go @@ -0,0 +1,68 @@ +package sshd + +import ( + "crypto/sha1" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/ssh" +) + +var errBanned = errors.New("banned") +var errNotWhitelisted = errors.New("not whitelisted") +var errNoInteractive = errors.New("public key authentication required") + +type Auth interface { + IsBanned(ssh.PublicKey) bool + IsWhitelisted(ssh.PublicKey) bool +} + +func MakeAuth(auth Auth) *ssh.ServerConfig { + config := ssh.ServerConfig{ + NoClientAuth: false, + // Auth-related things should be constant-time to avoid timing attacks. + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if auth.IsBanned(key) { + return nil, errBanned + } + if !auth.IsWhitelisted(key) { + return nil, errNotWhitelisted + } + perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": Fingerprint(key)}} + return perm, nil + }, + KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { + if auth.IsBanned(nil) { + return nil, errNoInteractive + } + if !auth.IsWhitelisted(nil) { + return nil, errNotWhitelisted + } + return nil, nil + }, + } + + return &config +} + +func MakeNoAuth() *ssh.ServerConfig { + config := ssh.ServerConfig{ + NoClientAuth: false, + // Auth-related things should be constant-time to avoid timing attacks. + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + return nil, nil + }, + KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { + return nil, nil + }, + } + + return &config +} + +func Fingerprint(k ssh.PublicKey) string { + hash := sha1.Sum(k.Marshal()) + r := fmt.Sprintf("% x", hash) + return strings.Replace(r, " ", ":", -1) +} diff --git a/sshd/doc.go b/sshd/doc.go new file mode 100644 index 0000000..4d94d8b --- /dev/null +++ b/sshd/doc.go @@ -0,0 +1,33 @@ +package sshd + +/* + + signer, err := ssh.ParsePrivateKey(privateKey) + + config := MakeNoAuth() + config.AddHostKey(signer) + + s, err := ListenSSH("0.0.0.0:22", config) + if err != nil { + // Handle opening socket error + } + + terminals := s.ServeTerminal() + + for term := range terminals { + go func() { + defer term.Close() + term.SetPrompt("...") + term.AutoCompleteCallback = nil // ... + + for { + line, err := term.Readline() + if err != nil { + break + } + term.Write(...) + } + + }() + } +*/ diff --git a/sshd/logger.go b/sshd/logger.go new file mode 100644 index 0000000..49a4456 --- /dev/null +++ b/sshd/logger.go @@ -0,0 +1,22 @@ +package sshd + +import "io" +import stdlog "log" + +var logger *stdlog.Logger + +func SetLogger(w io.Writer) { + flags := stdlog.Flags() + prefix := "[chat] " + logger = stdlog.New(w, prefix, flags) +} + +type nullWriter struct{} + +func (nullWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +func init() { + SetLogger(nullWriter{}) +} diff --git a/sshd/multi.go b/sshd/multi.go new file mode 100644 index 0000000..62447a7 --- /dev/null +++ b/sshd/multi.go @@ -0,0 +1,42 @@ +package sshd + +import ( + "fmt" + "io" + "strings" +) + +// Keep track of multiple errors and coerce them into one error +type MultiError []error + +func (e MultiError) Error() string { + switch len(e) { + case 0: + return "" + case 1: + return e[0].Error() + default: + errs := []string{} + for _, err := range e { + errs = append(errs, err.Error()) + } + return fmt.Sprintf("%d errors: %s", strings.Join(errs, "; ")) + } +} + +// Keep track of multiple closers and close them all as one closer +type MultiCloser []io.Closer + +func (c MultiCloser) Close() error { + errors := MultiError{} + for _, closer := range c { + err := closer.Close() + if err != nil { + errors = append(errors, err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} diff --git a/sshd/net.go b/sshd/net.go new file mode 100644 index 0000000..ba34bc0 --- /dev/null +++ b/sshd/net.go @@ -0,0 +1,68 @@ +package sshd + +import ( + "net" + "syscall" + + "golang.org/x/crypto/ssh" +) + +// Container for the connection and ssh-related configuration +type SSHListener struct { + net.Listener + config *ssh.ServerConfig +} + +// Make an SSH listener socket +func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { + socket, err := net.Listen("tcp", laddr) + if err != nil { + return nil, err + } + l := socket.(SSHListener) + l.config = config + return &l, nil +} + +func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) { + // Upgrade TCP connection to SSH connection + sshConn, channels, requests, err := ssh.NewServerConn(conn, l.config) + if err != nil { + return nil, err + } + + go ssh.DiscardRequests(requests) + return NewSession(sshConn, channels) +} + +// Accept incoming connections as terminal requests and yield them +func (l *SSHListener) ServeTerminal() <-chan *Terminal { + ch := make(chan *Terminal) + + go func() { + defer l.Close() + + for { + conn, err := l.Accept() + + if err != nil { + logger.Printf("Failed to accept connection: %v", err) + if err == syscall.EINVAL { + return + } + } + + // Goroutineify to resume accepting sockets early + go func() { + term, err := l.handleConn(conn) + if err != nil { + logger.Printf("Failed to handshake: %v", err) + return + } + ch <- term + }() + } + }() + + return ch +} diff --git a/sshd/pty.go b/sshd/pty.go new file mode 100644 index 0000000..5aecc3e --- /dev/null +++ b/sshd/pty.go @@ -0,0 +1,69 @@ +// Borrowed from go.crypto circa 2011 +package sshd + +import "encoding/binary" + +// parsePtyRequest parses the payload of the pty-req message and extracts the +// dimensions of the terminal. See RFC 4254, section 6.2. +func parsePtyRequest(s []byte) (width, height int, ok bool) { + _, s, ok = parseString(s) + if !ok { + return + } + width32, s, ok := parseUint32(s) + if !ok { + return + } + height32, _, ok := parseUint32(s) + width = int(width32) + height = int(height32) + if width < 1 { + ok = false + } + if height < 1 { + ok = false + } + return +} + +func parseWinchRequest(s []byte) (width, height int, ok bool) { + width32, s, ok := parseUint32(s) + if !ok { + return + } + height32, s, ok := parseUint32(s) + if !ok { + return + } + + width = int(width32) + height = int(height32) + if width < 1 { + ok = false + } + if height < 1 { + ok = false + } + return +} + +func parseString(in []byte) (out string, rest []byte, ok bool) { + if len(in) < 4 { + return + } + length := binary.BigEndian.Uint32(in) + if uint32(len(in)) < 4+length { + return + } + out = string(in[4 : 4+length]) + rest = in[4+length:] + ok = true + return +} + +func parseUint32(in []byte) (uint32, []byte, bool) { + if len(in) < 4 { + return 0, nil, false + } + return binary.BigEndian.Uint32(in), in[4:], true +} diff --git a/sshd/server.go b/sshd/server.go new file mode 100644 index 0000000..cd8980c --- /dev/null +++ b/sshd/server.go @@ -0,0 +1,98 @@ +package sshd + +import ( + "net" + "sync" + "syscall" + "time" + + "golang.org/x/crypto/ssh" +) + +// Server holds all the fields used by a server +type Server struct { + sshConfig *ssh.ServerConfig + done chan struct{} + started time.Time + sync.RWMutex +} + +// Initialize a new server +func NewServer(privateKey []byte) (*Server, error) { + signer, err := ssh.ParsePrivateKey(privateKey) + if err != nil { + return nil, err + } + + server := Server{ + done: make(chan struct{}), + started: time.Now(), + } + + config := MakeNoAuth() + config.AddHostKey(signer) + + server.sshConfig = config + + return &server, nil +} + +// Start starts the server +func (s *Server) Start(laddr string) error { + // Once a ServerConfig has been configured, connections can be + // accepted. + socket, err := net.Listen("tcp", laddr) + if err != nil { + return err + } + + logger.Infof("Listening on %s", laddr) + + go func() { + defer socket.Close() + for { + conn, err := socket.Accept() + + if err != nil { + logger.Printf("Failed to accept connection: %v", err) + if err == syscall.EINVAL { + // TODO: Handle shutdown more gracefully? + return + } + } + + // Goroutineify to resume accepting sockets early. + go func() { + // From a standard TCP connection to an encrypted SSH connection + sshConn, channels, requests, err := ssh.NewServerConn(conn, s.sshConfig) + if err != nil { + logger.Printf("Failed to handshake: %v", err) + return + } + + go ssh.DiscardRequests(requests) + + client := NewClient(s, sshConn) + go client.handleChannels(channels) + }() + } + }() + + go func() { + <-s.done + socket.Close() + }() + + return nil +} + +// Stop stops the server +func (s *Server) Stop() { + s.Lock() + for _, client := range s.clients { + client.Conn.Close() + } + s.Unlock() + + close(s.done) +} diff --git a/sshd/terminal.go b/sshd/terminal.go new file mode 100644 index 0000000..e872bf6 --- /dev/null +++ b/sshd/terminal.go @@ -0,0 +1,93 @@ +package sshd + +import ( + "errors" + "fmt" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/terminal" +) + +// Extending ssh/terminal to include a closer interface +type Terminal struct { + *terminal.Terminal + Conn ssh.Conn + Channel ssh.Channel +} + +// Make new terminal from a session channel +func NewTerminal(conn ssh.Conn, ch ssh.NewChannel) (*Terminal, error) { + if ch.ChannelType() != "session" { + return nil, errors.New("terminal requires session channel") + } + channel, requests, err := ch.Accept() + if err != nil { + return nil, err + } + term := Terminal{ + terminal.NewTerminal(channel, "Connecting..."), + conn, + channel, + } + + go term.listen(requests) + return &term, nil +} + +// Find session channel and make a Terminal from it +func NewSession(conn ssh.Conn, channels <-chan ssh.NewChannel) (term *Terminal, err error) { + for ch := range channels { + if t := ch.ChannelType(); t != "session" { + ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t)) + continue + } + + term, err = NewTerminal(conn, ch) + if err == nil { + break + } + } + + return term, err +} + +// Close terminal and ssh connection +func (t *Terminal) Close() error { + return MultiCloser{t.Channel, t.Conn}.Close() +} + +// Negotiate terminal type and settings +func (t *Terminal) listen(requests <-chan *ssh.Request) { + hasShell := false + + for req := range requests { + var width, height int + var ok bool + + switch req.Type { + case "shell": + if !hasShell { + ok = true + hasShell = true + } + case "pty-req": + width, height, ok = parsePtyRequest(req.Payload) + if ok { + // TODO: Hardcode width to 100000? + err := t.SetSize(width, height) + ok = err == nil + } + case "window-change": + width, height, ok = parseWinchRequest(req.Payload) + if ok { + // TODO: Hardcode width to 100000? + err := t.SetSize(width, height) + ok = err == nil + } + } + + if req.WantReply { + req.Reply(ok, nil) + } + } +} From 7beb7f99bbc60e38c4f99f23d4cddd72f62190d0 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Mon, 22 Dec 2014 15:53:30 -0800 Subject: [PATCH 09/60] Testing for net. --- sshd/logger.go | 2 +- sshd/net.go | 9 ++-- sshd/net_test.go | 137 +++++++++++++++++++++++++++++++++++++++++++++++ sshd/server.go | 98 --------------------------------- sshd/terminal.go | 4 +- 5 files changed, 143 insertions(+), 107 deletions(-) create mode 100644 sshd/net_test.go delete mode 100644 sshd/server.go diff --git a/sshd/logger.go b/sshd/logger.go index 49a4456..9f6998f 100644 --- a/sshd/logger.go +++ b/sshd/logger.go @@ -7,7 +7,7 @@ var logger *stdlog.Logger func SetLogger(w io.Writer) { flags := stdlog.Flags() - prefix := "[chat] " + prefix := "[sshd] " logger = stdlog.New(w, prefix, flags) } diff --git a/sshd/net.go b/sshd/net.go index ba34bc0..6a30976 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -2,7 +2,6 @@ package sshd import ( "net" - "syscall" "golang.org/x/crypto/ssh" ) @@ -19,8 +18,7 @@ func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { if err != nil { return nil, err } - l := socket.(SSHListener) - l.config = config + l := SSHListener{socket, config} return &l, nil } @@ -41,15 +39,14 @@ func (l *SSHListener) ServeTerminal() <-chan *Terminal { go func() { defer l.Close() + defer close(ch) for { conn, err := l.Accept() if err != nil { logger.Printf("Failed to accept connection: %v", err) - if err == syscall.EINVAL { - return - } + return } // Goroutineify to resume accepting sockets early diff --git a/sshd/net_test.go b/sshd/net_test.go new file mode 100644 index 0000000..6ec4311 --- /dev/null +++ b/sshd/net_test.go @@ -0,0 +1,137 @@ +package sshd + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "io" + "testing" + + "golang.org/x/crypto/ssh" +) + +// TODO: Move some of these into their own package? + +func MakeKey(bits int) (ssh.Signer, error) { + key, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(key) +} + +func NewClientSession(host string, name string, handler func(r io.Reader, w io.WriteCloser)) error { + config := &ssh.ClientConfig{ + User: name, + Auth: []ssh.AuthMethod{ + ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { + return + }), + }, + } + + conn, err := ssh.Dial("tcp", host, config) + if err != nil { + return err + } + defer conn.Close() + + session, err := conn.NewSession() + if err != nil { + return err + } + defer session.Close() + + in, err := session.StdinPipe() + if err != nil { + return err + } + + out, err := session.StdoutPipe() + if err != nil { + return err + } + + err = session.Shell() + if err != nil { + return err + } + + handler(out, in) + + return nil +} + +func TestServerInit(t *testing.T) { + config := MakeNoAuth() + s, err := ListenSSH(":badport", config) + if err == nil { + t.Fatal("should fail on bad port") + } + + s, err = ListenSSH(":0", config) + if err != nil { + t.Error(err) + } + + err = s.Close() + if err != nil { + t.Error(err) + } +} + +func TestServeTerminals(t *testing.T) { + signer, err := MakeKey(512) + config := MakeNoAuth() + config.AddHostKey(signer) + + s, err := ListenSSH(":0", config) + if err != nil { + t.Fatal(err) + } + + terminals := s.ServeTerminal() + + go func() { + // Accept one terminal, read from it, echo back, close. + term := <-terminals + term.SetPrompt("> ") + + line, err := term.ReadLine() + if err != nil { + t.Error(err) + } + _, err = term.Write([]byte("echo: " + line + "\r\n")) + if err != nil { + t.Error(err) + } + + term.Close() + }() + + host := s.Addr().String() + name := "foo" + + err = NewClientSession(host, name, func(r io.Reader, w io.WriteCloser) { + // Consume if there is anything + buf := new(bytes.Buffer) + w.Write([]byte("hello\r\n")) + + buf.Reset() + _, err := io.Copy(buf, r) + if err != nil { + t.Error(err) + } + + expected := "> hello\r\necho: hello\r\n" + actual := buf.String() + if actual != expected { + t.Errorf("Got `%s`; expected `%s`", actual, expected) + } + s.Close() + }) + + if err != nil { + t.Fatal(err) + } +} diff --git a/sshd/server.go b/sshd/server.go deleted file mode 100644 index cd8980c..0000000 --- a/sshd/server.go +++ /dev/null @@ -1,98 +0,0 @@ -package sshd - -import ( - "net" - "sync" - "syscall" - "time" - - "golang.org/x/crypto/ssh" -) - -// Server holds all the fields used by a server -type Server struct { - sshConfig *ssh.ServerConfig - done chan struct{} - started time.Time - sync.RWMutex -} - -// Initialize a new server -func NewServer(privateKey []byte) (*Server, error) { - signer, err := ssh.ParsePrivateKey(privateKey) - if err != nil { - return nil, err - } - - server := Server{ - done: make(chan struct{}), - started: time.Now(), - } - - config := MakeNoAuth() - config.AddHostKey(signer) - - server.sshConfig = config - - return &server, nil -} - -// Start starts the server -func (s *Server) Start(laddr string) error { - // Once a ServerConfig has been configured, connections can be - // accepted. - socket, err := net.Listen("tcp", laddr) - if err != nil { - return err - } - - logger.Infof("Listening on %s", laddr) - - go func() { - defer socket.Close() - for { - conn, err := socket.Accept() - - if err != nil { - logger.Printf("Failed to accept connection: %v", err) - if err == syscall.EINVAL { - // TODO: Handle shutdown more gracefully? - return - } - } - - // Goroutineify to resume accepting sockets early. - go func() { - // From a standard TCP connection to an encrypted SSH connection - sshConn, channels, requests, err := ssh.NewServerConn(conn, s.sshConfig) - if err != nil { - logger.Printf("Failed to handshake: %v", err) - return - } - - go ssh.DiscardRequests(requests) - - client := NewClient(s, sshConn) - go client.handleChannels(channels) - }() - } - }() - - go func() { - <-s.done - socket.Close() - }() - - return nil -} - -// Stop stops the server -func (s *Server) Stop() { - s.Lock() - for _, client := range s.clients { - client.Conn.Close() - } - s.Unlock() - - close(s.done) -} diff --git a/sshd/terminal.go b/sshd/terminal.go index e872bf6..51597c6 100644 --- a/sshd/terminal.go +++ b/sshd/terminal.go @@ -10,7 +10,7 @@ import ( // Extending ssh/terminal to include a closer interface type Terminal struct { - *terminal.Terminal + terminal.Terminal Conn ssh.Conn Channel ssh.Channel } @@ -25,7 +25,7 @@ func NewTerminal(conn ssh.Conn, ch ssh.NewChannel) (*Terminal, error) { return nil, err } term := Terminal{ - terminal.NewTerminal(channel, "Connecting..."), + *terminal.NewTerminal(channel, "Connecting..."), conn, channel, } From a1d5cc6735959367a7f288f4348e3db8ba6e3f47 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Mon, 22 Dec 2014 21:47:07 -0800 Subject: [PATCH 10/60] Set.Each, also builtin Channel broadcast goroutine --- chat/channel.go | 21 +++++++++++++++++++-- chat/channel_test.go | 12 ++---------- chat/host.go | 15 +-------------- chat/set.go | 9 +++++++++ 4 files changed, 31 insertions(+), 26 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index f26dbf2..180dada 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -3,6 +3,7 @@ package chat import "fmt" const historyLen = 20 +const channelBuffer = 10 // Channel definition, also a Set of User Items type Channel struct { @@ -10,19 +11,35 @@ type Channel struct { topic string history *History users *Set - broadcast chan<- Message + broadcast chan Message } -func NewChannel(id string, broadcast chan<- Message) *Channel { +// Create new channel and start broadcasting goroutine. +func NewChannel(id string) *Channel { + broadcast := make(chan Message, channelBuffer) + ch := Channel{ id: id, broadcast: broadcast, history: NewHistory(historyLen), users: NewSet(), } + + go func() { + for m := range broadcast { + ch.users.Each(func(u Item) { + u.(*User).Send(m) + }) + } + }() + return &ch } +func (ch *Channel) Close() { + close(ch.broadcast) +} + func (ch *Channel) Send(m Message) { ch.broadcast <- m } diff --git a/chat/channel_test.go b/chat/channel_test.go index 5ff2fb4..f261081 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -8,20 +8,12 @@ import ( func TestChannel(t *testing.T) { var expected, actual []byte - out := make(chan Message) - defer close(out) - s := &MockScreen{} u := NewUser("foo") - go func() { - for msg := range out { - t.Logf("Broadcasted: ", msg.String()) - u.Send(msg) - } - }() + ch := NewChannel("") + defer ch.Close() - ch := NewChannel("", out) err := ch.Join(u) if err != nil { t.Error(err) diff --git a/chat/host.go b/chat/host.go index ce8c905..9834ed4 100644 --- a/chat/host.go +++ b/chat/host.go @@ -24,18 +24,5 @@ func (h *Host) broadcast(ch *Channel, m Message) { } func (h *Host) CreateChannel(id string) *Channel { - out := make(chan Message, 20) - ch := NewChannel(id, out) - - go func() { - for msg := range out { - if msg.IsCommand() { - go h.handleCommand(msg) - continue - } - h.broadcast(ch, msg) - } - }() - - return ch + return NewChannel(id) } diff --git a/chat/set.go b/chat/set.go index d4442d7..8c01f87 100644 --- a/chat/set.go +++ b/chat/set.go @@ -87,6 +87,15 @@ func (s *Set) Remove(item Item) error { return nil } +// Loop over every item while holding a read lock and apply fn +func (s *Set) Each(fn func(item Item)) { + s.RLock() + for _, item := range s.lookup { + fn(item) + } + s.RUnlock() +} + // List users by prefix, case insensitive func (s *Set) ListPrefix(prefix string) []Item { r := []Item{} From eda2b7c0d930fcfd4436ce39f9a5573d3e103fa8 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Mon, 22 Dec 2014 22:21:07 -0800 Subject: [PATCH 11/60] Super broken but kinda working. --- chat/channel.go | 12 +++++--- chat/channel_test.go | 2 +- chat/command.go | 8 +++--- chat/host.go | 28 ------------------- chat/message.go | 11 ++++++-- cmd.go | 65 ++++++++++++++++++++++++++++++++++---------- sshd/doc.go | 3 +- 7 files changed, 75 insertions(+), 54 deletions(-) delete mode 100644 chat/host.go diff --git a/chat/channel.go b/chat/channel.go index 180dada..b52fb47 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -7,7 +7,6 @@ const channelBuffer = 10 // Channel definition, also a Set of User Items type Channel struct { - id string topic string history *History users *Set @@ -15,11 +14,10 @@ type Channel struct { } // Create new channel and start broadcasting goroutine. -func NewChannel(id string) *Channel { +func NewChannel() *Channel { broadcast := make(chan Message, channelBuffer) ch := Channel{ - id: id, broadcast: broadcast, history: NewHistory(historyLen), users: NewSet(), @@ -27,8 +25,14 @@ func NewChannel(id string) *Channel { go func() { for m := range broadcast { + // TODO: Handle commands etc? ch.users.Each(func(u Item) { - u.(*User).Send(m) + user := u.(*User) + if m.from == user { + // Skip + return + } + user.Send(m) }) } }() diff --git a/chat/channel_test.go b/chat/channel_test.go index f261081..3e16030 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -11,7 +11,7 @@ func TestChannel(t *testing.T) { s := &MockScreen{} u := NewUser("foo") - ch := NewChannel("") + ch := NewChannel() defer ch.Close() err := ch.Join(u) diff --git a/chat/command.go b/chat/command.go index 73ffa2f..896e524 100644 --- a/chat/command.go +++ b/chat/command.go @@ -8,7 +8,7 @@ import ( var ErrInvalidCommand error = errors.New("invalid command") var ErrNoOwner error = errors.New("command without owner") -type CmdHandler func(host Host, msg Message, args []string) error +type CmdHandler func(msg Message, args []string) error type Commands map[string]CmdHandler @@ -18,7 +18,7 @@ func (c Commands) Add(cmd string, handler CmdHandler) { } // Execute command message, assumes IsCommand was checked -func (c Commands) Run(host Host, msg Message) error { +func (c Commands) Run(msg Message) error { if msg.from == nil { return ErrNoOwner } @@ -29,7 +29,7 @@ func (c Commands) Run(host Host, msg Message) error { return ErrInvalidCommand } - return handler(host, msg, args) + return handler(msg, args) } var defaultCmdHandlers Commands @@ -37,7 +37,7 @@ var defaultCmdHandlers Commands func init() { c := Commands{} - c.Add("me", func(host Host, msg Message, args []string) error { + c.Add("me", func(msg Message, args []string) error { me := strings.TrimLeft(msg.Body, "/me") if me == "" { me = " is at a loss for words." diff --git a/chat/host.go b/chat/host.go deleted file mode 100644 index 9834ed4..0000000 --- a/chat/host.go +++ /dev/null @@ -1,28 +0,0 @@ -package chat - -// Host knows about all the commands and channels. -type Host struct { - defaultChannel *Channel - commands Commands - done chan struct{} -} - -func NewHost() *Host { - h := Host{ - commands: defaultCmdHandlers, - } - h.defaultChannel = h.CreateChannel("") - return &h -} - -func (h *Host) handleCommand(m Message) { - // TODO: ... -} - -func (h *Host) broadcast(ch *Channel, m Message) { - // TODO: ... -} - -func (h *Host) CreateChannel(id string) *Channel { - return NewChannel(id) -} diff --git a/chat/message.go b/chat/message.go index aee5d38..5281b1a 100644 --- a/chat/message.go +++ b/chat/message.go @@ -9,8 +9,9 @@ import ( // Container for messages sent to chat type Message struct { Body string - from *User // Not set for Sys messages - to *User // Only set for PMs + from *User // Not set for Sys messages + to *User // Only set for PMs + channel *Channel // Not set for global commands timestamp time.Time themeCache *map[*Theme]string } @@ -35,6 +36,12 @@ func (m *Message) From(u *User) *Message { return m } +// Set channel +func (m *Message) Channel(ch *Channel) *Message { + m.channel = ch + return m +} + // Render message based on the given theme func (m *Message) Render(*Theme) string { // TODO: Return []byte? diff --git a/cmd.go b/cmd.go index 89ad01a..6491cdc 100644 --- a/cmd.go +++ b/cmd.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "fmt" "io/ioutil" "net/http" @@ -13,6 +12,10 @@ import ( "github.com/alexcesaro/log" "github.com/alexcesaro/log/golog" "github.com/jessevdk/go-flags" + "golang.org/x/crypto/ssh" + + "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/sshd" ) import _ "net/http/pprof" @@ -52,9 +55,6 @@ func main() { }() } - // Initialize seed for random colors - RandomColorInit() - // Figure out the log level numVerbose := len(options.Verbose) if numVerbose > len(logLevels) { @@ -78,12 +78,55 @@ func main() { return } - server, err := NewServer(privateKey) + signer, err := ssh.ParsePrivateKey(privateKey) if err != nil { - logger.Errorf("Failed to create server: %v", err) + logger.Errorf("Failed to prase key: %v", err) return } + // TODO: MakeAuth + config := sshd.MakeNoAuth() + config.AddHostKey(signer) + + s, err := sshd.ListenSSH(options.Bind, config) + if err != nil { + logger.Errorf("Failed to listen on socket: %v", err) + return + } + defer s.Close() + + terminals := s.ServeTerminal() + channel := chat.NewChannel() + + // TODO: Move this elsewhere + go func() { + for term := range terminals { + go func() { + defer term.Close() + name := term.Conn.User() + term.SetPrompt(fmt.Sprintf("[%s]", name)) + // TODO: term.AutoCompleteCallback = ... + user := chat.NewUserScreen(name, term) + channel.Join(user) + + for { + // TODO: Handle commands etc? + line, err := term.ReadLine() + if err != nil { + break + } + m := chat.NewMessage(line).From(user) + channel.Send(*m) + } + + // TODO: Handle disconnect sooner (currently closes channel before removing) + channel.Leave(user) + user.Close() + }() + } + }() + + /* TODO: for _, fingerprint := range options.Admin { server.Op(fingerprint) } @@ -109,23 +152,17 @@ func main() { return } motdString := string(motd[:]) - /* hack to normalize line endings into \r\n */ + // hack to normalize line endings into \r\n motdString = strings.Replace(motdString, "\r\n", "\n", -1) motdString = strings.Replace(motdString, "\n", "\r\n", -1) server.SetMotd(motdString) } + */ // Construct interrupt handler sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) - err = server.Start(options.Bind) - if err != nil { - logger.Errorf("Failed to start server: %v", err) - return - } - <-sig // Wait for ^C signal logger.Warningf("Interrupt signal detected, shutting down.") - server.Stop() } diff --git a/sshd/doc.go b/sshd/doc.go index 4d94d8b..aacbcac 100644 --- a/sshd/doc.go +++ b/sshd/doc.go @@ -11,6 +11,7 @@ package sshd if err != nil { // Handle opening socket error } + defer s.Close() terminals := s.ServeTerminal() @@ -21,7 +22,7 @@ package sshd term.AutoCompleteCallback = nil // ... for { - line, err := term.Readline() + line, err := term.ReadLine() if err != nil { break } From 601a95c1cd2d82e6429bc21483569ff885a1ffc9 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Mon, 22 Dec 2014 22:54:29 -0800 Subject: [PATCH 12/60] Progress trying to make things less buggy, not much. --- chat/channel.go | 10 +++++++++- chat/set.go | 9 +++++++++ chat/theme.go | 3 +++ chat/user.go | 8 +++++++- cmd.go | 11 ++++++++--- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index b52fb47..d9ace6e 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -32,7 +32,11 @@ func NewChannel() *Channel { // Skip return } - user.Send(m) + err := user.Send(m) + if err != nil { + ch.Leave(user) + user.Close() + } }) } }() @@ -41,6 +45,10 @@ func NewChannel() *Channel { } func (ch *Channel) Close() { + ch.users.Each(func(u Item) { + u.(*User).Close() + }) + ch.users.Clear() close(ch.broadcast) } diff --git a/chat/set.go b/chat/set.go index 8c01f87..c5aef93 100644 --- a/chat/set.go +++ b/chat/set.go @@ -34,6 +34,15 @@ func NewSet() *Set { } } +// Remove all items and return the number removed +func (s *Set) Clear() int { + s.Lock() + n := len(s.lookup) + s.lookup = map[Id]Item{} + s.Unlock() + return n +} + // Size of the set right now func (s *Set) Len() int { return len(s.lookup) diff --git a/chat/theme.go b/chat/theme.go index d187256..f592327 100644 --- a/chat/theme.go +++ b/chat/theme.go @@ -23,6 +23,9 @@ const ( // Invert inverts the following text Invert = "\033[7m" + + // Newline + Newline = "\r\n" ) // Interface for Colors diff --git a/chat/user.go b/chat/user.go index ffc4e61..bd59981 100644 --- a/chat/user.go +++ b/chat/user.go @@ -19,6 +19,7 @@ type User struct { joined time.Time msg chan Message done chan struct{} + closed bool Config UserConfig } @@ -74,6 +75,7 @@ func (u *User) Wait() { // Disconnect user, stop accepting messages func (u *User) Close() { + u.closed = true close(u.done) close(u.msg) } @@ -94,7 +96,7 @@ func (u *User) ConsumeOne(out io.Writer) { func (u *User) consumeMsg(m Message, out io.Writer) { s := m.Render(u.Config.Theme) - _, err := out.Write([]byte(s)) + _, err := out.Write([]byte(s + Newline)) if err != nil { logger.Printf("Write failed to %s, closing: %s", u.Name(), err) u.Close() @@ -103,6 +105,10 @@ func (u *User) consumeMsg(m Message, out io.Writer) { // Add message to consume by user func (u *User) Send(m Message) error { + if u.closed { + return ErrUserClosed + } + select { case u.msg <- m: default: diff --git a/cmd.go b/cmd.go index 6491cdc..9d242f5 100644 --- a/cmd.go +++ b/cmd.go @@ -104,11 +104,18 @@ func main() { go func() { defer term.Close() name := term.Conn.User() - term.SetPrompt(fmt.Sprintf("[%s]", name)) + term.SetPrompt(fmt.Sprintf("[%s] ", name)) // TODO: term.AutoCompleteCallback = ... user := chat.NewUserScreen(name, term) + defer user.Close() channel.Join(user) + go func() { + // FIXME: This isn't working. + user.Wait() + channel.Leave(user) + }() + for { // TODO: Handle commands etc? line, err := term.ReadLine() @@ -120,8 +127,6 @@ func main() { } // TODO: Handle disconnect sooner (currently closes channel before removing) - channel.Leave(user) - user.Close() }() } }() From c1406dda556ce535dbef20e3f395cb670deb46b5 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Wed, 24 Dec 2014 20:31:54 -0800 Subject: [PATCH 13/60] Remove all old code; fixed Set remove; fixed disconnect bug; fixed term reference. --- chat/set.go | 2 +- client.go | 511 ---------------------------------------------- cmd.go | 42 +--- colors.go | 82 -------- history.go | 59 ------ history_test.go | 53 ----- pty.go | 69 ------- serve.go | 53 +++++ server.go | 515 ----------------------------------------------- sshd/terminal.go | 18 +- 10 files changed, 78 insertions(+), 1326 deletions(-) delete mode 100644 client.go delete mode 100644 colors.go delete mode 100644 history.go delete mode 100644 history_test.go delete mode 100644 pty.go create mode 100644 serve.go delete mode 100644 server.go diff --git a/chat/set.go b/chat/set.go index c5aef93..7ee9281 100644 --- a/chat/set.go +++ b/chat/set.go @@ -89,7 +89,7 @@ func (s *Set) Remove(item Item) error { defer s.Unlock() id := item.Id() _, found := s.lookup[id] - if found { + if !found { return ErrItemMissing } delete(s.lookup, id) diff --git a/client.go b/client.go deleted file mode 100644 index 1d19e76..0000000 --- a/client.go +++ /dev/null @@ -1,511 +0,0 @@ -package main - -import ( - "fmt" - "strings" - "sync" - "time" - - "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/terminal" -) - -const ( - // MsgBuffer is the length of the message buffer - MsgBuffer int = 50 - - // MaxMsgLength is the maximum length of a message - MaxMsgLength int = 1024 - - // HelpText is the text returned by /help - HelpText string = `Available commands: - /about - About this chat. - /exit - Exit the chat. - /help - Show this help text. - /list - List the users that are currently connected. - /beep - Enable BEL notifications on mention. - /me $ACTION - Show yourself doing an action. - /nick $NAME - Rename yourself to a new name. - /whois $NAME - Display information about another connected user. - /msg $NAME $MESSAGE - Sends a private message to a user. - /motd - Prints the Message of the Day. - /theme [color|mono] - Set client theme.` - - // OpHelpText is the additional text returned by /help if the client is an Op - OpHelpText string = `Available operator commands: - /ban $NAME - Banish a user from the chat - /kick $NAME - Kick em' out. - /op $NAME - Promote a user to server operator. - /silence $NAME - Revoke a user's ability to speak. - /shutdown $MESSAGE - Broadcast message and shutdown server. - /motd $MESSAGE - Set message shown whenever somebody joins. - /whitelist $FINGERPRINT - Add fingerprint to whitelist, prevent anyone else from joining. - /whitelist github.com/$USER - Add github user's pubkeys to whitelist.` - - // AboutText is the text returned by /about - AboutText string = `ssh-chat is made by @shazow. - - It is a custom ssh server built in Go to serve a chat experience - instead of a shell. - - Source: https://github.com/shazow/ssh-chat - - For more, visit shazow.net or follow at twitter.com/shazow` - - // RequiredWait is the time a client is required to wait between messages - RequiredWait time.Duration = time.Second / 2 -) - -// Client holds all the fields used by the client -type Client struct { - Server *Server - Conn *ssh.ServerConn - Msg chan string - Name string - Color string - Op bool - ready chan struct{} - term *terminal.Terminal - termWidth int - termHeight int - silencedUntil time.Time - lastTX time.Time - beepMe bool - colorMe bool - closed bool - sync.RWMutex -} - -// NewClient constructs a new client -func NewClient(server *Server, conn *ssh.ServerConn) *Client { - return &Client{ - Server: server, - Conn: conn, - Name: conn.User(), - Color: RandomColor256(), - Msg: make(chan string, MsgBuffer), - ready: make(chan struct{}, 1), - lastTX: time.Now(), - colorMe: true, - } -} - -// ColoredName returns the client name in its color -func (c *Client) ColoredName() string { - return ColorString(c.Color, c.Name) -} - -// SysMsg sends a message in continuous format over the message channel -func (c *Client) SysMsg(msg string, args ...interface{}) { - c.Send(ContinuousFormat(systemMessageFormat, "-> "+fmt.Sprintf(msg, args...))) -} - -// Write writes the given message -func (c *Client) Write(msg string) { - if !c.colorMe { - msg = DeColorString(msg) - } - c.term.Write([]byte(msg + "\r\n")) -} - -// WriteLines writes multiple messages -func (c *Client) WriteLines(msg []string) { - for _, line := range msg { - c.Write(line) - } -} - -// Send sends the given message -func (c *Client) Send(msg string) { - if len(msg) > MaxMsgLength || c.closed { - return - } - select { - case c.Msg <- msg: - default: - logger.Errorf("Msg buffer full, dropping: %s (%s)", c.Name, c.Conn.RemoteAddr()) - c.Conn.Close() - } -} - -// SendLines sends multiple messages -func (c *Client) SendLines(msg []string) { - for _, line := range msg { - c.Send(line) - } -} - -// IsSilenced checks if the client is silenced -func (c *Client) IsSilenced() bool { - return c.silencedUntil.After(time.Now()) -} - -// Silence silences a client for the given duration -func (c *Client) Silence(d time.Duration) { - c.silencedUntil = time.Now().Add(d) -} - -// Resize resizes the client to the given width and height -func (c *Client) Resize(width, height int) error { - width = 1000000 // TODO: Remove this dirty workaround for text overflow once ssh/terminal is fixed - err := c.term.SetSize(width, height) - if err != nil { - logger.Errorf("Resize failed: %dx%d", width, height) - return err - } - c.termWidth, c.termHeight = width, height - return nil -} - -// Rename renames the client to the given name -func (c *Client) Rename(name string) { - c.Name = name - var prompt string - - if c.colorMe { - prompt = c.ColoredName() - } else { - prompt = c.Name - } - - c.term.SetPrompt(fmt.Sprintf("[%s] ", prompt)) -} - -// Fingerprint returns the fingerprint -func (c *Client) Fingerprint() string { - if c.Conn.Permissions == nil { - return "" - } - return c.Conn.Permissions.Extensions["fingerprint"] -} - -// Emote formats and sends an emote -func (c *Client) Emote(message string) { - formatted := fmt.Sprintf("** %s%s", c.ColoredName(), message) - if c.IsSilenced() || len(message) > 1000 { - c.SysMsg("Message rejected") - } - c.Server.Broadcast(formatted, nil) -} - -func (c *Client) handleShell(channel ssh.Channel) { - defer channel.Close() - defer c.Conn.Close() - - // FIXME: This shouldn't live here, need to restructure the call chaining. - c.Server.Add(c) - go func() { - // Block until done, then remove. - c.Conn.Wait() - c.closed = true - c.Server.Remove(c) - close(c.Msg) - }() - - go func() { - for msg := range c.Msg { - c.Write(msg) - } - }() - - for { - line, err := c.term.ReadLine() - if err != nil { - break - } - - parts := strings.SplitN(line, " ", 3) - isCmd := strings.HasPrefix(parts[0], "/") - - if isCmd { - // TODO: Factor this out. - switch parts[0] { - case "/test-colors": // Shh, this command is a secret! - c.Write(ColorString("32", "Lorem ipsum dolor sit amet,")) - c.Write("consectetur " + ColorString("31;1", "adipiscing") + " elit.") - case "/exit": - channel.Close() - case "/help": - c.SysMsg(strings.Replace(HelpText, "\n", "\r\n", -1)) - if c.Server.IsOp(c) { - c.SysMsg(strings.Replace(OpHelpText, "\n", "\r\n", -1)) - } - case "/about": - c.SysMsg(strings.Replace(AboutText, "\n", "\r\n", -1)) - case "/uptime": - c.SysMsg(c.Server.Uptime()) - case "/beep": - c.beepMe = !c.beepMe - if c.beepMe { - c.SysMsg("I'll beep you good.") - } else { - c.SysMsg("No more beeps. :(") - } - case "/me": - me := strings.TrimLeft(line, "/me") - if me == "" { - me = " is at a loss for words." - } - c.Emote(me) - case "/slap": - slappee := "themself" - if len(parts) > 1 { - slappee = parts[1] - if len(parts[1]) > 100 { - slappee = "some long-named jerk" - } - } - c.Emote(fmt.Sprintf(" slaps %s around a bit with a large trout.", slappee)) - case "/nick": - if len(parts) == 2 { - c.Server.Rename(c, parts[1]) - } else { - c.SysMsg("Missing $NAME from: /nick $NAME") - } - case "/whois": - if len(parts) >= 2 { - client := c.Server.Who(parts[1]) - if client != nil { - version := reStripText.ReplaceAllString(string(client.Conn.ClientVersion()), "") - if len(version) > 100 { - version = "Evil Jerk with a superlong string" - } - c.SysMsg("%s is %s via %s", client.ColoredName(), client.Fingerprint(), version) - } else { - c.SysMsg("No such name: %s", parts[1]) - } - } else { - c.SysMsg("Missing $NAME from: /whois $NAME") - } - case "/names", "/list": - names := "" - nameList := c.Server.List(nil) - for _, name := range nameList { - names += c.Server.Who(name).ColoredName() + systemMessageFormat + ", " - } - if len(names) > 2 { - names = names[:len(names)-2] - } - c.SysMsg("%d connected: %s", len(nameList), names) - case "/ban": - if !c.Server.IsOp(c) { - c.SysMsg("You're not an admin.") - } else if len(parts) != 2 { - c.SysMsg("Missing $NAME from: /ban $NAME") - } else { - client := c.Server.Who(parts[1]) - if client == nil { - c.SysMsg("No such name: %s", parts[1]) - } else { - fingerprint := client.Fingerprint() - client.SysMsg("Banned by %s.", c.ColoredName()) - c.Server.Ban(fingerprint, nil) - client.Conn.Close() - c.Server.Broadcast(fmt.Sprintf("* %s was banned by %s", parts[1], c.ColoredName()), nil) - } - } - case "/op": - if !c.Server.IsOp(c) { - c.SysMsg("You're not an admin.") - } else if len(parts) != 2 { - c.SysMsg("Missing $NAME from: /op $NAME") - } else { - client := c.Server.Who(parts[1]) - if client == nil { - c.SysMsg("No such name: %s", parts[1]) - } else { - fingerprint := client.Fingerprint() - client.SysMsg("Made op by %s.", c.ColoredName()) - c.Server.Op(fingerprint) - } - } - case "/kick": - if !c.Server.IsOp(c) { - c.SysMsg("You're not an admin.") - } else if len(parts) != 2 { - c.SysMsg("Missing $NAME from: /kick $NAME") - } else { - client := c.Server.Who(parts[1]) - if client == nil { - c.SysMsg("No such name: %s", parts[1]) - } else { - client.SysMsg("Kicked by %s.", c.ColoredName()) - client.Conn.Close() - c.Server.Broadcast(fmt.Sprintf("* %s was kicked by %s", parts[1], c.ColoredName()), nil) - } - } - case "/silence": - if !c.Server.IsOp(c) { - c.SysMsg("You're not an admin.") - } else if len(parts) < 2 { - c.SysMsg("Missing $NAME from: /silence $NAME") - } else { - duration := time.Duration(5) * time.Minute - if len(parts) >= 3 { - parsedDuration, err := time.ParseDuration(parts[2]) - if err == nil { - duration = parsedDuration - } - } - client := c.Server.Who(parts[1]) - if client == nil { - c.SysMsg("No such name: %s", parts[1]) - } else { - client.Silence(duration) - client.SysMsg("Silenced for %s by %s.", duration, c.ColoredName()) - } - } - case "/shutdown": - if !c.Server.IsOp(c) { - c.SysMsg("You're not an admin.") - } else { - var split = strings.SplitN(line, " ", 2) - var msg string - if len(split) > 1 { - msg = split[1] - } else { - msg = "" - } - // Shutdown after 5 seconds - go func() { - c.Server.Broadcast(ColorString("31", msg), nil) - time.Sleep(time.Second * 5) - c.Server.Stop() - }() - } - case "/msg": /* Send a PM */ - /* Make sure we have a recipient and a message */ - if len(parts) < 2 { - c.SysMsg("Missing $NAME from: /msg $NAME $MESSAGE") - break - } else if len(parts) < 3 { - c.SysMsg("Missing $MESSAGE from: /msg $NAME $MESSAGE") - break - } - /* Ask the server to send the message */ - if err := c.Server.Privmsg(parts[1], parts[2], c); nil != err { - c.SysMsg("Unable to send message to %v: %v", parts[1], err) - } - case "/motd": /* print motd */ - if !c.Server.IsOp(c) { - c.Server.MotdUnicast(c) - } else if len(parts) < 2 { - c.Server.MotdUnicast(c) - } else { - var newmotd string - if len(parts) == 2 { - newmotd = parts[1] - } else { - newmotd = parts[1] + " " + parts[2] - } - c.Server.SetMotd(newmotd) - c.Server.MotdBroadcast(c) - } - case "/theme": - if len(parts) < 2 { - c.SysMsg("Missing $THEME from: /theme $THEME") - c.SysMsg("Choose either color or mono") - } else { - // Sets colorMe attribute of client - if parts[1] == "mono" { - c.colorMe = false - } else if parts[1] == "color" { - c.colorMe = true - } - // Rename to reset prompt - c.Rename(c.Name) - } - - case "/whitelist": /* whitelist a fingerprint */ - if !c.Server.IsOp(c) { - c.SysMsg("You're not an admin.") - } else if len(parts) != 2 { - c.SysMsg("Missing $FINGERPRINT from: /whitelist $FINGERPRINT") - } else { - fingerprint := parts[1] - go func() { - err = c.Server.Whitelist(fingerprint) - if err != nil { - c.SysMsg("Error adding to whitelist: %s", err) - } else { - c.SysMsg("Added %s to the whitelist", fingerprint) - } - }() - } - case "/version": - c.SysMsg("Version " + buildCommit) - - default: - c.SysMsg("Invalid command: %s", line) - } - continue - } - - msg := fmt.Sprintf("%s: %s", c.ColoredName(), line) - /* Rate limit */ - if time.Now().Sub(c.lastTX) < RequiredWait { - c.SysMsg("Rate limiting in effect.") - continue - } - if c.IsSilenced() || len(msg) > 1000 || len(line) < 1 { - c.SysMsg("Message rejected.") - continue - } - c.Server.Broadcast(msg, c) - c.lastTX = time.Now() - } - -} - -func (c *Client) handleChannels(channels <-chan ssh.NewChannel) { - prompt := fmt.Sprintf("[%s] ", c.ColoredName()) - - hasShell := false - - for ch := range channels { - if t := ch.ChannelType(); t != "session" { - ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t)) - continue - } - - channel, requests, err := ch.Accept() - if err != nil { - logger.Errorf("Could not accept channel: %v", err) - continue - } - defer channel.Close() - - c.term = terminal.NewTerminal(channel, prompt) - c.term.AutoCompleteCallback = c.Server.AutoCompleteFunction - - for req := range requests { - var width, height int - var ok bool - - switch req.Type { - case "shell": - if c.term != nil && !hasShell { - go c.handleShell(channel) - ok = true - hasShell = true - } - case "pty-req": - width, height, ok = parsePtyRequest(req.Payload) - if ok { - err := c.Resize(width, height) - ok = err == nil - } - case "window-change": - width, height, ok = parseWinchRequest(req.Payload) - if ok { - err := c.Resize(width, height) - ok = err == nil - } - } - - if req.WantReply { - req.Reply(ok, nil) - } - } - } -} diff --git a/cmd.go b/cmd.go index 9d242f5..212e3f2 100644 --- a/cmd.go +++ b/cmd.go @@ -64,6 +64,12 @@ func main() { logLevel := logLevels[numVerbose] logger = golog.New(os.Stderr, logLevel) + if logLevel == log.Debug { + // Enable logging from submodules + chat.SetLogger(os.Stderr) + sshd.SetLogger(os.Stderr) + } + privateKeyPath := options.Identity if strings.HasPrefix(privateKeyPath, "~") { user, err := user.Current() @@ -95,41 +101,7 @@ func main() { } defer s.Close() - terminals := s.ServeTerminal() - channel := chat.NewChannel() - - // TODO: Move this elsewhere - go func() { - for term := range terminals { - go func() { - defer term.Close() - name := term.Conn.User() - term.SetPrompt(fmt.Sprintf("[%s] ", name)) - // TODO: term.AutoCompleteCallback = ... - user := chat.NewUserScreen(name, term) - defer user.Close() - channel.Join(user) - - go func() { - // FIXME: This isn't working. - user.Wait() - channel.Leave(user) - }() - - for { - // TODO: Handle commands etc? - line, err := term.ReadLine() - if err != nil { - break - } - m := chat.NewMessage(line).From(user) - channel.Send(*m) - } - - // TODO: Handle disconnect sooner (currently closes channel before removing) - }() - } - }() + go Serve(s) /* TODO: for _, fingerprint := range options.Admin { diff --git a/colors.go b/colors.go deleted file mode 100644 index 6bfc5ca..0000000 --- a/colors.go +++ /dev/null @@ -1,82 +0,0 @@ -package main - -import ( - "fmt" - "math/rand" - "regexp" - "strings" - "time" -) - -const ( - // Reset resets the color - Reset = "\033[0m" - - // Bold makes the following text bold - Bold = "\033[1m" - - // Dim dims the following text - Dim = "\033[2m" - - // Italic makes the following text italic - Italic = "\033[3m" - - // Underline underlines the following text - Underline = "\033[4m" - - // Blink blinks the following text - Blink = "\033[5m" - - // Invert inverts the following text - Invert = "\033[7m" -) - -var colors = []string{"31", "32", "33", "34", "35", "36", "37", "91", "92", "93", "94", "95", "96", "97"} - -// deColor is used for removing ANSI Escapes -var deColor = regexp.MustCompile("\033\\[[\\d;]+m") - -// DeColorString removes all color from the given string -func DeColorString(s string) string { - s = deColor.ReplaceAllString(s, "") - return s -} - -func randomReadableColor() int { - for { - i := rand.Intn(256) - if (16 <= i && i <= 18) || (232 <= i && i <= 237) { - // Remove the ones near black, this is kinda sadpanda. - continue - } - return i - } -} - -// RandomColor256 returns a random (of 256) color -func RandomColor256() string { - return fmt.Sprintf("38;05;%d", randomReadableColor()) -} - -// RandomColor returns a random color -func RandomColor() string { - return colors[rand.Intn(len(colors))] -} - -// ColorString returns a message in the given color -func ColorString(color string, msg string) string { - return Bold + "\033[" + color + "m" + msg + Reset -} - -// RandomColorInit initializes the random seed -func RandomColorInit() { - rand.Seed(time.Now().UTC().UnixNano()) -} - -// ContinuousFormat is a horrible hack to "continue" the previous string color -// and format after a RESET has been encountered. -// -// This is not HTML where you can just do a to resume your previous formatting! -func ContinuousFormat(format string, str string) string { - return systemMessageFormat + strings.Replace(str, Reset, format, -1) + Reset -} diff --git a/history.go b/history.go deleted file mode 100644 index 74ef513..0000000 --- a/history.go +++ /dev/null @@ -1,59 +0,0 @@ -// TODO: Split this out into its own module, it's kinda neat. -package main - -import "sync" - -// History contains the history entries -type History struct { - entries []string - head int - size int - lock sync.Mutex -} - -// NewHistory constructs a new history of the given size -func NewHistory(size int) *History { - return &History{ - entries: make([]string, size), - } -} - -// Add adds the given entry to the entries in the history -func (h *History) Add(entry string) { - h.lock.Lock() - defer h.lock.Unlock() - - max := cap(h.entries) - h.head = (h.head + 1) % max - h.entries[h.head] = entry - if h.size < max { - h.size++ - } -} - -// Len returns the number of entries in the history -func (h *History) Len() int { - return h.size -} - -// Get the entry with the given number -func (h *History) Get(num int) []string { - h.lock.Lock() - defer h.lock.Unlock() - - max := cap(h.entries) - if num > h.size { - num = h.size - } - - r := make([]string, num) - for i := 0; i < num; i++ { - idx := (h.head - i) % max - if idx < 0 { - idx += max - } - r[num-i-1] = h.entries[idx] - } - - return r -} diff --git a/history_test.go b/history_test.go deleted file mode 100644 index 0eab1c7..0000000 --- a/history_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "reflect" - "testing" -) - -func TestHistory(t *testing.T) { - var r, expected []string - var size int - - h := NewHistory(5) - - r = h.Get(10) - expected = []string{} - if !reflect.DeepEqual(r, expected) { - t.Errorf("Got: %v, Expected: %v", r, expected) - } - - h.Add("1") - - if size = h.Len(); size != 1 { - t.Errorf("Wrong size: %v", size) - } - - r = h.Get(1) - expected = []string{"1"} - if !reflect.DeepEqual(r, expected) { - t.Errorf("Got: %v, Expected: %v", r, expected) - } - - h.Add("2") - h.Add("3") - h.Add("4") - h.Add("5") - h.Add("6") - - if size = h.Len(); size != 5 { - t.Errorf("Wrong size: %v", size) - } - - r = h.Get(2) - expected = []string{"5", "6"} - if !reflect.DeepEqual(r, expected) { - t.Errorf("Got: %v, Expected: %v", r, expected) - } - - r = h.Get(10) - expected = []string{"2", "3", "4", "5", "6"} - if !reflect.DeepEqual(r, expected) { - t.Errorf("Got: %v, Expected: %v", r, expected) - } -} diff --git a/pty.go b/pty.go deleted file mode 100644 index e635fba..0000000 --- a/pty.go +++ /dev/null @@ -1,69 +0,0 @@ -// Borrowed from go.crypto circa 2011 -package main - -import "encoding/binary" - -// parsePtyRequest parses the payload of the pty-req message and extracts the -// dimensions of the terminal. See RFC 4254, section 6.2. -func parsePtyRequest(s []byte) (width, height int, ok bool) { - _, s, ok = parseString(s) - if !ok { - return - } - width32, s, ok := parseUint32(s) - if !ok { - return - } - height32, _, ok := parseUint32(s) - width = int(width32) - height = int(height32) - if width < 1 { - ok = false - } - if height < 1 { - ok = false - } - return -} - -func parseWinchRequest(s []byte) (width, height int, ok bool) { - width32, s, ok := parseUint32(s) - if !ok { - return - } - height32, s, ok := parseUint32(s) - if !ok { - return - } - - width = int(width32) - height = int(height32) - if width < 1 { - ok = false - } - if height < 1 { - ok = false - } - return -} - -func parseString(in []byte) (out string, rest []byte, ok bool) { - if len(in) < 4 { - return - } - length := binary.BigEndian.Uint32(in) - if uint32(len(in)) < 4+length { - return - } - out = string(in[4 : 4+length]) - rest = in[4+length:] - ok = true - return -} - -func parseUint32(in []byte) (uint32, []byte, bool) { - if len(in) < 4 { - return 0, nil, false - } - return binary.BigEndian.Uint32(in), in[4:], true -} diff --git a/serve.go b/serve.go new file mode 100644 index 0000000..0e97ca8 --- /dev/null +++ b/serve.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + + "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/sshd" +) + +func HandleTerminal(term *sshd.Terminal, channel *chat.Channel) { + defer term.Close() + name := term.Conn.User() + term.SetPrompt(fmt.Sprintf("[%s] ", name)) + // TODO: term.AutoCompleteCallback = ... + + user := chat.NewUserScreen(name, term) + defer user.Close() + + err := channel.Join(user) + if err != nil { + logger.Errorf("Failed to join: %s", err) + return + } + defer func() { + err := channel.Leave(user) + if err != nil { + logger.Errorf("Failed to leave: %s", err) + } + }() + + for { + // TODO: Handle commands etc? + line, err := term.ReadLine() + if err != nil { + // TODO: Catch EOF specifically? + logger.Errorf("Terminal reading error: %s", err) + return + } + m := chat.NewMessage(line).From(user) + channel.Send(*m) + } +} + +// Serve a chat service onto the sshd server. +func Serve(listener *sshd.SSHListener) { + terminals := listener.ServeTerminal() + channel := chat.NewChannel() + + for term := range terminals { + go HandleTerminal(term, channel) + } + +} diff --git a/server.go b/server.go deleted file mode 100644 index 181d2f7..0000000 --- a/server.go +++ /dev/null @@ -1,515 +0,0 @@ -package main - -import ( - "bufio" - "crypto/md5" - "encoding/base64" - "fmt" - "net" - "net/http" - "regexp" - "strings" - "sync" - "syscall" - "time" - - "golang.org/x/crypto/ssh" -) - -const ( - maxNameLength = 32 - historyLength = 20 - systemMessageFormat = "\033[1;90m" - privateMessageFormat = "\033[1m" - highlightFormat = Bold + "\033[48;5;11m\033[38;5;16m" - beep = "\007" -) - -var ( - reStripText = regexp.MustCompile("[^0-9A-Za-z_.-]") -) - -// Clients is a map of clients -type Clients map[string]*Client - -// Server holds all the fields used by a server -type Server struct { - sshConfig *ssh.ServerConfig - done chan struct{} - clients Clients - count int - history *History - motd string - whitelist map[string]struct{} // fingerprint lookup - admins map[string]struct{} // fingerprint lookup - bannedPK map[string]*time.Time // fingerprint lookup - started time.Time - sync.RWMutex -} - -// NewServer constructs a new server -func NewServer(privateKey []byte) (*Server, error) { - signer, err := ssh.ParsePrivateKey(privateKey) - if err != nil { - return nil, err - } - - server := Server{ - done: make(chan struct{}), - clients: Clients{}, - count: 0, - history: NewHistory(historyLength), - motd: "", - whitelist: map[string]struct{}{}, - admins: map[string]struct{}{}, - bannedPK: map[string]*time.Time{}, - started: time.Now(), - } - - config := ssh.ServerConfig{ - NoClientAuth: false, - // Auth-related things should be constant-time to avoid timing attacks. - PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - fingerprint := Fingerprint(key) - if server.IsBanned(fingerprint) { - return nil, fmt.Errorf("Banned.") - } - if !server.IsWhitelisted(fingerprint) { - return nil, fmt.Errorf("Not Whitelisted.") - } - perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": fingerprint}} - return perm, nil - }, - KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { - if server.IsBanned("") { - return nil, fmt.Errorf("Interactive login disabled.") - } - if !server.IsWhitelisted("") { - return nil, fmt.Errorf("Not Whitelisted.") - } - return nil, nil - }, - } - config.AddHostKey(signer) - - server.sshConfig = &config - - return &server, nil -} - -// Len returns the number of clients -func (s *Server) Len() int { - return len(s.clients) -} - -// SysMsg broadcasts the given message to everyone -func (s *Server) SysMsg(msg string, args ...interface{}) { - s.Broadcast(ContinuousFormat(systemMessageFormat, " * "+fmt.Sprintf(msg, args...)), nil) -} - -// Broadcast broadcasts the given message to everyone except for the given client -func (s *Server) Broadcast(msg string, except *Client) { - logger.Debugf("Broadcast to %d: %s", s.Len(), msg) - s.history.Add(msg) - - s.RLock() - defer s.RUnlock() - - for _, client := range s.clients { - if except != nil && client == except { - continue - } - - if strings.Contains(msg, client.Name) { - // Turn message red if client's name is mentioned, and send BEL if they have enabled beeping - personalMsg := strings.Replace(msg, client.Name, highlightFormat+client.Name+Reset, -1) - if client.beepMe { - personalMsg += beep - } - client.Send(personalMsg) - } else { - client.Send(msg) - } - } -} - -// Privmsg sends a message to a particular nick, if it exists -func (s *Server) Privmsg(nick, message string, sender *Client) error { - // Get the recipient - target, ok := s.clients[strings.ToLower(nick)] - if !ok { - return fmt.Errorf("no client with that nick") - } - // Send the message - target.Msg <- fmt.Sprintf(beep+"[PM from %v] %s%v%s", sender.ColoredName(), privateMessageFormat, message, Reset) - logger.Debugf("PM from %v to %v: %v", sender.Name, nick, message) - return nil -} - -// SetMotd sets the Message of the Day (MOTD) -func (s *Server) SetMotd(motd string) { - s.motd = motd -} - -// MotdUnicast sends the MOTD as a SysMsg -func (s *Server) MotdUnicast(client *Client) { - if s.motd == "" { - return - } - client.SysMsg(s.motd) -} - -// MotdBroadcast broadcasts the MOTD -func (s *Server) MotdBroadcast(client *Client) { - if s.motd == "" { - return - } - s.Broadcast(ContinuousFormat(systemMessageFormat, fmt.Sprintf(" * New MOTD set by %s.", client.ColoredName())), client) - s.Broadcast(s.motd, client) -} - -// Add adds the client to the list of clients -func (s *Server) Add(client *Client) { - go func() { - s.MotdUnicast(client) - client.SendLines(s.history.Get(10)) - }() - - s.Lock() - s.count++ - - newName, err := s.proposeName(client.Name) - if err != nil { - client.SysMsg("Your name '%s' is not available, renamed to '%s'. Use /nick to change it.", client.Name, ColorString(client.Color, newName)) - } - - client.Rename(newName) - s.clients[strings.ToLower(client.Name)] = client - num := len(s.clients) - s.Unlock() - - s.Broadcast(ContinuousFormat(systemMessageFormat, fmt.Sprintf(" * %s joined. (Total connected: %d)", client.Name, num)), client) -} - -// Remove removes the given client from the list of clients -func (s *Server) Remove(client *Client) { - s.Lock() - delete(s.clients, strings.ToLower(client.Name)) - s.Unlock() - - s.SysMsg("%s left.", client.Name) -} - -func (s *Server) proposeName(name string) (string, error) { - // Assumes caller holds lock. - var err error - name = reStripText.ReplaceAllString(name, "") - - if len(name) > maxNameLength { - name = name[:maxNameLength] - } else if len(name) == 0 { - name = fmt.Sprintf("Guest%d", s.count) - } - - _, collision := s.clients[strings.ToLower(name)] - if collision { - err = fmt.Errorf("%s is not available", name) - name = fmt.Sprintf("Guest%d", s.count) - } - - return name, err -} - -// Rename renames the given client (user) -func (s *Server) Rename(client *Client, newName string) { - var oldName string - if strings.ToLower(newName) == strings.ToLower(client.Name) { - oldName = client.Name - client.Rename(newName) - } else { - s.Lock() - newName, err := s.proposeName(newName) - if err != nil { - client.SysMsg("%s", err) - s.Unlock() - return - } - - // TODO: Use a channel/goroutine for adding clients, rather than locks? - delete(s.clients, strings.ToLower(client.Name)) - oldName = client.Name - client.Rename(newName) - s.clients[strings.ToLower(client.Name)] = client - s.Unlock() - } - s.SysMsg("%s is now known as %s.", ColorString(client.Color, oldName), ColorString(client.Color, newName)) -} - -// List lists the clients with the given prefix -func (s *Server) List(prefix *string) []string { - r := []string{} - - s.RLock() - defer s.RUnlock() - - for name, client := range s.clients { - if prefix != nil && !strings.HasPrefix(name, strings.ToLower(*prefix)) { - continue - } - r = append(r, client.Name) - } - - return r -} - -// Who returns the client with a given name -func (s *Server) Who(name string) *Client { - return s.clients[strings.ToLower(name)] -} - -// Op adds the given fingerprint to the list of admins -func (s *Server) Op(fingerprint string) { - logger.Infof("Adding admin: %s", fingerprint) - s.Lock() - s.admins[fingerprint] = struct{}{} - s.Unlock() -} - -// Whitelist adds the given fingerprint to the whitelist -func (s *Server) Whitelist(fingerprint string) error { - if fingerprint == "" { - return fmt.Errorf("Invalid fingerprint.") - } - if strings.HasPrefix(fingerprint, "github.com/") { - return s.whitelistIdentityURL(fingerprint) - } - - return s.whitelistFingerprint(fingerprint) -} - -func (s *Server) whitelistIdentityURL(user string) error { - logger.Infof("Adding github account %s to whitelist", user) - - user = strings.Replace(user, "github.com/", "", -1) - keys, err := getGithubPubKeys(user) - if err != nil { - return err - } - if len(keys) == 0 { - return fmt.Errorf("No keys for github user %s", user) - } - for _, key := range keys { - fingerprint := Fingerprint(key) - s.whitelistFingerprint(fingerprint) - } - return nil -} - -func (s *Server) whitelistFingerprint(fingerprint string) error { - logger.Infof("Adding whitelist: %s", fingerprint) - s.Lock() - s.whitelist[fingerprint] = struct{}{} - s.Unlock() - return nil -} - -// Client for getting github pub keys -var client = http.Client{ - Timeout: time.Duration(10 * time.Second), -} - -// Returns an array of public keys for the given github user URL -func getGithubPubKeys(user string) ([]ssh.PublicKey, error) { - resp, err := client.Get("http://github.com/" + user + ".keys") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - pubs := []ssh.PublicKey{} - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - text := scanner.Text() - if text == "Not Found" { - continue - } - - splitKey := strings.SplitN(text, " ", -1) - - // In case of malformated key - if len(splitKey) < 2 { - continue - } - - bodyDecoded, err := base64.StdEncoding.DecodeString(splitKey[1]) - if err != nil { - return nil, err - } - - pub, err := ssh.ParsePublicKey(bodyDecoded) - if err != nil { - return nil, err - } - - pubs = append(pubs, pub) - } - return pubs, nil -} - -// Uptime returns the time since the server was started -func (s *Server) Uptime() string { - return time.Now().Sub(s.started).String() -} - -// IsOp checks if the given client is Op -func (s *Server) IsOp(client *Client) bool { - _, r := s.admins[client.Fingerprint()] - return r -} - -// IsWhitelisted checks if the given fingerprint is whitelisted -func (s *Server) IsWhitelisted(fingerprint string) bool { - /* if no whitelist, anyone is welcome */ - if len(s.whitelist) == 0 { - return true - } - - /* otherwise, check for whitelist presence */ - _, r := s.whitelist[fingerprint] - return r -} - -// IsBanned checks if the given fingerprint is banned -func (s *Server) IsBanned(fingerprint string) bool { - ban, hasBan := s.bannedPK[fingerprint] - if !hasBan { - return false - } - if ban == nil { - return true - } - if ban.Before(time.Now()) { - s.Unban(fingerprint) - return false - } - return true -} - -// Ban bans a fingerprint for the given duration -func (s *Server) Ban(fingerprint string, duration *time.Duration) { - var until *time.Time - s.Lock() - if duration != nil { - when := time.Now().Add(*duration) - until = &when - } - s.bannedPK[fingerprint] = until - s.Unlock() -} - -// Unban unbans a banned fingerprint -func (s *Server) Unban(fingerprint string) { - s.Lock() - delete(s.bannedPK, fingerprint) - s.Unlock() -} - -// Start starts the server -func (s *Server) Start(laddr string) error { - // Once a ServerConfig has been configured, connections can be - // accepted. - socket, err := net.Listen("tcp", laddr) - if err != nil { - return err - } - - logger.Infof("Listening on %s", laddr) - - go func() { - defer socket.Close() - for { - conn, err := socket.Accept() - - if err != nil { - logger.Errorf("Failed to accept connection: %v", err) - if err == syscall.EINVAL { - // TODO: Handle shutdown more gracefully? - return - } - } - - // Goroutineify to resume accepting sockets early. - go func() { - // From a standard TCP connection to an encrypted SSH connection - sshConn, channels, requests, err := ssh.NewServerConn(conn, s.sshConfig) - if err != nil { - logger.Errorf("Failed to handshake: %v", err) - return - } - - version := reStripText.ReplaceAllString(string(sshConn.ClientVersion()), "") - if len(version) > 100 { - version = "Evil Jerk with a superlong string" - } - logger.Infof("Connection #%d from: %s, %s, %s", s.count+1, sshConn.RemoteAddr(), sshConn.User(), version) - - go ssh.DiscardRequests(requests) - - client := NewClient(s, sshConn) - go client.handleChannels(channels) - }() - } - }() - - go func() { - <-s.done - socket.Close() - }() - - return nil -} - -// AutoCompleteFunction handles auto completion of nicks -func (s *Server) AutoCompleteFunction(line string, pos int, key rune) (newLine string, newPos int, ok bool) { - if key == 9 { - shortLine := strings.Split(line[:pos], " ") - partialNick := shortLine[len(shortLine)-1] - - nicks := s.List(&partialNick) - if len(nicks) > 0 { - nick := nicks[len(nicks)-1] - posPartialNick := pos - len(partialNick) - if len(shortLine) < 2 { - nick += ": " - } else { - nick += " " - } - newLine = strings.Replace(line[posPartialNick:], - partialNick, nick, 1) - newLine = line[:posPartialNick] + newLine - newPos = pos + (len(nick) - len(partialNick)) - ok = true - } - } else { - ok = false - } - return -} - -// Stop stops the server -func (s *Server) Stop() { - s.Lock() - for _, client := range s.clients { - client.Conn.Close() - } - s.Unlock() - - close(s.done) -} - -// Fingerprint returns the fingerprint based on a public key -func Fingerprint(k ssh.PublicKey) string { - hash := md5.Sum(k.Marshal()) - r := fmt.Sprintf("% x", hash) - return strings.Replace(r, " ", ":", -1) -} diff --git a/sshd/terminal.go b/sshd/terminal.go index 51597c6..14318b3 100644 --- a/sshd/terminal.go +++ b/sshd/terminal.go @@ -31,6 +31,12 @@ func NewTerminal(conn ssh.Conn, ch ssh.NewChannel) (*Terminal, error) { } go term.listen(requests) + go func() { + // FIXME: Is this necessary? + conn.Wait() + channel.Close() + }() + return &term, nil } @@ -48,12 +54,22 @@ func NewSession(conn ssh.Conn, channels <-chan ssh.NewChannel) (term *Terminal, } } + if term != nil { + // Reject the rest. + // FIXME: Do we need this? + go func() { + for ch := range channels { + ch.Reject(ssh.Prohibited, "only one session allowed") + } + }() + } + return term, err } // Close terminal and ssh connection func (t *Terminal) Close() error { - return MultiCloser{t.Channel, t.Conn}.Close() + return t.Conn.Close() } // Negotiate terminal type and settings From d3b0a56d78658f32a3691aa602e17418724a487a Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 25 Dec 2014 00:09:43 -0800 Subject: [PATCH 14/60] Added Host struct. --- Makefile | 4 +-- cmd.go | 5 +-- host.go | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ serve.go | 53 ------------------------------ 4 files changed, 104 insertions(+), 57 deletions(-) create mode 100644 host.go delete mode 100644 serve.go diff --git a/Makefile b/Makefile index 6e26b4b..f2dbb15 100644 --- a/Makefile +++ b/Makefile @@ -28,5 +28,5 @@ debug: $(BINARY) $(KEY) ./$(BINARY) --pprof 6060 -i $(KEY) --bind ":$(PORT)" -vv test: - go test . - golint + go test ./... + golint ./... diff --git a/cmd.go b/cmd.go index 212e3f2..3fbef43 100644 --- a/cmd.go +++ b/cmd.go @@ -71,7 +71,7 @@ func main() { } privateKeyPath := options.Identity - if strings.HasPrefix(privateKeyPath, "~") { + if strings.HasPrefix(privateKeyPath, "~/") { user, err := user.Current() if err == nil { privateKeyPath = strings.Replace(privateKeyPath, "~", user.HomeDir, 1) @@ -101,7 +101,8 @@ func main() { } defer s.Close() - go Serve(s) + host := NewHost(s) + go host.Serve() /* TODO: for _, fingerprint := range options.Admin { diff --git a/host.go b/host.go new file mode 100644 index 0000000..885ef0d --- /dev/null +++ b/host.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "io" + + "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/sshd" +) + +// Host is the bridge between sshd and chat modules +// TODO: Should be easy to add support for multiple channels, if we want. +type Host struct { + listener *sshd.SSHListener + channel *chat.Channel +} + +// NewHost creates a Host on top of an existing listener +func NewHost(listener *sshd.SSHListener) *Host { + h := Host{ + listener: listener, + channel: chat.NewChannel(), + } + return &h +} + +// Connect a specific Terminal to this host and its channel +func (h *Host) Connect(term *sshd.Terminal) { + defer term.Close() + name := term.Conn.User() + term.SetPrompt(fmt.Sprintf("[%s] ", name)) + // TODO: term.AutoCompleteCallback = ... + + user := chat.NewUserScreen(name, term) + defer user.Close() + + err := h.channel.Join(user) + if err != nil { + logger.Errorf("Failed to join: %s", err) + return + } + + for { + // TODO: Handle commands etc? + line, err := term.ReadLine() + if err == io.EOF { + // Closed + break + } else if err != nil { + logger.Errorf("Terminal reading error: %s", err) + break + } + m := chat.NewMessage(line).From(user) + h.channel.Send(*m) + } + + err = h.channel.Leave(user) + if err != nil { + logger.Errorf("Failed to leave: %s", err) + return + } +} + +// Serve our chat channel onto the listener +func (h *Host) Serve() { + terminals := h.listener.ServeTerminal() + + for term := range terminals { + go h.Connect(term) + } +} + +/* TODO: ... +func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine string, newPos int, ok bool) { + if key != 9 { + return + } + + shortLine := strings.Split(line[:pos], " ") + partialNick := shortLine[len(shortLine)-1] + nicks := h.channel.users.ListPrefix(&partialNick) + if len(nicks) == 0 { + return + } + + nick := nicks[len(nicks)-1] + posPartialNick := pos - len(partialNick) + if len(shortLine) < 2 { + nick += ": " + } else { + nick += " " + } + newLine = strings.Replace(line[posPartialNick:], partialNick, nick, 1) + newLine = line[:posPartialNick] + newLine + newPos = pos + (len(nick) - len(partialNick)) + ok = true + return +} +*/ diff --git a/serve.go b/serve.go deleted file mode 100644 index 0e97ca8..0000000 --- a/serve.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/shazow/ssh-chat/chat" - "github.com/shazow/ssh-chat/sshd" -) - -func HandleTerminal(term *sshd.Terminal, channel *chat.Channel) { - defer term.Close() - name := term.Conn.User() - term.SetPrompt(fmt.Sprintf("[%s] ", name)) - // TODO: term.AutoCompleteCallback = ... - - user := chat.NewUserScreen(name, term) - defer user.Close() - - err := channel.Join(user) - if err != nil { - logger.Errorf("Failed to join: %s", err) - return - } - defer func() { - err := channel.Leave(user) - if err != nil { - logger.Errorf("Failed to leave: %s", err) - } - }() - - for { - // TODO: Handle commands etc? - line, err := term.ReadLine() - if err != nil { - // TODO: Catch EOF specifically? - logger.Errorf("Terminal reading error: %s", err) - return - } - m := chat.NewMessage(line).From(user) - channel.Send(*m) - } -} - -// Serve a chat service onto the sshd server. -func Serve(listener *sshd.SSHListener) { - terminals := listener.ServeTerminal() - channel := chat.NewChannel() - - for term := range terminals { - go HandleTerminal(term, channel) - } - -} From 3bb4bbf991a744cedcc474f545161d7ea2f8b00f Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 25 Dec 2014 00:37:50 -0800 Subject: [PATCH 15/60] AutoCompleteFunction is back. --- chat/channel.go | 11 +++++++++++ host.go | 35 ++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index d9ace6e..27a4e86 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -83,3 +83,14 @@ func (ch *Channel) Topic() string { func (ch *Channel) SetTopic(s string) { ch.topic = s } + +// NamesPrefix lists all members' names with a given prefix, used to query +// for autocompletion purposes. +func (ch *Channel) NamesPrefix(prefix string) []string { + users := ch.users.ListPrefix(prefix) + names := make([]string, len(users)) + for i, u := range users { + names[i] = u.(*User).Name() + } + return names +} diff --git a/host.go b/host.go index 885ef0d..1aa4b0f 100644 --- a/host.go +++ b/host.go @@ -3,6 +3,7 @@ package main import ( "fmt" "io" + "strings" "github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/sshd" @@ -29,7 +30,7 @@ func (h *Host) Connect(term *sshd.Terminal) { defer term.Close() name := term.Conn.User() term.SetPrompt(fmt.Sprintf("[%s] ", name)) - // TODO: term.AutoCompleteCallback = ... + term.AutoCompleteCallback = h.AutoCompleteFunction user := chat.NewUserScreen(name, term) defer user.Close() @@ -70,30 +71,34 @@ func (h *Host) Serve() { } } -/* TODO: ... +// AutoCompleteFunction is a callback for terminal autocompletion func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine string, newPos int, ok bool) { if key != 9 { return } - shortLine := strings.Split(line[:pos], " ") - partialNick := shortLine[len(shortLine)-1] - nicks := h.channel.users.ListPrefix(&partialNick) - if len(nicks) == 0 { + fields := strings.Fields(line[:pos]) + partial := fields[len(fields)-1] + names := h.channel.NamesPrefix(partial) + if len(names) == 0 { + // Didn't find anything return } - nick := nicks[len(nicks)-1] - posPartialNick := pos - len(partialNick) - if len(shortLine) < 2 { - nick += ": " + name := names[len(names)-1] + posPartial := pos - len(partial) + + // Append suffix separator + if len(fields) < 2 { + name += ": " } else { - nick += " " + name += " " } - newLine = strings.Replace(line[posPartialNick:], partialNick, nick, 1) - newLine = line[:posPartialNick] + newLine - newPos = pos + (len(nick) - len(partialNick)) + + // Reposition the cursor + newLine = strings.Replace(line[posPartial:], partial, name, 1) + newLine = line[:posPartial] + newLine + newPos = pos + (len(name) - len(partial)) ok = true return } -*/ From dac5cfbb5e22576c17383bf4a34cc77923c59f74 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 25 Dec 2014 16:25:02 -0800 Subject: [PATCH 16/60] Split up message types, added exit codes, basic command handling. --- chat/channel.go | 83 +++++++++++----- chat/channel_test.go | 13 ++- chat/command.go | 23 +++-- chat/message.go | 229 +++++++++++++++++++++++++++++++++---------- chat/message_test.go | 8 +- chat/user_test.go | 6 +- cmd.go | 7 +- host.go | 8 +- 8 files changed, 272 insertions(+), 105 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index 27a4e86..efa16d9 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -1,50 +1,39 @@ package chat -import "fmt" +import ( + "errors" + "fmt" +) const historyLen = 20 const channelBuffer = 10 +var ErrChannelClosed = errors.New("channel closed") + // Channel definition, also a Set of User Items type Channel struct { topic string history *History users *Set broadcast chan Message + commands Commands + closed bool } // Create new channel and start broadcasting goroutine. func NewChannel() *Channel { broadcast := make(chan Message, channelBuffer) - ch := Channel{ + return &Channel{ broadcast: broadcast, history: NewHistory(historyLen), users: NewSet(), + commands: defaultCmdHandlers, } - - go func() { - for m := range broadcast { - // TODO: Handle commands etc? - ch.users.Each(func(u Item) { - user := u.(*User) - if m.from == user { - // Skip - return - } - err := user.Send(m) - if err != nil { - ch.Leave(user) - user.Close() - } - }) - } - }() - - return &ch } func (ch *Channel) Close() { + ch.closed = true ch.users.Each(func(u Item) { u.(*User).Close() }) @@ -52,17 +41,63 @@ func (ch *Channel) Close() { close(ch.broadcast) } +// Handle a message, will block until done. +func (ch *Channel) handleMsg(m Message) { + switch m.(type) { + case CommandMsg: + cmd := m.(CommandMsg) + err := ch.commands.Run(cmd) + if err != nil { + m := NewSystemMsg(fmt.Sprintf("Err: %s", err), cmd.from) + go ch.handleMsg(m) + } + case MessageTo: + user := m.(MessageTo).To() + user.Send(m) + default: + fromMsg, skip := m.(MessageFrom) + var skipUser *User + if skip { + skipUser = fromMsg.From() + } + + ch.users.Each(func(u Item) { + user := u.(*User) + if skip && skipUser == user { + // Skip + return + } + err := user.Send(m) + if err != nil { + ch.Leave(user) + user.Close() + } + }) + } +} + +// Serve will consume the broadcast channel and handle the messages, should be +// run in a goroutine. +func (ch *Channel) Serve() { + for m := range ch.broadcast { + go ch.handleMsg(m) + } +} + func (ch *Channel) Send(m Message) { ch.broadcast <- m } func (ch *Channel) Join(u *User) error { + if ch.closed { + return ErrChannelClosed + } err := ch.users.Add(u) if err != nil { return err } s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.users.Len()) - ch.Send(*NewMessage(s)) + ch.Send(NewAnnounceMsg(s)) return nil } @@ -72,7 +107,7 @@ func (ch *Channel) Leave(u *User) error { return err } s := fmt.Sprintf("%s left.", u.Name()) - ch.Send(*NewMessage(s)) + ch.Send(NewAnnounceMsg(s)) return nil } diff --git a/chat/channel_test.go b/chat/channel_test.go index 3e16030..25abaeb 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -12,25 +12,28 @@ func TestChannel(t *testing.T) { u := NewUser("foo") ch := NewChannel() + go ch.Serve() defer ch.Close() err := ch.Join(u) if err != nil { - t.Error(err) + t.Fatal(err) } u.ConsumeOne(s) - expected = []byte(" * foo joined. (Connected: 1)") + expected = []byte(" * foo joined. (Connected: 1)" + Newline) s.Read(&actual) if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } + // XXX + t.Skip() - m := NewMessage("hello").From(u) - ch.Send(*m) + m := NewPublicMsg("hello", u) + ch.Send(m) u.ConsumeOne(s) - expected = []byte("foo: hello") + expected = []byte("foo: hello" + Newline) s.Read(&actual) if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) diff --git a/chat/command.go b/chat/command.go index 896e524..89fd799 100644 --- a/chat/command.go +++ b/chat/command.go @@ -5,31 +5,30 @@ import ( "strings" ) -var ErrInvalidCommand error = errors.New("invalid command") -var ErrNoOwner error = errors.New("command without owner") +var ErrInvalidCommand = errors.New("invalid command") +var ErrNoOwner = errors.New("command without owner") -type CmdHandler func(msg Message, args []string) error +type CommandHandler func(c CommandMsg) error -type Commands map[string]CmdHandler +type Commands map[string]CommandHandler // Register command -func (c Commands) Add(cmd string, handler CmdHandler) { - c[cmd] = handler +func (c Commands) Add(command string, handler CommandHandler) { + c[command] = handler } // Execute command message, assumes IsCommand was checked -func (c Commands) Run(msg Message) error { +func (c Commands) Run(msg CommandMsg) error { if msg.from == nil { return ErrNoOwner } - cmd, args := msg.ParseCommand() - handler, ok := c[cmd] + handler, ok := c[msg.Command()] if !ok { return ErrInvalidCommand } - return handler(msg, args) + return handler(msg) } var defaultCmdHandlers Commands @@ -37,8 +36,8 @@ var defaultCmdHandlers Commands func init() { c := Commands{} - c.Add("me", func(msg Message, args []string) error { - me := strings.TrimLeft(msg.Body, "/me") + c.Add("/me", func(msg CommandMsg) error { + me := strings.TrimLeft(msg.body, "/me") if me == "" { me = " is at a loss for words." } diff --git a/chat/message.go b/chat/message.go index 5281b1a..ce02eb9 100644 --- a/chat/message.go +++ b/chat/message.go @@ -6,74 +6,197 @@ import ( "time" ) -// Container for messages sent to chat -type Message struct { - Body string - from *User // Not set for Sys messages - to *User // Only set for PMs - channel *Channel // Not set for global commands - timestamp time.Time - themeCache *map[*Theme]string +// Message is an interface for messages. +type Message interface { + Render(*Theme) string + String() string } -func NewMessage(body string) *Message { - m := Message{ - Body: body, - timestamp: time.Now(), +type MessageTo interface { + Message + To() *User +} + +type MessageFrom interface { + Message + From() *User +} + +func ParseInput(body string, from *User) Message { + m := NewPublicMsg(body, from) + cmd, isCmd := m.ParseCommand() + if isCmd { + return cmd } - return &m -} - -// Set message recipient -func (m *Message) To(u *User) *Message { - m.to = u return m } -// Set message sender -func (m *Message) From(u *User) *Message { - m.from = u - return m +// Msg is a base type for other message types. +type Msg struct { + Message + body string + timestamp time.Time + // TODO: themeCache *map[*Theme]string } -// Set channel -func (m *Message) Channel(ch *Channel) *Message { - m.channel = ch - return m -} - -// Render message based on the given theme -func (m *Message) Render(*Theme) string { - // TODO: Return []byte? +// Render message based on a theme. +func (m *Msg) Render(t *Theme) string { // TODO: Render based on theme // TODO: Cache based on theme - var msg string - if m.to != nil && m.from != nil { - msg = fmt.Sprintf("[PM from %s] %s", m.from.Name(), m.Body) - } else if m.from != nil { - msg = fmt.Sprintf("%s: %s", m.from.Name(), m.Body) - } else if m.to != nil { - msg = fmt.Sprintf("-> %s", m.Body) - } else { - msg = fmt.Sprintf(" * %s", m.Body) - } - return msg + return m.body } -// Render message without a theme -func (m *Message) String() string { +func (m *Msg) String() string { return m.Render(nil) } -// Wether message is a command (starts with /) -func (m *Message) IsCommand() bool { - return strings.HasPrefix(m.Body, "/") +// PublicMsg is any message from a user sent to the channel. +type PublicMsg struct { + Msg + from *User } -// Parse command (assumes IsCommand was already called) -func (m *Message) ParseCommand() (string, []string) { - // TODO: Tokenize this properly, to support quoted args? - cmd := strings.Split(m.Body, " ") - args := cmd[1:] - return cmd[0][1:], args +func NewPublicMsg(body string, from *User) *PublicMsg { + return &PublicMsg{ + Msg: Msg{ + body: body, + timestamp: time.Now(), + }, + from: from, + } +} + +func (m *PublicMsg) From() *User { + return m.from +} + +func (m *PublicMsg) ParseCommand() (*CommandMsg, bool) { + // Check if the message is a command + if !strings.HasPrefix(m.body, "/") { + return nil, false + } + + // Parse + // TODO: Handle quoted fields properly + fields := strings.Fields(m.body) + command, args := fields[0], fields[1:] + msg := CommandMsg{ + PublicMsg: m, + command: command, + args: args, + } + return &msg, true +} + +func (m *PublicMsg) Render(t *Theme) string { + return fmt.Sprintf("%s: %s", m.from.Name(), m.body) +} + +func (m *PublicMsg) String() string { + return m.Render(nil) +} + +// EmoteMsg is a /me message sent to the channel. +type EmoteMsg struct { + PublicMsg +} + +func (m *EmoteMsg) Render(t *Theme) string { + return fmt.Sprintf("** %s %s", m.from.Name(), m.body) +} + +func (m *EmoteMsg) String() string { + return m.Render(nil) +} + +// PrivateMsg is a message sent to another user, not shown to anyone else. +type PrivateMsg struct { + PublicMsg + to *User +} + +func NewPrivateMsg(body string, from *User, to *User) *PrivateMsg { + return &PrivateMsg{ + PublicMsg: *NewPublicMsg(body, from), + to: to, + } +} + +func (m *PrivateMsg) To() *User { + return m.to +} + +func (m *PrivateMsg) Render(t *Theme) string { + return fmt.Sprintf("[PM from %s] %s", m.from.Name(), m.body) +} + +func (m *PrivateMsg) String() string { + return m.Render(nil) +} + +// SystemMsg is a response sent from the server directly to a user, not shown +// to anyone else. Usually in response to something, like /help. +type SystemMsg struct { + Msg + to *User +} + +func NewSystemMsg(body string, to *User) *SystemMsg { + return &SystemMsg{ + Msg: Msg{ + body: body, + timestamp: time.Now(), + }, + to: to, + } +} + +func (m *SystemMsg) Render(t *Theme) string { + return fmt.Sprintf("-> %s", m.body) +} + +func (m *SystemMsg) String() string { + return m.Render(nil) +} + +func (m *SystemMsg) To() *User { + return m.to +} + +// AnnounceMsg is a message sent from the server to everyone, like a join or +// leave event. +type AnnounceMsg struct { + Msg +} + +func NewAnnounceMsg(body string) *AnnounceMsg { + return &AnnounceMsg{ + Msg: Msg{ + body: body, + timestamp: time.Now(), + }, + } +} + +func (m *AnnounceMsg) Render(t *Theme) string { + return fmt.Sprintf(" * %s", m.body) +} + +func (m *AnnounceMsg) String() string { + return m.Render(nil) +} + +type CommandMsg struct { + *PublicMsg + command string + args []string + channel *Channel +} + +func (m *CommandMsg) Command() string { + return m.command +} + +func (m *CommandMsg) Args() []string { + return m.args } diff --git a/chat/message_test.go b/chat/message_test.go index 120a7ca..80bc98a 100644 --- a/chat/message_test.go +++ b/chat/message_test.go @@ -6,26 +6,26 @@ func TestMessage(t *testing.T) { var expected, actual string expected = " * foo" - actual = NewMessage("foo").String() + actual = NewAnnounceMsg("foo").String() if actual != expected { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } u := NewUser("foo") expected = "foo: hello" - actual = NewMessage("hello").From(u).String() + actual = NewPublicMsg("hello", u).String() if actual != expected { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } expected = "-> hello" - actual = NewMessage("hello").To(u).String() + actual = NewSystemMsg("hello", u).String() if actual != expected { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } expected = "[PM from foo] hello" - actual = NewMessage("hello").From(u).To(u).String() + actual = NewPrivateMsg("hello", u, u).String() if actual != expected { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } diff --git a/chat/user_test.go b/chat/user_test.go index 390f12d..ce2d4ef 100644 --- a/chat/user_test.go +++ b/chat/user_test.go @@ -10,14 +10,14 @@ func TestMakeUser(t *testing.T) { s := &MockScreen{} u := NewUser("foo") - m := NewMessage("hello") + m := NewAnnounceMsg("hello") defer u.Close() - u.Send(*m) + u.Send(m) u.ConsumeOne(s) s.Read(&actual) - expected = []byte(m.String()) + expected = []byte(m.String() + Newline) if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } diff --git a/cmd.go b/cmd.go index 3fbef43..8c8a576 100644 --- a/cmd.go +++ b/cmd.go @@ -46,6 +46,7 @@ func main() { if p == nil { fmt.Print(err) } + os.Exit(1) return } @@ -81,12 +82,14 @@ func main() { privateKey, err := ioutil.ReadFile(privateKeyPath) if err != nil { logger.Errorf("Failed to load identity: %v", err) + os.Exit(2) return } signer, err := ssh.ParsePrivateKey(privateKey) if err != nil { - logger.Errorf("Failed to prase key: %v", err) + logger.Errorf("Failed to parse key: %v", err) + os.Exit(3) return } @@ -97,6 +100,7 @@ func main() { s, err := sshd.ListenSSH(options.Bind, config) if err != nil { logger.Errorf("Failed to listen on socket: %v", err) + os.Exit(4) return } defer s.Close() @@ -143,4 +147,5 @@ func main() { <-sig // Wait for ^C signal logger.Warningf("Interrupt signal detected, shutting down.") + os.Exit(0) } diff --git a/host.go b/host.go index 1aa4b0f..4b72a77 100644 --- a/host.go +++ b/host.go @@ -18,10 +18,12 @@ type Host struct { // NewHost creates a Host on top of an existing listener func NewHost(listener *sshd.SSHListener) *Host { + ch := chat.NewChannel() h := Host{ listener: listener, - channel: chat.NewChannel(), + channel: ch, } + go ch.Serve() return &h } @@ -51,8 +53,8 @@ func (h *Host) Connect(term *sshd.Terminal) { logger.Errorf("Terminal reading error: %s", err) break } - m := chat.NewMessage(line).From(user) - h.channel.Send(*m) + m := chat.ParseInput(line, user) + h.channel.Send(m) } err = h.channel.Leave(user) From 5dad20d24165dd576df487e4542054b8b95a507e Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 26 Dec 2014 12:11:03 -0800 Subject: [PATCH 17/60] close once, handleMsg api consistency. --- chat/channel.go | 24 ++++++++++++++---------- chat/channel_test.go | 29 ++++++++++++++++++++++------- chat/command.go | 11 +++++------ chat/message.go | 4 ++++ chat/message_test.go | 6 ++++++ chat/user.go | 6 +++--- 6 files changed, 54 insertions(+), 26 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index efa16d9..ec7fb89 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -3,6 +3,7 @@ package chat import ( "errors" "fmt" + "sync" ) const historyLen = 20 @@ -18,6 +19,7 @@ type Channel struct { broadcast chan Message commands Commands closed bool + closeOnce *sync.Once } // Create new channel and start broadcasting goroutine. @@ -33,26 +35,28 @@ func NewChannel() *Channel { } func (ch *Channel) Close() { - ch.closed = true - ch.users.Each(func(u Item) { - u.(*User).Close() + ch.closeOnce.Do(func() { + ch.closed = true + ch.users.Each(func(u Item) { + u.(*User).Close() + }) + ch.users.Clear() + close(ch.broadcast) }) - ch.users.Clear() - close(ch.broadcast) } // Handle a message, will block until done. func (ch *Channel) handleMsg(m Message) { - switch m.(type) { - case CommandMsg: - cmd := m.(CommandMsg) - err := ch.commands.Run(cmd) + switch m := m.(type) { + case *CommandMsg: + cmd := *m + err := ch.commands.Run(ch, cmd) if err != nil { m := NewSystemMsg(fmt.Sprintf("Err: %s", err), cmd.from) go ch.handleMsg(m) } case MessageTo: - user := m.(MessageTo).To() + user := m.To() user.Send(m) default: fromMsg, skip := m.(MessageFrom) diff --git a/chat/channel_test.go b/chat/channel_test.go index 25abaeb..625c8f2 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -5,14 +5,26 @@ import ( "testing" ) -func TestChannel(t *testing.T) { +func TestChannelServe(t *testing.T) { + ch := NewChannel() + ch.Send(NewAnnounceMsg("hello")) + + received := <-ch.broadcast + actual := received.String() + expected := " * hello" + + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } +} + +func TestChannelJoin(t *testing.T) { var expected, actual []byte s := &MockScreen{} u := NewUser("foo") ch := NewChannel() - go ch.Serve() defer ch.Close() err := ch.Join(u) @@ -20,20 +32,23 @@ func TestChannel(t *testing.T) { t.Fatal(err) } + m := <-ch.broadcast + if m.(*AnnounceMsg) == nil { + t.Fatal("Did not receive correct msg: %v", m) + } + ch.handleMsg(m) + u.ConsumeOne(s) expected = []byte(" * foo joined. (Connected: 1)" + Newline) s.Read(&actual) if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } - // XXX - t.Skip() - m := NewPublicMsg("hello", u) - ch.Send(m) + ch.Send(NewSystemMsg("hello", u)) u.ConsumeOne(s) - expected = []byte("foo: hello" + Newline) + expected = []byte("-> hello" + Newline) s.Read(&actual) if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) diff --git a/chat/command.go b/chat/command.go index 89fd799..0fe6a79 100644 --- a/chat/command.go +++ b/chat/command.go @@ -8,7 +8,7 @@ import ( var ErrInvalidCommand = errors.New("invalid command") var ErrNoOwner = errors.New("command without owner") -type CommandHandler func(c CommandMsg) error +type CommandHandler func(*Channel, CommandMsg) error type Commands map[string]CommandHandler @@ -18,7 +18,7 @@ func (c Commands) Add(command string, handler CommandHandler) { } // Execute command message, assumes IsCommand was checked -func (c Commands) Run(msg CommandMsg) error { +func (c Commands) Run(channel *Channel, msg CommandMsg) error { if msg.from == nil { return ErrNoOwner } @@ -28,7 +28,7 @@ func (c Commands) Run(msg CommandMsg) error { return ErrInvalidCommand } - return handler(msg) + return handler(channel, msg) } var defaultCmdHandlers Commands @@ -36,14 +36,13 @@ var defaultCmdHandlers Commands func init() { c := Commands{} - c.Add("/me", func(msg CommandMsg) error { + c.Add("/me", func(channel *Channel, msg CommandMsg) error { me := strings.TrimLeft(msg.body, "/me") if me == "" { me = " is at a loss for words." } - // XXX: Finish this. - + channel.Send(NewEmoteMsg(me, msg.From())) return nil }) diff --git a/chat/message.go b/chat/message.go index ce02eb9..1779cfc 100644 --- a/chat/message.go +++ b/chat/message.go @@ -101,6 +101,10 @@ type EmoteMsg struct { PublicMsg } +func NewEmoteMsg(body string, from *User) *EmoteMsg { + return &EmoteMsg{*NewPublicMsg(body, from)} +} + func (m *EmoteMsg) Render(t *Theme) string { return fmt.Sprintf("** %s %s", m.from.Name(), m.body) } diff --git a/chat/message_test.go b/chat/message_test.go index 80bc98a..fafe4f8 100644 --- a/chat/message_test.go +++ b/chat/message_test.go @@ -18,6 +18,12 @@ func TestMessage(t *testing.T) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } + expected = "** foo sighs." + actual = NewEmoteMsg("sighs.", u).String() + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + expected = "-> hello" actual = NewSystemMsg("hello", u).String() if actual != expected { diff --git a/chat/user.go b/chat/user.go index bd59981..05ca517 100644 --- a/chat/user.go +++ b/chat/user.go @@ -85,16 +85,16 @@ func (u *User) Close() { // TODO: Not sure if this is a great API. func (u *User) Consume(out io.Writer) { for m := range u.msg { - u.consumeMsg(m, out) + u.handleMsg(m, out) } } // Consume one message and stop, mostly for testing func (u *User) ConsumeOne(out io.Writer) { - u.consumeMsg(<-u.msg, out) + u.handleMsg(<-u.msg, out) } -func (u *User) consumeMsg(m Message, out io.Writer) { +func (u *User) handleMsg(m Message, out io.Writer) { s := m.Render(u.Config.Theme) _, err := out.Write([]byte(s + Newline)) if err != nil { From 999e1919e72648d03cba0e146c71ab5a003de1c3 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 26 Dec 2014 12:20:44 -0800 Subject: [PATCH 18/60] Tests pass. --- chat/channel.go | 9 ++++++++- chat/channel_test.go | 11 ++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index ec7fb89..77eb22c 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -19,7 +19,7 @@ type Channel struct { broadcast chan Message commands Commands closed bool - closeOnce *sync.Once + closeOnce sync.Once } // Create new channel and start broadcasting goroutine. @@ -34,6 +34,7 @@ func NewChannel() *Channel { } } +// Close the channel and all the users it contains. func (ch *Channel) Close() { ch.closeOnce.Do(func() { ch.closed = true @@ -47,6 +48,7 @@ func (ch *Channel) Close() { // Handle a message, will block until done. func (ch *Channel) handleMsg(m Message) { + logger.Printf("ch.handleMsg(%v)", m) switch m := m.(type) { case *CommandMsg: cmd := *m @@ -88,10 +90,12 @@ func (ch *Channel) Serve() { } } +// Send message, buffered by a chan. func (ch *Channel) Send(m Message) { ch.broadcast <- m } +// Join the channel as a user, will announce. func (ch *Channel) Join(u *User) error { if ch.closed { return ErrChannelClosed @@ -105,6 +109,7 @@ func (ch *Channel) Join(u *User) error { return nil } +// Leave the channel as a user, will announce. func (ch *Channel) Leave(u *User) error { err := ch.users.Remove(u) if err != nil { @@ -115,10 +120,12 @@ func (ch *Channel) Leave(u *User) error { return nil } +// Topic of the channel. func (ch *Channel) Topic() string { return ch.topic } +// SetTopic will set the topic of the channel. func (ch *Channel) SetTopic(s string) { ch.topic = s } diff --git a/chat/channel_test.go b/chat/channel_test.go index 625c8f2..07e0c8b 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -1,6 +1,7 @@ package chat import ( + "os" "reflect" "testing" ) @@ -21,10 +22,13 @@ func TestChannelServe(t *testing.T) { func TestChannelJoin(t *testing.T) { var expected, actual []byte + SetLogger(os.Stderr) + s := &MockScreen{} u := NewUser("foo") ch := NewChannel() + go ch.Serve() defer ch.Close() err := ch.Join(u) @@ -32,12 +36,6 @@ func TestChannelJoin(t *testing.T) { t.Fatal(err) } - m := <-ch.broadcast - if m.(*AnnounceMsg) == nil { - t.Fatal("Did not receive correct msg: %v", m) - } - ch.handleMsg(m) - u.ConsumeOne(s) expected = []byte(" * foo joined. (Connected: 1)" + Newline) s.Read(&actual) @@ -46,7 +44,6 @@ func TestChannelJoin(t *testing.T) { } ch.Send(NewSystemMsg("hello", u)) - u.ConsumeOne(s) expected = []byte("-> hello" + Newline) s.Read(&actual) From 325f8921da202a6fe4da97cddf73e0e43229a6d7 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 26 Dec 2014 12:26:53 -0800 Subject: [PATCH 19/60] /me works now, with test. --- chat/channel_test.go | 8 ++++++++ chat/command.go | 2 ++ chat/message.go | 15 ++++++++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/chat/channel_test.go b/chat/channel_test.go index 07e0c8b..de06bce 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -50,4 +50,12 @@ func TestChannelJoin(t *testing.T) { if !reflect.DeepEqual(actual, expected) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } + + ch.Send(ParseInput("/me says hello.", u)) + u.ConsumeOne(s) + expected = []byte("** foo says hello." + Newline) + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } } diff --git a/chat/command.go b/chat/command.go index 0fe6a79..23df6e2 100644 --- a/chat/command.go +++ b/chat/command.go @@ -40,6 +40,8 @@ func init() { me := strings.TrimLeft(msg.body, "/me") if me == "" { me = " is at a loss for words." + } else { + me = me[1:] } channel.Send(NewEmoteMsg(me, msg.From())) diff --git a/chat/message.go b/chat/message.go index 1779cfc..92be95b 100644 --- a/chat/message.go +++ b/chat/message.go @@ -96,13 +96,22 @@ func (m *PublicMsg) String() string { return m.Render(nil) } -// EmoteMsg is a /me message sent to the channel. +// EmoteMsg is a /me message sent to the channel. It specifically does not +// extend PublicMsg because it doesn't implement MessageFrom to allow the +// sender to see the emote. type EmoteMsg struct { - PublicMsg + Msg + from *User } func NewEmoteMsg(body string, from *User) *EmoteMsg { - return &EmoteMsg{*NewPublicMsg(body, from)} + return &EmoteMsg{ + Msg: Msg{ + body: body, + timestamp: time.Now(), + }, + from: from, + } } func (m *EmoteMsg) Render(t *Theme) string { From b40136c3e128f8ab25a25721bc411d4324aa1dc2 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 26 Dec 2014 14:34:13 -0800 Subject: [PATCH 20/60] Added /nick and /exit commands. --- chat/channel.go | 10 +++--- chat/command.go | 91 +++++++++++++++++++++++++++++++++++++++++++------ chat/message.go | 5 +++ chat/user.go | 32 +++++++++-------- host.go | 21 ++++++++++-- 5 files changed, 127 insertions(+), 32 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index 77eb22c..e7e6f46 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -30,7 +30,7 @@ func NewChannel() *Channel { broadcast: broadcast, history: NewHistory(historyLen), users: NewSet(), - commands: defaultCmdHandlers, + commands: *defaultCmdHandlers, } } @@ -47,15 +47,15 @@ func (ch *Channel) Close() { } // Handle a message, will block until done. -func (ch *Channel) handleMsg(m Message) { - logger.Printf("ch.handleMsg(%v)", m) +func (ch *Channel) HandleMsg(m Message) { + logger.Printf("ch.HandleMsg(%v)", m) switch m := m.(type) { case *CommandMsg: cmd := *m err := ch.commands.Run(ch, cmd) if err != nil { m := NewSystemMsg(fmt.Sprintf("Err: %s", err), cmd.from) - go ch.handleMsg(m) + go ch.HandleMsg(m) } case MessageTo: user := m.To() @@ -86,7 +86,7 @@ func (ch *Channel) handleMsg(m Message) { // run in a goroutine. func (ch *Channel) Serve() { for m := range ch.broadcast { - go ch.handleMsg(m) + go ch.HandleMsg(m) } } diff --git a/chat/command.go b/chat/command.go index 23df6e2..f2b0c46 100644 --- a/chat/command.go +++ b/chat/command.go @@ -2,28 +2,66 @@ package chat import ( "errors" + "fmt" + "sort" "strings" + "sync" ) var ErrInvalidCommand = errors.New("invalid command") var ErrNoOwner = errors.New("command without owner") +var ErrMissingArg = errors.New("missing argument") type CommandHandler func(*Channel, CommandMsg) error -type Commands map[string]CommandHandler - -// Register command -func (c Commands) Add(command string, handler CommandHandler) { - c[command] = handler +type Commands struct { + handlers map[string]CommandHandler + help map[string]string + sync.RWMutex } -// Execute command message, assumes IsCommand was checked +func NewCommands() *Commands { + return &Commands{ + handlers: map[string]CommandHandler{}, + help: map[string]string{}, + } +} + +// Register command. If help string is empty, it will be hidden from Help(). +func (c Commands) Add(command string, help string, handler CommandHandler) { + c.Lock() + defer c.Unlock() + + c.handlers[command] = handler + + if help != "" { + c.help[command] = help + } +} + +// Alias will add another command for the same handler, won't get added to help. +func (c Commands) Alias(command string, alias string) error { + c.Lock() + defer c.Unlock() + + handler, ok := c.handlers[command] + if !ok { + return ErrInvalidCommand + } + c.handlers[alias] = handler + return nil +} + +// Execute command message, assumes IsCommand was checked. func (c Commands) Run(channel *Channel, msg CommandMsg) error { if msg.from == nil { return ErrNoOwner } - handler, ok := c[msg.Command()] + c.RLock() + defer c.RUnlock() + + handler, ok := c.handlers[msg.Command()] if !ok { return ErrInvalidCommand } @@ -31,12 +69,26 @@ func (c Commands) Run(channel *Channel, msg CommandMsg) error { return handler(channel, msg) } -var defaultCmdHandlers Commands +// Help will return collated help text as one string. +func (c Commands) Help() string { + c.RLock() + defer c.RUnlock() + + r := make([]string, len(c.help)) + for cmd, line := range c.help { + r = append(r, fmt.Sprintf("%s %s", cmd, line)) + } + sort.Strings(r) + + return strings.Join(r, Newline) +} + +var defaultCmdHandlers *Commands func init() { - c := Commands{} + c := NewCommands() - c.Add("/me", func(channel *Channel, msg CommandMsg) error { + c.Add("/me", "", func(channel *Channel, msg CommandMsg) error { me := strings.TrimLeft(msg.body, "/me") if me == "" { me = " is at a loss for words." @@ -48,5 +100,24 @@ func init() { return nil }) + c.Add("/exit", "Exit the chat.", func(channel *Channel, msg CommandMsg) error { + msg.From().Close() + return nil + }) + c.Alias("/exit", "/quit") + + c.Add("/nick", "NAME\tRename yourself.", func(channel *Channel, msg CommandMsg) error { + args := msg.Args() + if len(args) != 1 { + return ErrMissingArg + } + u := msg.From() + oldName := u.Name() + u.SetName(args[0]) + + channel.Send(NewAnnounceMsg(fmt.Sprintf("%s is now known as %s.", oldName, u.Name()))) + return nil + }) + defaultCmdHandlers = c } diff --git a/chat/message.go b/chat/message.go index 92be95b..803d2aa 100644 --- a/chat/message.go +++ b/chat/message.go @@ -10,6 +10,7 @@ import ( type Message interface { Render(*Theme) string String() string + Command() string } type MessageTo interface { @@ -50,6 +51,10 @@ func (m *Msg) String() string { return m.Render(nil) } +func (m *Msg) Command() string { + return "" +} + // PublicMsg is any message from a user sent to the channel. type PublicMsg struct { Msg diff --git a/chat/user.go b/chat/user.go index 05ca517..413b4bb 100644 --- a/chat/user.go +++ b/chat/user.go @@ -4,6 +4,7 @@ import ( "errors" "io" "math/rand" + "sync" "time" ) @@ -13,14 +14,15 @@ var ErrUserClosed = errors.New("user closed") // User definition, implemented set Item interface and io.Writer type User struct { - name string - op bool - colorIdx int - joined time.Time - msg chan Message - done chan struct{} - closed bool - Config UserConfig + Config UserConfig + name string + op bool + colorIdx int + joined time.Time + msg chan Message + done chan struct{} + closed bool + closeOnce sync.Once } func NewUser(name string) *User { @@ -75,9 +77,11 @@ func (u *User) Wait() { // Disconnect user, stop accepting messages func (u *User) Close() { - u.closed = true - close(u.done) - close(u.msg) + u.closeOnce.Do(func() { + u.closed = true + close(u.done) + close(u.msg) + }) } // Consume message buffer into an io.Writer. Will block, should be called in a @@ -85,16 +89,16 @@ func (u *User) Close() { // TODO: Not sure if this is a great API. func (u *User) Consume(out io.Writer) { for m := range u.msg { - u.handleMsg(m, out) + u.HandleMsg(m, out) } } // Consume one message and stop, mostly for testing func (u *User) ConsumeOne(out io.Writer) { - u.handleMsg(<-u.msg, out) + u.HandleMsg(<-u.msg, out) } -func (u *User) handleMsg(m Message, out io.Writer) { +func (u *User) HandleMsg(m Message, out io.Writer) { s := m.Render(u.Config.Theme) _, err := out.Write([]byte(s + Newline)) if err != nil { diff --git a/host.go b/host.go index 4b72a77..6325c20 100644 --- a/host.go +++ b/host.go @@ -29,14 +29,22 @@ func NewHost(listener *sshd.SSHListener) *Host { // Connect a specific Terminal to this host and its channel func (h *Host) Connect(term *sshd.Terminal) { - defer term.Close() name := term.Conn.User() - term.SetPrompt(fmt.Sprintf("[%s] ", name)) term.AutoCompleteCallback = h.AutoCompleteFunction user := chat.NewUserScreen(name, term) + go func() { + // Close term once user is closed. + user.Wait() + term.Close() + }() defer user.Close() + refreshPrompt := func() { + term.SetPrompt(fmt.Sprintf("[%s] ", user.Name())) + } + refreshPrompt() + err := h.channel.Join(user) if err != nil { logger.Errorf("Failed to join: %s", err) @@ -54,7 +62,14 @@ func (h *Host) Connect(term *sshd.Terminal) { break } m := chat.ParseInput(line, user) - h.channel.Send(m) + // FIXME: Any reason to use h.channel.Send(m) instead? + h.channel.HandleMsg(m) + if m.Command() == "/nick" { + // Hijack /nick command to update terminal synchronously. Wouldn't + // work if we use h.channel.Send(m) above. + // FIXME: This is hacky, how do we improve the API to allow for this? + refreshPrompt() + } } err = h.channel.Leave(user) From f3a4045ed9f58a1da19c4063919dadf6602c9dbe Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 26 Dec 2014 16:05:04 -0800 Subject: [PATCH 21/60] Added /nick, /names --- chat/command.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/chat/command.go b/chat/command.go index f2b0c46..506c4f8 100644 --- a/chat/command.go +++ b/chat/command.go @@ -74,7 +74,7 @@ func (c Commands) Help() string { c.RLock() defer c.RUnlock() - r := make([]string, len(c.help)) + r := []string{} for cmd, line := range c.help { r = append(r, fmt.Sprintf("%s %s", cmd, line)) } @@ -88,6 +88,11 @@ var defaultCmdHandlers *Commands func init() { c := NewCommands() + c.Add("/help", "", func(channel *Channel, msg CommandMsg) error { + channel.Send(NewSystemMsg("Available commands:"+Newline+c.Help(), msg.From())) + return nil + }) + c.Add("/me", "", func(channel *Channel, msg CommandMsg) error { me := strings.TrimLeft(msg.body, "/me") if me == "" { @@ -100,13 +105,13 @@ func init() { return nil }) - c.Add("/exit", "Exit the chat.", func(channel *Channel, msg CommandMsg) error { + c.Add("/exit", "- Exit the chat.", func(channel *Channel, msg CommandMsg) error { msg.From().Close() return nil }) c.Alias("/exit", "/quit") - c.Add("/nick", "NAME\tRename yourself.", func(channel *Channel, msg CommandMsg) error { + c.Add("/nick", "NAME - Rename yourself.", func(channel *Channel, msg CommandMsg) error { args := msg.Args() if len(args) != 1 { return ErrMissingArg @@ -115,9 +120,19 @@ func init() { oldName := u.Name() u.SetName(args[0]) - channel.Send(NewAnnounceMsg(fmt.Sprintf("%s is now known as %s.", oldName, u.Name()))) + body := fmt.Sprintf("%s is now known as %s.", oldName, u.Name()) + channel.Send(NewAnnounceMsg(body)) return nil }) + c.Add("/names", "- List users who are connected.", func(channel *Channel, msg CommandMsg) error { + // TODO: colorize + names := channel.NamesPrefix("") + body := fmt.Sprintf("%d connected: %s", len(names), strings.Join(names, ", ")) + channel.Send(NewSystemMsg(body, msg.From())) + return nil + }) + c.Alias("/names", "/list") + defaultCmdHandlers = c } From 4c5dff7960af8e38905dd154d6f7eb2b19b8e34c Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 26 Dec 2014 17:40:57 -0800 Subject: [PATCH 22/60] Themes are working, and /theme command. --- chat/channel.go | 1 - chat/channel_test.go | 3 -- chat/command.go | 25 ++++++++++++++++ chat/message.go | 12 +++++--- chat/theme.go | 37 ++++++++++++++++++----- chat/theme_test.go | 71 ++++++++++++++++++++++++++++++++++++++++++++ chat/user.go | 14 ++++++--- host.go | 27 ++++++++++++----- host_test.go | 27 +++++++++++++++++ 9 files changed, 189 insertions(+), 28 deletions(-) create mode 100644 chat/theme_test.go create mode 100644 host_test.go diff --git a/chat/channel.go b/chat/channel.go index e7e6f46..2107419 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -48,7 +48,6 @@ func (ch *Channel) Close() { // Handle a message, will block until done. func (ch *Channel) HandleMsg(m Message) { - logger.Printf("ch.HandleMsg(%v)", m) switch m := m.(type) { case *CommandMsg: cmd := *m diff --git a/chat/channel_test.go b/chat/channel_test.go index de06bce..2532fb2 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -1,7 +1,6 @@ package chat import ( - "os" "reflect" "testing" ) @@ -22,8 +21,6 @@ func TestChannelServe(t *testing.T) { func TestChannelJoin(t *testing.T) { var expected, actual []byte - SetLogger(os.Stderr) - s := &MockScreen{} u := NewUser("foo") diff --git a/chat/command.go b/chat/command.go index 506c4f8..0225662 100644 --- a/chat/command.go +++ b/chat/command.go @@ -134,5 +134,30 @@ func init() { }) c.Alias("/names", "/list") + c.Add("/theme", "[mono|colors] - Set your color theme.", func(channel *Channel, msg CommandMsg) error { + user := msg.From() + args := msg.Args() + if len(args) == 0 { + theme := "plain" + if user.Config.Theme != nil { + theme = user.Config.Theme.Id() + } + body := fmt.Sprintf("Current theme: %s", theme) + channel.Send(NewSystemMsg(body, user)) + return nil + } + + id := args[0] + for _, t := range Themes { + if t.Id() == id { + user.Config.Theme = &t + body := fmt.Sprintf("Set theme: %s", id) + channel.Send(NewSystemMsg(body, user)) + return nil + } + } + return errors.New("theme not found") + }) + defaultCmdHandlers = c } diff --git a/chat/message.go b/chat/message.go index 803d2aa..955bc23 100644 --- a/chat/message.go +++ b/chat/message.go @@ -44,11 +44,11 @@ type Msg struct { func (m *Msg) Render(t *Theme) string { // TODO: Render based on theme // TODO: Cache based on theme - return m.body + return m.String() } func (m *Msg) String() string { - return m.Render(nil) + return m.body } func (m *Msg) Command() string { @@ -94,11 +94,15 @@ func (m *PublicMsg) ParseCommand() (*CommandMsg, bool) { } func (m *PublicMsg) Render(t *Theme) string { - return fmt.Sprintf("%s: %s", m.from.Name(), m.body) + if t == nil { + return m.String() + } + + return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body) } func (m *PublicMsg) String() string { - return m.Render(nil) + return fmt.Sprintf("%s: %s", m.from.Name(), m.body) } // EmoteMsg is a /me message sent to the channel. It specifically does not diff --git a/chat/theme.go b/chat/theme.go index f592327..9ada33f 100644 --- a/chat/theme.go +++ b/chat/theme.go @@ -35,7 +35,7 @@ type Color interface { } // 256 color type, for terminals who support it -type Color256 int8 +type Color256 uint8 // String version of this color func (c Color256) String() string { @@ -68,7 +68,19 @@ type Palette struct { // Get a color by index, overflows are looped around. func (p Palette) Get(i int) Color { - return p.colors[i%p.size] + return p.colors[i%(p.size-1)] +} + +func (p Palette) Len() int { + return p.size +} + +func (p Palette) String() string { + r := "" + for _, c := range p.colors { + r += c.Format("X") + } + return r } // Collection of settings for chat @@ -79,13 +91,17 @@ type Theme struct { names *Palette } +func (t Theme) Id() string { + return t.id +} + // Colorize name string given some index -func (t Theme) ColorName(s string, i int) string { +func (t Theme) ColorName(u *User) string { if t.names == nil { - return s + return u.name } - return t.names.Get(i).Format(s) + return t.names.Get(u.colorIdx).Format(u.name) } // Colorize the PM string @@ -113,16 +129,19 @@ var Themes []Theme var DefaultTheme *Theme func readableColors256() *Palette { + size := 247 p := Palette{ - colors: make([]Color, 246), - size: 246, + colors: make([]Color, size), + size: size, } + j := 0 for i := 0; i < 256; i++ { if (16 <= i && i <= 18) || (232 <= i && i <= 237) { // Remove the ones near black, this is kinda sadpanda. continue } - p.colors = append(p.colors, Color256(i)) + p.colors[j] = Color256(i) + j++ } return &p } @@ -134,6 +153,8 @@ func init() { Theme{ id: "colors", names: palette, + sys: palette.Get(8), // Grey + pm: palette.Get(7), // White }, Theme{ id: "mono", diff --git a/chat/theme_test.go b/chat/theme_test.go new file mode 100644 index 0000000..f385982 --- /dev/null +++ b/chat/theme_test.go @@ -0,0 +1,71 @@ +package chat + +import ( + "fmt" + "testing" +) + +func TestThemePalette(t *testing.T) { + var expected, actual string + + palette := readableColors256() + color := palette.Get(5) + if color == nil { + t.Fatal("Failed to return a color from palette.") + } + + actual = color.String() + expected = "38;05;5" + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + actual = color.Format("foo") + expected = "\033[38;05;5mfoo\033[0m" + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + actual = palette.Get(palette.Len() + 1).String() + expected = fmt.Sprintf("38;05;%d", 2) + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + +} + +func TestTheme(t *testing.T) { + var expected, actual string + + colorTheme := Themes[0] + color := colorTheme.sys + if color == nil { + t.Fatal("Sys color should not be empty for first theme.") + } + + actual = color.Format("foo") + expected = "\033[38;05;8mfoo\033[0m" + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + actual = colorTheme.ColorSys("foo") + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + u := NewUser("foo") + u.colorIdx = 4 + actual = colorTheme.ColorName(u) + expected = "\033[38;05;4mfoo\033[0m" + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + msg := NewPublicMsg("hello", u) + actual = msg.Render(&colorTheme) + expected = "\033[38;05;4mfoo\033[0m: hello" + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } +} diff --git a/chat/user.go b/chat/user.go index 413b4bb..4f113d5 100644 --- a/chat/user.go +++ b/chat/user.go @@ -44,20 +44,26 @@ func NewUserScreen(name string, screen io.Writer) *User { return u } -// Return unique identifier for user +// Id of the user, a unique identifier within a set func (u *User) Id() Id { return Id(u.name) } -// Return user's name +// Name of the user func (u *User) Name() string { return u.name } -// Return set user's name +// SetName will change the name of the user and reset the colorIdx func (u *User) SetName(name string) { u.name = name - u.colorIdx = rand.Int() + u.SetColorIdx(rand.Int()) +} + +// SetColorIdx will set the colorIdx to a specific value, primarily used for +// testing. +func (u *User) SetColorIdx(idx int) { + u.colorIdx = idx } // Return whether user is an admin diff --git a/host.go b/host.go index 6325c20..2c85ccd 100644 --- a/host.go +++ b/host.go @@ -33,6 +33,7 @@ func (h *Host) Connect(term *sshd.Terminal) { term.AutoCompleteCallback = h.AutoCompleteFunction user := chat.NewUserScreen(name, term) + user.Config.Theme = &chat.Themes[0] go func() { // Close term once user is closed. user.Wait() @@ -40,10 +41,7 @@ func (h *Host) Connect(term *sshd.Terminal) { }() defer user.Close() - refreshPrompt := func() { - term.SetPrompt(fmt.Sprintf("[%s] ", user.Name())) - } - refreshPrompt() + term.SetPrompt(GetPrompt(user)) err := h.channel.Join(user) if err != nil { @@ -52,7 +50,6 @@ func (h *Host) Connect(term *sshd.Terminal) { } for { - // TODO: Handle commands etc? line, err := term.ReadLine() if err == io.EOF { // Closed @@ -62,13 +59,18 @@ func (h *Host) Connect(term *sshd.Terminal) { break } m := chat.ParseInput(line, user) + // FIXME: Any reason to use h.channel.Send(m) instead? h.channel.HandleMsg(m) - if m.Command() == "/nick" { + + cmd := m.Command() + if cmd == "/nick" || cmd == "/theme" { // Hijack /nick command to update terminal synchronously. Wouldn't // work if we use h.channel.Send(m) above. - // FIXME: This is hacky, how do we improve the API to allow for this? - refreshPrompt() + // + // 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)) } } @@ -119,3 +121,12 @@ func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine str ok = true return } + +// RefreshPrompt will update the terminal prompt with the latest user name. +func GetPrompt(user *chat.User) string { + name := user.Name() + if user.Config.Theme != nil { + name = user.Config.Theme.ColorName(user) + } + return fmt.Sprintf("[%s] ", name) +} diff --git a/host_test.go b/host_test.go new file mode 100644 index 0000000..882c5f9 --- /dev/null +++ b/host_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + "github.com/shazow/ssh-chat/chat" +) + +func TestHostGetPrompt(t *testing.T) { + var expected, actual string + + u := chat.NewUser("foo") + u.SetColorIdx(2) + + actual = GetPrompt(u) + expected = "[foo] " + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + u.Config.Theme = &chat.Themes[0] + actual = GetPrompt(u) + expected = "[\033[38;05;2mfoo\033[0m] " + if actual != expected { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } +} From 6f7410c7a0ba2697b8d03d74143fef3b4feedda6 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 26 Dec 2014 17:59:29 -0800 Subject: [PATCH 23/60] Making a dent in golint: 94 -> 70 --- chat/channel.go | 6 ++++-- chat/command.go | 16 +++++++++++++--- chat/set.go | 35 ++++++++++++++++++----------------- host.go | 2 +- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index 2107419..646fe0c 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -9,6 +9,8 @@ import ( const historyLen = 20 const channelBuffer = 10 +// The error returned when a message is sent to a channel that is already +// closed. var ErrChannelClosed = errors.New("channel closed") // Channel definition, also a Set of User Items @@ -22,7 +24,7 @@ type Channel struct { closeOnce sync.Once } -// Create new channel and start broadcasting goroutine. +// NewChannel creates a new channel. func NewChannel() *Channel { broadcast := make(chan Message, channelBuffer) @@ -46,7 +48,7 @@ func (ch *Channel) Close() { }) } -// Handle a message, will block until done. +// HandleMsg reacts to a message, will block until done. func (ch *Channel) HandleMsg(m Message) { switch m := m.(type) { case *CommandMsg: diff --git a/chat/command.go b/chat/command.go index 0225662..cb329d1 100644 --- a/chat/command.go +++ b/chat/command.go @@ -8,18 +8,27 @@ import ( "sync" ) +// The error returned when an invalid command is issued. var ErrInvalidCommand = errors.New("invalid command") + +// The error returned when a command is given without an owner. var ErrNoOwner = errors.New("command without owner") + +// The error returned when a command is performed without the necessary number +// of arguments. var ErrMissingArg = errors.New("missing argument") +// CommandHandler is the function signature for command handlers.. type CommandHandler func(*Channel, CommandMsg) error +// Commands is a registry of available commands. type Commands struct { handlers map[string]CommandHandler help map[string]string sync.RWMutex } +// NewCommands returns a new Commands registry. func NewCommands() *Commands { return &Commands{ handlers: map[string]CommandHandler{}, @@ -27,7 +36,8 @@ func NewCommands() *Commands { } } -// Register command. If help string is empty, it will be hidden from Help(). +// Add will register a command. If help string is empty, it will be hidden from +// Help(). func (c Commands) Add(command string, help string, handler CommandHandler) { c.Lock() defer c.Unlock() @@ -52,9 +62,9 @@ func (c Commands) Alias(command string, alias string) error { return nil } -// Execute command message, assumes IsCommand was checked. +// Run executes a command message. func (c Commands) Run(channel *Channel, msg CommandMsg) error { - if msg.from == nil { + if msg.From == nil { return ErrNoOwner } diff --git a/chat/set.go b/chat/set.go index 7ee9281..2248de8 100644 --- a/chat/set.go +++ b/chat/set.go @@ -6,35 +6,35 @@ import ( "sync" ) -var ErrIdTaken error = errors.New("id already taken") -var ErrItemMissing error = errors.New("item does not exist") +// The error returned when an added id already exists in the set. +var ErrIdTaken = errors.New("id already taken") -// Unique identifier for an item +// The error returned when a requested item does not exist in the set. +var ErrItemMissing = errors.New("item does not exist") + +// Id is a unique identifier for an item. type Id string -// A prefix for a unique identifier -type IdPrefix Id - -// An interface for items to store-able in the set +// Item is an interface for items to store-able in the set type Item interface { Id() Id } -// Set with string lookup +// Set with string lookup. // TODO: Add trie for efficient prefix lookup? type Set struct { lookup map[Id]Item sync.RWMutex } -// Create a new set +// NewSet creates a new set. func NewSet() *Set { return &Set{ lookup: map[Id]Item{}, } } -// Remove all items and return the number removed +// Clear removes all items and returns the number removed. func (s *Set) Clear() int { s.Lock() n := len(s.lookup) @@ -43,12 +43,12 @@ func (s *Set) Clear() int { return n } -// Size of the set right now +// Len returns the size of the set right now. func (s *Set) Len() int { return len(s.lookup) } -// Check if user belongs in this set +// In checks if an item exists in this set. func (s *Set) In(item Item) bool { s.RLock() _, ok := s.lookup[item.Id()] @@ -56,7 +56,7 @@ func (s *Set) In(item Item) bool { return ok } -// Get user by name +// Get returns an item with the given Id. func (s *Set) Get(id Id) (Item, error) { s.RLock() item, ok := s.lookup[id] @@ -69,7 +69,7 @@ func (s *Set) Get(id Id) (Item, error) { return item, nil } -// Add user to set if user does not exist already +// Add item to this set if it does not exist already. func (s *Set) Add(item Item) error { s.Lock() defer s.Unlock() @@ -83,7 +83,7 @@ func (s *Set) Add(item Item) error { return nil } -// Remove user from set +// Remove item from this set. func (s *Set) Remove(item Item) error { s.Lock() defer s.Unlock() @@ -96,7 +96,8 @@ func (s *Set) Remove(item Item) error { return nil } -// Loop over every item while holding a read lock and apply fn +// Each loops over every item while holding a read lock and applies fn to each +// element. func (s *Set) Each(fn func(item Item)) { s.RLock() for _, item := range s.lookup { @@ -105,7 +106,7 @@ func (s *Set) Each(fn func(item Item)) { s.RUnlock() } -// List users by prefix, case insensitive +// ListPrefix returns a list of items with a prefix, case insensitive. func (s *Set) ListPrefix(prefix string) []Item { r := []Item{} prefix = strings.ToLower(prefix) diff --git a/host.go b/host.go index 2c85ccd..680d553 100644 --- a/host.go +++ b/host.go @@ -122,7 +122,7 @@ func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine str return } -// RefreshPrompt will update the terminal prompt with the latest user name. +// GetPrompt will render the terminal prompt string based on the user. func GetPrompt(user *chat.User) string { name := user.Name() if user.Config.Theme != nil { From fb7cd838211cdccb0b11f59472be7a0a6fdf005a Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 1 Jan 2015 15:37:28 -0800 Subject: [PATCH 24/60] CommandHandler func -> Command struct --- chat/command.go | 175 ++++++++++++++++++++++++++++-------------------- 1 file changed, 103 insertions(+), 72 deletions(-) diff --git a/chat/command.go b/chat/command.go index cb329d1..26650ea 100644 --- a/chat/command.go +++ b/chat/command.go @@ -18,52 +18,59 @@ var ErrNoOwner = errors.New("command without owner") // of arguments. var ErrMissingArg = errors.New("missing argument") -// CommandHandler is the function signature for command handlers.. -type CommandHandler func(*Channel, CommandMsg) error +// The error returned when a command is added without a prefix. +var ErrMissingPrefix = errors.New("command missing prefix") + +// Command is a definition of a handler for a command. +type Command struct { + Prefix string + PrefixHelp string + Help string + Handler func(*Channel, CommandMsg) error +} // Commands is a registry of available commands. type Commands struct { - handlers map[string]CommandHandler - help map[string]string + commands map[string]Command sync.RWMutex } // NewCommands returns a new Commands registry. func NewCommands() *Commands { return &Commands{ - handlers: map[string]CommandHandler{}, - help: map[string]string{}, + commands: map[string]Command{}, } } // Add will register a command. If help string is empty, it will be hidden from // Help(). -func (c Commands) Add(command string, help string, handler CommandHandler) { +func (c *Commands) Add(cmd Command) error { c.Lock() defer c.Unlock() - c.handlers[command] = handler - - if help != "" { - c.help[command] = help + if cmd.Prefix == "" { + return ErrMissingPrefix } + + c.commands[cmd.Prefix] = cmd + return nil } // Alias will add another command for the same handler, won't get added to help. -func (c Commands) Alias(command string, alias string) error { +func (c *Commands) Alias(command string, alias string) error { c.Lock() defer c.Unlock() - handler, ok := c.handlers[command] + cmd, ok := c.commands[command] if !ok { return ErrInvalidCommand } - c.handlers[alias] = handler + c.commands[alias] = cmd return nil } // Run executes a command message. -func (c Commands) Run(channel *Channel, msg CommandMsg) error { +func (c *Commands) Run(channel *Channel, msg CommandMsg) error { if msg.From == nil { return ErrNoOwner } @@ -71,22 +78,22 @@ func (c Commands) Run(channel *Channel, msg CommandMsg) error { c.RLock() defer c.RUnlock() - handler, ok := c.handlers[msg.Command()] + cmd, ok := c.commands[msg.Command()] if !ok { return ErrInvalidCommand } - return handler(channel, msg) + return cmd.Handler(channel, msg) } // Help will return collated help text as one string. -func (c Commands) Help() string { +func (c *Commands) Help() string { c.RLock() defer c.RUnlock() r := []string{} - for cmd, line := range c.help { - r = append(r, fmt.Sprintf("%s %s", cmd, line)) + for _, cmd := range c.commands { + r = append(r, fmt.Sprintf("%s %s - %s", cmd.Prefix, cmd.PrefixHelp, cmd.Help)) } sort.Strings(r) @@ -98,75 +105,99 @@ var defaultCmdHandlers *Commands func init() { c := NewCommands() - c.Add("/help", "", func(channel *Channel, msg CommandMsg) error { - channel.Send(NewSystemMsg("Available commands:"+Newline+c.Help(), msg.From())) - return nil + c.Add(Command{ + Prefix: "/help", + Handler: func(channel *Channel, msg CommandMsg) error { + channel.Send(NewSystemMsg("Available commands:"+Newline+c.Help(), msg.From())) + return nil + }, }) - c.Add("/me", "", func(channel *Channel, msg CommandMsg) error { - me := strings.TrimLeft(msg.body, "/me") - if me == "" { - me = " is at a loss for words." - } else { - me = me[1:] - } + c.Add(Command{ + Prefix: "/me", + Handler: func(channel *Channel, msg CommandMsg) error { + me := strings.TrimLeft(msg.body, "/me") + if me == "" { + me = " is at a loss for words." + } else { + me = me[1:] + } - channel.Send(NewEmoteMsg(me, msg.From())) - return nil + channel.Send(NewEmoteMsg(me, msg.From())) + return nil + }, }) - c.Add("/exit", "- Exit the chat.", func(channel *Channel, msg CommandMsg) error { - msg.From().Close() - return nil + c.Add(Command{ + Prefix: "/exit", + Help: "Exit the chat.", + Handler: func(channel *Channel, msg CommandMsg) error { + msg.From().Close() + return nil + }, }) c.Alias("/exit", "/quit") - c.Add("/nick", "NAME - Rename yourself.", func(channel *Channel, msg CommandMsg) error { - args := msg.Args() - if len(args) != 1 { - return ErrMissingArg - } - u := msg.From() - oldName := u.Name() - u.SetName(args[0]) + c.Add(Command{ + Prefix: "/nick", + PrefixHelp: "NAME", + Help: "Rename yourself.", + Handler: func(channel *Channel, msg CommandMsg) error { + args := msg.Args() + if len(args) != 1 { + return ErrMissingArg + } + u := msg.From() + oldName := u.Name() + u.SetName(args[0]) - body := fmt.Sprintf("%s is now known as %s.", oldName, u.Name()) - channel.Send(NewAnnounceMsg(body)) - return nil + body := fmt.Sprintf("%s is now known as %s.", oldName, u.Name()) + channel.Send(NewAnnounceMsg(body)) + return nil + }, }) - c.Add("/names", "- List users who are connected.", func(channel *Channel, msg CommandMsg) error { - // TODO: colorize - names := channel.NamesPrefix("") - body := fmt.Sprintf("%d connected: %s", len(names), strings.Join(names, ", ")) - channel.Send(NewSystemMsg(body, msg.From())) - return nil + c.Add(Command{ + Prefix: "/names", + Help: "List users who are connected.", + Handler: func(channel *Channel, msg CommandMsg) error { + // TODO: colorize + names := channel.NamesPrefix("") + body := fmt.Sprintf("%d connected: %s", len(names), strings.Join(names, ", ")) + channel.Send(NewSystemMsg(body, msg.From())) + return nil + }, }) c.Alias("/names", "/list") - c.Add("/theme", "[mono|colors] - Set your color theme.", func(channel *Channel, msg CommandMsg) error { - user := msg.From() - args := msg.Args() - if len(args) == 0 { - theme := "plain" - if user.Config.Theme != nil { - theme = user.Config.Theme.Id() - } - body := fmt.Sprintf("Current theme: %s", theme) - channel.Send(NewSystemMsg(body, user)) - return nil - } - - id := args[0] - for _, t := range Themes { - if t.Id() == id { - user.Config.Theme = &t - body := fmt.Sprintf("Set theme: %s", id) + c.Add(Command{ + Prefix: "/theme", + PrefixHelp: "[mono|colors]", + Help: "Set your color theme.", + Handler: func(channel *Channel, msg CommandMsg) error { + user := msg.From() + args := msg.Args() + if len(args) == 0 { + theme := "plain" + if user.Config.Theme != nil { + theme = user.Config.Theme.Id() + } + body := fmt.Sprintf("Current theme: %s", theme) channel.Send(NewSystemMsg(body, user)) return nil } - } - return errors.New("theme not found") + + id := args[0] + for _, t := range Themes { + if t.Id() == id { + user.Config.Theme = &t + body := fmt.Sprintf("Set theme: %s", id) + channel.Send(NewSystemMsg(body, user)) + return nil + } + } + return errors.New("theme not found") + }, }) defaultCmdHandlers = c From 3b5f4faf76bea4c0ea3204cef8b1daec9123500b Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 1 Jan 2015 16:17:52 -0800 Subject: [PATCH 25/60] Padded help output, because why not. --- chat/command.go | 26 ++++++++++------------ chat/help.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 chat/help.go diff --git a/chat/command.go b/chat/command.go index 26650ea..9047b4d 100644 --- a/chat/command.go +++ b/chat/command.go @@ -3,7 +3,6 @@ package chat import ( "errors" "fmt" - "sort" "strings" "sync" ) @@ -23,22 +22,25 @@ var ErrMissingPrefix = errors.New("command missing prefix") // Command is a definition of a handler for a command. type Command struct { - Prefix string + // The command's key, such as /foo + Prefix string + // Extra help regarding arguments PrefixHelp string - Help string - Handler func(*Channel, CommandMsg) error + // If omitted, command is hidden from /help + Help string + Handler func(*Channel, CommandMsg) error } // Commands is a registry of available commands. type Commands struct { - commands map[string]Command + commands map[string]*Command sync.RWMutex } // NewCommands returns a new Commands registry. func NewCommands() *Commands { return &Commands{ - commands: map[string]Command{}, + commands: map[string]*Command{}, } } @@ -52,7 +54,7 @@ func (c *Commands) Add(cmd Command) error { return ErrMissingPrefix } - c.commands[cmd.Prefix] = cmd + c.commands[cmd.Prefix] = &cmd return nil } @@ -91,13 +93,9 @@ func (c *Commands) Help() string { c.RLock() defer c.RUnlock() - r := []string{} - for _, cmd := range c.commands { - r = append(r, fmt.Sprintf("%s %s - %s", cmd.Prefix, cmd.PrefixHelp, cmd.Help)) - } - sort.Strings(r) - - return strings.Join(r, Newline) + // TODO: Could cache this... + help := NewCommandsHelp(c) + return help.String() } var defaultCmdHandlers *Commands diff --git a/chat/help.go b/chat/help.go new file mode 100644 index 0000000..c6e6856 --- /dev/null +++ b/chat/help.go @@ -0,0 +1,58 @@ +package chat + +import ( + "fmt" + "sort" + "strings" +) + +type helpItem struct { + Prefix string + Text string +} + +type help struct { + items []helpItem + prefixWidth int +} + +// NewCommandsHelp creates a help container from a commands container. +func NewCommandsHelp(c *Commands) *help { + lookup := map[string]struct{}{} + h := help{ + items: []helpItem{}, + } + for _, cmd := range c.commands { + if cmd.Help == "" { + // Skip hidden commands. + continue + } + _, exists := lookup[cmd.Prefix] + if exists { + // Duplicate (alias) + continue + } + lookup[cmd.Prefix] = struct{}{} + prefix := fmt.Sprintf("%s %s", cmd.Prefix, cmd.PrefixHelp) + h.add(helpItem{prefix, cmd.Help}) + } + return &h +} + +func (h *help) add(item helpItem) { + h.items = append(h.items, item) + if len(item.Prefix) > h.prefixWidth { + h.prefixWidth = len(item.Prefix) + } +} + +func (h help) String() string { + r := []string{} + format := fmt.Sprintf("%%-%ds - %%s", h.prefixWidth) + for _, item := range h.items { + r = append(r, fmt.Sprintf(format, item.Prefix, item.Text)) + } + + sort.Strings(r) + return strings.Join(r, Newline) +} From 6874601c0b4d583eaae38ba1aa38b9687c1f37e9 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 1 Jan 2015 17:09:08 -0800 Subject: [PATCH 26/60] Op command support, and /op --- chat/channel.go | 9 ++++- chat/command.go | 97 +++++++++++++++++++++++++++++-------------------- chat/help.go | 4 +- chat/user.go | 11 ------ 4 files changed, 68 insertions(+), 53 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index 646fe0c..3bdca40 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -18,6 +18,7 @@ type Channel struct { topic string history *History users *Set + ops *Set broadcast chan Message commands Commands closed bool @@ -32,10 +33,16 @@ func NewChannel() *Channel { broadcast: broadcast, history: NewHistory(historyLen), users: NewSet(), - commands: *defaultCmdHandlers, + ops: NewSet(), + commands: *defaultCommands, } } +// SetCommands sets the channel's command handlers. +func (ch *Channel) SetCommands(commands Commands) { + ch.commands = commands +} + // Close the channel and all the users it contains. func (ch *Channel) Close() { ch.closeOnce.Do(func() { diff --git a/chat/command.go b/chat/command.go index 9047b4d..50d50e5 100644 --- a/chat/command.go +++ b/chat/command.go @@ -1,10 +1,10 @@ +// FIXME: Would be sweet if we could piggyback on a cli parser or something. package chat import ( "errors" "fmt" "strings" - "sync" ) // The error returned when an invalid command is issued. @@ -29,58 +29,41 @@ type Command struct { // If omitted, command is hidden from /help Help string Handler func(*Channel, CommandMsg) error + // Command requires Op permissions + Op bool } // Commands is a registry of available commands. -type Commands struct { - commands map[string]*Command - sync.RWMutex -} - -// NewCommands returns a new Commands registry. -func NewCommands() *Commands { - return &Commands{ - commands: map[string]*Command{}, - } -} +type Commands map[string]*Command // Add will register a command. If help string is empty, it will be hidden from // Help(). -func (c *Commands) Add(cmd Command) error { - c.Lock() - defer c.Unlock() - +func (c Commands) Add(cmd Command) error { if cmd.Prefix == "" { return ErrMissingPrefix } - c.commands[cmd.Prefix] = &cmd + c[cmd.Prefix] = &cmd return nil } // Alias will add another command for the same handler, won't get added to help. -func (c *Commands) Alias(command string, alias string) error { - c.Lock() - defer c.Unlock() - - cmd, ok := c.commands[command] +func (c Commands) Alias(command string, alias string) error { + cmd, ok := c[command] if !ok { return ErrInvalidCommand } - c.commands[alias] = cmd + c[alias] = cmd return nil } // Run executes a command message. -func (c *Commands) Run(channel *Channel, msg CommandMsg) error { +func (c Commands) Run(channel *Channel, msg CommandMsg) error { if msg.From == nil { return ErrNoOwner } - c.RLock() - defer c.RUnlock() - - cmd, ok := c.commands[msg.Command()] + cmd, ok := c[msg.Command()] if !ok { return ErrInvalidCommand } @@ -89,24 +72,35 @@ func (c *Commands) Run(channel *Channel, msg CommandMsg) error { } // Help will return collated help text as one string. -func (c *Commands) Help() string { - c.RLock() - defer c.RUnlock() - - // TODO: Could cache this... - help := NewCommandsHelp(c) - return help.String() +func (c Commands) Help(showOp bool) string { + // Filter by op + op := []*Command{} + normal := []*Command{} + for _, cmd := range c { + if cmd.Op { + op = append(op, cmd) + } else { + normal = append(normal, cmd) + } + } + help := "Available commands:" + Newline + NewCommandsHelp(normal).String() + if showOp { + help += Newline + "Operator commands:" + Newline + NewCommandsHelp(op).String() + } + return help } -var defaultCmdHandlers *Commands +var defaultCommands *Commands func init() { - c := NewCommands() + c := Commands{} c.Add(Command{ Prefix: "/help", Handler: func(channel *Channel, msg CommandMsg) error { - channel.Send(NewSystemMsg("Available commands:"+Newline+c.Help(), msg.From())) + user := msg.From() + op := channel.ops.In(user) + channel.Send(NewSystemMsg(channel.commands.Help(op), user)) return nil }, }) @@ -198,5 +192,30 @@ func init() { }, }) - defaultCmdHandlers = c + c.Add(Command{ + Prefix: "/op", + PrefixHelp: "USER", + Help: "Mark user as admin.", + Handler: func(channel *Channel, msg CommandMsg) error { + if !channel.ops.In(msg.From()) { + return errors.New("must be op") + } + + args := msg.Args() + if len(args) != 1 { + return errors.New("must specify user") + } + + // TODO: Add support for fingerprint-based op'ing. + user, err := channel.users.Get(Id(args[0])) + if err != nil { + return errors.New("user not found") + } + + channel.ops.Add(user) + return nil + }, + }) + + defaultCommands = &c } diff --git a/chat/help.go b/chat/help.go index c6e6856..9e9cd42 100644 --- a/chat/help.go +++ b/chat/help.go @@ -17,12 +17,12 @@ type help struct { } // NewCommandsHelp creates a help container from a commands container. -func NewCommandsHelp(c *Commands) *help { +func NewCommandsHelp(c []*Command) *help { lookup := map[string]struct{}{} h := help{ items: []helpItem{}, } - for _, cmd := range c.commands { + for _, cmd := range c { if cmd.Help == "" { // Skip hidden commands. continue diff --git a/chat/user.go b/chat/user.go index 4f113d5..542f0bc 100644 --- a/chat/user.go +++ b/chat/user.go @@ -16,7 +16,6 @@ var ErrUserClosed = errors.New("user closed") type User struct { Config UserConfig name string - op bool colorIdx int joined time.Time msg chan Message @@ -66,16 +65,6 @@ func (u *User) SetColorIdx(idx int) { u.colorIdx = idx } -// Return whether user is an admin -func (u *User) Op() bool { - return u.op -} - -// Set whether user is an admin -func (u *User) SetOp(op bool) { - u.op = op -} - // Block until user is closed func (u *User) Wait() { <-u.done From 6a662bf35839b7b51e2e4c25bb490b4c99c2a738 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 1 Jan 2015 18:40:10 -0800 Subject: [PATCH 27/60] Channel Member now wrapping User with metadata, new Auth struct. --- chat/channel.go | 54 ++++++++++++++++++++++++++++++++++-------------- chat/command.go | 15 +++++++------- cmd.go | 17 ++++++++------- host.go | 12 +++++++++-- sshd/auth.go | 29 ++++++++++---------------- sshd/terminal.go | 6 +++--- 6 files changed, 80 insertions(+), 53 deletions(-) diff --git a/chat/channel.go b/chat/channel.go index 3bdca40..b4de26e 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -13,12 +13,17 @@ const channelBuffer = 10 // closed. var ErrChannelClosed = errors.New("channel closed") +// Member is a User with per-Channel metadata attached to it. +type Member struct { + *User + Op bool +} + // Channel definition, also a Set of User Items type Channel struct { topic string history *History - users *Set - ops *Set + members *Set broadcast chan Message commands Commands closed bool @@ -32,8 +37,7 @@ func NewChannel() *Channel { return &Channel{ broadcast: broadcast, history: NewHistory(historyLen), - users: NewSet(), - ops: NewSet(), + members: NewSet(), commands: *defaultCommands, } } @@ -47,10 +51,10 @@ func (ch *Channel) SetCommands(commands Commands) { func (ch *Channel) Close() { ch.closeOnce.Do(func() { ch.closed = true - ch.users.Each(func(u Item) { + ch.members.Each(func(u Item) { u.(*User).Close() }) - ch.users.Clear() + ch.members.Clear() close(ch.broadcast) }) } @@ -75,8 +79,8 @@ func (ch *Channel) HandleMsg(m Message) { skipUser = fromMsg.From() } - ch.users.Each(func(u Item) { - user := u.(*User) + ch.members.Each(func(u Item) { + user := u.(*Member).User if skip && skipUser == user { // Skip return @@ -108,18 +112,18 @@ func (ch *Channel) Join(u *User) error { if ch.closed { return ErrChannelClosed } - err := ch.users.Add(u) + err := ch.members.Add(&Member{u, false}) if err != nil { return err } - s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.users.Len()) + s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.members.Len()) ch.Send(NewAnnounceMsg(s)) return nil } -// Leave the channel as a user, will announce. +// Leave the channel as a user, will announce. Mostly used during setup. func (ch *Channel) Leave(u *User) error { - err := ch.users.Remove(u) + err := ch.members.Remove(u) if err != nil { return err } @@ -128,6 +132,26 @@ func (ch *Channel) Leave(u *User) error { return nil } +// Member returns a corresponding Member object to a User if the Member is +// present in this channel. +func (ch *Channel) Member(u *User) (*Member, bool) { + m, err := ch.members.Get(u.Id()) + if err != nil { + return nil, false + } + // Check that it's the same user + if m.(*Member).User != u { + return nil, false + } + return m.(*Member), true +} + +// IsOp returns whether a user is an operator in this channel. +func (ch *Channel) IsOp(u *User) bool { + m, ok := ch.Member(u) + return ok && m.Op +} + // Topic of the channel. func (ch *Channel) Topic() string { return ch.topic @@ -141,9 +165,9 @@ func (ch *Channel) SetTopic(s string) { // NamesPrefix lists all members' names with a given prefix, used to query // for autocompletion purposes. func (ch *Channel) NamesPrefix(prefix string) []string { - users := ch.users.ListPrefix(prefix) - names := make([]string, len(users)) - for i, u := range users { + members := ch.members.ListPrefix(prefix) + names := make([]string, len(members)) + for i, u := range members { names[i] = u.(*User).Name() } return names diff --git a/chat/command.go b/chat/command.go index 50d50e5..431ecb9 100644 --- a/chat/command.go +++ b/chat/command.go @@ -98,9 +98,8 @@ func init() { c.Add(Command{ Prefix: "/help", Handler: func(channel *Channel, msg CommandMsg) error { - user := msg.From() - op := channel.ops.In(user) - channel.Send(NewSystemMsg(channel.commands.Help(op), user)) + op := channel.IsOp(msg.From()) + channel.Send(NewSystemMsg(channel.commands.Help(op), msg.From())) return nil }, }) @@ -193,11 +192,12 @@ func init() { }) c.Add(Command{ + Op: true, Prefix: "/op", PrefixHelp: "USER", Help: "Mark user as admin.", Handler: func(channel *Channel, msg CommandMsg) error { - if !channel.ops.In(msg.From()) { + if !channel.IsOp(msg.From()) { return errors.New("must be op") } @@ -206,13 +206,14 @@ func init() { return errors.New("must specify user") } - // TODO: Add support for fingerprint-based op'ing. - user, err := channel.users.Get(Id(args[0])) + // TODO: Add support for fingerprint-based op'ing. This will + // probably need to live in host land. + member, err := channel.members.Get(Id(args[0])) if err != nil { return errors.New("user not found") } - channel.ops.Add(user) + member.(*Member).Op = true return nil }, }) diff --git a/cmd.go b/cmd.go index 8c8a576..e7bfc68 100644 --- a/cmd.go +++ b/cmd.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "fmt" "io/ioutil" "net/http" @@ -93,8 +94,8 @@ func main() { return } - // TODO: MakeAuth - config := sshd.MakeNoAuth() + auth := Auth{} + config := sshd.MakeAuth(auth) config.AddHostKey(signer) s, err := sshd.ListenSSH(options.Bind, config) @@ -106,11 +107,10 @@ func main() { defer s.Close() host := NewHost(s) - go host.Serve() + host.auth = &auth - /* TODO: for _, fingerprint := range options.Admin { - server.Op(fingerprint) + auth.Op(fingerprint) } if options.Whitelist != "" { @@ -123,7 +123,7 @@ func main() { scanner := bufio.NewScanner(file) for scanner.Scan() { - server.Whitelist(scanner.Text()) + auth.Whitelist(scanner.Text()) } } @@ -137,9 +137,10 @@ func main() { // hack to normalize line endings into \r\n motdString = strings.Replace(motdString, "\r\n", "\n", -1) motdString = strings.Replace(motdString, "\n", "\r\n", -1) - server.SetMotd(motdString) + host.SetMotd(motdString) } - */ + + go host.Serve() // Construct interrupt handler sig := make(chan os.Signal, 1) diff --git a/host.go b/host.go index 680d553..c6c8de7 100644 --- a/host.go +++ b/host.go @@ -14,9 +14,12 @@ import ( type Host struct { listener *sshd.SSHListener channel *chat.Channel + + motd string + auth *Auth } -// NewHost creates a Host on top of an existing listener +// NewHost creates a Host on top of an existing listener. func NewHost(listener *sshd.SSHListener) *Host { ch := chat.NewChannel() h := Host{ @@ -27,7 +30,12 @@ func NewHost(listener *sshd.SSHListener) *Host { return &h } -// Connect a specific Terminal to this host and its channel +// SetMotd sets the host's message of the day. +func (h *Host) SetMotd(motd string) { + h.motd = motd +} + +// Connect a specific Terminal to this host and its channel. func (h *Host) Connect(term *sshd.Terminal) { name := term.Conn.User() term.AutoCompleteCallback = h.AutoCompleteFunction diff --git a/sshd/auth.go b/sshd/auth.go index d271a85..90134e5 100644 --- a/sshd/auth.go +++ b/sshd/auth.go @@ -9,13 +9,9 @@ import ( "golang.org/x/crypto/ssh" ) -var errBanned = errors.New("banned") -var errNotWhitelisted = errors.New("not whitelisted") -var errNoInteractive = errors.New("public key authentication required") - type Auth interface { - IsBanned(ssh.PublicKey) bool - IsWhitelisted(ssh.PublicKey) bool + AllowAnonymous() bool + Check(string) (bool, error) } func MakeAuth(auth Auth) *ssh.ServerConfig { @@ -23,21 +19,17 @@ func MakeAuth(auth Auth) *ssh.ServerConfig { NoClientAuth: false, // Auth-related things should be constant-time to avoid timing attacks. PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - if auth.IsBanned(key) { - return nil, errBanned + fingerprint := Fingerprint(key) + ok, err := auth.Check(fingerprint) + if !ok { + return nil, err } - if !auth.IsWhitelisted(key) { - return nil, errNotWhitelisted - } - perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": Fingerprint(key)}} + perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": fingerprint}} return perm, nil }, KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { - if auth.IsBanned(nil) { - return nil, errNoInteractive - } - if !auth.IsWhitelisted(nil) { - return nil, errNotWhitelisted + if !auth.AllowAnonymous() { + return nil, errors.New("public key authentication required") } return nil, nil }, @@ -51,7 +43,8 @@ func MakeNoAuth() *ssh.ServerConfig { NoClientAuth: false, // Auth-related things should be constant-time to avoid timing attacks. PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - return nil, nil + perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": Fingerprint(key)}} + return perm, nil }, KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { return nil, nil diff --git a/sshd/terminal.go b/sshd/terminal.go index 14318b3..196b9be 100644 --- a/sshd/terminal.go +++ b/sshd/terminal.go @@ -11,12 +11,12 @@ import ( // Extending ssh/terminal to include a closer interface type Terminal struct { terminal.Terminal - Conn ssh.Conn + Conn *ssh.ServerConn Channel ssh.Channel } // Make new terminal from a session channel -func NewTerminal(conn ssh.Conn, ch ssh.NewChannel) (*Terminal, error) { +func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) { if ch.ChannelType() != "session" { return nil, errors.New("terminal requires session channel") } @@ -41,7 +41,7 @@ func NewTerminal(conn ssh.Conn, ch ssh.NewChannel) (*Terminal, error) { } // Find session channel and make a Terminal from it -func NewSession(conn ssh.Conn, channels <-chan ssh.NewChannel) (term *Terminal, err error) { +func NewSession(conn *ssh.ServerConn, channels <-chan ssh.NewChannel) (term *Terminal, err error) { for ch := range channels { if t := ch.ChannelType(); t != "session" { ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t)) From 4dd80fb822d50f1716bb4b0eaef33b57dd6ce127 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Thu, 1 Jan 2015 18:51:34 -0800 Subject: [PATCH 28/60] Tests pass. --- auth.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ chat/channel.go | 4 +-- chat/command.go | 11 +++++--- chat/help.go | 2 +- host.go | 7 +++++ 5 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 auth.go diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..27a7077 --- /dev/null +++ b/auth.go @@ -0,0 +1,68 @@ +package main + +import ( + "errors" + "sync" + + "github.com/shazow/ssh-chat/sshd" +) + +// Auth stores fingerprint lookups +type Auth struct { + whitelist map[string]struct{} + banned map[string]struct{} + ops map[string]struct{} + + sshd.Auth + sync.RWMutex +} + +// AllowAnonymous determines if anonymous users are permitted. +func (a Auth) AllowAnonymous() bool { + a.RLock() + ok := len(a.whitelist) == 0 + a.RUnlock() + return ok +} + +// Check determines if a pubkey fingerprint is permitted. +func (a Auth) Check(fingerprint string) (bool, error) { + a.RLock() + defer a.RUnlock() + + if len(a.whitelist) > 0 { + // Only check whitelist if there is something in it, otherwise it's disabled. + _, whitelisted := a.whitelist[fingerprint] + if !whitelisted { + return false, errors.New("not whitelisted") + } + } + + _, banned := a.banned[fingerprint] + if banned { + return false, errors.New("banned") + } + + return true, nil +} + +// Op will set a fingerprint as a known operator. +func (a *Auth) Op(fingerprint string) { + a.Lock() + a.ops[fingerprint] = struct{}{} + a.Unlock() +} + +// Whitelist will set a fingerprint as a whitelisted user. +func (a *Auth) Whitelist(fingerprint string) { + a.Lock() + a.whitelist[fingerprint] = struct{}{} + a.Unlock() +} + +// Ban will set a fingerprint as banned. +func (a *Auth) Ban(fingerprint string) { + a.Lock() + a.banned[fingerprint] = struct{}{} + a.Unlock() +} diff --git a/chat/channel.go b/chat/channel.go index b4de26e..68fee01 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -51,8 +51,8 @@ func (ch *Channel) SetCommands(commands Commands) { func (ch *Channel) Close() { ch.closeOnce.Do(func() { ch.closed = true - ch.members.Each(func(u Item) { - u.(*User).Close() + ch.members.Each(func(m Item) { + m.(*Member).Close() }) ch.members.Clear() close(ch.broadcast) diff --git a/chat/command.go b/chat/command.go index 431ecb9..66e9446 100644 --- a/chat/command.go +++ b/chat/command.go @@ -1,6 +1,7 @@ -// FIXME: Would be sweet if we could piggyback on a cli parser or something. package chat +// FIXME: Would be sweet if we could piggyback on a cli parser or something. + import ( "errors" "fmt" @@ -93,8 +94,12 @@ func (c Commands) Help(showOp bool) string { var defaultCommands *Commands func init() { - c := Commands{} + defaultCommands = &Commands{} + InitCommands(defaultCommands) +} +// InitCommands injects default commands into a Commands registry. +func InitCommands(c *Commands) { c.Add(Command{ Prefix: "/help", Handler: func(channel *Channel, msg CommandMsg) error { @@ -217,6 +222,4 @@ func init() { return nil }, }) - - defaultCommands = &c } diff --git a/chat/help.go b/chat/help.go index 9e9cd42..0ab62c6 100644 --- a/chat/help.go +++ b/chat/help.go @@ -17,7 +17,7 @@ type help struct { } // NewCommandsHelp creates a help container from a commands container. -func NewCommandsHelp(c []*Command) *help { +func NewCommandsHelp(c []*Command) fmt.Stringer { lookup := map[string]struct{}{} h := help{ items: []helpItem{}, diff --git a/host.go b/host.go index c6c8de7..175ba11 100644 --- a/host.go +++ b/host.go @@ -14,6 +14,7 @@ import ( type Host struct { listener *sshd.SSHListener channel *chat.Channel + commands *chat.Commands motd string auth *Auth @@ -26,6 +27,12 @@ func NewHost(listener *sshd.SSHListener) *Host { listener: listener, channel: ch, } + + // Make our own commands registry instance. + commands := chat.Commands{} + chat.InitCommands(&commands) + ch.SetCommands(commands) + go ch.Serve() return &h } From e29540c73b41bdaab8b0ea0b916730ccd196acd4 Mon Sep 17 00:00:00 2001 From: Nick Presta Date: Sat, 3 Jan 2015 01:28:48 -0500 Subject: [PATCH 29/60] Fixing /names from panic --- chat/channel.go | 2 +- chat/channel_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/chat/channel.go b/chat/channel.go index 68fee01..94916de 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -168,7 +168,7 @@ func (ch *Channel) NamesPrefix(prefix string) []string { members := ch.members.ListPrefix(prefix) names := make([]string, len(members)) for i, u := range members { - names[i] = u.(*User).Name() + names[i] = u.(*Member).User.Name() } return names } diff --git a/chat/channel_test.go b/chat/channel_test.go index 2532fb2..10e990d 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -56,3 +56,29 @@ func TestChannelJoin(t *testing.T) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } } + +func TestChannelNames(t *testing.T) { + var expected, actual []byte + + s := &MockScreen{} + u := NewUser("foo") + + ch := NewChannel() + go ch.Serve() + defer ch.Close() + + err := ch.Join(u) + if err != nil { + t.Fatal(err) + } + + <-ch.broadcast + + ch.Send(ParseInput("/names", u)) + u.ConsumeOne(s) + expected = []byte("-> 1 connected: foo" + Newline) + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } +} From a2ee2000bbe7a7b44f475caf09feecb2cd424c9b Mon Sep 17 00:00:00 2001 From: Nick Presta Date: Sat, 3 Jan 2015 01:28:48 -0500 Subject: [PATCH 30/60] Adding impl for quiet mode with tests. --- chat/channel.go | 6 +++ chat/channel_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++ chat/command.go | 18 ++++++++ chat/user.go | 7 +++ 4 files changed, 139 insertions(+) diff --git a/chat/channel.go b/chat/channel.go index 94916de..4dadbae 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -85,6 +85,12 @@ func (ch *Channel) HandleMsg(m Message) { // Skip return } + if _, ok := m.(*AnnounceMsg); ok { + if user.Config.Quiet { + // Skip + return + } + } err := user.Send(m) if err != nil { ch.Leave(user) diff --git a/chat/channel_test.go b/chat/channel_test.go index 10e990d..9ba73d8 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -57,6 +57,113 @@ func TestChannelJoin(t *testing.T) { } } +func TestChannelDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { + u := NewUser("foo") + u.Config = UserConfig{ + Quiet: true, + } + + ch := NewChannel() + defer ch.Close() + + err := ch.Join(u) + if err != nil { + t.Fatal(err) + } + + // Drain the initial Join message + <-ch.broadcast + + go func() { + for msg := range u.msg { + if _, ok := msg.(*AnnounceMsg); ok { + t.Errorf("Got unexpected `%T`", msg) + } + } + }() + + // Call with an AnnounceMsg and all the other types + // and assert we received only non-announce messages + ch.HandleMsg(NewAnnounceMsg("Ignored")) + // Assert we still get all other types of messages + ch.HandleMsg(NewEmoteMsg("hello", u)) + ch.HandleMsg(NewSystemMsg("hello", u)) + ch.HandleMsg(NewPrivateMsg("hello", u, u)) + ch.HandleMsg(NewPublicMsg("hello", u)) +} + +func TestChannelQuietToggleBroadcasts(t *testing.T) { + u := NewUser("foo") + u.Config = UserConfig{ + Quiet: true, + } + + ch := NewChannel() + defer ch.Close() + + err := ch.Join(u) + if err != nil { + t.Fatal(err) + } + + // Drain the initial Join message + <-ch.broadcast + + u.ToggleQuietMode() + + expectedMsg := NewAnnounceMsg("Ignored") + ch.HandleMsg(expectedMsg) + msg := <-u.msg + if _, ok := msg.(*AnnounceMsg); !ok { + t.Errorf("Got: `%T`; Expected: `%T`", msg, expectedMsg) + } + + u.ToggleQuietMode() + + ch.HandleMsg(NewAnnounceMsg("Ignored")) + ch.HandleMsg(NewSystemMsg("hello", u)) + msg = <-u.msg + if _, ok := msg.(*AnnounceMsg); ok { + t.Errorf("Got unexpected `%T`", msg) + } +} + +func TestQuietToggleDisplayState(t *testing.T) { + var expected, actual []byte + + s := &MockScreen{} + u := NewUser("foo") + + ch := NewChannel() + go ch.Serve() + defer ch.Close() + + err := ch.Join(u) + if err != nil { + t.Fatal(err) + } + + // Drain the initial Join message + <-ch.broadcast + + ch.Send(ParseInput("/quiet", u)) + u.ConsumeOne(s) + expected = []byte("-> Quiet mode is toggled ON" + Newline) + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } + + ch.Send(ParseInput("/quiet", u)) + u.ConsumeOne(s) + expected = []byte("-> Quiet mode is toggled OFF" + Newline) + + s.Read(&actual) + if !reflect.DeepEqual(actual, expected) { + t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + } +} + func TestChannelNames(t *testing.T) { var expected, actual []byte @@ -72,6 +179,7 @@ func TestChannelNames(t *testing.T) { t.Fatal(err) } + // Drain the initial Join message <-ch.broadcast ch.Send(ParseInput("/names", u)) diff --git a/chat/command.go b/chat/command.go index 66e9446..1328c49 100644 --- a/chat/command.go +++ b/chat/command.go @@ -196,6 +196,24 @@ func InitCommands(c *Commands) { }, }) + c.Add(Command{ + Prefix: "/quiet", + Help: "Silence announcement-type messages (join, part, rename, etc).", + Handler: func(channel *Channel, msg CommandMsg) error { + u := msg.From() + u.ToggleQuietMode() + + var body string + if u.Config.Quiet { + body = "Quiet mode is toggled ON" + } else { + body = "Quiet mode is toggled OFF" + } + channel.Send(NewSystemMsg(body, u)) + return nil + }, + }) + c.Add(Command{ Op: true, Prefix: "/op", diff --git a/chat/user.go b/chat/user.go index 542f0bc..203a48a 100644 --- a/chat/user.go +++ b/chat/user.go @@ -59,6 +59,11 @@ func (u *User) SetName(name string) { u.SetColorIdx(rand.Int()) } +// ToggleQuietMode will toggle whether or not quiet mode is enabled +func (u *User) ToggleQuietMode() { + u.Config.Quiet = !u.Config.Quiet +} + // SetColorIdx will set the colorIdx to a specific value, primarily used for // testing. func (u *User) SetColorIdx(idx int) { @@ -122,6 +127,7 @@ func (u *User) Send(m Message) error { type UserConfig struct { Highlight bool Bell bool + Quiet bool Theme *Theme } @@ -132,6 +138,7 @@ func init() { DefaultUserConfig = &UserConfig{ Highlight: true, Bell: false, + Quiet: false, } // TODO: Seed random? From 23d06faa6830625806048865e1937dcfed20c927 Mon Sep 17 00:00:00 2001 From: Nick Presta Date: Sat, 3 Jan 2015 00:40:53 -0500 Subject: [PATCH 31/60] Adding passphrase prompt and env var. --- auth.go | 9 +++++++++ cmd.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/auth.go b/auth.go index 27a7077..6357564 100644 --- a/auth.go +++ b/auth.go @@ -17,6 +17,15 @@ type Auth struct { sync.RWMutex } +// NewAuth creates a new default Auth. +func NewAuth() Auth { + return Auth{ + whitelist: make(map[string]struct{}), + banned: make(map[string]struct{}), + ops: make(map[string]struct{}), + } +} + // AllowAnonymous determines if anonymous users are permitted. func (a Auth) AllowAnonymous() bool { a.RLock() diff --git a/cmd.go b/cmd.go index e7bfc68..4e78b77 100644 --- a/cmd.go +++ b/cmd.go @@ -2,6 +2,8 @@ package main import ( "bufio" + "crypto/x509" + "encoding/pem" "fmt" "io/ioutil" "net/http" @@ -14,6 +16,7 @@ import ( "github.com/alexcesaro/log/golog" "github.com/jessevdk/go-flags" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/terminal" "github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/sshd" @@ -80,21 +83,19 @@ func main() { } } - privateKey, err := ioutil.ReadFile(privateKeyPath) + privateKey, err := readPrivateKey(privateKeyPath) if err != nil { - logger.Errorf("Failed to load identity: %v", err) + logger.Errorf("Couldn't read private key: %v", err) os.Exit(2) - return } signer, err := ssh.ParsePrivateKey(privateKey) if err != nil { logger.Errorf("Failed to parse key: %v", err) os.Exit(3) - return } - auth := Auth{} + auth := NewAuth() config := sshd.MakeAuth(auth) config.AddHostKey(signer) @@ -102,7 +103,6 @@ func main() { if err != nil { logger.Errorf("Failed to listen on socket: %v", err) os.Exit(4) - return } defer s.Close() @@ -150,3 +150,43 @@ func main() { logger.Warningf("Interrupt signal detected, shutting down.") os.Exit(0) } + +// readPrivateKey attempts to read your private key and possibly decrypt it if it +// requires a passphrase. +// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`), +// is not set. +func readPrivateKey(privateKeyPath string) ([]byte, error) { + privateKey, err := ioutil.ReadFile(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to load identity: %v", err) + } + + block, rest := pem.Decode(privateKey) + if len(rest) > 0 { + return nil, fmt.Errorf("extra data when decoding private key") + } + if !x509.IsEncryptedPEMBlock(block) { + return privateKey, nil + } + + passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE")) + if len(passphrase) == 0 { + fmt.Printf("Enter passphrase: ") + passphrase, err = terminal.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return nil, fmt.Errorf("couldn't read passphrase: %v", err) + } + fmt.Println() + } + der, err := x509.DecryptPEMBlock(block, passphrase) + if err != nil { + return nil, fmt.Errorf("decrypt failed: %v", err) + } + + privateKey = pem.EncodeToMemory(&pem.Block{ + Type: block.Type, + Bytes: der, + }) + + return privateKey, nil +} From 492d50d521679d3dd135ee531dc4caaee3df36ee Mon Sep 17 00:00:00 2001 From: Matt Croydon Date: Sun, 4 Jan 2015 21:57:22 -0600 Subject: [PATCH 32/60] Default port to 2022, updating documentation to match new default. Addresses nice to have in #76. Also print bind information on startup. An alternative to this approach would be to use info level logging, but the issue makes me think we want it all the time. I altered the README to show use of non-default port 22 which makes the note about root/sudo below still make sense. --- README.md | 4 ++-- cmd.go | 4 +++- sshd/doc.go | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dc86e53..b5ec05c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Usage: Application Options: -v, --verbose Show verbose logging. -i, --identity= Private key to identify server with. (~/.ssh/id_rsa) - --bind= Host and port to listen on. (0.0.0.0:22) + --bind= Host and port to listen on. (0.0.0.0:2022) --admin= Fingerprint of pubkey to mark as admin. --whitelist= Optional file of pubkey fingerprints that are allowed to connect --motd= Message of the Day file (optional) @@ -40,7 +40,7 @@ After doing `go get github.com/shazow/ssh-chat` on this repo, you should be able to run a command like: ``` -$ ssh-chat --verbose --bind ":2022" --identity ~/.ssh/id_dsa +$ ssh-chat --verbose --bind ":22" --identity ~/.ssh/id_dsa ``` To bind on port 22, you'll need to make sure it's free (move any other ssh diff --git a/cmd.go b/cmd.go index 4e78b77..fe6181e 100644 --- a/cmd.go +++ b/cmd.go @@ -27,7 +27,7 @@ import _ "net/http/pprof" type Options struct { Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."` 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:22"` + Bind string `long:"bind" description:"Host and port to listen on." default:"0.0.0.0:2022"` Admin []string `long:"admin" description:"Fingerprint of pubkey to mark as admin."` Whitelist string `long:"whitelist" description:"Optional file of pubkey fingerprints who are allowed to connect."` Motd string `long:"motd" description:"Optional Message of the Day file."` @@ -106,6 +106,8 @@ func main() { } defer s.Close() + fmt.Printf("Listening for connections on %v\n", options.Bind) + host := NewHost(s) host.auth = &auth diff --git a/sshd/doc.go b/sshd/doc.go index aacbcac..21cd914 100644 --- a/sshd/doc.go +++ b/sshd/doc.go @@ -7,7 +7,7 @@ package sshd config := MakeNoAuth() config.AddHostKey(signer) - s, err := ListenSSH("0.0.0.0:22", config) + s, err := ListenSSH("0.0.0.0:2022", config) if err != nil { // Handle opening socket error } From 98e57c61d5a8837fb945fb40ad08b433862b1a4c Mon Sep 17 00:00:00 2001 From: Matt Croydon Date: Mon, 5 Jan 2015 19:57:09 -0600 Subject: [PATCH 33/60] Updated listening message to use the actual host/port. --- cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd.go b/cmd.go index fe6181e..dee6919 100644 --- a/cmd.go +++ b/cmd.go @@ -106,7 +106,7 @@ func main() { } defer s.Close() - fmt.Printf("Listening for connections on %v\n", options.Bind) + fmt.Printf("Listening for connections on %v\n", s.Addr().String()) host := NewHost(s) host.auth = &auth From 0c5c7b50b662b1e71721089952beafd9964ce574 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Tue, 6 Jan 2015 21:42:57 -0800 Subject: [PATCH 34/60] Resolve name collision to GuestX, with test. --- cmd.go | 1 + host.go | 21 ++++++++--- host_test.go | 91 ++++++++++++++++++++++++++++++++++++++++++++++-- sshd/client.go | 65 ++++++++++++++++++++++++++++++++++ sshd/net_test.go | 56 +---------------------------- 5 files changed, 172 insertions(+), 62 deletions(-) create mode 100644 sshd/client.go diff --git a/cmd.go b/cmd.go index e7bfc68..b97ec18 100644 --- a/cmd.go +++ b/cmd.go @@ -108,6 +108,7 @@ func main() { host := NewHost(s) host.auth = &auth + host.theme = &chat.Themes[0] for _, fingerprint := range options.Admin { auth.Op(fingerprint) diff --git a/host.go b/host.go index 175ba11..376c966 100644 --- a/host.go +++ b/host.go @@ -16,8 +16,12 @@ type Host struct { channel *chat.Channel commands *chat.Commands - motd string - auth *Auth + motd string + auth *Auth + count int + + // Default theme + theme *chat.Theme } // NewHost creates a Host on top of an existing listener. @@ -48,7 +52,7 @@ func (h *Host) Connect(term *sshd.Terminal) { term.AutoCompleteCallback = h.AutoCompleteFunction user := chat.NewUserScreen(name, term) - user.Config.Theme = &chat.Themes[0] + user.Config.Theme = h.theme go func() { // Close term once user is closed. user.Wait() @@ -56,14 +60,21 @@ func (h *Host) Connect(term *sshd.Terminal) { }() defer user.Close() - term.SetPrompt(GetPrompt(user)) - err := h.channel.Join(user) + if err == chat.ErrIdTaken { + // Try again... + user.SetName(fmt.Sprintf("Guest%d", h.count)) + err = h.channel.Join(user) + } if err != nil { logger.Errorf("Failed to join: %s", err) return } + // Successfully joined. + term.SetPrompt(GetPrompt(user)) + h.count++ + for { line, err := term.ReadLine() if err == io.EOF { diff --git a/host_test.go b/host_test.go index 882c5f9..d86c353 100644 --- a/host_test.go +++ b/host_test.go @@ -1,11 +1,23 @@ package main import ( + "bufio" + "io" + "strings" "testing" "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/sshd" ) +func stripPrompt(s string) string { + pos := strings.LastIndex(s, "\033[K") + if pos < 0 { + return s + } + return s[pos+3:] +} + func TestHostGetPrompt(t *testing.T) { var expected, actual string @@ -15,13 +27,88 @@ func TestHostGetPrompt(t *testing.T) { actual = GetPrompt(u) expected = "[foo] " if actual != expected { - t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + t.Errorf("Got: %q; Expected: %q", actual, expected) } u.Config.Theme = &chat.Themes[0] actual = GetPrompt(u) expected = "[\033[38;05;2mfoo\033[0m] " if actual != expected { - t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) + t.Errorf("Got: %q; Expected: %q", actual, expected) } } + +func TestHostNameCollision(t *testing.T) { + key, err := sshd.NewRandomKey(512) + if err != nil { + t.Fatal(err) + } + config := sshd.MakeNoAuth() + config.AddHostKey(key) + + s, err := sshd.ListenSSH(":0", config) + if err != nil { + t.Fatal(err) + } + host := NewHost(s) + go host.Serve() + + done := make(chan struct{}, 1) + + // First client + go func() { + err = sshd.NewClientSession(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) { + scanner := bufio.NewScanner(r) + + // Consume the initial buffer + scanner.Scan() + actual := scanner.Text() + if !strings.HasPrefix(actual, "[foo] ") { + t.Errorf("First client failed to get 'foo' name.") + } + + actual = stripPrompt(actual) + expected := " * foo joined. (Connected: 1)" + if actual != expected { + t.Errorf("Got %q; expected %q", actual, expected) + } + + // Ready for second client + done <- struct{}{} + + scanner.Scan() + actual = stripPrompt(scanner.Text()) + expected = " * Guest1 joined. (Connected: 2)" + if actual != expected { + t.Errorf("Got %q; expected %q", actual, expected) + } + + // Wrap it up. + close(done) + }) + if err != nil { + t.Fatal(err) + } + }() + + // Wait for first client + <-done + + // Second client + err = sshd.NewClientSession(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) { + scanner := bufio.NewScanner(r) + + // Consume the initial buffer + scanner.Scan() + actual := scanner.Text() + if !strings.HasPrefix(actual, "[Guest1] ") { + t.Errorf("Second client did not get Guest1 name.") + } + }) + if err != nil { + t.Fatal(err) + } + + <-done + s.Close() +} diff --git a/sshd/client.go b/sshd/client.go new file mode 100644 index 0000000..60dab6e --- /dev/null +++ b/sshd/client.go @@ -0,0 +1,65 @@ +package sshd + +import ( + "crypto/rand" + "crypto/rsa" + "io" + + "golang.org/x/crypto/ssh" +) + +// NewRandomKey generates a random key of a desired bit length. +func NewRandomKey(bits int) (ssh.Signer, error) { + key, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(key) +} + +// NewClientConfig creates a barebones ssh.ClientConfig to be used with ssh.Dial. +func NewClientConfig(name string) *ssh.ClientConfig { + return &ssh.ClientConfig{ + User: name, + Auth: []ssh.AuthMethod{ + ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { + return + }), + }, + } +} + +// NewClientSession makes a barebones SSH client session, used for testing. +func NewClientSession(host string, name string, handler func(r io.Reader, w io.WriteCloser)) error { + config := NewClientConfig(name) + conn, err := ssh.Dial("tcp", host, config) + if err != nil { + return err + } + defer conn.Close() + + session, err := conn.NewSession() + if err != nil { + return err + } + defer session.Close() + + in, err := session.StdinPipe() + if err != nil { + return err + } + + out, err := session.StdoutPipe() + if err != nil { + return err + } + + err = session.Shell() + if err != nil { + return err + } + + handler(out, in) + + return nil +} diff --git a/sshd/net_test.go b/sshd/net_test.go index 6ec4311..8321b30 100644 --- a/sshd/net_test.go +++ b/sshd/net_test.go @@ -2,66 +2,12 @@ package sshd import ( "bytes" - "crypto/rand" - "crypto/rsa" "io" "testing" - - "golang.org/x/crypto/ssh" ) // TODO: Move some of these into their own package? -func MakeKey(bits int) (ssh.Signer, error) { - key, err := rsa.GenerateKey(rand.Reader, bits) - if err != nil { - return nil, err - } - return ssh.NewSignerFromKey(key) -} - -func NewClientSession(host string, name string, handler func(r io.Reader, w io.WriteCloser)) error { - config := &ssh.ClientConfig{ - User: name, - Auth: []ssh.AuthMethod{ - ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { - return - }), - }, - } - - conn, err := ssh.Dial("tcp", host, config) - if err != nil { - return err - } - defer conn.Close() - - session, err := conn.NewSession() - if err != nil { - return err - } - defer session.Close() - - in, err := session.StdinPipe() - if err != nil { - return err - } - - out, err := session.StdoutPipe() - if err != nil { - return err - } - - err = session.Shell() - if err != nil { - return err - } - - handler(out, in) - - return nil -} - func TestServerInit(t *testing.T) { config := MakeNoAuth() s, err := ListenSSH(":badport", config) @@ -81,7 +27,7 @@ func TestServerInit(t *testing.T) { } func TestServeTerminals(t *testing.T) { - signer, err := MakeKey(512) + signer, err := NewRandomKey(512) config := MakeNoAuth() config.AddHostKey(signer) From d8d5deac1c6b04f169ba6ddc31738142c925ec9e Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 10 Jan 2015 12:44:06 -0800 Subject: [PATCH 35/60] Use authorized_keys-style public keys rather than fingerprints. Tests for whitelisting. --- auth.go | 61 +++++++++++++++++--------- auth_test.go | 62 +++++++++++++++++++++++++++ cmd.go | 101 ++++++++++++++++++++------------------------ host_test.go | 47 ++++++++++++++++++++- key.go | 49 +++++++++++++++++++++ sshd/auth.go | 31 +++++++++----- sshd/client.go | 4 +- sshd/client_test.go | 44 +++++++++++++++++++ sshd/net.go | 1 + sshd/net_test.go | 4 +- 10 files changed, 310 insertions(+), 94 deletions(-) create mode 100644 auth_test.go create mode 100644 key.go create mode 100644 sshd/client_test.go diff --git a/auth.go b/auth.go index 6357564..4d6de86 100644 --- a/auth.go +++ b/auth.go @@ -5,24 +5,39 @@ import ( "sync" "github.com/shazow/ssh-chat/sshd" + "golang.org/x/crypto/ssh" ) +// The error returned a key is checked that is not whitelisted, with whitelisting required. +var ErrNotWhitelisted = errors.New("not whitelisted") + +// The error returned a key is checked that is banned. +var ErrBanned = errors.New("banned") + +// AuthKey is the type that our lookups are keyed against. +type AuthKey string + +// NewAuthKey returns an AuthKey from an ssh.PublicKey. +func NewAuthKey(key ssh.PublicKey) AuthKey { + // FIXME: Is there a way to index pubkeys without marshal'ing them into strings? + return AuthKey(string(key.Marshal())) +} + // Auth stores fingerprint lookups type Auth struct { - whitelist map[string]struct{} - banned map[string]struct{} - ops map[string]struct{} - sshd.Auth sync.RWMutex + whitelist map[AuthKey]struct{} + banned map[AuthKey]struct{} + ops map[AuthKey]struct{} } // NewAuth creates a new default Auth. -func NewAuth() Auth { - return Auth{ - whitelist: make(map[string]struct{}), - banned: make(map[string]struct{}), - ops: make(map[string]struct{}), +func NewAuth() *Auth { + return &Auth{ + whitelist: make(map[AuthKey]struct{}), + banned: make(map[AuthKey]struct{}), + ops: make(map[AuthKey]struct{}), } } @@ -35,43 +50,49 @@ func (a Auth) AllowAnonymous() bool { } // Check determines if a pubkey fingerprint is permitted. -func (a Auth) Check(fingerprint string) (bool, error) { +func (a Auth) Check(key ssh.PublicKey) (bool, error) { + authkey := NewAuthKey(key) + a.RLock() defer a.RUnlock() if len(a.whitelist) > 0 { // Only check whitelist if there is something in it, otherwise it's disabled. - _, whitelisted := a.whitelist[fingerprint] + + _, whitelisted := a.whitelist[authkey] if !whitelisted { - return false, errors.New("not whitelisted") + return false, ErrNotWhitelisted } } - _, banned := a.banned[fingerprint] + _, banned := a.banned[authkey] if banned { - return false, errors.New("banned") + return false, ErrBanned } return true, nil } // Op will set a fingerprint as a known operator. -func (a *Auth) Op(fingerprint string) { +func (a *Auth) Op(key ssh.PublicKey) { + authkey := NewAuthKey(key) a.Lock() - a.ops[fingerprint] = struct{}{} + a.ops[authkey] = struct{}{} a.Unlock() } // Whitelist will set a fingerprint as a whitelisted user. -func (a *Auth) Whitelist(fingerprint string) { +func (a *Auth) Whitelist(key ssh.PublicKey) { + authkey := NewAuthKey(key) a.Lock() - a.whitelist[fingerprint] = struct{}{} + a.whitelist[authkey] = struct{}{} a.Unlock() } // Ban will set a fingerprint as banned. -func (a *Auth) Ban(fingerprint string) { +func (a *Auth) Ban(key ssh.PublicKey) { + authkey := NewAuthKey(key) a.Lock() - a.banned[fingerprint] = struct{}{} + a.banned[authkey] = struct{}{} a.Unlock() } diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..cb7e521 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + + "golang.org/x/crypto/ssh" +) + +func NewRandomPublicKey(bits int) (ssh.PublicKey, error) { + key, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, err + } + + return ssh.NewPublicKey(key.Public()) +} + +func ClonePublicKey(key ssh.PublicKey) (ssh.PublicKey, error) { + return ssh.ParsePublicKey(key.Marshal()) +} + +func TestAuthWhitelist(t *testing.T) { + key, err := NewRandomPublicKey(512) + if err != nil { + t.Fatal(err) + } + + auth := NewAuth() + ok, err := auth.Check(key) + if !ok || err != nil { + t.Error("Failed to permit in default state:", err) + } + + auth.Whitelist(key) + + key_clone, err := ClonePublicKey(key) + if err != nil { + t.Fatal(err) + } + + if string(key_clone.Marshal()) != string(key.Marshal()) { + t.Error("Clone key does not match.") + } + + ok, err = auth.Check(key_clone) + if !ok || err != nil { + t.Error("Failed to permit whitelisted:", err) + } + + key2, err := NewRandomPublicKey(512) + if err != nil { + t.Fatal(err) + } + + ok, err = auth.Check(key2) + if ok || err == nil { + t.Error("Failed to restrict not whitelisted:", err) + } + +} diff --git a/cmd.go b/cmd.go index 9f5104b..e244361 100644 --- a/cmd.go +++ b/cmd.go @@ -2,8 +2,6 @@ package main import ( "bufio" - "crypto/x509" - "encoding/pem" "fmt" "io/ioutil" "net/http" @@ -16,7 +14,6 @@ import ( "github.com/alexcesaro/log/golog" "github.com/jessevdk/go-flags" "golang.org/x/crypto/ssh" - "golang.org/x/crypto/ssh/terminal" "github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/sshd" @@ -25,13 +22,13 @@ import _ "net/http/pprof" // Options contains the flag options type Options struct { - Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."` - 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:"Fingerprint of pubkey to mark as admin."` - Whitelist string `long:"whitelist" description:"Optional file of pubkey fingerprints who are allowed to connect."` - Motd string `long:"motd" description:"Optional Message of the Day file."` - Pprof int `long:"pprof" description:"Enable pprof http server for profiling."` + Verbose []bool `short:"v" long:"verbose" description:"Show verbose logging."` + 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."` + 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."` + Pprof int `long:"pprof" description:"Enable pprof http server for profiling."` } var logLevels = []log.Level{ @@ -83,7 +80,7 @@ func main() { } } - privateKey, err := readPrivateKey(privateKeyPath) + privateKey, err := ReadPrivateKey(privateKeyPath) if err != nil { logger.Errorf("Couldn't read private key: %v", err) os.Exit(2) @@ -109,25 +106,35 @@ func main() { fmt.Printf("Listening for connections on %v\n", s.Addr().String()) host := NewHost(s) - host.auth = &auth + host.auth = auth host.theme = &chat.Themes[0] - for _, fingerprint := range options.Admin { - auth.Op(fingerprint) + err = fromFile(options.Admin, func(line []byte) error { + key, _, _, _, err := ssh.ParseAuthorizedKey(line) + if err != nil { + return err + } + auth.Op(key) + logger.Debugf("Added admin: %s", line) + return nil + }) + if err != nil { + logger.Errorf("Failed to load admins: %v", err) + os.Exit(5) } - if options.Whitelist != "" { - file, err := os.Open(options.Whitelist) + err = fromFile(options.Whitelist, func(line []byte) error { + key, _, _, _, err := ssh.ParseAuthorizedKey(line) if err != nil { - logger.Errorf("Could not open whitelist file") - return - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - auth.Whitelist(scanner.Text()) + return err } + auth.Whitelist(key) + logger.Debugf("Whitelisted: %s", line) + return nil + }) + if err != nil { + logger.Errorf("Failed to load whitelist: %v", err) + os.Exit(5) } if options.Motd != "" { @@ -154,42 +161,24 @@ func main() { os.Exit(0) } -// readPrivateKey attempts to read your private key and possibly decrypt it if it -// requires a passphrase. -// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`), -// is not set. -func readPrivateKey(privateKeyPath string) ([]byte, error) { - privateKey, err := ioutil.ReadFile(privateKeyPath) +func fromFile(path string, handler func(line []byte) error) error { + if path == "" { + // Skip + return nil + } + + file, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("failed to load identity: %v", err) + return err } + defer file.Close() - block, rest := pem.Decode(privateKey) - if len(rest) > 0 { - return nil, fmt.Errorf("extra data when decoding private key") - } - if !x509.IsEncryptedPEMBlock(block) { - return privateKey, nil - } - - passphrase := []byte(os.Getenv("IDENTITY_PASSPHRASE")) - if len(passphrase) == 0 { - fmt.Printf("Enter passphrase: ") - passphrase, err = terminal.ReadPassword(int(os.Stdin.Fd())) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + err := handler(scanner.Bytes()) if err != nil { - return nil, fmt.Errorf("couldn't read passphrase: %v", err) + return err } - fmt.Println() } - der, err := x509.DecryptPEMBlock(block, passphrase) - if err != nil { - return nil, fmt.Errorf("decrypt failed: %v", err) - } - - privateKey = pem.EncodeToMemory(&pem.Block{ - Type: block.Type, - Bytes: der, - }) - - return privateKey, nil + return nil } diff --git a/host_test.go b/host_test.go index d86c353..abee9f3 100644 --- a/host_test.go +++ b/host_test.go @@ -2,12 +2,15 @@ package main import ( "bufio" + "crypto/rand" + "crypto/rsa" "io" "strings" "testing" "github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/sshd" + "golang.org/x/crypto/ssh" ) func stripPrompt(s string) string { @@ -39,7 +42,7 @@ func TestHostGetPrompt(t *testing.T) { } func TestHostNameCollision(t *testing.T) { - key, err := sshd.NewRandomKey(512) + key, err := sshd.NewRandomSigner(512) if err != nil { t.Fatal(err) } @@ -50,6 +53,7 @@ func TestHostNameCollision(t *testing.T) { if err != nil { t.Fatal(err) } + defer s.Close() host := NewHost(s) go host.Serve() @@ -110,5 +114,44 @@ func TestHostNameCollision(t *testing.T) { } <-done - s.Close() +} + +func TestHostWhitelist(t *testing.T) { + key, err := sshd.NewRandomSigner(512) + if err != nil { + t.Fatal(err) + } + + auth := NewAuth() + config := sshd.MakeAuth(auth) + config.AddHostKey(key) + + s, err := sshd.ListenSSH(":0", config) + if err != nil { + t.Fatal(err) + } + defer s.Close() + host := NewHost(s) + host.auth = auth + go host.Serve() + + target := s.Addr().String() + + err = sshd.NewClientSession(target, "foo", func(r io.Reader, w io.WriteCloser) {}) + if err != nil { + t.Error(err) + } + + clientkey, err := rsa.GenerateKey(rand.Reader, 512) + if err != nil { + t.Fatal(err) + } + + clientpubkey, _ := ssh.NewPublicKey(clientkey.Public()) + auth.Whitelist(clientpubkey) + + err = sshd.NewClientSession(target, "foo", func(r io.Reader, w io.WriteCloser) {}) + if err == nil { + t.Error("Failed to block unwhitelisted connection.") + } } diff --git a/key.go b/key.go new file mode 100644 index 0000000..0135e1b --- /dev/null +++ b/key.go @@ -0,0 +1,49 @@ +package main + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "os" + + "code.google.com/p/gopass" +) + +// ReadPrivateKey attempts to read your private key and possibly decrypt it if it +// requires a passphrase. +// This function will prompt for a passphrase on STDIN if the environment variable (`IDENTITY_PASSPHRASE`), +// is not set. +func ReadPrivateKey(path string) ([]byte, error) { + privateKey, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to load identity: %v", err) + } + + block, rest := pem.Decode(privateKey) + if len(rest) > 0 { + return nil, fmt.Errorf("extra data when decoding private key") + } + if !x509.IsEncryptedPEMBlock(block) { + return privateKey, nil + } + + passphrase := os.Getenv("IDENTITY_PASSPHRASE") + if passphrase == "" { + passphrase, err = gopass.GetPass("Enter passphrase: ") + if err != nil { + return nil, fmt.Errorf("couldn't read passphrase: %v", err) + } + } + der, err := x509.DecryptPEMBlock(block, []byte(passphrase)) + if err != nil { + return nil, fmt.Errorf("decrypt failed: %v", err) + } + + privateKey = pem.EncodeToMemory(&pem.Block{ + Type: block.Type, + Bytes: der, + }) + + return privateKey, nil +} diff --git a/sshd/auth.go b/sshd/auth.go index 90134e5..339d158 100644 --- a/sshd/auth.go +++ b/sshd/auth.go @@ -1,30 +1,34 @@ package sshd import ( - "crypto/sha1" + "crypto/sha256" + "encoding/base64" "errors" - "fmt" - "strings" "golang.org/x/crypto/ssh" ) +// Auth is used to authenticate connections based on public keys. type Auth interface { + // Whether to allow connections without a public key. AllowAnonymous() bool - Check(string) (bool, error) + // Given public key, return if the connection should be permitted. + Check(ssh.PublicKey) (bool, error) } +// MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation. func MakeAuth(auth Auth) *ssh.ServerConfig { config := ssh.ServerConfig{ NoClientAuth: false, // Auth-related things should be constant-time to avoid timing attacks. PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - fingerprint := Fingerprint(key) - ok, err := auth.Check(fingerprint) + ok, err := auth.Check(key) if !ok { return nil, err } - perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": fingerprint}} + perm := &ssh.Permissions{Extensions: map[string]string{ + "pubkey": string(ssh.MarshalAuthorizedKey(key)), + }} return perm, nil }, KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { @@ -38,12 +42,16 @@ func MakeAuth(auth Auth) *ssh.ServerConfig { return &config } +// MakeNoAuth makes a simple ssh.ServerConfig which allows all connections. +// Primarily used for testing. func MakeNoAuth() *ssh.ServerConfig { config := ssh.ServerConfig{ NoClientAuth: false, // Auth-related things should be constant-time to avoid timing attacks. PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - perm := &ssh.Permissions{Extensions: map[string]string{"fingerprint": Fingerprint(key)}} + perm := &ssh.Permissions{Extensions: map[string]string{ + "pubkey": string(ssh.MarshalAuthorizedKey(key)), + }} return perm, nil }, KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { @@ -54,8 +62,9 @@ func MakeNoAuth() *ssh.ServerConfig { return &config } +// Fingerprint performs a SHA256 BASE64 fingerprint of the PublicKey, similar to OpenSSH. +// See: https://anongit.mindrot.org/openssh.git/commit/?id=56d1c83cdd1ac func Fingerprint(k ssh.PublicKey) string { - hash := sha1.Sum(k.Marshal()) - r := fmt.Sprintf("% x", hash) - return strings.Replace(r, " ", ":", -1) + hash := sha256.Sum256(k.Marshal()) + return base64.StdEncoding.EncodeToString(hash[:]) } diff --git a/sshd/client.go b/sshd/client.go index 60dab6e..9a01065 100644 --- a/sshd/client.go +++ b/sshd/client.go @@ -8,8 +8,8 @@ import ( "golang.org/x/crypto/ssh" ) -// NewRandomKey generates a random key of a desired bit length. -func NewRandomKey(bits int) (ssh.Signer, error) { +// NewRandomSigner generates a random key of a desired bit length. +func NewRandomSigner(bits int) (ssh.Signer, error) { key, err := rsa.GenerateKey(rand.Reader, bits) if err != nil { return nil, err diff --git a/sshd/client_test.go b/sshd/client_test.go new file mode 100644 index 0000000..2fd109f --- /dev/null +++ b/sshd/client_test.go @@ -0,0 +1,44 @@ +package sshd + +import ( + "errors" + "testing" + + "golang.org/x/crypto/ssh" +) + +var errRejectAuth = errors.New("not welcome here") + +type RejectAuth struct{} + +func (a RejectAuth) AllowAnonymous() bool { + return false +} +func (a RejectAuth) Check(ssh.PublicKey) (bool, error) { + return false, errRejectAuth +} + +func consume(ch <-chan *Terminal) { + for range ch {} +} + +func TestClientReject(t *testing.T) { + signer, err := NewRandomSigner(512) + config := MakeAuth(RejectAuth{}) + config.AddHostKey(signer) + + s, err := ListenSSH(":0", config) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + go consume(s.ServeTerminal()) + + conn, err := ssh.Dial("tcp", s.Addr().String(), NewClientConfig("foo")) + if err == nil { + defer conn.Close() + t.Error("Failed to reject conncetion") + } + t.Log(err) +} diff --git a/sshd/net.go b/sshd/net.go index 6a30976..bb94432 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -29,6 +29,7 @@ func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) { return nil, err } + // FIXME: Disconnect if too many faulty requests? (Avoid DoS.) go ssh.DiscardRequests(requests) return NewSession(sshConn, channels) } diff --git a/sshd/net_test.go b/sshd/net_test.go index 8321b30..724ce77 100644 --- a/sshd/net_test.go +++ b/sshd/net_test.go @@ -6,8 +6,6 @@ import ( "testing" ) -// TODO: Move some of these into their own package? - func TestServerInit(t *testing.T) { config := MakeNoAuth() s, err := ListenSSH(":badport", config) @@ -27,7 +25,7 @@ func TestServerInit(t *testing.T) { } func TestServeTerminals(t *testing.T) { - signer, err := NewRandomKey(512) + signer, err := NewRandomSigner(512) config := MakeNoAuth() config.AddHostKey(signer) From d5626b7514f67be6dee6f70cd7e8ca4fdef57a91 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 10 Jan 2015 13:46:36 -0800 Subject: [PATCH 36/60] Abstracted sshd.Connection; Op works now. --- auth.go | 29 ++++++++++++++++++++++++++++- auth_test.go | 6 +++--- chat/channel.go | 11 ++++++----- chat/channel_test.go | 10 +++++----- host.go | 17 ++++++++++++++--- sshd/client_test.go | 3 ++- sshd/terminal.go | 37 +++++++++++++++++++++++++++++++++++-- 7 files changed, 93 insertions(+), 20 deletions(-) diff --git a/auth.go b/auth.go index 4d6de86..d218366 100644 --- a/auth.go +++ b/auth.go @@ -81,7 +81,16 @@ func (a *Auth) Op(key ssh.PublicKey) { a.Unlock() } -// Whitelist will set a fingerprint as a whitelisted user. +// IsOp checks if a public key is an op. +func (a Auth) IsOp(key ssh.PublicKey) bool { + authkey := NewAuthKey(key) + a.RLock() + _, ok := a.ops[authkey] + a.RUnlock() + return ok +} + +// Whitelist will set a public key as a whitelisted user. func (a *Auth) Whitelist(key ssh.PublicKey) { authkey := NewAuthKey(key) a.Lock() @@ -89,6 +98,15 @@ func (a *Auth) Whitelist(key ssh.PublicKey) { a.Unlock() } +// IsWhitelisted checks if a public key is whitelisted. +func (a Auth) IsWhitelisted(key ssh.PublicKey) bool { + authkey := NewAuthKey(key) + a.RLock() + _, ok := a.whitelist[authkey] + a.RUnlock() + return ok +} + // Ban will set a fingerprint as banned. func (a *Auth) Ban(key ssh.PublicKey) { authkey := NewAuthKey(key) @@ -96,3 +114,12 @@ func (a *Auth) Ban(key ssh.PublicKey) { a.banned[authkey] = struct{}{} a.Unlock() } + +// IsBanned will set a fingerprint as banned. +func (a Auth) IsBanned(key ssh.PublicKey) bool { + authkey := NewAuthKey(key) + a.RLock() + _, ok := a.whitelist[authkey] + a.RUnlock() + return ok +} diff --git a/auth_test.go b/auth_test.go index cb7e521..3b11212 100644 --- a/auth_test.go +++ b/auth_test.go @@ -35,16 +35,16 @@ func TestAuthWhitelist(t *testing.T) { auth.Whitelist(key) - key_clone, err := ClonePublicKey(key) + keyClone, err := ClonePublicKey(key) if err != nil { t.Fatal(err) } - if string(key_clone.Marshal()) != string(key.Marshal()) { + if string(keyClone.Marshal()) != string(key.Marshal()) { t.Error("Clone key does not match.") } - ok, err = auth.Check(key_clone) + ok, err = auth.Check(keyClone) if !ok || err != nil { t.Error("Failed to permit whitelisted:", err) } diff --git a/chat/channel.go b/chat/channel.go index 4dadbae..34d981f 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -114,17 +114,18 @@ func (ch *Channel) Send(m Message) { } // Join the channel as a user, will announce. -func (ch *Channel) Join(u *User) error { +func (ch *Channel) Join(u *User) (*Member, error) { if ch.closed { - return ErrChannelClosed + return nil, ErrChannelClosed } - err := ch.members.Add(&Member{u, false}) + member := Member{u, false} + err := ch.members.Add(&member) if err != nil { - return err + return nil, err } s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.members.Len()) ch.Send(NewAnnounceMsg(s)) - return nil + return &member, nil } // Leave the channel as a user, will announce. Mostly used during setup. diff --git a/chat/channel_test.go b/chat/channel_test.go index 9ba73d8..13c9bdc 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -28,7 +28,7 @@ func TestChannelJoin(t *testing.T) { go ch.Serve() defer ch.Close() - err := ch.Join(u) + _, err := ch.Join(u) if err != nil { t.Fatal(err) } @@ -66,7 +66,7 @@ func TestChannelDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { ch := NewChannel() defer ch.Close() - err := ch.Join(u) + _, err := ch.Join(u) if err != nil { t.Fatal(err) } @@ -101,7 +101,7 @@ func TestChannelQuietToggleBroadcasts(t *testing.T) { ch := NewChannel() defer ch.Close() - err := ch.Join(u) + _, err := ch.Join(u) if err != nil { t.Fatal(err) } @@ -138,7 +138,7 @@ func TestQuietToggleDisplayState(t *testing.T) { go ch.Serve() defer ch.Close() - err := ch.Join(u) + _, err := ch.Join(u) if err != nil { t.Fatal(err) } @@ -174,7 +174,7 @@ func TestChannelNames(t *testing.T) { go ch.Serve() defer ch.Close() - err := ch.Join(u) + _, err := ch.Join(u) if err != nil { t.Fatal(err) } diff --git a/host.go b/host.go index 376c966..51a1fb5 100644 --- a/host.go +++ b/host.go @@ -46,9 +46,17 @@ func (h *Host) SetMotd(motd string) { h.motd = motd } +func (h Host) isOp(conn sshd.Connection) bool { + key, ok := conn.PublicKey() + if !ok { + return false + } + return h.auth.IsOp(key) +} + // Connect a specific Terminal to this host and its channel. func (h *Host) Connect(term *sshd.Terminal) { - name := term.Conn.User() + name := term.Conn.Name() term.AutoCompleteCallback = h.AutoCompleteFunction user := chat.NewUserScreen(name, term) @@ -60,11 +68,11 @@ func (h *Host) Connect(term *sshd.Terminal) { }() defer user.Close() - err := h.channel.Join(user) + member, err := h.channel.Join(user) if err == chat.ErrIdTaken { // Try again... user.SetName(fmt.Sprintf("Guest%d", h.count)) - err = h.channel.Join(user) + member, err = h.channel.Join(user) } if err != nil { logger.Errorf("Failed to join: %s", err) @@ -75,6 +83,9 @@ func (h *Host) Connect(term *sshd.Terminal) { term.SetPrompt(GetPrompt(user)) h.count++ + // Should the user be op'd? + member.Op = h.isOp(term.Conn) + for { line, err := term.ReadLine() if err == io.EOF { diff --git a/sshd/client_test.go b/sshd/client_test.go index 2fd109f..b115e8f 100644 --- a/sshd/client_test.go +++ b/sshd/client_test.go @@ -19,7 +19,8 @@ func (a RejectAuth) Check(ssh.PublicKey) (bool, error) { } func consume(ch <-chan *Terminal) { - for range ch {} + for _ = range ch { + } } func TestClientReject(t *testing.T) { diff --git a/sshd/terminal.go b/sshd/terminal.go index 196b9be..bfd16a0 100644 --- a/sshd/terminal.go +++ b/sshd/terminal.go @@ -8,10 +8,43 @@ import ( "golang.org/x/crypto/ssh/terminal" ) +// Connection is an interface with fields necessary to operate an sshd host. +type Connection interface { + PublicKey() (ssh.PublicKey, bool) + Name() string + Close() error +} + +type sshConn struct { + *ssh.ServerConn +} + +func (c sshConn) PublicKey() (ssh.PublicKey, bool) { + if c.Permissions == nil { + return nil, false + } + + s, ok := c.Permissions.Extensions["pubkey"] + if !ok { + return nil, false + } + + key, err := ssh.ParsePublicKey([]byte(s)) + if err != nil { + return nil, false + } + + return key, true +} + +func (c sshConn) Name() string { + return c.User() +} + // Extending ssh/terminal to include a closer interface type Terminal struct { terminal.Terminal - Conn *ssh.ServerConn + Conn Connection Channel ssh.Channel } @@ -26,7 +59,7 @@ func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) { } term := Terminal{ *terminal.NewTerminal(channel, "Connecting..."), - conn, + sshConn{conn}, channel, } From 11e92b57180cb5e89051432589af0940a4e32c34 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 10 Jan 2015 17:27:55 -0800 Subject: [PATCH 37/60] Fixed key storage. --- sshd/auth.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sshd/auth.go b/sshd/auth.go index 339d158..3cf0855 100644 --- a/sshd/auth.go +++ b/sshd/auth.go @@ -27,7 +27,7 @@ func MakeAuth(auth Auth) *ssh.ServerConfig { return nil, err } perm := &ssh.Permissions{Extensions: map[string]string{ - "pubkey": string(ssh.MarshalAuthorizedKey(key)), + "pubkey": string(key.Marshal()), }} return perm, nil }, @@ -50,7 +50,7 @@ func MakeNoAuth() *ssh.ServerConfig { // Auth-related things should be constant-time to avoid timing attacks. PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { perm := &ssh.Permissions{Extensions: map[string]string{ - "pubkey": string(ssh.MarshalAuthorizedKey(key)), + "pubkey": string(key.Marshal()), }} return perm, nil }, From e626eab6241907b2e06934982446b9e71ccc4263 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 10 Jan 2015 18:05:31 -0800 Subject: [PATCH 38/60] /kick and /msg --- chat/channel.go | 14 +++++++--- chat/command.go | 8 +++--- chat/user.go | 1 + command.go | 39 +++++++++++++++++++++++++++ host.go | 72 ++++++++++++++++++++++++++++++++++++++++++++----- sshd/multi.go | 42 ----------------------------- sshd/pty.go | 3 ++- 7 files changed, 122 insertions(+), 57 deletions(-) create mode 100644 command.go delete mode 100644 sshd/multi.go diff --git a/chat/channel.go b/chat/channel.go index 34d981f..4fadb69 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -142,12 +142,20 @@ func (ch *Channel) Leave(u *User) error { // Member returns a corresponding Member object to a User if the Member is // present in this channel. func (ch *Channel) Member(u *User) (*Member, bool) { - m, err := ch.members.Get(u.Id()) - if err != nil { + m, ok := ch.MemberById(u.Id()) + if !ok { return nil, false } // Check that it's the same user - if m.(*Member).User != u { + if m.User != u { + return nil, false + } + return m, true +} + +func (ch *Channel) MemberById(id Id) (*Member, bool) { + m, err := ch.members.Get(id) + if err != nil { return nil, false } return m.(*Member), true diff --git a/chat/command.go b/chat/command.go index 1328c49..351d090 100644 --- a/chat/command.go +++ b/chat/command.go @@ -86,7 +86,7 @@ func (c Commands) Help(showOp bool) string { } help := "Available commands:" + Newline + NewCommandsHelp(normal).String() if showOp { - help += Newline + "Operator commands:" + Newline + NewCommandsHelp(op).String() + help += Newline + "-> Operator commands:" + Newline + NewCommandsHelp(op).String() } return help } @@ -231,12 +231,12 @@ func InitCommands(c *Commands) { // TODO: Add support for fingerprint-based op'ing. This will // probably need to live in host land. - member, err := channel.members.Get(Id(args[0])) - if err != nil { + member, ok := channel.MemberById(Id(args[0])) + if !ok { return errors.New("user not found") } - member.(*Member).Op = true + member.Op = true return nil }, }) diff --git a/chat/user.go b/chat/user.go index 203a48a..75ea330 100644 --- a/chat/user.go +++ b/chat/user.go @@ -20,6 +20,7 @@ type User struct { joined time.Time msg chan Message done chan struct{} + replyTo *User // Set when user gets a /msg, for replying. closed bool closeOnce sync.Once } diff --git a/command.go b/command.go new file mode 100644 index 0000000..4700054 --- /dev/null +++ b/command.go @@ -0,0 +1,39 @@ +package main + +import ( + "errors" + + "github.com/shazow/ssh-chat/chat" +) + +// InitCommands adds host-specific commands to a Commands container. +func InitCommands(h *Host, c *chat.Commands) { + c.Add(chat.Command{ + Op: true, + Prefix: "/msg", + PrefixHelp: "USER MESSAGE", + Help: "Send MESSAGE to USER.", + Handler: func(channel *chat.Channel, msg chat.CommandMsg) error { + if !channel.IsOp(msg.From()) { + return errors.New("must be op") + } + + args := msg.Args() + switch len(args) { + case 0: + return errors.New("must specify user") + case 1: + return errors.New("must specify message") + } + + member, ok := channel.MemberById(chat.Id(args[0])) + if !ok { + return errors.New("user not found") + } + + m := chat.NewPrivateMsg("hello", msg.From(), member.User) + channel.Send(m) + return nil + }, + }) +} diff --git a/host.go b/host.go index 51a1fb5..d3510e7 100644 --- a/host.go +++ b/host.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "io" "strings" @@ -9,6 +10,15 @@ import ( "github.com/shazow/ssh-chat/sshd" ) +// GetPrompt will render the terminal prompt string based on the user. +func GetPrompt(user *chat.User) string { + name := user.Name() + if user.Config.Theme != nil { + name = user.Config.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 channels, if we want. type Host struct { @@ -35,6 +45,7 @@ func NewHost(listener *sshd.SSHListener) *Host { // Make our own commands registry instance. commands := chat.Commands{} chat.InitCommands(&commands) + h.InitCommands(&commands) ch.SetCommands(commands) go ch.Serve() @@ -159,11 +170,58 @@ func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine str return } -// GetPrompt will render the terminal prompt string based on the user. -func GetPrompt(user *chat.User) string { - name := user.Name() - if user.Config.Theme != nil { - name = user.Config.Theme.ColorName(user) - } - return fmt.Sprintf("[%s] ", name) +// 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(channel *chat.Channel, msg chat.CommandMsg) error { + args := msg.Args() + switch len(args) { + case 0: + return errors.New("must specify user") + case 1: + return errors.New("must specify message") + } + + member, ok := channel.MemberById(chat.Id(args[0])) + if !ok { + return errors.New("user not found") + } + + m := chat.NewPrivateMsg(strings.Join(args[2:], " "), msg.From(), member.User) + channel.Send(m) + return nil + }, + }) + + // Op commands + c.Add(chat.Command{ + Op: true, + Prefix: "/kick", + PrefixHelp: "USER", + Help: "Kick USER from the server.", + Handler: func(channel *chat.Channel, msg chat.CommandMsg) error { + if !channel.IsOp(msg.From()) { + return errors.New("must be op") + } + + args := msg.Args() + if len(args) == 0 { + return errors.New("must specify user") + } + + member, ok := channel.MemberById(chat.Id(args[0])) + if !ok { + return errors.New("user not found") + } + + body := fmt.Sprintf("%s was kicked by %s.", member.Name(), msg.From().Name()) + channel.Send(chat.NewAnnounceMsg(body)) + member.User.Close() + return nil + }, + }) } diff --git a/sshd/multi.go b/sshd/multi.go deleted file mode 100644 index 62447a7..0000000 --- a/sshd/multi.go +++ /dev/null @@ -1,42 +0,0 @@ -package sshd - -import ( - "fmt" - "io" - "strings" -) - -// Keep track of multiple errors and coerce them into one error -type MultiError []error - -func (e MultiError) Error() string { - switch len(e) { - case 0: - return "" - case 1: - return e[0].Error() - default: - errs := []string{} - for _, err := range e { - errs = append(errs, err.Error()) - } - return fmt.Sprintf("%d errors: %s", strings.Join(errs, "; ")) - } -} - -// Keep track of multiple closers and close them all as one closer -type MultiCloser []io.Closer - -func (c MultiCloser) Close() error { - errors := MultiError{} - for _, closer := range c { - err := closer.Close() - if err != nil { - errors = append(errors, err) - } - } - if len(errors) == 0 { - return nil - } - return errors -} diff --git a/sshd/pty.go b/sshd/pty.go index 5aecc3e..06d34f0 100644 --- a/sshd/pty.go +++ b/sshd/pty.go @@ -1,8 +1,9 @@ -// Borrowed from go.crypto circa 2011 package sshd import "encoding/binary" +// Helpers below are borrowed from go.crypto circa 2011: + // parsePtyRequest parses the payload of the pty-req message and extracts the // dimensions of the terminal. See RFC 4254, section 6.2. func parsePtyRequest(s []byte) (width, height int, ok bool) { From 723313bcb29106d4ac818da9bde82a3cb5972d17 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 10 Jan 2015 19:48:17 -0800 Subject: [PATCH 39/60] Bugfixen. --- cmd.go | 2 +- command.go | 39 --------------------------------------- host.go | 6 +++--- 3 files changed, 4 insertions(+), 43 deletions(-) delete mode 100644 command.go diff --git a/cmd.go b/cmd.go index e244361..9a22209 100644 --- a/cmd.go +++ b/cmd.go @@ -60,7 +60,7 @@ func main() { // Figure out the log level numVerbose := len(options.Verbose) if numVerbose > len(logLevels) { - numVerbose = len(logLevels) + numVerbose = len(logLevels) - 1 } logLevel := logLevels[numVerbose] diff --git a/command.go b/command.go deleted file mode 100644 index 4700054..0000000 --- a/command.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "errors" - - "github.com/shazow/ssh-chat/chat" -) - -// InitCommands adds host-specific commands to a Commands container. -func InitCommands(h *Host, c *chat.Commands) { - c.Add(chat.Command{ - Op: true, - Prefix: "/msg", - PrefixHelp: "USER MESSAGE", - Help: "Send MESSAGE to USER.", - Handler: func(channel *chat.Channel, msg chat.CommandMsg) error { - if !channel.IsOp(msg.From()) { - return errors.New("must be op") - } - - args := msg.Args() - switch len(args) { - case 0: - return errors.New("must specify user") - case 1: - return errors.New("must specify message") - } - - member, ok := channel.MemberById(chat.Id(args[0])) - if !ok { - return errors.New("user not found") - } - - m := chat.NewPrivateMsg("hello", msg.From(), member.User) - channel.Send(m) - return nil - }, - }) -} diff --git a/host.go b/host.go index d3510e7..f2aa5f7 100644 --- a/host.go +++ b/host.go @@ -94,7 +94,7 @@ func (h *Host) Connect(term *sshd.Terminal) { term.SetPrompt(GetPrompt(user)) h.count++ - // Should the user be op'd? + // Should the user be op'd on join? member.Op = h.isOp(term.Conn) for { @@ -191,7 +191,7 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("user not found") } - m := chat.NewPrivateMsg(strings.Join(args[2:], " "), msg.From(), member.User) + m := chat.NewPrivateMsg(strings.Join(args[1:], " "), msg.From(), member.User) channel.Send(m) return nil }, @@ -220,7 +220,7 @@ func (h *Host) InitCommands(c *chat.Commands) { body := fmt.Sprintf("%s was kicked by %s.", member.Name(), msg.From().Name()) channel.Send(chat.NewAnnounceMsg(body)) - member.User.Close() + member.Close() return nil }, }) From 587b487927f4bff1b5d3cba8ecb5046dc1e59241 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 10 Jan 2015 20:11:46 -0800 Subject: [PATCH 40/60] Mild refactoring towards decoupling channel from host. --- host.go | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/host.go b/host.go index f2aa5f7..7c5621d 100644 --- a/host.go +++ b/host.go @@ -170,6 +170,14 @@ func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine str return } +func (h *Host) GetUser(name string) (*chat.User, bool) { + m, ok := h.channel.MemberById(chat.Id(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) { @@ -186,12 +194,12 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("must specify message") } - member, ok := channel.MemberById(chat.Id(args[0])) + target, ok := h.GetUser(args[0]) if !ok { return errors.New("user not found") } - m := chat.NewPrivateMsg(strings.Join(args[1:], " "), msg.From(), member.User) + m := chat.NewPrivateMsg(strings.Join(args[1:], " "), msg.From(), target) channel.Send(m) return nil }, @@ -213,14 +221,45 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("must specify user") } - member, ok := channel.MemberById(chat.Id(args[0])) + target, ok := h.GetUser(args[0]) if !ok { return errors.New("user not found") } - body := fmt.Sprintf("%s was kicked by %s.", member.Name(), msg.From().Name()) + body := fmt.Sprintf("%s was kicked by %s.", target.Name(), msg.From().Name()) channel.Send(chat.NewAnnounceMsg(body)) - member.Close() + target.Close() + return nil + }, + }) + + c.Add(chat.Command{ + Op: true, + Prefix: "/ban", + PrefixHelp: "USER", + Help: "Ban USER from the server.", + Handler: func(channel *chat.Channel, msg chat.CommandMsg) error { + // TODO: This only bans their pubkey if they have one. Would be + // nice to IP-ban too while we're at it. + if !channel.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") + } + + // XXX: Figure out how to link a public key to a target. + //h.auth.Ban(target.Conn.PublicKey()) + body := fmt.Sprintf("%s was banned by %s.", target.Name(), msg.From().Name()) + channel.Send(chat.NewAnnounceMsg(body)) + target.Close() return nil }, }) From 50540d26e9b1e1847d291b16acbc69f7578ce2aa Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 11 Jan 2015 14:12:51 -0800 Subject: [PATCH 41/60] Passing /kick test. --- host.go | 1 + host_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++++--- sshd/client.go | 11 ++++++-- sshd/net_test.go | 4 +-- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/host.go b/host.go index 7c5621d..936e8d7 100644 --- a/host.go +++ b/host.go @@ -170,6 +170,7 @@ func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine str return } +// GetUser returns a chat.User based on a name. func (h *Host) GetUser(name string) (*chat.User, bool) { m, ok := h.channel.MemberById(chat.Id(name)) if !ok { diff --git a/host_test.go b/host_test.go index abee9f3..fb47a61 100644 --- a/host_test.go +++ b/host_test.go @@ -5,8 +5,10 @@ import ( "crypto/rand" "crypto/rsa" "io" + "io/ioutil" "strings" "testing" + "time" "github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/sshd" @@ -61,7 +63,7 @@ func TestHostNameCollision(t *testing.T) { // First client go func() { - err = sshd.NewClientSession(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) { + err = sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) { scanner := bufio.NewScanner(r) // Consume the initial buffer @@ -99,7 +101,7 @@ func TestHostNameCollision(t *testing.T) { <-done // Second client - err = sshd.NewClientSession(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) { + err = sshd.ConnectShell(s.Addr().String(), "foo", func(r io.Reader, w io.WriteCloser) { scanner := bufio.NewScanner(r) // Consume the initial buffer @@ -137,7 +139,7 @@ func TestHostWhitelist(t *testing.T) { target := s.Addr().String() - err = sshd.NewClientSession(target, "foo", func(r io.Reader, w io.WriteCloser) {}) + err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) {}) if err != nil { t.Error(err) } @@ -150,8 +152,67 @@ func TestHostWhitelist(t *testing.T) { clientpubkey, _ := ssh.NewPublicKey(clientkey.Public()) auth.Whitelist(clientpubkey) - err = sshd.NewClientSession(target, "foo", func(r io.Reader, w io.WriteCloser) {}) + err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) {}) if err == nil { t.Error("Failed to block unwhitelisted connection.") } } + +func TestHostKick(t *testing.T) { + key, err := sshd.NewRandomSigner(512) + if err != nil { + t.Fatal(err) + } + + auth := NewAuth() + config := sshd.MakeAuth(auth) + config.AddHostKey(key) + + s, err := sshd.ListenSSH(":0", config) + if err != nil { + t.Fatal(err) + } + defer s.Close() + addr := s.Addr().String() + host := NewHost(s) + go host.Serve() + + connected := make(chan struct{}) + done := make(chan struct{}) + + go func() { + // First client + err = sshd.ConnectShell(addr, "foo", func(r io.Reader, w io.WriteCloser) { + // Make op + member, _ := host.channel.MemberById("foo") + member.Op = true + + // Block until second client is here + connected <- struct{}{} + w.Write([]byte("/kick bar\r\n")) + }) + if err != nil { + t.Fatal(err) + } + }() + + go func() { + // Second client + err = sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) { + <-connected + + // Consume while we're connected. Should break when kicked. + ioutil.ReadAll(r) + }) + if err != nil { + t.Fatal(err) + } + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second * 1): + t.Fatal("Timeout.") + } +} diff --git a/sshd/client.go b/sshd/client.go index 9a01065..13d5dea 100644 --- a/sshd/client.go +++ b/sshd/client.go @@ -29,8 +29,8 @@ func NewClientConfig(name string) *ssh.ClientConfig { } } -// NewClientSession makes a barebones SSH client session, used for testing. -func NewClientSession(host string, name string, handler func(r io.Reader, w io.WriteCloser)) error { +// ConnectShell makes a barebones SSH client session, used for testing. +func ConnectShell(host string, name string, handler func(r io.Reader, w io.WriteCloser)) error { config := NewClientConfig(name) conn, err := ssh.Dial("tcp", host, config) if err != nil { @@ -54,6 +54,13 @@ func NewClientSession(host string, name string, handler func(r io.Reader, w io.W return err } + /* FIXME: Do we want to request a PTY? + err = session.RequestPty("xterm", 80, 40, ssh.TerminalModes{}) + if err != nil { + return err + } + */ + err = session.Shell() if err != nil { return err diff --git a/sshd/net_test.go b/sshd/net_test.go index 724ce77..c250525 100644 --- a/sshd/net_test.go +++ b/sshd/net_test.go @@ -56,7 +56,7 @@ func TestServeTerminals(t *testing.T) { host := s.Addr().String() name := "foo" - err = NewClientSession(host, name, func(r io.Reader, w io.WriteCloser) { + err = ConnectShell(host, name, func(r io.Reader, w io.WriteCloser) { // Consume if there is anything buf := new(bytes.Buffer) w.Write([]byte("hello\r\n")) @@ -70,7 +70,7 @@ func TestServeTerminals(t *testing.T) { expected := "> hello\r\necho: hello\r\n" actual := buf.String() if actual != expected { - t.Errorf("Got `%s`; expected `%s`", actual, expected) + t.Errorf("Got %q; expected %q", actual, expected) } s.Close() }) From b94911f05239ad26d33ab8c83462c534734848ff Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 11 Jan 2015 14:31:51 -0800 Subject: [PATCH 42/60] Fix hang condition on kick. --- chat/channel.go | 1 - 1 file changed, 1 deletion(-) diff --git a/chat/channel.go b/chat/channel.go index 4fadb69..a5fd0c3 100644 --- a/chat/channel.go +++ b/chat/channel.go @@ -93,7 +93,6 @@ func (ch *Channel) HandleMsg(m Message) { } err := user.Send(m) if err != nil { - ch.Leave(user) user.Close() } }) From b99083ee6eef1ee15026b344874505e17d622dbb Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 16 Jan 2015 12:30:18 -0800 Subject: [PATCH 43/60] Connection-level rate limiting. --- sshd/net.go | 3 +++ sshd/ratelimit.go | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 sshd/ratelimit.go diff --git a/sshd/net.go b/sshd/net.go index bb94432..7ded1e7 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -2,7 +2,9 @@ package sshd import ( "net" + "time" + "github.com/shazow/rateio" "golang.org/x/crypto/ssh" ) @@ -24,6 +26,7 @@ func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) { // Upgrade TCP connection to SSH connection + conn = ReadLimitConn(conn, rateio.NewGracefulLimiter(1000, time.Minute*2, time.Second*3)) sshConn, channels, requests, err := ssh.NewServerConn(conn, l.config) if err != nil { return nil, err diff --git a/sshd/ratelimit.go b/sshd/ratelimit.go new file mode 100644 index 0000000..c80f0ac --- /dev/null +++ b/sshd/ratelimit.go @@ -0,0 +1,25 @@ +package sshd + +import ( + "io" + "net" + + "github.com/shazow/rateio" +) + +type limitedConn struct { + net.Conn + io.Reader // Our rate-limited io.Reader for net.Conn +} + +func (r *limitedConn) Read(p []byte) (n int, err error) { + return r.Reader.Read(p) +} + +// ReadLimitConn returns a net.Conn whose io.Reader interface is rate-limited by limiter. +func ReadLimitConn(conn net.Conn, limiter rateio.Limiter) net.Conn { + return &limitedConn{ + Conn: conn, + Reader: rateio.NewReader(conn, limiter), + } +} From cc25d17bdc13c31bca170ba675a6236c2853d858 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 16 Jan 2015 12:35:57 -0800 Subject: [PATCH 44/60] Configurable rate limiting for sshd --- cmd.go | 1 + sshd/net.go | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd.go b/cmd.go index 9a22209..a763885 100644 --- a/cmd.go +++ b/cmd.go @@ -102,6 +102,7 @@ func main() { os.Exit(4) } defer s.Close() + s.RateLimit = true fmt.Printf("Listening for connections on %v\n", s.Addr().String()) diff --git a/sshd/net.go b/sshd/net.go index 7ded1e7..5e782d8 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -11,7 +11,8 @@ import ( // Container for the connection and ssh-related configuration type SSHListener struct { net.Listener - config *ssh.ServerConfig + config *ssh.ServerConfig + RateLimit bool } // Make an SSH listener socket @@ -20,13 +21,17 @@ func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { if err != nil { return nil, err } - l := SSHListener{socket, config} + l := SSHListener{Listener: socket, config: config} return &l, nil } func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) { + if l.RateLimit { + // TODO: Configurable Limiter? + conn = ReadLimitConn(conn, rateio.NewGracefulLimiter(1000, time.Minute*2, time.Second*3)) + } + // Upgrade TCP connection to SSH connection - conn = ReadLimitConn(conn, rateio.NewGracefulLimiter(1000, time.Minute*2, time.Second*3)) sshConn, channels, requests, err := ssh.NewServerConn(conn, l.config) if err != nil { return nil, err From 3c4e6994c291c69461e99846e74a7a7e96f8e2b9 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Fri, 16 Jan 2015 21:53:22 -0800 Subject: [PATCH 45/60] chat.Channel->chat.Room, /ban, /whois, chat.User.Identifier - Renamed chat.Channel -> chat.Room - /ban works, supports IP also - /whois works - chat.User now accepts an Identifier interface rather than name - Tweaked rate limiting --- auth.go | 80 ++++++---- chat/channel.go | 188 ----------------------- chat/command.go | 53 +++---- chat/doc.go | 2 +- chat/message.go | 6 +- chat/room.go | 200 +++++++++++++++++++++++++ chat/{channel_test.go => room_test.go} | 22 +-- chat/set.go | 53 ++++--- chat/theme.go | 4 +- chat/user.go | 45 +++--- host.go | 98 ++++++++---- host_test.go | 2 +- identity.go | 51 +++++++ sshd/auth.go | 10 +- sshd/net.go | 2 +- sshd/terminal.go | 14 +- 16 files changed, 486 insertions(+), 344 deletions(-) delete mode 100644 chat/channel.go create mode 100644 chat/room.go rename chat/{channel_test.go => room_test.go} (90%) create mode 100644 identity.go diff --git a/auth.go b/auth.go index d218366..bce92bf 100644 --- a/auth.go +++ b/auth.go @@ -2,9 +2,9 @@ package main import ( "errors" + "net" "sync" - "github.com/shazow/ssh-chat/sshd" "golang.org/x/crypto/ssh" ) @@ -17,27 +17,38 @@ var ErrBanned = errors.New("banned") // AuthKey is the type that our lookups are keyed against. type AuthKey string -// NewAuthKey returns an AuthKey from an ssh.PublicKey. -func NewAuthKey(key ssh.PublicKey) AuthKey { +// NewAuthKey returns string from an ssh.PublicKey. +func NewAuthKey(key ssh.PublicKey) string { + if key == nil { + return "" + } // FIXME: Is there a way to index pubkeys without marshal'ing them into strings? - return AuthKey(string(key.Marshal())) + return string(key.Marshal()) +} + +// NewAuthAddr returns a string from a net.Addr +func NewAuthAddr(addr net.Addr) string { + host, _, _ := net.SplitHostPort(addr.String()) + return host } // Auth stores fingerprint lookups +// TODO: Add timed auth by using a time.Time instead of struct{} for values. type Auth struct { - sshd.Auth sync.RWMutex - whitelist map[AuthKey]struct{} - banned map[AuthKey]struct{} - ops map[AuthKey]struct{} + bannedAddr map[string]struct{} + banned map[string]struct{} + whitelist map[string]struct{} + ops map[string]struct{} } // NewAuth creates a new default Auth. func NewAuth() *Auth { return &Auth{ - whitelist: make(map[AuthKey]struct{}), - banned: make(map[AuthKey]struct{}), - ops: make(map[AuthKey]struct{}), + bannedAddr: make(map[string]struct{}), + banned: make(map[string]struct{}), + whitelist: make(map[string]struct{}), + ops: make(map[string]struct{}), } } @@ -50,7 +61,7 @@ func (a Auth) AllowAnonymous() bool { } // Check determines if a pubkey fingerprint is permitted. -func (a Auth) Check(key ssh.PublicKey) (bool, error) { +func (a Auth) Check(addr net.Addr, key ssh.PublicKey) (bool, error) { authkey := NewAuthKey(key) a.RLock() @@ -63,9 +74,13 @@ func (a Auth) Check(key ssh.PublicKey) (bool, error) { if !whitelisted { return false, ErrNotWhitelisted } + return true, nil } _, banned := a.banned[authkey] + if !banned { + _, banned = a.bannedAddr[NewAuthAddr(addr)] + } if banned { return false, ErrBanned } @@ -75,6 +90,10 @@ func (a Auth) Check(key ssh.PublicKey) (bool, error) { // Op will set a fingerprint as a known operator. func (a *Auth) Op(key ssh.PublicKey) { + if key == nil { + // Don't process empty keys. + return + } authkey := NewAuthKey(key) a.Lock() a.ops[authkey] = struct{}{} @@ -83,6 +102,9 @@ func (a *Auth) Op(key ssh.PublicKey) { // IsOp checks if a public key is an op. func (a Auth) IsOp(key ssh.PublicKey) bool { + if key == nil { + return false + } authkey := NewAuthKey(key) a.RLock() _, ok := a.ops[authkey] @@ -92,34 +114,34 @@ func (a Auth) IsOp(key ssh.PublicKey) bool { // Whitelist will set a public key as a whitelisted user. func (a *Auth) Whitelist(key ssh.PublicKey) { + if key == nil { + // Don't process empty keys. + return + } authkey := NewAuthKey(key) a.Lock() a.whitelist[authkey] = struct{}{} a.Unlock() } -// IsWhitelisted checks if a public key is whitelisted. -func (a Auth) IsWhitelisted(key ssh.PublicKey) bool { - authkey := NewAuthKey(key) - a.RLock() - _, ok := a.whitelist[authkey] - a.RUnlock() - return ok -} - -// Ban will set a fingerprint as banned. +// Ban will set a public key as banned. func (a *Auth) Ban(key ssh.PublicKey) { + if key == nil { + // Don't process empty keys. + return + } authkey := NewAuthKey(key) + a.Lock() a.banned[authkey] = struct{}{} a.Unlock() } -// IsBanned will set a fingerprint as banned. -func (a Auth) IsBanned(key ssh.PublicKey) bool { - authkey := NewAuthKey(key) - a.RLock() - _, ok := a.whitelist[authkey] - a.RUnlock() - return ok +// Ban will set an IP address as banned. +func (a *Auth) BanAddr(addr net.Addr) { + key := NewAuthAddr(addr) + + a.Lock() + a.bannedAddr[key] = struct{}{} + a.Unlock() } diff --git a/chat/channel.go b/chat/channel.go deleted file mode 100644 index a5fd0c3..0000000 --- a/chat/channel.go +++ /dev/null @@ -1,188 +0,0 @@ -package chat - -import ( - "errors" - "fmt" - "sync" -) - -const historyLen = 20 -const channelBuffer = 10 - -// The error returned when a message is sent to a channel that is already -// closed. -var ErrChannelClosed = errors.New("channel closed") - -// Member is a User with per-Channel metadata attached to it. -type Member struct { - *User - Op bool -} - -// Channel definition, also a Set of User Items -type Channel struct { - topic string - history *History - members *Set - broadcast chan Message - commands Commands - closed bool - closeOnce sync.Once -} - -// NewChannel creates a new channel. -func NewChannel() *Channel { - broadcast := make(chan Message, channelBuffer) - - return &Channel{ - broadcast: broadcast, - history: NewHistory(historyLen), - members: NewSet(), - commands: *defaultCommands, - } -} - -// SetCommands sets the channel's command handlers. -func (ch *Channel) SetCommands(commands Commands) { - ch.commands = commands -} - -// Close the channel and all the users it contains. -func (ch *Channel) Close() { - ch.closeOnce.Do(func() { - ch.closed = true - ch.members.Each(func(m Item) { - m.(*Member).Close() - }) - ch.members.Clear() - close(ch.broadcast) - }) -} - -// HandleMsg reacts to a message, will block until done. -func (ch *Channel) HandleMsg(m Message) { - switch m := m.(type) { - case *CommandMsg: - cmd := *m - err := ch.commands.Run(ch, cmd) - if err != nil { - m := NewSystemMsg(fmt.Sprintf("Err: %s", err), cmd.from) - go ch.HandleMsg(m) - } - case MessageTo: - user := m.To() - user.Send(m) - default: - fromMsg, skip := m.(MessageFrom) - var skipUser *User - if skip { - skipUser = fromMsg.From() - } - - ch.members.Each(func(u Item) { - user := u.(*Member).User - if skip && skipUser == user { - // Skip - return - } - if _, ok := m.(*AnnounceMsg); ok { - if user.Config.Quiet { - // Skip - return - } - } - err := user.Send(m) - if err != nil { - user.Close() - } - }) - } -} - -// Serve will consume the broadcast channel and handle the messages, should be -// run in a goroutine. -func (ch *Channel) Serve() { - for m := range ch.broadcast { - go ch.HandleMsg(m) - } -} - -// Send message, buffered by a chan. -func (ch *Channel) Send(m Message) { - ch.broadcast <- m -} - -// Join the channel as a user, will announce. -func (ch *Channel) Join(u *User) (*Member, error) { - if ch.closed { - return nil, ErrChannelClosed - } - member := Member{u, false} - err := ch.members.Add(&member) - if err != nil { - return nil, err - } - s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), ch.members.Len()) - ch.Send(NewAnnounceMsg(s)) - return &member, nil -} - -// Leave the channel as a user, will announce. Mostly used during setup. -func (ch *Channel) Leave(u *User) error { - err := ch.members.Remove(u) - if err != nil { - return err - } - s := fmt.Sprintf("%s left.", u.Name()) - ch.Send(NewAnnounceMsg(s)) - return nil -} - -// Member returns a corresponding Member object to a User if the Member is -// present in this channel. -func (ch *Channel) Member(u *User) (*Member, bool) { - m, ok := ch.MemberById(u.Id()) - if !ok { - return nil, false - } - // Check that it's the same user - if m.User != u { - return nil, false - } - return m, true -} - -func (ch *Channel) MemberById(id Id) (*Member, bool) { - m, err := ch.members.Get(id) - if err != nil { - return nil, false - } - return m.(*Member), true -} - -// IsOp returns whether a user is an operator in this channel. -func (ch *Channel) IsOp(u *User) bool { - m, ok := ch.Member(u) - return ok && m.Op -} - -// Topic of the channel. -func (ch *Channel) Topic() string { - return ch.topic -} - -// SetTopic will set the topic of the channel. -func (ch *Channel) SetTopic(s string) { - ch.topic = s -} - -// NamesPrefix lists all members' names with a given prefix, used to query -// for autocompletion purposes. -func (ch *Channel) NamesPrefix(prefix string) []string { - members := ch.members.ListPrefix(prefix) - names := make([]string, len(members)) - for i, u := range members { - names[i] = u.(*Member).User.Name() - } - return names -} diff --git a/chat/command.go b/chat/command.go index 351d090..aa6d3f5 100644 --- a/chat/command.go +++ b/chat/command.go @@ -29,7 +29,7 @@ type Command struct { PrefixHelp string // If omitted, command is hidden from /help Help string - Handler func(*Channel, CommandMsg) error + Handler func(*Room, CommandMsg) error // Command requires Op permissions Op bool } @@ -59,7 +59,7 @@ func (c Commands) Alias(command string, alias string) error { } // Run executes a command message. -func (c Commands) Run(channel *Channel, msg CommandMsg) error { +func (c Commands) Run(room *Room, msg CommandMsg) error { if msg.From == nil { return ErrNoOwner } @@ -69,7 +69,7 @@ func (c Commands) Run(channel *Channel, msg CommandMsg) error { return ErrInvalidCommand } - return cmd.Handler(channel, msg) + return cmd.Handler(room, msg) } // Help will return collated help text as one string. @@ -102,16 +102,16 @@ func init() { func InitCommands(c *Commands) { c.Add(Command{ Prefix: "/help", - Handler: func(channel *Channel, msg CommandMsg) error { - op := channel.IsOp(msg.From()) - channel.Send(NewSystemMsg(channel.commands.Help(op), msg.From())) + Handler: func(room *Room, msg CommandMsg) error { + op := room.IsOp(msg.From()) + room.Send(NewSystemMsg(room.commands.Help(op), msg.From())) return nil }, }) c.Add(Command{ Prefix: "/me", - Handler: func(channel *Channel, msg CommandMsg) error { + Handler: func(room *Room, msg CommandMsg) error { me := strings.TrimLeft(msg.body, "/me") if me == "" { me = " is at a loss for words." @@ -119,7 +119,7 @@ func InitCommands(c *Commands) { me = me[1:] } - channel.Send(NewEmoteMsg(me, msg.From())) + room.Send(NewEmoteMsg(me, msg.From())) return nil }, }) @@ -127,7 +127,7 @@ func InitCommands(c *Commands) { c.Add(Command{ Prefix: "/exit", Help: "Exit the chat.", - Handler: func(channel *Channel, msg CommandMsg) error { + Handler: func(room *Room, msg CommandMsg) error { msg.From().Close() return nil }, @@ -138,17 +138,20 @@ func InitCommands(c *Commands) { Prefix: "/nick", PrefixHelp: "NAME", Help: "Rename yourself.", - Handler: func(channel *Channel, msg CommandMsg) error { + Handler: func(room *Room, msg CommandMsg) error { args := msg.Args() if len(args) != 1 { return ErrMissingArg } u := msg.From() - oldName := u.Name() - u.SetName(args[0]) + oldId := u.Id() + u.SetId(Id(args[0])) - body := fmt.Sprintf("%s is now known as %s.", oldName, u.Name()) - channel.Send(NewAnnounceMsg(body)) + err := room.Rename(oldId, u) + if err != nil { + u.SetId(oldId) + return err + } return nil }, }) @@ -156,11 +159,11 @@ func InitCommands(c *Commands) { c.Add(Command{ Prefix: "/names", Help: "List users who are connected.", - Handler: func(channel *Channel, msg CommandMsg) error { + Handler: func(room *Room, msg CommandMsg) error { // TODO: colorize - names := channel.NamesPrefix("") + names := room.NamesPrefix("") body := fmt.Sprintf("%d connected: %s", len(names), strings.Join(names, ", ")) - channel.Send(NewSystemMsg(body, msg.From())) + room.Send(NewSystemMsg(body, msg.From())) return nil }, }) @@ -170,7 +173,7 @@ func InitCommands(c *Commands) { Prefix: "/theme", PrefixHelp: "[mono|colors]", Help: "Set your color theme.", - Handler: func(channel *Channel, msg CommandMsg) error { + Handler: func(room *Room, msg CommandMsg) error { user := msg.From() args := msg.Args() if len(args) == 0 { @@ -179,7 +182,7 @@ func InitCommands(c *Commands) { theme = user.Config.Theme.Id() } body := fmt.Sprintf("Current theme: %s", theme) - channel.Send(NewSystemMsg(body, user)) + room.Send(NewSystemMsg(body, user)) return nil } @@ -188,7 +191,7 @@ func InitCommands(c *Commands) { if t.Id() == id { user.Config.Theme = &t body := fmt.Sprintf("Set theme: %s", id) - channel.Send(NewSystemMsg(body, user)) + room.Send(NewSystemMsg(body, user)) return nil } } @@ -199,7 +202,7 @@ func InitCommands(c *Commands) { c.Add(Command{ Prefix: "/quiet", Help: "Silence announcement-type messages (join, part, rename, etc).", - Handler: func(channel *Channel, msg CommandMsg) error { + Handler: func(room *Room, msg CommandMsg) error { u := msg.From() u.ToggleQuietMode() @@ -209,7 +212,7 @@ func InitCommands(c *Commands) { } else { body = "Quiet mode is toggled OFF" } - channel.Send(NewSystemMsg(body, u)) + room.Send(NewSystemMsg(body, u)) return nil }, }) @@ -219,8 +222,8 @@ func InitCommands(c *Commands) { Prefix: "/op", PrefixHelp: "USER", Help: "Mark user as admin.", - Handler: func(channel *Channel, msg CommandMsg) error { - if !channel.IsOp(msg.From()) { + Handler: func(room *Room, msg CommandMsg) error { + if !room.IsOp(msg.From()) { return errors.New("must be op") } @@ -231,7 +234,7 @@ func InitCommands(c *Commands) { // TODO: Add support for fingerprint-based op'ing. This will // probably need to live in host land. - member, ok := channel.MemberById(Id(args[0])) + member, ok := room.MemberById(Id(args[0])) if !ok { return errors.New("user not found") } diff --git a/chat/doc.go b/chat/doc.go index 7c80e02..22760e7 100644 --- a/chat/doc.go +++ b/chat/doc.go @@ -4,7 +4,7 @@ with the intention of using with the intention of using as the backend for ssh-chat. This package should not know anything about sockets. It should expose io-style -interfaces and channels for communicating with any method of transnport. +interfaces and rooms for communicating with any method of transnport. TODO: Add usage examples here. diff --git a/chat/message.go b/chat/message.go index 955bc23..2a804a9 100644 --- a/chat/message.go +++ b/chat/message.go @@ -55,7 +55,7 @@ func (m *Msg) Command() string { return "" } -// PublicMsg is any message from a user sent to the channel. +// PublicMsg is any message from a user sent to the room. type PublicMsg struct { Msg from *User @@ -105,7 +105,7 @@ func (m *PublicMsg) String() string { return fmt.Sprintf("%s: %s", m.from.Name(), m.body) } -// EmoteMsg is a /me message sent to the channel. It specifically does not +// EmoteMsg is a /me message sent to the room. It specifically does not // extend PublicMsg because it doesn't implement MessageFrom to allow the // sender to see the emote. type EmoteMsg struct { @@ -212,7 +212,7 @@ type CommandMsg struct { *PublicMsg command string args []string - channel *Channel + room *Room } func (m *CommandMsg) Command() string { diff --git a/chat/room.go b/chat/room.go new file mode 100644 index 0000000..896f92f --- /dev/null +++ b/chat/room.go @@ -0,0 +1,200 @@ +package chat + +import ( + "errors" + "fmt" + "sync" +) + +const historyLen = 20 +const roomBuffer = 10 + +// The error returned when a message is sent to a room that is already +// closed. +var ErrRoomClosed = errors.New("room closed") + +// Member is a User with per-Room metadata attached to it. +type Member struct { + *User + Op bool +} + +// Room definition, also a Set of User Items +type Room struct { + topic string + history *History + members *Set + broadcast chan Message + commands Commands + closed bool + closeOnce sync.Once +} + +// NewRoom creates a new room. +func NewRoom() *Room { + broadcast := make(chan Message, roomBuffer) + + return &Room{ + broadcast: broadcast, + history: NewHistory(historyLen), + members: NewSet(), + commands: *defaultCommands, + } +} + +// SetCommands sets the room's command handlers. +func (r *Room) SetCommands(commands Commands) { + r.commands = commands +} + +// Close the room and all the users it contains. +func (r *Room) Close() { + r.closeOnce.Do(func() { + r.closed = true + r.members.Each(func(m Identifier) { + m.(*Member).Close() + }) + r.members.Clear() + close(r.broadcast) + }) +} + +// HandleMsg reacts to a message, will block until done. +func (r *Room) HandleMsg(m Message) { + switch m := m.(type) { + case *CommandMsg: + cmd := *m + err := r.commands.Run(r, cmd) + if err != nil { + m := NewSystemMsg(fmt.Sprintf("Err: %s", err), cmd.from) + go r.HandleMsg(m) + } + case MessageTo: + user := m.To() + user.Send(m) + default: + fromMsg, skip := m.(MessageFrom) + var skipUser *User + if skip { + skipUser = fromMsg.From() + } + + r.members.Each(func(u Identifier) { + user := u.(*Member).User + if skip && skipUser == user { + // Skip + return + } + if _, ok := m.(*AnnounceMsg); ok { + if user.Config.Quiet { + // Skip + return + } + } + err := user.Send(m) + if err != nil { + user.Close() + } + }) + } +} + +// Serve will consume the broadcast room and handle the messages, should be +// run in a goroutine. +func (r *Room) Serve() { + for m := range r.broadcast { + go r.HandleMsg(m) + } +} + +// Send message, buffered by a chan. +func (r *Room) Send(m Message) { + r.broadcast <- m +} + +// Join the room as a user, will announce. +func (r *Room) Join(u *User) (*Member, error) { + if r.closed { + return nil, ErrRoomClosed + } + member := Member{u, false} + err := r.members.Add(&member) + if err != nil { + return nil, err + } + s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), r.members.Len()) + r.Send(NewAnnounceMsg(s)) + return &member, nil +} + +// Leave the room as a user, will announce. Mostly used during setup. +func (r *Room) Leave(u *User) error { + err := r.members.Remove(u) + if err != nil { + return err + } + s := fmt.Sprintf("%s left.", u.Name()) + r.Send(NewAnnounceMsg(s)) + return nil +} + +// Rename member with a new identity. This will not call rename on the member. +func (r *Room) Rename(oldId Id, identity Identifier) error { + err := r.members.Replace(oldId, identity) + if err != nil { + return err + } + + s := fmt.Sprintf("%s is now known as %s.", oldId, identity.Id()) + r.Send(NewAnnounceMsg(s)) + return nil +} + +// Member returns a corresponding Member object to a User if the Member is +// present in this room. +func (r *Room) Member(u *User) (*Member, bool) { + m, ok := r.MemberById(u.Id()) + if !ok { + return nil, false + } + // Check that it's the same user + if m.User != u { + return nil, false + } + return m, true +} + +func (r *Room) MemberById(id Id) (*Member, bool) { + m, err := r.members.Get(id) + if err != nil { + return nil, false + } + return m.(*Member), true +} + +// IsOp returns whether a user is an operator in this room. +func (r *Room) IsOp(u *User) bool { + m, ok := r.Member(u) + return ok && m.Op +} + +// Topic of the room. +func (r *Room) Topic() string { + return r.topic +} + +// SetTopic will set the topic of the room. +func (r *Room) SetTopic(s string) { + r.topic = s +} + +// NamesPrefix lists all members' names with a given prefix, used to query +// for autocompletion purposes. +func (r *Room) NamesPrefix(prefix string) []string { + members := r.members.ListPrefix(prefix) + names := make([]string, len(members)) + for i, u := range members { + names[i] = u.(*Member).User.Name() + } + return names +} diff --git a/chat/channel_test.go b/chat/room_test.go similarity index 90% rename from chat/channel_test.go rename to chat/room_test.go index 13c9bdc..4f39dd7 100644 --- a/chat/channel_test.go +++ b/chat/room_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -func TestChannelServe(t *testing.T) { - ch := NewChannel() +func TestRoomServe(t *testing.T) { + ch := NewRoom() ch.Send(NewAnnounceMsg("hello")) received := <-ch.broadcast @@ -18,13 +18,13 @@ func TestChannelServe(t *testing.T) { } } -func TestChannelJoin(t *testing.T) { +func TestRoomJoin(t *testing.T) { var expected, actual []byte s := &MockScreen{} u := NewUser("foo") - ch := NewChannel() + ch := NewRoom() go ch.Serve() defer ch.Close() @@ -57,13 +57,13 @@ func TestChannelJoin(t *testing.T) { } } -func TestChannelDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { +func TestRoomDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { u := NewUser("foo") u.Config = UserConfig{ Quiet: true, } - ch := NewChannel() + ch := NewRoom() defer ch.Close() _, err := ch.Join(u) @@ -92,13 +92,13 @@ func TestChannelDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { ch.HandleMsg(NewPublicMsg("hello", u)) } -func TestChannelQuietToggleBroadcasts(t *testing.T) { +func TestRoomQuietToggleBroadcasts(t *testing.T) { u := NewUser("foo") u.Config = UserConfig{ Quiet: true, } - ch := NewChannel() + ch := NewRoom() defer ch.Close() _, err := ch.Join(u) @@ -134,7 +134,7 @@ func TestQuietToggleDisplayState(t *testing.T) { s := &MockScreen{} u := NewUser("foo") - ch := NewChannel() + ch := NewRoom() go ch.Serve() defer ch.Close() @@ -164,13 +164,13 @@ func TestQuietToggleDisplayState(t *testing.T) { } } -func TestChannelNames(t *testing.T) { +func TestRoomNames(t *testing.T) { var expected, actual []byte s := &MockScreen{} u := NewUser("foo") - ch := NewChannel() + ch := NewRoom() go ch.Serve() defer ch.Close() diff --git a/chat/set.go b/chat/set.go index 2248de8..d032a34 100644 --- a/chat/set.go +++ b/chat/set.go @@ -12,25 +12,17 @@ var ErrIdTaken = errors.New("id already taken") // The error returned when a requested item does not exist in the set. var ErrItemMissing = errors.New("item does not exist") -// Id is a unique identifier for an item. -type Id string - -// Item is an interface for items to store-able in the set -type Item interface { - Id() Id -} - // Set with string lookup. // TODO: Add trie for efficient prefix lookup? type Set struct { - lookup map[Id]Item + lookup map[Id]Identifier sync.RWMutex } // NewSet creates a new set. func NewSet() *Set { return &Set{ - lookup: map[Id]Item{}, + lookup: map[Id]Identifier{}, } } @@ -38,7 +30,7 @@ func NewSet() *Set { func (s *Set) Clear() int { s.Lock() n := len(s.lookup) - s.lookup = map[Id]Item{} + s.lookup = map[Id]Identifier{} s.Unlock() return n } @@ -49,7 +41,7 @@ func (s *Set) Len() int { } // In checks if an item exists in this set. -func (s *Set) In(item Item) bool { +func (s *Set) In(item Identifier) bool { s.RLock() _, ok := s.lookup[item.Id()] s.RUnlock() @@ -57,7 +49,7 @@ func (s *Set) In(item Item) bool { } // Get returns an item with the given Id. -func (s *Set) Get(id Id) (Item, error) { +func (s *Set) Get(id Id) (Identifier, error) { s.RLock() item, ok := s.lookup[id] s.RUnlock() @@ -70,7 +62,7 @@ func (s *Set) Get(id Id) (Item, error) { } // Add item to this set if it does not exist already. -func (s *Set) Add(item Item) error { +func (s *Set) Add(item Identifier) error { s.Lock() defer s.Unlock() @@ -84,7 +76,7 @@ func (s *Set) Add(item Item) error { } // Remove item from this set. -func (s *Set) Remove(item Item) error { +func (s *Set) Remove(item Identifier) error { s.Lock() defer s.Unlock() id := item.Id() @@ -96,9 +88,34 @@ func (s *Set) Remove(item Item) error { return nil } +// Replace item from old Id with new Identifier. +// Used for moving the same identifier to a new Id, such as a rename. +func (s *Set) Replace(oldId Id, item Identifier) error { + s.Lock() + defer s.Unlock() + + // Check if it already exists + _, found := s.lookup[item.Id()] + if found { + return ErrIdTaken + } + + // Remove oldId + _, found = s.lookup[oldId] + if !found { + return ErrItemMissing + } + delete(s.lookup, oldId) + + // Add new identifier + s.lookup[item.Id()] = item + + return nil +} + // Each loops over every item while holding a read lock and applies fn to each // element. -func (s *Set) Each(fn func(item Item)) { +func (s *Set) Each(fn func(item Identifier)) { s.RLock() for _, item := range s.lookup { fn(item) @@ -107,8 +124,8 @@ func (s *Set) Each(fn func(item Item)) { } // ListPrefix returns a list of items with a prefix, case insensitive. -func (s *Set) ListPrefix(prefix string) []Item { - r := []Item{} +func (s *Set) ListPrefix(prefix string) []Identifier { + r := []Identifier{} prefix = strings.ToLower(prefix) s.RLock() diff --git a/chat/theme.go b/chat/theme.go index 9ada33f..dbf9e82 100644 --- a/chat/theme.go +++ b/chat/theme.go @@ -98,10 +98,10 @@ func (t Theme) Id() string { // Colorize name string given some index func (t Theme) ColorName(u *User) string { if t.names == nil { - return u.name + return u.Name() } - return t.names.Get(u.colorIdx).Format(u.name) + return t.names.Get(u.colorIdx).Format(u.Name()) } // Colorize the PM string diff --git a/chat/user.go b/chat/user.go index 75ea330..b7ab4a8 100644 --- a/chat/user.go +++ b/chat/user.go @@ -12,10 +12,20 @@ const messageBuffer = 20 var ErrUserClosed = errors.New("user closed") +// Id is a unique immutable identifier for a user. +type Id string + +// Identifier is an interface that can uniquely identify itself. +type Identifier interface { + Id() Id + SetId(Id) + Name() string +} + // User definition, implemented set Item interface and io.Writer type User struct { + Identifier Config UserConfig - name string colorIdx int joined time.Time msg chan Message @@ -25,38 +35,29 @@ type User struct { closeOnce sync.Once } -func NewUser(name string) *User { +func NewUser(identity Identifier) *User { u := User{ - Config: *DefaultUserConfig, - joined: time.Now(), - msg: make(chan Message, messageBuffer), - done: make(chan struct{}, 1), + Identifier: identity, + Config: *DefaultUserConfig, + joined: time.Now(), + msg: make(chan Message, messageBuffer), + done: make(chan struct{}, 1), } - u.SetName(name) + u.SetColorIdx(rand.Int()) return &u } -func NewUserScreen(name string, screen io.Writer) *User { - u := NewUser(name) +func NewUserScreen(identity Identifier, screen io.Writer) *User { + u := NewUser(identity) go u.Consume(screen) return u } -// Id of the user, a unique identifier within a set -func (u *User) Id() Id { - return Id(u.name) -} - -// Name of the user -func (u *User) Name() string { - return u.name -} - -// SetName will change the name of the user and reset the colorIdx -func (u *User) SetName(name string) { - u.name = name +// Rename the user with a new Identifier. +func (u *User) SetId(id Id) { + u.Identifier.SetId(id) u.SetColorIdx(rand.Int()) } diff --git a/host.go b/host.go index 936e8d7..5747310 100644 --- a/host.go +++ b/host.go @@ -20,10 +20,10 @@ func GetPrompt(user *chat.User) string { } // Host is the bridge between sshd and chat modules -// TODO: Should be easy to add support for multiple channels, if we want. +// TODO: Should be easy to add support for multiple rooms, if we want. type Host struct { + *chat.Room listener *sshd.SSHListener - channel *chat.Channel commands *chat.Commands motd string @@ -36,19 +36,19 @@ type Host struct { // NewHost creates a Host on top of an existing listener. func NewHost(listener *sshd.SSHListener) *Host { - ch := chat.NewChannel() + room := chat.NewRoom() h := Host{ + Room: room, listener: listener, - channel: ch, } // Make our own commands registry instance. commands := chat.Commands{} chat.InitCommands(&commands) h.InitCommands(&commands) - ch.SetCommands(commands) + room.SetCommands(commands) - go ch.Serve() + go room.Serve() return &h } @@ -58,19 +58,19 @@ func (h *Host) SetMotd(motd string) { } func (h Host) isOp(conn sshd.Connection) bool { - key, ok := conn.PublicKey() - if !ok { + key := conn.PublicKey() + if key == nil { return false } return h.auth.IsOp(key) } -// Connect a specific Terminal to this host and its channel. +// Connect a specific Terminal to this host and its room. func (h *Host) Connect(term *sshd.Terminal) { - name := term.Conn.Name() + id := NewIdentity(term.Conn) term.AutoCompleteCallback = h.AutoCompleteFunction - user := chat.NewUserScreen(name, term) + user := chat.NewUserScreen(id, term) user.Config.Theme = h.theme go func() { // Close term once user is closed. @@ -79,11 +79,11 @@ func (h *Host) Connect(term *sshd.Terminal) { }() defer user.Close() - member, err := h.channel.Join(user) + member, err := h.Join(user) if err == chat.ErrIdTaken { // Try again... - user.SetName(fmt.Sprintf("Guest%d", h.count)) - member, err = h.channel.Join(user) + id.SetName(fmt.Sprintf("Guest%d", h.count)) + member, err = h.Join(user) } if err != nil { logger.Errorf("Failed to join: %s", err) @@ -108,13 +108,13 @@ func (h *Host) Connect(term *sshd.Terminal) { } m := chat.ParseInput(line, user) - // FIXME: Any reason to use h.channel.Send(m) instead? - h.channel.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.channel.Send(m) above. + // 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. @@ -122,14 +122,14 @@ func (h *Host) Connect(term *sshd.Terminal) { } } - err = h.channel.Leave(user) + err = h.Leave(user) if err != nil { logger.Errorf("Failed to leave: %s", err) return } } -// Serve our chat channel onto the listener +// Serve our chat room onto the listener func (h *Host) Serve() { terminals := h.listener.ServeTerminal() @@ -146,7 +146,7 @@ func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine str fields := strings.Fields(line[:pos]) partial := fields[len(fields)-1] - names := h.channel.NamesPrefix(partial) + names := h.NamesPrefix(partial) if len(names) == 0 { // Didn't find anything return @@ -172,7 +172,7 @@ func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine str // GetUser returns a chat.User based on a name. func (h *Host) GetUser(name string) (*chat.User, bool) { - m, ok := h.channel.MemberById(chat.Id(name)) + m, ok := h.MemberById(chat.Id(name)) if !ok { return nil, false } @@ -186,7 +186,7 @@ func (h *Host) InitCommands(c *chat.Commands) { Prefix: "/msg", PrefixHelp: "USER MESSAGE", Help: "Send MESSAGE to USER.", - Handler: func(channel *chat.Channel, msg chat.CommandMsg) error { + Handler: func(room *chat.Room, msg chat.CommandMsg) error { args := msg.Args() switch len(args) { case 0: @@ -201,7 +201,7 @@ func (h *Host) InitCommands(c *chat.Commands) { } m := chat.NewPrivateMsg(strings.Join(args[1:], " "), msg.From(), target) - channel.Send(m) + room.Send(m) return nil }, }) @@ -212,8 +212,8 @@ func (h *Host) InitCommands(c *chat.Commands) { Prefix: "/kick", PrefixHelp: "USER", Help: "Kick USER from the server.", - Handler: func(channel *chat.Channel, msg chat.CommandMsg) error { - if !channel.IsOp(msg.From()) { + Handler: func(room *chat.Room, msg chat.CommandMsg) error { + if !room.IsOp(msg.From()) { return errors.New("must be op") } @@ -228,7 +228,7 @@ func (h *Host) InitCommands(c *chat.Commands) { } body := fmt.Sprintf("%s was kicked by %s.", target.Name(), msg.From().Name()) - channel.Send(chat.NewAnnounceMsg(body)) + room.Send(chat.NewAnnounceMsg(body)) target.Close() return nil }, @@ -239,10 +239,9 @@ func (h *Host) InitCommands(c *chat.Commands) { Prefix: "/ban", PrefixHelp: "USER", Help: "Ban USER from the server.", - Handler: func(channel *chat.Channel, msg chat.CommandMsg) error { - // TODO: This only bans their pubkey if they have one. Would be - // nice to IP-ban too while we're at it. - if !channel.IsOp(msg.From()) { + Handler: func(room *chat.Room, msg chat.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") } @@ -256,11 +255,44 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("user not found") } - // XXX: Figure out how to link a public key to a target. - //h.auth.Ban(target.Conn.PublicKey()) + id := target.Identifier.(*Identity) + h.auth.Ban(id.PublicKey()) + h.auth.BanAddr(id.RemoteAddr()) + body := fmt.Sprintf("%s was banned by %s.", target.Name(), msg.From().Name()) - channel.Send(chat.NewAnnounceMsg(body)) + room.Send(chat.NewAnnounceMsg(body)) target.Close() + + logger.Debugf("Banned: \n-> %s", id.Whois()) + + return nil + }, + }) + + c.Add(chat.Command{ + Op: true, + Prefix: "/whois", + PrefixHelp: "USER", + Help: "Information about USER.", + Handler: func(room *chat.Room, msg chat.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") + } + + target, ok := h.GetUser(args[0]) + if !ok { + return errors.New("user not found") + } + + id := target.Identifier.(*Identity) + room.Send(chat.NewSystemMsg(id.Whois(), msg.From())) + return nil }, }) diff --git a/host_test.go b/host_test.go index fb47a61..9c57439 100644 --- a/host_test.go +++ b/host_test.go @@ -184,7 +184,7 @@ func TestHostKick(t *testing.T) { // First client err = sshd.ConnectShell(addr, "foo", func(r io.Reader, w io.WriteCloser) { // Make op - member, _ := host.channel.MemberById("foo") + member, _ := host.Room.MemberById("foo") member.Op = true // Block until second client is here diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..7df812b --- /dev/null +++ b/identity.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "net" + + "github.com/shazow/ssh-chat/chat" + "github.com/shazow/ssh-chat/sshd" +) + +// Identity is a container for everything that identifies a client. +type Identity struct { + sshd.Connection + id chat.Id +} + +// NewIdentity returns a new identity object from an sshd.Connection. +func NewIdentity(conn sshd.Connection) *Identity { + id := chat.Id(conn.Name()) + return &Identity{ + Connection: conn, + id: id, + } +} + +func (i Identity) Id() chat.Id { + return chat.Id(i.id) +} + +func (i *Identity) SetId(id chat.Id) { + i.id = id +} + +func (i *Identity) SetName(name string) { + i.SetId(chat.Id(name)) +} + +func (i Identity) Name() string { + return string(i.id) +} + +func (i Identity) Whois() string { + ip, _, _ := net.SplitHostPort(i.RemoteAddr().String()) + fingerprint := "(no public key)" + if i.PublicKey() != nil { + fingerprint = sshd.Fingerprint(i.PublicKey()) + } + return fmt.Sprintf("name: %s"+chat.Newline+ + " > ip: %s"+chat.Newline+ + " > fingerprint: %s", i.Name(), ip, fingerprint) +} diff --git a/sshd/auth.go b/sshd/auth.go index 3cf0855..163caa0 100644 --- a/sshd/auth.go +++ b/sshd/auth.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/base64" "errors" + "net" "golang.org/x/crypto/ssh" ) @@ -12,8 +13,8 @@ import ( type Auth interface { // Whether to allow connections without a public key. AllowAnonymous() bool - // Given public key, return if the connection should be permitted. - Check(ssh.PublicKey) (bool, error) + // Given address and public key, return if the connection should be permitted. + Check(net.Addr, ssh.PublicKey) (bool, error) } // MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation. @@ -22,7 +23,7 @@ func MakeAuth(auth Auth) *ssh.ServerConfig { NoClientAuth: false, // Auth-related things should be constant-time to avoid timing attacks. PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - ok, err := auth.Check(key) + ok, err := auth.Check(conn.RemoteAddr(), key) if !ok { return nil, err } @@ -35,7 +36,8 @@ func MakeAuth(auth Auth) *ssh.ServerConfig { if !auth.AllowAnonymous() { return nil, errors.New("public key authentication required") } - return nil, nil + _, err := auth.Check(conn.RemoteAddr(), nil) + return nil, err }, } diff --git a/sshd/net.go b/sshd/net.go index 5e782d8..69a30da 100644 --- a/sshd/net.go +++ b/sshd/net.go @@ -28,7 +28,7 @@ func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) { if l.RateLimit { // TODO: Configurable Limiter? - conn = ReadLimitConn(conn, rateio.NewGracefulLimiter(1000, time.Minute*2, time.Second*3)) + conn = ReadLimitConn(conn, rateio.NewGracefulLimiter(1024*10, time.Minute*2, time.Second*3)) } // Upgrade TCP connection to SSH connection diff --git a/sshd/terminal.go b/sshd/terminal.go index bfd16a0..3da2d65 100644 --- a/sshd/terminal.go +++ b/sshd/terminal.go @@ -3,6 +3,7 @@ package sshd import ( "errors" "fmt" + "net" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/terminal" @@ -10,7 +11,8 @@ import ( // Connection is an interface with fields necessary to operate an sshd host. type Connection interface { - PublicKey() (ssh.PublicKey, bool) + PublicKey() ssh.PublicKey + RemoteAddr() net.Addr Name() string Close() error } @@ -19,22 +21,22 @@ type sshConn struct { *ssh.ServerConn } -func (c sshConn) PublicKey() (ssh.PublicKey, bool) { +func (c sshConn) PublicKey() ssh.PublicKey { if c.Permissions == nil { - return nil, false + return nil } s, ok := c.Permissions.Extensions["pubkey"] if !ok { - return nil, false + return nil } key, err := ssh.ParsePublicKey([]byte(s)) if err != nil { - return nil, false + return nil } - return key, true + return key } func (c sshConn) Name() string { From 838285ba43e6ad38cd6c74d2049a86ff3f14a56e Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 17 Jan 2015 13:26:26 -0800 Subject: [PATCH 46/60] chat.Color->chat.Style, highlighting works. --- chat/message.go | 12 +++++++++++- chat/theme.go | 48 +++++++++++++++++++++++++++++++++++------------- chat/user.go | 33 +++++++++++++++++++++++++++------ host.go | 2 ++ 4 files changed, 75 insertions(+), 20 deletions(-) diff --git a/chat/message.go b/chat/message.go index 2a804a9..c98805c 100644 --- a/chat/message.go +++ b/chat/message.go @@ -2,6 +2,7 @@ package chat import ( "fmt" + "regexp" "strings" "time" ) @@ -101,6 +102,15 @@ func (m *PublicMsg) Render(t *Theme) string { return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body) } +func (m *PublicMsg) RenderHighlighted(t *Theme, highlight *regexp.Regexp) string { + if highlight == nil || t == nil { + return m.Render(t) + } + + body := highlight.ReplaceAllString(m.body, t.Highlight("${1}")) + return fmt.Sprintf("%s: %s", t.ColorName(m.from), body) +} + func (m *PublicMsg) String() string { return fmt.Sprintf("%s: %s", m.from.Name(), m.body) } @@ -212,7 +222,7 @@ type CommandMsg struct { *PublicMsg command string args []string - room *Room + room *Room } func (m *CommandMsg) Command() string { diff --git a/chat/theme.go b/chat/theme.go index dbf9e82..5e7af3c 100644 --- a/chat/theme.go +++ b/chat/theme.go @@ -28,12 +28,24 @@ const ( Newline = "\r\n" ) -// Interface for Colors -type Color interface { +// Interface for Styles +type Style interface { String() string Format(string) string } +// General hardcoded style, mostly used as a crutch until we flesh out the +// framework to support backgrounds etc. +type style string + +func (c style) String() string { + return string(c) +} + +func (c style) Format(s string) string { + return c.String() + s + Reset +} + // 256 color type, for terminals who support it type Color256 uint8 @@ -62,12 +74,12 @@ func (c Color0) Format(s string) string { // Container for a collection of colors type Palette struct { - colors []Color + colors []Style size int } // Get a color by index, overflows are looped around. -func (p Palette) Get(i int) Color { +func (p Palette) Get(i int) Style { return p.colors[i%(p.size-1)] } @@ -85,10 +97,11 @@ func (p Palette) String() string { // Collection of settings for chat type Theme struct { - id string - sys Color - pm Color - names *Palette + id string + sys Style + pm Style + highlight Style + names *Palette } func (t Theme) Id() string { @@ -122,6 +135,14 @@ func (t Theme) ColorSys(s string) string { return t.sys.Format(s) } +// Highlight a matched string, usually name +func (t Theme) Highlight(s string) string { + if t.highlight == nil { + return s + } + return t.highlight.Format(s) +} + // List of initialzied themes var Themes []Theme @@ -131,7 +152,7 @@ var DefaultTheme *Theme func readableColors256() *Palette { size := 247 p := Palette{ - colors: make([]Color, size), + colors: make([]Style, size), size: size, } j := 0 @@ -151,10 +172,11 @@ func init() { Themes = []Theme{ Theme{ - id: "colors", - names: palette, - sys: palette.Get(8), // Grey - pm: palette.Get(7), // White + id: "colors", + names: palette, + sys: palette.Get(8), // Grey + pm: palette.Get(7), // White + highlight: style(Bold + "\033[48;5;11m\033[38;5;16m"), // Yellow highlight }, Theme{ id: "mono", diff --git a/chat/user.go b/chat/user.go index b7ab4a8..b82c830 100644 --- a/chat/user.go +++ b/chat/user.go @@ -2,13 +2,16 @@ package chat import ( "errors" + "fmt" "io" "math/rand" + "regexp" "sync" "time" ) const messageBuffer = 20 +const reHighlight = `\b(%s)\b` var ErrUserClosed = errors.New("user closed") @@ -100,9 +103,28 @@ func (u *User) ConsumeOne(out io.Writer) { u.HandleMsg(<-u.msg, out) } +// SetHighlight sets the highlighting regular expression to match string. +func (u *User) SetHighlight(s string) error { + re, err := regexp.Compile(fmt.Sprintf(reHighlight, s)) + if err != nil { + return err + } + u.Config.Highlight = re + return nil +} + +func (u User) render(m Message) string { + switch m := m.(type) { + case *PublicMsg: + return m.RenderHighlighted(u.Config.Theme, u.Config.Highlight) + Newline + default: + return m.Render(u.Config.Theme) + Newline + } +} + func (u *User) HandleMsg(m Message, out io.Writer) { - s := m.Render(u.Config.Theme) - _, err := out.Write([]byte(s + Newline)) + r := u.render(m) + _, err := out.Write([]byte(r)) if err != nil { logger.Printf("Write failed to %s, closing: %s", u.Name(), err) u.Close() @@ -127,7 +149,7 @@ func (u *User) Send(m Message) error { // Container for per-user configurations. type UserConfig struct { - Highlight bool + Highlight *regexp.Regexp Bell bool Quiet bool Theme *Theme @@ -138,9 +160,8 @@ var DefaultUserConfig *UserConfig func init() { DefaultUserConfig = &UserConfig{ - Highlight: true, - Bell: false, - Quiet: false, + Bell: true, + Quiet: false, } // TODO: Seed random? diff --git a/host.go b/host.go index 5747310..bc74851 100644 --- a/host.go +++ b/host.go @@ -92,6 +92,7 @@ func (h *Host) Connect(term *sshd.Terminal) { // Successfully joined. term.SetPrompt(GetPrompt(user)) + user.SetHighlight(user.Name()) h.count++ // Should the user be op'd on join? @@ -119,6 +120,7 @@ func (h *Host) Connect(term *sshd.Terminal) { // 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()) } } From 391a66a8769f9f055ad2f8e70fc657bdae1ec734 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sat, 17 Jan 2015 15:56:29 -0800 Subject: [PATCH 47/60] BEL --- chat/message.go | 18 ++++++++++++------ chat/theme.go | 3 +++ chat/user.go | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/chat/message.go b/chat/message.go index c98805c..d271e0b 100644 --- a/chat/message.go +++ b/chat/message.go @@ -2,7 +2,6 @@ package chat import ( "fmt" - "regexp" "strings" "time" ) @@ -102,13 +101,20 @@ func (m *PublicMsg) Render(t *Theme) string { return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body) } -func (m *PublicMsg) RenderHighlighted(t *Theme, highlight *regexp.Regexp) string { - if highlight == nil || t == nil { - return m.Render(t) +func (m *PublicMsg) RenderFor(cfg UserConfig) string { + if cfg.Highlight == nil || cfg.Theme == nil { + return m.Render(cfg.Theme) } - body := highlight.ReplaceAllString(m.body, t.Highlight("${1}")) - return fmt.Sprintf("%s: %s", t.ColorName(m.from), body) + if !cfg.Highlight.MatchString(m.body) { + return m.Render(cfg.Theme) + } + + body := cfg.Highlight.ReplaceAllString(m.body, cfg.Theme.Highlight("${1}")) + if cfg.Bell { + body += Bel + } + return fmt.Sprintf("%s: %s", cfg.Theme.ColorName(m.from), body) } func (m *PublicMsg) String() string { diff --git a/chat/theme.go b/chat/theme.go index 5e7af3c..4d052f4 100644 --- a/chat/theme.go +++ b/chat/theme.go @@ -26,6 +26,9 @@ const ( // Newline Newline = "\r\n" + + // BEL + Bel = "\007" ) // Interface for Styles diff --git a/chat/user.go b/chat/user.go index b82c830..df8114b 100644 --- a/chat/user.go +++ b/chat/user.go @@ -116,7 +116,7 @@ func (u *User) SetHighlight(s string) error { func (u User) render(m Message) string { switch m := m.(type) { case *PublicMsg: - return m.RenderHighlighted(u.Config.Theme, u.Config.Highlight) + Newline + return m.RenderFor(u.Config) + Newline default: return m.Render(u.Config.Theme) + Newline } From 12402c23387029de681ed91eddad9ab7cd20dca8 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 17:53:29 -0800 Subject: [PATCH 48/60] Autocomplete commands. --- host.go | 61 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/host.go b/host.go index bc74851..c88e46c 100644 --- a/host.go +++ b/host.go @@ -24,7 +24,7 @@ func GetPrompt(user *chat.User) string { type Host struct { *chat.Room listener *sshd.SSHListener - commands *chat.Commands + commands chat.Commands motd string auth *Auth @@ -40,13 +40,13 @@ func NewHost(listener *sshd.SSHListener) *Host { h := Host{ Room: room, listener: listener, + commands: chat.Commands{}, } // Make our own commands registry instance. - commands := chat.Commands{} - chat.InitCommands(&commands) - h.InitCommands(&commands) - room.SetCommands(commands) + chat.InitCommands(&h.commands) + h.InitCommands(&h.commands) + room.SetCommands(h.commands) go room.Serve() return &h @@ -140,34 +140,61 @@ func (h *Host) 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 is a callback for terminal autocompletion func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine string, newPos int, ok bool) { if key != 9 { return } - fields := strings.Fields(line[:pos]) - partial := fields[len(fields)-1] - names := h.NamesPrefix(partial) - if len(names) == 0 { - // Didn't find anything + if strings.HasSuffix(line[:pos], " ") { + // Don't autocomplete spaces. return } - name := names[len(names)-1] + fields := strings.Fields(line[:pos]) + isFirst := len(fields) < 2 + partial := fields[len(fields)-1] posPartial := pos - len(partial) - // Append suffix separator - if len(fields) < 2 { - name += ": " + var completed string + if isFirst && strings.HasPrefix(partial, "/") { + // Command + completed = h.completeCommand(partial) } else { - name += " " + // Name + completed = h.completeName(partial) + if completed == "" { + return + } + if isFirst { + completed += ":" + } } + completed += " " // Reposition the cursor - newLine = strings.Replace(line[posPartial:], partial, name, 1) + newLine = strings.Replace(line[posPartial:], partial, completed, 1) newLine = line[:posPartial] + newLine - newPos = pos + (len(name) - len(partial)) + newPos = pos + (len(completed) - len(partial)) ok = true return } From 544c9789c039172f95faca20fde83184c7f515fc Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 18:11:30 -0800 Subject: [PATCH 49/60] /reply command with autocomplete. --- chat/command.go | 2 +- chat/user.go | 15 ++++++- host.go | 103 +++++++++++++++++++++++++++++++----------------- 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/chat/command.go b/chat/command.go index aa6d3f5..047ea52 100644 --- a/chat/command.go +++ b/chat/command.go @@ -201,7 +201,7 @@ func InitCommands(c *Commands) { c.Add(Command{ Prefix: "/quiet", - Help: "Silence announcement-type messages (join, part, rename, etc).", + Help: "Silence room announcements.", Handler: func(room *Room, msg CommandMsg) error { u := msg.From() u.ToggleQuietMode() diff --git a/chat/user.go b/chat/user.go index df8114b..32be8ca 100644 --- a/chat/user.go +++ b/chat/user.go @@ -64,6 +64,16 @@ func (u *User) SetId(id Id) { u.SetColorIdx(rand.Int()) } +// ReplyTo returns the last user that messaged this user. +func (u *User) ReplyTo() *User { + return u.replyTo +} + +// SetReplyTo sets the last user to message this user. +func (u *User) SetReplyTo(user *User) { + u.replyTo = user +} + // ToggleQuietMode will toggle whether or not quiet mode is enabled func (u *User) ToggleQuietMode() { u.Config.Quiet = !u.Config.Quiet @@ -113,10 +123,13 @@ func (u *User) SetHighlight(s string) error { return nil } -func (u User) render(m Message) string { +func (u *User) render(m Message) string { switch m := m.(type) { case *PublicMsg: return m.RenderFor(u.Config) + Newline + case *PrivateMsg: + u.SetReplyTo(m.From()) + return m.Render(u.Config.Theme) + Newline default: return m.Render(u.Config.Theme) + Newline } diff --git a/host.go b/host.go index c88e46c..8d87142 100644 --- a/host.go +++ b/host.go @@ -68,8 +68,6 @@ func (h Host) isOp(conn sshd.Connection) bool { // Connect a specific Terminal to this host and its room. func (h *Host) Connect(term *sshd.Terminal) { id := NewIdentity(term.Conn) - term.AutoCompleteCallback = h.AutoCompleteFunction - user := chat.NewUserScreen(id, term) user.Config.Theme = h.theme go func() { @@ -92,6 +90,7 @@ func (h *Host) Connect(term *sshd.Terminal) { // Successfully joined. term.SetPrompt(GetPrompt(user)) + term.AutoCompleteCallback = h.AutoCompleteFunction(user) user.SetHighlight(user.Name()) h.count++ @@ -159,44 +158,52 @@ func (h Host) completeCommand(partial string) string { return "" } -// AutoCompleteFunction is a callback for terminal autocompletion -func (h *Host) AutoCompleteFunction(line string, pos int, key rune) (newLine string, newPos int, ok bool) { - if key != 9 { - return - } - - if strings.HasSuffix(line[:pos], " ") { - // Don't autocomplete spaces. - return - } - - fields := strings.Fields(line[:pos]) - isFirst := len(fields) < 2 - partial := fields[len(fields)-1] - posPartial := pos - len(partial) - - var completed string - if isFirst && strings.HasPrefix(partial, "/") { - // Command - completed = h.completeCommand(partial) - } else { - // Name - completed = h.completeName(partial) - if completed == "" { +// AutoCompleteFunction returns a callback for terminal autocompletion +func (h *Host) AutoCompleteFunction(u *chat.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 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 + if strings.HasSuffix(line[:pos], " ") { + // Don't autocomplete spaces. + return + } + + fields := strings.Fields(line[:pos]) + isFirst := len(fields) < 2 + partial := fields[len(fields)-1] + posPartial := pos - len(partial) + + var completed string + if isFirst && strings.HasPrefix(partial, "/") { + // Command + completed = h.completeCommand(partial) + if completed == "/reply" { + replyTo := u.ReplyTo() + if replyTo != nil { + completed = "/msg " + replyTo.Name() + } + } + } 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 chat.User based on a name. @@ -235,6 +242,28 @@ func (h *Host) InitCommands(c *chat.Commands) { }, }) + c.Add(chat.Command{ + Prefix: "/reply", + PrefixHelp: "MESSAGE", + Help: "Reply with MESSAGE to the previous private message.", + Handler: func(room *chat.Room, msg chat.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") + } + + m := chat.NewPrivateMsg(strings.Join(args, " "), msg.From(), target) + room.Send(m) + return nil + }, + }) + // Op commands c.Add(chat.Command{ Op: true, From 0c2148699272fc5ddf30818cd31c144c4c619d7d Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 18:55:47 -0800 Subject: [PATCH 50/60] History backfill, also tests pass. --- auth.go | 3 +++ auth_test.go | 6 +++--- chat/command.go | 4 ++-- chat/history.go | 22 +++++++++++----------- chat/history_test.go | 39 ++++++++++++++++++--------------------- chat/message.go | 8 +++++++- chat/message_test.go | 14 +++++++++++++- chat/room.go | 18 ++++++++++++------ chat/room_test.go | 10 +++++----- chat/set.go | 12 ++++++------ chat/set_test.go | 4 ++-- chat/theme_test.go | 2 +- chat/user.go | 9 +++------ chat/user_test.go | 2 +- host.go | 2 +- host_test.go | 2 +- identity.go | 15 +++++++-------- sshd/client_test.go | 3 ++- 18 files changed, 98 insertions(+), 77 deletions(-) diff --git a/auth.go b/auth.go index bce92bf..26a217c 100644 --- a/auth.go +++ b/auth.go @@ -28,6 +28,9 @@ func NewAuthKey(key ssh.PublicKey) string { // NewAuthAddr returns a string from a net.Addr func NewAuthAddr(addr net.Addr) string { + if addr == nil { + return "" + } host, _, _ := net.SplitHostPort(addr.String()) return host } diff --git a/auth_test.go b/auth_test.go index 3b11212..eb29773 100644 --- a/auth_test.go +++ b/auth_test.go @@ -28,7 +28,7 @@ func TestAuthWhitelist(t *testing.T) { } auth := NewAuth() - ok, err := auth.Check(key) + ok, err := auth.Check(nil, key) if !ok || err != nil { t.Error("Failed to permit in default state:", err) } @@ -44,7 +44,7 @@ func TestAuthWhitelist(t *testing.T) { t.Error("Clone key does not match.") } - ok, err = auth.Check(keyClone) + ok, err = auth.Check(nil, keyClone) if !ok || err != nil { t.Error("Failed to permit whitelisted:", err) } @@ -54,7 +54,7 @@ func TestAuthWhitelist(t *testing.T) { t.Fatal(err) } - ok, err = auth.Check(key2) + ok, err = auth.Check(nil, key2) if ok || err == nil { t.Error("Failed to restrict not whitelisted:", err) } diff --git a/chat/command.go b/chat/command.go index 047ea52..d4873f3 100644 --- a/chat/command.go +++ b/chat/command.go @@ -145,7 +145,7 @@ func InitCommands(c *Commands) { } u := msg.From() oldId := u.Id() - u.SetId(Id(args[0])) + u.SetId(args[0]) err := room.Rename(oldId, u) if err != nil { @@ -234,7 +234,7 @@ func InitCommands(c *Commands) { // TODO: Add support for fingerprint-based op'ing. This will // probably need to live in host land. - member, ok := room.MemberById(Id(args[0])) + member, ok := room.MemberById(args[0]) if !ok { return errors.New("user not found") } diff --git a/chat/history.go b/chat/history.go index b0f78a6..44513a6 100644 --- a/chat/history.go +++ b/chat/history.go @@ -4,23 +4,23 @@ import "sync" // History contains the history entries type History struct { - entries []interface{} + entries []Message head int size int - sync.RWMutex + lock sync.Mutex } // NewHistory constructs a new history of the given size func NewHistory(size int) *History { return &History{ - entries: make([]interface{}, size), + entries: make([]Message, size), } } // Add adds the given entry to the entries in the history -func (h *History) Add(entry interface{}) { - h.Lock() - defer h.Unlock() +func (h *History) Add(entry Message) { + h.lock.Lock() + defer h.lock.Unlock() max := cap(h.entries) h.head = (h.head + 1) % max @@ -35,17 +35,17 @@ func (h *History) Len() int { return h.size } -// Get recent entries -func (h *History) Get(num int) []interface{} { - h.RLock() - defer h.RUnlock() +// Get the entry with the given number +func (h *History) Get(num int) []Message { + h.lock.Lock() + defer h.lock.Unlock() max := cap(h.entries) if num > h.size { num = h.size } - r := make([]interface{}, num) + r := make([]Message, num) for i := 0; i < num; i++ { idx := (h.head - i) % max if idx < 0 { diff --git a/chat/history_test.go b/chat/history_test.go index fb166c4..de767ec 100644 --- a/chat/history_test.go +++ b/chat/history_test.go @@ -2,64 +2,61 @@ package chat import "testing" -func equal(a []interface{}, b []string) bool { +func msgEqual(a []Message, b []Message) bool { if len(a) != len(b) { return false } - - for i := 0; i < len(a); i++ { - if a[0] != b[0] { + for i := range a { + if a[i].String() != b[i].String() { return false } } - return true } func TestHistory(t *testing.T) { - var r []interface{} - var expected []string + var r, expected []Message var size int h := NewHistory(5) r = h.Get(10) - expected = []string{} - if !equal(r, expected) { + expected = []Message{} + if !msgEqual(r, expected) { t.Errorf("Got: %v, Expected: %v", r, expected) } - h.Add("1") + h.Add(NewMsg("1")) if size = h.Len(); size != 1 { t.Errorf("Wrong size: %v", size) } r = h.Get(1) - expected = []string{"1"} - if !equal(r, expected) { + expected = []Message{NewMsg("1")} + if !msgEqual(r, expected) { t.Errorf("Got: %v, Expected: %v", r, expected) } - h.Add("2") - h.Add("3") - h.Add("4") - h.Add("5") - h.Add("6") + h.Add(NewMsg("2")) + h.Add(NewMsg("3")) + h.Add(NewMsg("4")) + h.Add(NewMsg("5")) + h.Add(NewMsg("6")) if size = h.Len(); size != 5 { t.Errorf("Wrong size: %v", size) } r = h.Get(2) - expected = []string{"5", "6"} - if !equal(r, expected) { + expected = []Message{NewMsg("5"), NewMsg("6")} + if !msgEqual(r, expected) { t.Errorf("Got: %v, Expected: %v", r, expected) } r = h.Get(10) - expected = []string{"2", "3", "4", "5", "6"} - if !equal(r, expected) { + expected = []Message{NewMsg("2"), NewMsg("3"), NewMsg("4"), NewMsg("5"), NewMsg("6")} + if !msgEqual(r, expected) { t.Errorf("Got: %v, Expected: %v", r, expected) } } diff --git a/chat/message.go b/chat/message.go index d271e0b..2fc857d 100644 --- a/chat/message.go +++ b/chat/message.go @@ -34,12 +34,18 @@ func ParseInput(body string, from *User) Message { // Msg is a base type for other message types. type Msg struct { - Message body string timestamp time.Time // TODO: themeCache *map[*Theme]string } +func NewMsg(body string) *Msg { + return &Msg{ + body: body, + timestamp: time.Now(), + } +} + // Render message based on a theme. func (m *Msg) Render(t *Theme) string { // TODO: Render based on theme diff --git a/chat/message_test.go b/chat/message_test.go index fafe4f8..bafe014 100644 --- a/chat/message_test.go +++ b/chat/message_test.go @@ -2,6 +2,18 @@ package chat import "testing" +type testId string + +func (i testId) Id() string { + return string(i) +} +func (i testId) SetId(s string) { + // no-op +} +func (i testId) Name() string { + return i.Id() +} + func TestMessage(t *testing.T) { var expected, actual string @@ -11,7 +23,7 @@ func TestMessage(t *testing.T) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } - u := NewUser("foo") + u := NewUser(testId("foo")) expected = "foo: hello" actual = NewPublicMsg("hello", u).String() if actual != expected { diff --git a/chat/room.go b/chat/room.go index 896f92f..e6f68b5 100644 --- a/chat/room.go +++ b/chat/room.go @@ -79,6 +79,7 @@ func (r *Room) HandleMsg(m Message) { skipUser = fromMsg.From() } + r.history.Add(m) r.members.Each(func(u Identifier) { user := u.(*Member).User if skip && skipUser == user { @@ -91,10 +92,7 @@ func (r *Room) HandleMsg(m Message) { return } } - err := user.Send(m) - if err != nil { - user.Close() - } + user.Send(m) }) } } @@ -112,6 +110,13 @@ func (r *Room) Send(m Message) { r.broadcast <- m } +// History feeds the room's recent message history to the user's handler. +func (r *Room) History(u *User) { + for _, m := range r.history.Get(historyLen) { + u.Send(m) + } +} + // Join the room as a user, will announce. func (r *Room) Join(u *User) (*Member, error) { if r.closed { @@ -122,6 +127,7 @@ func (r *Room) Join(u *User) (*Member, error) { if err != nil { return nil, err } + r.History(u) s := fmt.Sprintf("%s joined. (Connected: %d)", u.Name(), r.members.Len()) r.Send(NewAnnounceMsg(s)) return &member, nil @@ -139,7 +145,7 @@ func (r *Room) Leave(u *User) error { } // Rename member with a new identity. This will not call rename on the member. -func (r *Room) Rename(oldId Id, identity Identifier) error { +func (r *Room) Rename(oldId string, identity Identifier) error { err := r.members.Replace(oldId, identity) if err != nil { return err @@ -164,7 +170,7 @@ func (r *Room) Member(u *User) (*Member, bool) { return m, true } -func (r *Room) MemberById(id Id) (*Member, bool) { +func (r *Room) MemberById(id string) (*Member, bool) { m, err := r.members.Get(id) if err != nil { return nil, false diff --git a/chat/room_test.go b/chat/room_test.go index 4f39dd7..e415a0c 100644 --- a/chat/room_test.go +++ b/chat/room_test.go @@ -22,7 +22,7 @@ func TestRoomJoin(t *testing.T) { var expected, actual []byte s := &MockScreen{} - u := NewUser("foo") + u := NewUser(testId("foo")) ch := NewRoom() go ch.Serve() @@ -58,7 +58,7 @@ func TestRoomJoin(t *testing.T) { } func TestRoomDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { - u := NewUser("foo") + u := NewUser(testId("foo")) u.Config = UserConfig{ Quiet: true, } @@ -93,7 +93,7 @@ func TestRoomDoesntBroadcastAnnounceMessagesWhenQuiet(t *testing.T) { } func TestRoomQuietToggleBroadcasts(t *testing.T) { - u := NewUser("foo") + u := NewUser(testId("foo")) u.Config = UserConfig{ Quiet: true, } @@ -132,7 +132,7 @@ func TestQuietToggleDisplayState(t *testing.T) { var expected, actual []byte s := &MockScreen{} - u := NewUser("foo") + u := NewUser(testId("foo")) ch := NewRoom() go ch.Serve() @@ -168,7 +168,7 @@ func TestRoomNames(t *testing.T) { var expected, actual []byte s := &MockScreen{} - u := NewUser("foo") + u := NewUser(testId("foo")) ch := NewRoom() go ch.Serve() diff --git a/chat/set.go b/chat/set.go index d032a34..8617e14 100644 --- a/chat/set.go +++ b/chat/set.go @@ -15,14 +15,14 @@ var ErrItemMissing = errors.New("item does not exist") // Set with string lookup. // TODO: Add trie for efficient prefix lookup? type Set struct { - lookup map[Id]Identifier + lookup map[string]Identifier sync.RWMutex } // NewSet creates a new set. func NewSet() *Set { return &Set{ - lookup: map[Id]Identifier{}, + lookup: map[string]Identifier{}, } } @@ -30,7 +30,7 @@ func NewSet() *Set { func (s *Set) Clear() int { s.Lock() n := len(s.lookup) - s.lookup = map[Id]Identifier{} + s.lookup = map[string]Identifier{} s.Unlock() return n } @@ -49,7 +49,7 @@ func (s *Set) In(item Identifier) bool { } // Get returns an item with the given Id. -func (s *Set) Get(id Id) (Identifier, error) { +func (s *Set) Get(id string) (Identifier, error) { s.RLock() item, ok := s.lookup[id] s.RUnlock() @@ -88,9 +88,9 @@ func (s *Set) Remove(item Identifier) error { return nil } -// Replace item from old Id with new Identifier. +// Replace item from old id with new Identifier. // Used for moving the same identifier to a new Id, such as a rename. -func (s *Set) Replace(oldId Id, item Identifier) error { +func (s *Set) Replace(oldId string, item Identifier) error { s.Lock() defer s.Unlock() diff --git a/chat/set_test.go b/chat/set_test.go index 60038c1..b92bdeb 100644 --- a/chat/set_test.go +++ b/chat/set_test.go @@ -5,7 +5,7 @@ import "testing" func TestSet(t *testing.T) { var err error s := NewSet() - u := NewUser("foo") + u := NewUser(testId("foo")) if s.In(u) { t.Errorf("Set should be empty.") @@ -20,7 +20,7 @@ func TestSet(t *testing.T) { t.Errorf("Set should contain user.") } - u2 := NewUser("bar") + u2 := NewUser(testId("bar")) err = s.Add(u2) if err != nil { t.Error(err) diff --git a/chat/theme_test.go b/chat/theme_test.go index f385982..28b9d43 100644 --- a/chat/theme_test.go +++ b/chat/theme_test.go @@ -54,7 +54,7 @@ func TestTheme(t *testing.T) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } - u := NewUser("foo") + u := NewUser(testId("foo")) u.colorIdx = 4 actual = colorTheme.ColorName(u) expected = "\033[38;05;4mfoo\033[0m" diff --git a/chat/user.go b/chat/user.go index 32be8ca..4a31816 100644 --- a/chat/user.go +++ b/chat/user.go @@ -15,13 +15,10 @@ const reHighlight = `\b(%s)\b` var ErrUserClosed = errors.New("user closed") -// Id is a unique immutable identifier for a user. -type Id string - // Identifier is an interface that can uniquely identify itself. type Identifier interface { - Id() Id - SetId(Id) + Id() string + SetId(string) Name() string } @@ -59,7 +56,7 @@ func NewUserScreen(identity Identifier, screen io.Writer) *User { } // Rename the user with a new Identifier. -func (u *User) SetId(id Id) { +func (u *User) SetId(id string) { u.Identifier.SetId(id) u.SetColorIdx(rand.Int()) } diff --git a/chat/user_test.go b/chat/user_test.go index ce2d4ef..37ecc29 100644 --- a/chat/user_test.go +++ b/chat/user_test.go @@ -9,7 +9,7 @@ func TestMakeUser(t *testing.T) { var actual, expected []byte s := &MockScreen{} - u := NewUser("foo") + u := NewUser(testId("foo")) m := NewAnnounceMsg("hello") defer u.Close() diff --git a/host.go b/host.go index 8d87142..e153207 100644 --- a/host.go +++ b/host.go @@ -208,7 +208,7 @@ func (h *Host) AutoCompleteFunction(u *chat.User) func(line string, pos int, key // GetUser returns a chat.User based on a name. func (h *Host) GetUser(name string) (*chat.User, bool) { - m, ok := h.MemberById(chat.Id(name)) + m, ok := h.MemberById(name) if !ok { return nil, false } diff --git a/host_test.go b/host_test.go index 9c57439..2e79fb0 100644 --- a/host_test.go +++ b/host_test.go @@ -26,7 +26,7 @@ func stripPrompt(s string) string { func TestHostGetPrompt(t *testing.T) { var expected, actual string - u := chat.NewUser("foo") + u := chat.NewUser(&Identity{nil, "foo"}) u.SetColorIdx(2) actual = GetPrompt(u) diff --git a/identity.go b/identity.go index 7df812b..c41d382 100644 --- a/identity.go +++ b/identity.go @@ -11,32 +11,31 @@ import ( // Identity is a container for everything that identifies a client. type Identity struct { sshd.Connection - id chat.Id + id string } // NewIdentity returns a new identity object from an sshd.Connection. func NewIdentity(conn sshd.Connection) *Identity { - id := chat.Id(conn.Name()) return &Identity{ Connection: conn, - id: id, + id: conn.Name(), } } -func (i Identity) Id() chat.Id { - return chat.Id(i.id) +func (i Identity) Id() string { + return i.id } -func (i *Identity) SetId(id chat.Id) { +func (i *Identity) SetId(id string) { i.id = id } func (i *Identity) SetName(name string) { - i.SetId(chat.Id(name)) + i.SetId(name) } func (i Identity) Name() string { - return string(i.id) + return i.id } func (i Identity) Whois() string { diff --git a/sshd/client_test.go b/sshd/client_test.go index b115e8f..651c67e 100644 --- a/sshd/client_test.go +++ b/sshd/client_test.go @@ -2,6 +2,7 @@ package sshd import ( "errors" + "net" "testing" "golang.org/x/crypto/ssh" @@ -14,7 +15,7 @@ type RejectAuth struct{} func (a RejectAuth) AllowAnonymous() bool { return false } -func (a RejectAuth) Check(ssh.PublicKey) (bool, error) { +func (a RejectAuth) Check(net.Addr, ssh.PublicKey) (bool, error) { return false, errRejectAuth } From 6c972e6e58c0629d05d62704397a5e397ac785bf Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 19:11:35 -0800 Subject: [PATCH 51/60] Message rate limiting, input length, and ignore empty lines. --- host.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/host.go b/host.go index e153207..5c85470 100644 --- a/host.go +++ b/host.go @@ -5,11 +5,15 @@ import ( "fmt" "io" "strings" + "time" + "github.com/shazow/rateio" "github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/sshd" ) +const maxInputLength int = 1024 + // GetPrompt will render the terminal prompt string based on the user. func GetPrompt(user *chat.User) string { name := user.Name() @@ -96,6 +100,7 @@ func (h *Host) Connect(term *sshd.Terminal) { // Should the user be op'd on join? member.Op = h.isOp(term.Conn) + ratelimit := rateio.NewSimpleLimiter(3, time.Second*3) for { line, err := term.ReadLine() @@ -106,6 +111,21 @@ func (h *Host) Connect(term *sshd.Terminal) { logger.Errorf("Terminal reading error: %s", err) break } + + err = ratelimit.Count(1) + if err != nil { + user.Send(chat.NewSystemMsg("Message rejected: Rate limiting is in effect.", user)) + continue + } + if len(line) > maxInputLength { + user.Send(chat.NewSystemMsg("Message rejected: Input too long.", user)) + continue + } + if line == "" { + // Silently ignore empty lines. + continue + } + m := chat.ParseInput(line, user) // FIXME: Any reason to use h.room.Send(m) instead? From c33f4284f96a46f54d49fb7b87a60ead5db71c52 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 19:27:49 -0800 Subject: [PATCH 52/60] Colorize Sys/Announce messages. --- chat/message.go | 14 ++++++++++---- chat/theme.go | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/chat/message.go b/chat/message.go index 2fc857d..b4bfee5 100644 --- a/chat/message.go +++ b/chat/message.go @@ -196,11 +196,14 @@ func NewSystemMsg(body string, to *User) *SystemMsg { } func (m *SystemMsg) Render(t *Theme) string { - return fmt.Sprintf("-> %s", m.body) + if t == nil { + return m.String() + } + return t.ColorSys(m.String()) } func (m *SystemMsg) String() string { - return m.Render(nil) + return fmt.Sprintf("-> %s", m.body) } func (m *SystemMsg) To() *User { @@ -223,11 +226,14 @@ func NewAnnounceMsg(body string) *AnnounceMsg { } func (m *AnnounceMsg) Render(t *Theme) string { - return fmt.Sprintf(" * %s", m.body) + if t == nil { + return m.String() + } + return t.ColorSys(m.String()) } func (m *AnnounceMsg) String() string { - return m.Render(nil) + return fmt.Sprintf(" * %s", m.body) } type CommandMsg struct { diff --git a/chat/theme.go b/chat/theme.go index 4d052f4..27085c0 100644 --- a/chat/theme.go +++ b/chat/theme.go @@ -186,5 +186,10 @@ func init() { }, } + // Debug for printing colors: + //for _, color := range palette.colors { + // fmt.Print(color.Format(color.String() + " ")) + //} + DefaultTheme = &Themes[0] } From 080b6e8f1ba4b73b7e4f66a6ee0ba18aabe54be0 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 19:55:01 -0800 Subject: [PATCH 53/60] /motd --- cmd.go | 2 +- host.go | 50 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/cmd.go b/cmd.go index a763885..721c597 100644 --- a/cmd.go +++ b/cmd.go @@ -144,7 +144,7 @@ func main() { logger.Errorf("Failed to load MOTD file: %v", err) return } - motdString := string(motd[:]) + motdString := strings.TrimSpace(string(motd)) // hack to normalize line endings into \r\n motdString = strings.Replace(motdString, "\r\n", "\n", -1) motdString = strings.Replace(motdString, "\n", "\r\n", -1) diff --git a/host.go b/host.go index 5c85470..3f59233 100644 --- a/host.go +++ b/host.go @@ -81,6 +81,11 @@ func (h *Host) Connect(term *sshd.Terminal) { }() defer user.Close() + // Send MOTD + if h.motd != "" { + user.Send(chat.NewAnnounceMsg(h.motd)) + } + member, err := h.Join(user) if err == chat.ErrIdTaken { // Try again... @@ -284,6 +289,28 @@ func (h *Host) InitCommands(c *chat.Commands) { }, }) + c.Add(chat.Command{ + Prefix: "/whois", + PrefixHelp: "USER", + Help: "Information about USER.", + Handler: func(room *chat.Room, msg chat.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) + room.Send(chat.NewSystemMsg(id.Whois(), msg.From())) + + return nil + }, + }) + // Op commands c.Add(chat.Command{ Op: true, @@ -349,28 +376,27 @@ func (h *Host) InitCommands(c *chat.Commands) { c.Add(chat.Command{ Op: true, - Prefix: "/whois", - PrefixHelp: "USER", - Help: "Information about USER.", + Prefix: "/motd", + PrefixHelp: "MESSAGE", + Help: "Set the MESSAGE of the day.", Handler: func(room *chat.Room, msg chat.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") } + motd := "" args := msg.Args() - if len(args) == 0 { - return errors.New("must specify user") + if len(args) > 0 { + motd = strings.Join(args, " ") } - target, ok := h.GetUser(args[0]) - if !ok { - return errors.New("user not found") + h.motd = motd + body := fmt.Sprintf("New message of the day set by %s:", msg.From().Name()) + room.Send(chat.NewAnnounceMsg(body)) + if motd != "" { + room.Send(chat.NewAnnounceMsg(motd)) } - id := target.Identifier.(*Identity) - room.Send(chat.NewSystemMsg(id.Whois(), msg.From())) - return nil }, }) From 84df305ddfcd66dce168268f65cb7146a6680039 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 20:03:08 -0800 Subject: [PATCH 54/60] /op with pubkey. --- chat/command.go | 27 --------------------------- host.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/chat/command.go b/chat/command.go index d4873f3..ea313c8 100644 --- a/chat/command.go +++ b/chat/command.go @@ -216,31 +216,4 @@ func InitCommands(c *Commands) { return nil }, }) - - c.Add(Command{ - Op: true, - Prefix: "/op", - PrefixHelp: "USER", - Help: "Mark user as admin.", - Handler: func(room *Room, msg CommandMsg) error { - if !room.IsOp(msg.From()) { - return errors.New("must be op") - } - - args := msg.Args() - if len(args) != 1 { - return errors.New("must specify user") - } - - // TODO: Add support for fingerprint-based op'ing. This will - // probably need to live in host land. - member, ok := room.MemberById(args[0]) - if !ok { - return errors.New("user not found") - } - - member.Op = true - return nil - }, - }) } diff --git a/host.go b/host.go index 3f59233..2e1a57f 100644 --- a/host.go +++ b/host.go @@ -400,4 +400,34 @@ func (h *Host) InitCommands(c *chat.Commands) { return nil }, }) + + c.Add(chat.Command{ + Op: true, + Prefix: "/op", + PrefixHelp: "USER", + Help: "Set USER as admin.", + Handler: func(room *chat.Room, msg chat.CommandMsg) error { + if !room.IsOp(msg.From()) { + return errors.New("must be op") + } + + args := msg.Args() + if len(args) != 1 { + return errors.New("must specify user") + } + + member, ok := room.MemberById(args[0]) + if !ok { + return errors.New("user not found") + } + member.Op = true + id := member.Identifier.(*Identity) + h.auth.Op(id.PublicKey()) + + body := fmt.Sprintf("Made op by %s.", msg.From().Name()) + room.Send(chat.NewSystemMsg(body, member.User)) + + return nil + }, + }) } From 76bfdeeb70281af6d19abfd6a4980aaf30b23f6a Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 20:07:21 -0800 Subject: [PATCH 55/60] /slap --- chat/command.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/chat/command.go b/chat/command.go index ea313c8..df83575 100644 --- a/chat/command.go +++ b/chat/command.go @@ -114,7 +114,7 @@ func InitCommands(c *Commands) { Handler: func(room *Room, msg CommandMsg) error { me := strings.TrimLeft(msg.body, "/me") if me == "" { - me = " is at a loss for words." + me = "is at a loss for words." } else { me = me[1:] } @@ -216,4 +216,21 @@ func InitCommands(c *Commands) { return nil }, }) + + c.Add(Command{ + Prefix: "/slap", + PrefixHelp: "NAME", + Handler: func(room *Room, msg CommandMsg) error { + var me string + args := msg.Args() + if len(args) == 0 { + me = "slaps themselves around a bit with a large trout." + } else { + me = fmt.Sprintf("slaps %s around a bit with a large trout.", strings.Join(args, " ")) + } + + room.Send(NewEmoteMsg(me, msg.From())) + return nil + }, + }) } From 6c83bcb06a21cf27569ccb76b60fadf80a65d616 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 20:16:08 -0800 Subject: [PATCH 56/60] Rename fix. --- chat/command.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/chat/command.go b/chat/command.go index df83575..fcd5d05 100644 --- a/chat/command.go +++ b/chat/command.go @@ -144,12 +144,18 @@ func InitCommands(c *Commands) { return ErrMissingArg } u := msg.From() - oldId := u.Id() - u.SetId(args[0]) - err := room.Rename(oldId, u) + member, ok := room.MemberById(u.Id()) + if !ok { + return errors.New("failed to find member") + } + + oldId := member.Id() + member.SetId(args[0]) + + err := room.Rename(oldId, member) if err != nil { - u.SetId(oldId) + member.SetId(oldId) return err } return nil From 797d8c92be3bbb20761cb899a402a39106da59e9 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Sun, 18 Jan 2015 22:05:49 -0800 Subject: [PATCH 57/60] --log file with timestamps --- chat/history.go | 30 ++++++++++++++++++++++++------ chat/message.go | 5 +++++ chat/room.go | 6 ++++++ cmd.go | 14 +++++++++++++- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/chat/history.go b/chat/history.go index 44513a6..6b999ca 100644 --- a/chat/history.go +++ b/chat/history.go @@ -1,13 +1,20 @@ package chat -import "sync" +import ( + "fmt" + "io" + "sync" +) + +const timestampFmt = "2006-01-02 15:04:05" // History contains the history entries type History struct { + sync.RWMutex entries []Message head int size int - lock sync.Mutex + out io.Writer } // NewHistory constructs a new history of the given size @@ -19,8 +26,8 @@ func NewHistory(size int) *History { // Add adds the given entry to the entries in the history func (h *History) Add(entry Message) { - h.lock.Lock() - defer h.lock.Unlock() + h.Lock() + defer h.Unlock() max := cap(h.entries) h.head = (h.head + 1) % max @@ -28,6 +35,10 @@ func (h *History) Add(entry Message) { if h.size < max { h.size++ } + + if h.out != nil { + fmt.Fprintf(h.out, "[%s] %s\n", entry.Timestamp().UTC().Format(timestampFmt), entry.String()) + } } // Len returns the number of entries in the history @@ -37,8 +48,8 @@ func (h *History) Len() int { // Get the entry with the given number func (h *History) Get(num int) []Message { - h.lock.Lock() - defer h.lock.Unlock() + h.RLock() + defer h.RUnlock() max := cap(h.entries) if num > h.size { @@ -56,3 +67,10 @@ func (h *History) Get(num int) []Message { return r } + +// SetOutput sets the output for logging added messages +func (h *History) SetOutput(w io.Writer) { + h.Lock() + h.out = w + h.Unlock() +} diff --git a/chat/message.go b/chat/message.go index b4bfee5..9a0b30a 100644 --- a/chat/message.go +++ b/chat/message.go @@ -11,6 +11,7 @@ type Message interface { Render(*Theme) string String() string Command() string + Timestamp() time.Time } type MessageTo interface { @@ -61,6 +62,10 @@ func (m *Msg) Command() string { return "" } +func (m *Msg) Timestamp() time.Time { + return m.timestamp +} + // PublicMsg is any message from a user sent to the room. type PublicMsg struct { Msg diff --git a/chat/room.go b/chat/room.go index e6f68b5..041fe9e 100644 --- a/chat/room.go +++ b/chat/room.go @@ -3,6 +3,7 @@ package chat import ( "errors" "fmt" + "io" "sync" ) @@ -59,6 +60,11 @@ func (r *Room) Close() { }) } +// SetLogging sets logging output for the room's history +func (r *Room) SetLogging(out io.Writer) { + r.history.SetOutput(out) +} + // HandleMsg reacts to a message, will block until done. func (r *Room) HandleMsg(m Message) { switch m := m.(type) { diff --git a/cmd.go b/cmd.go index 721c597..a342a02 100644 --- a/cmd.go +++ b/cmd.go @@ -28,6 +28,7 @@ type Options struct { Admin string `long:"admin" 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."` Pprof int `long:"pprof" description:"Enable pprof http server for profiling."` } @@ -116,7 +117,7 @@ func main() { return err } auth.Op(key) - logger.Debugf("Added admin: %s", line) + logger.Debugf("Added admin: %s", sshd.Fingerprint(key)) return nil }) if err != nil { @@ -151,6 +152,17 @@ func main() { host.SetMotd(motdString) } + if options.Log == "-" { + host.SetLogging(os.Stdout) + } else if options.Log != "" { + fp, err := os.OpenFile(options.Log, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + logger.Errorf("Failed to open log file for writing: %v", err) + return + } + host.SetLogging(fp) + } + go host.Serve() // Construct interrupt handler From 69ea63bf88651127cc331884719dc7555989de34 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Mon, 19 Jan 2015 19:16:37 -0800 Subject: [PATCH 58/60] /ban and /op now support durations, also all other auth things in the api. --- auth.go | 104 +++++++++++++++++++++++++-------------------------- auth_test.go | 2 +- cmd.go | 5 +-- host.go | 22 ++++++++--- host_test.go | 2 +- logger.go | 9 +++++ set.go | 70 ++++++++++++++++++++++++++++++++++ set_test.go | 58 ++++++++++++++++++++++++++++ 8 files changed, 209 insertions(+), 63 deletions(-) create mode 100644 set.go create mode 100644 set_test.go diff --git a/auth.go b/auth.go index 26a217c..8c86b26 100644 --- a/auth.go +++ b/auth.go @@ -4,7 +4,9 @@ import ( "errors" "net" "sync" + "time" + "github.com/shazow/ssh-chat/sshd" "golang.org/x/crypto/ssh" ) @@ -14,16 +16,13 @@ var ErrNotWhitelisted = errors.New("not whitelisted") // The error returned a key is checked that is banned. var ErrBanned = errors.New("banned") -// AuthKey is the type that our lookups are keyed against. -type AuthKey string - // NewAuthKey returns string from an ssh.PublicKey. func NewAuthKey(key ssh.PublicKey) string { if key == nil { return "" } // FIXME: Is there a way to index pubkeys without marshal'ing them into strings? - return string(key.Marshal()) + return sshd.Fingerprint(key) } // NewAuthAddr returns a string from a net.Addr @@ -39,50 +38,43 @@ func NewAuthAddr(addr net.Addr) string { // TODO: Add timed auth by using a time.Time instead of struct{} for values. type Auth struct { sync.RWMutex - bannedAddr map[string]struct{} - banned map[string]struct{} - whitelist map[string]struct{} - ops map[string]struct{} + bannedAddr *Set + banned *Set + whitelist *Set + ops *Set } // NewAuth creates a new default Auth. func NewAuth() *Auth { return &Auth{ - bannedAddr: make(map[string]struct{}), - banned: make(map[string]struct{}), - whitelist: make(map[string]struct{}), - ops: make(map[string]struct{}), + bannedAddr: NewSet(), + banned: NewSet(), + whitelist: NewSet(), + ops: NewSet(), } } // AllowAnonymous determines if anonymous users are permitted. func (a Auth) AllowAnonymous() bool { - a.RLock() - ok := len(a.whitelist) == 0 - a.RUnlock() - return ok + return a.whitelist.Len() == 0 } // Check determines if a pubkey fingerprint is permitted. -func (a Auth) Check(addr net.Addr, key ssh.PublicKey) (bool, error) { +func (a *Auth) Check(addr net.Addr, key ssh.PublicKey) (bool, error) { authkey := NewAuthKey(key) - a.RLock() - defer a.RUnlock() - - if len(a.whitelist) > 0 { + if a.whitelist.Len() != 0 { // Only check whitelist if there is something in it, otherwise it's disabled. - - _, whitelisted := a.whitelist[authkey] + whitelisted := a.whitelist.In(authkey) if !whitelisted { return false, ErrNotWhitelisted } return true, nil } - _, banned := a.banned[authkey] + banned := a.banned.In(authkey) if !banned { - _, banned = a.bannedAddr[NewAuthAddr(addr)] + banned = a.bannedAddr.In(NewAuthAddr(addr)) } if banned { return false, ErrBanned @@ -91,60 +83,68 @@ func (a Auth) Check(addr net.Addr, key ssh.PublicKey) (bool, error) { return true, nil } -// Op will set a fingerprint as a known operator. -func (a *Auth) Op(key ssh.PublicKey) { +// Op sets a public key as a known operator. +func (a *Auth) Op(key ssh.PublicKey, d time.Duration) { if key == nil { - // Don't process empty keys. return } authkey := NewAuthKey(key) - a.Lock() - a.ops[authkey] = struct{}{} - a.Unlock() + if d != 0 { + a.ops.AddExpiring(authkey, d) + } else { + a.ops.Add(authkey) + } + logger.Debugf("Added to ops: %s (for %s)", authkey, d) } // IsOp checks if a public key is an op. -func (a Auth) IsOp(key ssh.PublicKey) bool { +func (a *Auth) IsOp(key ssh.PublicKey) bool { if key == nil { return false } authkey := NewAuthKey(key) - a.RLock() - _, ok := a.ops[authkey] - a.RUnlock() - return ok + return a.ops.In(authkey) } // Whitelist will set a public key as a whitelisted user. -func (a *Auth) Whitelist(key ssh.PublicKey) { +func (a *Auth) Whitelist(key ssh.PublicKey, d time.Duration) { if key == nil { - // Don't process empty keys. return } authkey := NewAuthKey(key) - a.Lock() - a.whitelist[authkey] = struct{}{} - a.Unlock() + if d != 0 { + a.whitelist.AddExpiring(authkey, d) + } else { + a.whitelist.Add(authkey) + } + logger.Debugf("Added to whitelist: %s (for %s)", authkey, d) } // Ban will set a public key as banned. -func (a *Auth) Ban(key ssh.PublicKey) { +func (a *Auth) Ban(key ssh.PublicKey, d time.Duration) { if key == nil { - // Don't process empty keys. return } - authkey := NewAuthKey(key) + a.BanFingerprint(NewAuthKey(key), d) +} - a.Lock() - a.banned[authkey] = struct{}{} - a.Unlock() +// BanFingerprint will set a public key fingerprint as banned. +func (a *Auth) BanFingerprint(authkey string, d time.Duration) { + if d != 0 { + a.banned.AddExpiring(authkey, d) + } else { + a.banned.Add(authkey) + } + logger.Debugf("Added to banned: %s (for %s)", authkey, d) } // Ban will set an IP address as banned. -func (a *Auth) BanAddr(addr net.Addr) { +func (a *Auth) BanAddr(addr net.Addr, d time.Duration) { key := NewAuthAddr(addr) - - a.Lock() - a.bannedAddr[key] = struct{}{} - a.Unlock() + if d != 0 { + a.bannedAddr.AddExpiring(key, d) + } else { + a.bannedAddr.Add(key) + } + logger.Debugf("Added to bannedAddr: %s (for %s)", key, d) } diff --git a/auth_test.go b/auth_test.go index eb29773..981a1d6 100644 --- a/auth_test.go +++ b/auth_test.go @@ -33,7 +33,7 @@ func TestAuthWhitelist(t *testing.T) { t.Error("Failed to permit in default state:", err) } - auth.Whitelist(key) + auth.Whitelist(key, 0) keyClone, err := ClonePublicKey(key) if err != nil { diff --git a/cmd.go b/cmd.go index a342a02..e60acb9 100644 --- a/cmd.go +++ b/cmd.go @@ -116,8 +116,7 @@ func main() { if err != nil { return err } - auth.Op(key) - logger.Debugf("Added admin: %s", sshd.Fingerprint(key)) + auth.Op(key, 0) return nil }) if err != nil { @@ -130,7 +129,7 @@ func main() { if err != nil { return err } - auth.Whitelist(key) + auth.Whitelist(key, 0) logger.Debugf("Whitelisted: %s", line) return nil }) diff --git a/host.go b/host.go index 2e1a57f..d099772 100644 --- a/host.go +++ b/host.go @@ -342,7 +342,7 @@ func (h *Host) InitCommands(c *chat.Commands) { c.Add(chat.Command{ Op: true, Prefix: "/ban", - PrefixHelp: "USER", + PrefixHelp: "USER [DURATION]", Help: "Ban USER from the server.", Handler: func(room *chat.Room, msg chat.CommandMsg) error { // TODO: Would be nice to specify what to ban. Key? Ip? etc. @@ -360,9 +360,14 @@ func (h *Host) InitCommands(c *chat.Commands) { return errors.New("user not found") } + var until time.Duration = 0 + if len(args) > 1 { + until, _ = time.ParseDuration(args[1]) + } + id := target.Identifier.(*Identity) - h.auth.Ban(id.PublicKey()) - h.auth.BanAddr(id.RemoteAddr()) + 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(chat.NewAnnounceMsg(body)) @@ -404,7 +409,7 @@ func (h *Host) InitCommands(c *chat.Commands) { c.Add(chat.Command{ Op: true, Prefix: "/op", - PrefixHelp: "USER", + PrefixHelp: "USER [DURATION]", Help: "Set USER as admin.", Handler: func(room *chat.Room, msg chat.CommandMsg) error { if !room.IsOp(msg.From()) { @@ -412,17 +417,22 @@ func (h *Host) InitCommands(c *chat.Commands) { } args := msg.Args() - if len(args) != 1 { + if len(args) == 0 { return errors.New("must specify user") } + var until time.Duration = 0 + if len(args) > 1 { + until, _ = time.ParseDuration(args[1]) + } + member, ok := room.MemberById(args[0]) if !ok { return errors.New("user not found") } member.Op = true id := member.Identifier.(*Identity) - h.auth.Op(id.PublicKey()) + h.auth.Op(id.PublicKey(), until) body := fmt.Sprintf("Made op by %s.", msg.From().Name()) room.Send(chat.NewSystemMsg(body, member.User)) diff --git a/host_test.go b/host_test.go index 2e79fb0..76bbe6b 100644 --- a/host_test.go +++ b/host_test.go @@ -150,7 +150,7 @@ func TestHostWhitelist(t *testing.T) { } clientpubkey, _ := ssh.NewPublicKey(clientkey.Public()) - auth.Whitelist(clientpubkey) + auth.Whitelist(clientpubkey, 0) err = sshd.ConnectShell(target, "foo", func(r io.Reader, w io.WriteCloser) {}) if err == nil { diff --git a/logger.go b/logger.go index 8fe9842..4fabd05 100644 --- a/logger.go +++ b/logger.go @@ -1,7 +1,16 @@ package main import ( + "bytes" + + "github.com/alexcesaro/log" "github.com/alexcesaro/log/golog" ) var logger *golog.Logger + +func init() { + // Set a default null logger + var b bytes.Buffer + logger = golog.New(&b, log.Debug) +} diff --git a/set.go b/set.go new file mode 100644 index 0000000..86afe13 --- /dev/null +++ b/set.go @@ -0,0 +1,70 @@ +package main + +import ( + "sync" + "time" +) + +type expiringValue struct { + time.Time +} + +func (v expiringValue) Bool() bool { + return time.Now().Before(v.Time) +} + +type value struct{} + +func (v value) Bool() bool { + return true +} + +type SetValue interface { + Bool() bool +} + +// Set with expire-able keys +type Set struct { + lookup map[string]SetValue + sync.Mutex +} + +// NewSet creates a new set. +func NewSet() *Set { + return &Set{ + lookup: map[string]SetValue{}, + } +} + +// Len returns the size of the set right now. +func (s *Set) Len() int { + return len(s.lookup) +} + +// In checks if an item exists in this set. +func (s *Set) In(key string) bool { + s.Lock() + v, ok := s.lookup[key] + if ok && !v.Bool() { + ok = false + delete(s.lookup, key) + } + s.Unlock() + return ok +} + +// Add item to this set, replace if it exists. +func (s *Set) Add(key string) { + s.Lock() + s.lookup[key] = value{} + s.Unlock() +} + +// Add item to this set, replace if it exists. +func (s *Set) AddExpiring(key string, d time.Duration) time.Time { + until := time.Now().Add(d) + s.Lock() + s.lookup[key] = expiringValue{until} + s.Unlock() + return until +} diff --git a/set_test.go b/set_test.go new file mode 100644 index 0000000..0a4b9ea --- /dev/null +++ b/set_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "testing" + "time" +) + +func TestSetExpiring(t *testing.T) { + s := NewSet() + if s.In("foo") { + t.Error("Matched before set.") + } + + s.Add("foo") + if !s.In("foo") { + t.Errorf("Not matched after set") + } + if s.Len() != 1 { + t.Error("Not len 1 after set") + } + + v := expiringValue{time.Now().Add(-time.Nanosecond * 1)} + if v.Bool() { + t.Errorf("expiringValue now is not expiring") + } + + v = expiringValue{time.Now().Add(time.Minute * 2)} + if !v.Bool() { + t.Errorf("expiringValue in 2 minutes is expiring now") + } + + until := s.AddExpiring("bar", time.Minute*2) + if !until.After(time.Now().Add(time.Minute*1)) || !until.Before(time.Now().Add(time.Minute*3)) { + t.Errorf("until is not a minute after %s: %s", time.Now(), until) + } + val, ok := s.lookup["bar"] + if !ok { + t.Errorf("bar not in lookup") + } + if !until.Equal(val.(expiringValue).Time) { + t.Errorf("bar's until is not equal to the expected value") + } + if !val.Bool() { + t.Errorf("bar expired immediately") + } + + if !s.In("bar") { + t.Errorf("Not matched after timed set") + } + if s.Len() != 2 { + t.Error("Not len 2 after set") + } + + s.AddExpiring("bar", time.Nanosecond*1) + if s.In("bar") { + t.Error("Matched after expired timer") + } +} From 9335a2139b6ab94169979debc102d389928c0e0c Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Mon, 19 Jan 2015 23:15:27 -0800 Subject: [PATCH 59/60] Sanitize names on join and /nick. --- chat/command.go | 3 +-- chat/room.go | 10 ++++++++++ chat/sanitize.go | 17 +++++++++++++++++ host.go | 2 +- identity.go | 2 +- 5 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 chat/sanitize.go diff --git a/chat/command.go b/chat/command.go index fcd5d05..eb085c9 100644 --- a/chat/command.go +++ b/chat/command.go @@ -151,8 +151,7 @@ func InitCommands(c *Commands) { } oldId := member.Id() - member.SetId(args[0]) - + member.SetId(SanitizeName(args[0])) err := room.Rename(oldId, member) if err != nil { member.SetId(oldId) diff --git a/chat/room.go b/chat/room.go index 041fe9e..7d1b3af 100644 --- a/chat/room.go +++ b/chat/room.go @@ -14,6 +14,10 @@ const roomBuffer = 10 // closed. var ErrRoomClosed = errors.New("room closed") +// The error returned when a user attempts to join with an invalid name, such +// as empty string. +var ErrInvalidName = errors.New("invalid name") + // Member is a User with per-Room metadata attached to it. type Member struct { *User @@ -128,6 +132,9 @@ func (r *Room) Join(u *User) (*Member, error) { if r.closed { return nil, ErrRoomClosed } + if u.Id() == "" { + return nil, ErrInvalidName + } member := Member{u, false} err := r.members.Add(&member) if err != nil { @@ -152,6 +159,9 @@ func (r *Room) Leave(u *User) error { // Rename member with a new identity. This will not call rename on the member. func (r *Room) Rename(oldId string, identity Identifier) error { + if identity.Id() == "" { + return ErrInvalidName + } err := r.members.Replace(oldId, identity) if err != nil { return err diff --git a/chat/sanitize.go b/chat/sanitize.go new file mode 100644 index 0000000..8b162cd --- /dev/null +++ b/chat/sanitize.go @@ -0,0 +1,17 @@ +package chat + +import "regexp" + +var reStripName = regexp.MustCompile("[^\\w.-]") + +// SanitizeName returns a name with only allowed characters. +func SanitizeName(s string) string { + return reStripName.ReplaceAllString(s, "") +} + +var reStripData = regexp.MustCompile("[^[:ascii:]]") + +// SanitizeData returns a string with only allowed characters for client-provided metadata inputs. +func SanitizeData(s string) string { + return reStripData.ReplaceAllString(s, "") +} diff --git a/host.go b/host.go index d099772..e8226f2 100644 --- a/host.go +++ b/host.go @@ -87,7 +87,7 @@ func (h *Host) Connect(term *sshd.Terminal) { } member, err := h.Join(user) - if err == chat.ErrIdTaken { + if err != nil { // Try again... id.SetName(fmt.Sprintf("Guest%d", h.count)) member, err = h.Join(user) diff --git a/identity.go b/identity.go index c41d382..bfd46fa 100644 --- a/identity.go +++ b/identity.go @@ -18,7 +18,7 @@ type Identity struct { func NewIdentity(conn sshd.Connection) *Identity { return &Identity{ Connection: conn, - id: conn.Name(), + id: chat.SanitizeName(conn.Name()), } } From c4ffd6f26373f9d49c36aaca6ff9a5e814d58af5 Mon Sep 17 00:00:00 2001 From: Andrey Petrov Date: Tue, 20 Jan 2015 14:23:37 -0800 Subject: [PATCH 60/60] /version, /uptime --- host.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/host.go b/host.go index e8226f2..baa5095 100644 --- a/host.go +++ b/host.go @@ -311,6 +311,24 @@ func (h *Host) InitCommands(c *chat.Commands) { }, }) + // Hidden commands + c.Add(chat.Command{ + Prefix: "/version", + Handler: func(room *chat.Room, msg chat.CommandMsg) error { + room.Send(chat.NewSystemMsg(buildCommit, msg.From())) + return nil + }, + }) + + timeStarted := time.Now() + c.Add(chat.Command{ + Prefix: "/uptime", + Handler: func(room *chat.Room, msg chat.CommandMsg) error { + room.Send(chat.NewSystemMsg(time.Now().Sub(timeStarted).String(), msg.From())) + return nil + }, + }) + // Op commands c.Add(chat.Command{ Op: true,