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/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/chat/channel.go b/chat/channel.go index 68fee01..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) @@ -168,7 +174,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..9ba73d8 100644 --- a/chat/channel_test.go +++ b/chat/channel_test.go @@ -56,3 +56,137 @@ func TestChannelJoin(t *testing.T) { t.Errorf("Got: `%s`; Expected: `%s`", actual, expected) } } + +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 + + 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("/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) + } +} 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? diff --git a/cmd.go b/cmd.go index b97ec18..9f5104b 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" @@ -24,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."` @@ -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,10 +103,11 @@ func main() { if err != nil { logger.Errorf("Failed to listen on socket: %v", err) os.Exit(4) - return } defer s.Close() + fmt.Printf("Listening for connections on %v\n", s.Addr().String()) + host := NewHost(s) host.auth = &auth host.theme = &chat.Themes[0] @@ -151,3 +153,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 +} 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 }