]> git.r.bdr.sh - rbdr/mobius/blob - hotline/client.go
Merge pull request #92 from jhalter/fix_login_regression
[rbdr/mobius] / hotline / client.go
1 package hotline
2
3 import (
4 "bytes"
5 "embed"
6 "encoding/binary"
7 "errors"
8 "fmt"
9 "github.com/gdamore/tcell/v2"
10 "github.com/rivo/tview"
11 "github.com/stretchr/testify/mock"
12 "go.uber.org/zap"
13 "gopkg.in/yaml.v3"
14 "math/big"
15 "math/rand"
16 "net"
17 "os"
18 "strings"
19 "time"
20 )
21
22 const (
23 trackerListPage = "trackerList"
24 serverUIPage = "serverUI"
25 )
26
27 //go:embed banners/*.txt
28 var bannerDir embed.FS
29
30 type Bookmark struct {
31 Name string `yaml:"Name"`
32 Addr string `yaml:"Addr"`
33 Login string `yaml:"Login"`
34 Password string `yaml:"Password"`
35 }
36
37 type ClientPrefs struct {
38 Username string `yaml:"Username"`
39 IconID int `yaml:"IconID"`
40 Bookmarks []Bookmark `yaml:"Bookmarks"`
41 Tracker string `yaml:"Tracker"`
42 EnableBell bool `yaml:"EnableBell"`
43 }
44
45 func (cp *ClientPrefs) IconBytes() []byte {
46 iconBytes := make([]byte, 2)
47 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
48 return iconBytes
49 }
50
51 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
52 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
53
54 return nil
55 }
56
57 func readConfig(cfgPath string) (*ClientPrefs, error) {
58 fh, err := os.Open(cfgPath)
59 if err != nil {
60 return nil, err
61 }
62
63 prefs := ClientPrefs{}
64 decoder := yaml.NewDecoder(fh)
65 if err := decoder.Decode(&prefs); err != nil {
66 return nil, err
67 }
68 return &prefs, nil
69 }
70
71 type Client struct {
72 cfgPath string
73 DebugBuf *DebugBuffer
74 Connection net.Conn
75 Login *[]byte
76 Password *[]byte
77 Flags *[]byte
78 ID *[]byte
79 Version []byte
80 UserAccess []byte
81 filePath []string
82 UserList []User
83 Logger *zap.SugaredLogger
84 activeTasks map[uint32]*Transaction
85 serverName string
86
87 pref *ClientPrefs
88
89 Handlers map[uint16]clientTHandler
90
91 UI *UI
92
93 Inbox chan *Transaction
94 }
95
96 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
97 c := &Client{
98 cfgPath: cfgPath,
99 Logger: logger,
100 activeTasks: make(map[uint32]*Transaction),
101 Handlers: clientHandlers,
102 }
103 c.UI = NewUI(c)
104
105 prefs, err := readConfig(cfgPath)
106 if err != nil {
107 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
108 }
109 c.pref = prefs
110
111 return c
112 }
113
114 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
115 type DebugBuffer struct {
116 TextView *tview.TextView
117 }
118
119 func (db *DebugBuffer) Write(p []byte) (int, error) {
120 return db.TextView.Write(p)
121 }
122
123 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
124 func (db *DebugBuffer) Sync() error {
125 return nil
126 }
127
128 func randomBanner() string {
129 rand.Seed(time.Now().UnixNano())
130
131 bannerFiles, _ := bannerDir.ReadDir("banners")
132 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
133
134 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
135 }
136
137 type clientTransaction struct {
138 Name string
139 Handler func(*Client, *Transaction) ([]Transaction, error)
140 }
141
142 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
143 return ch.Handler(cc, t)
144 }
145
146 type clientTHandler interface {
147 Handle(*Client, *Transaction) ([]Transaction, error)
148 }
149
150 type mockClientHandler struct {
151 mock.Mock
152 }
153
154 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 args := mh.Called(cc, t)
156 return args.Get(0).([]Transaction), args.Error(1)
157 }
158
159 var clientHandlers = map[uint16]clientTHandler{
160 // Server initiated
161 tranChatMsg: clientTransaction{
162 Name: "tranChatMsg",
163 Handler: handleClientChatMsg,
164 },
165 tranLogin: clientTransaction{
166 Name: "tranLogin",
167 Handler: handleClientTranLogin,
168 },
169 tranShowAgreement: clientTransaction{
170 Name: "tranShowAgreement",
171 Handler: handleClientTranShowAgreement,
172 },
173 tranUserAccess: clientTransaction{
174 Name: "tranUserAccess",
175 Handler: handleClientTranUserAccess,
176 },
177 tranGetUserNameList: clientTransaction{
178 Name: "tranGetUserNameList",
179 Handler: handleClientGetUserNameList,
180 },
181 tranNotifyChangeUser: clientTransaction{
182 Name: "tranNotifyChangeUser",
183 Handler: handleNotifyChangeUser,
184 },
185 tranNotifyDeleteUser: clientTransaction{
186 Name: "tranNotifyDeleteUser",
187 Handler: handleNotifyDeleteUser,
188 },
189 tranGetMsgs: clientTransaction{
190 Name: "tranNotifyDeleteUser",
191 Handler: handleGetMsgs,
192 },
193 tranGetFileNameList: clientTransaction{
194 Name: "tranGetFileNameList",
195 Handler: handleGetFileNameList,
196 },
197 tranServerMsg: clientTransaction{
198 Name: "tranServerMsg",
199 Handler: handleTranServerMsg,
200 },
201 tranKeepAlive: clientTransaction{
202 Name: "tranKeepAlive",
203 Handler: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
204 return t, err
205 },
206 },
207 }
208
209 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
210 time := time.Now().Format(time.RFC850)
211
212 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
213 msg += "\n\nAt " + time
214 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
215
216 msgBox := tview.NewTextView().SetScrollable(true)
217 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
218 msgBox.SetTitle(title).SetBorder(true)
219 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
220 switch event.Key() {
221 case tcell.KeyEscape:
222 c.UI.Pages.RemovePage("serverMsgModal" + time)
223 }
224 return event
225 })
226
227 centeredFlex := tview.NewFlex().
228 AddItem(nil, 0, 1, false).
229 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
230 AddItem(nil, 0, 1, false).
231 AddItem(msgBox, 0, 2, true).
232 AddItem(nil, 0, 1, false), 0, 2, true).
233 AddItem(nil, 0, 1, false)
234
235 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
236 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
237
238 return res, err
239 }
240
241 func (c *Client) showErrMsg(msg string) {
242 time := time.Now().Format(time.RFC850)
243
244 title := "| Error |"
245
246 msgBox := tview.NewTextView().SetScrollable(true)
247 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
248 msgBox.SetTitle(title).SetBorder(true)
249 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
250 switch event.Key() {
251 case tcell.KeyEscape:
252 c.UI.Pages.RemovePage("serverMsgModal" + time)
253 }
254 return event
255 })
256
257 centeredFlex := tview.NewFlex().
258 AddItem(nil, 0, 1, false).
259 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
260 AddItem(nil, 0, 1, false).
261 AddItem(msgBox, 0, 2, true).
262 AddItem(nil, 0, 1, false), 0, 2, true).
263 AddItem(nil, 0, 1, false)
264
265 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
266 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
267 }
268
269 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
270 if t.IsError() {
271 c.showErrMsg(string(t.GetField(fieldError).Data))
272 c.Logger.Infof("Error: %s", t.GetField(fieldError).Data)
273 return res, err
274 }
275
276 fTree := tview.NewTreeView().SetTopLevel(1)
277 root := tview.NewTreeNode("Root")
278 fTree.SetRoot(root).SetCurrentNode(root)
279 fTree.SetBorder(true).SetTitle("| Files |")
280 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
281 switch event.Key() {
282 case tcell.KeyEscape:
283 c.UI.Pages.RemovePage("files")
284 c.filePath = []string{}
285 case tcell.KeyEnter:
286 selectedNode := fTree.GetCurrentNode()
287
288 if selectedNode.GetText() == "<- Back" {
289 c.filePath = c.filePath[:len(c.filePath)-1]
290 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
291
292 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
293 c.UI.HLClient.Logger.Errorw("err", "err", err)
294 }
295 return event
296 }
297
298 entry := selectedNode.GetReference().(*FileNameWithInfo)
299
300 if bytes.Equal(entry.Type[:], []byte("fldr")) {
301 c.Logger.Infow("get new directory listing", "name", string(entry.name))
302
303 c.filePath = append(c.filePath, string(entry.name))
304 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
305
306 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
307 c.UI.HLClient.Logger.Errorw("err", "err", err)
308 }
309 } else {
310 // TODO: initiate file download
311 c.Logger.Infow("download file", "name", string(entry.name))
312 }
313 }
314
315 return event
316 })
317
318 if len(c.filePath) > 0 {
319 node := tview.NewTreeNode("<- Back")
320 root.AddChild(node)
321 }
322
323 for _, f := range t.Fields {
324 var fn FileNameWithInfo
325 err = fn.UnmarshalBinary(f.Data)
326 if err != nil {
327 return nil, nil
328 }
329
330 if bytes.Equal(fn.Type[:], []byte("fldr")) {
331 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
332 node.SetReference(&fn)
333 root.AddChild(node)
334 } else {
335 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
336
337 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
338 node.SetReference(&fn)
339 root.AddChild(node)
340 }
341
342 }
343
344 centerFlex := tview.NewFlex().
345 AddItem(nil, 0, 1, false).
346 AddItem(tview.NewFlex().
347 SetDirection(tview.FlexRow).
348 AddItem(nil, 0, 1, false).
349 AddItem(fTree, 20, 1, true).
350 AddItem(nil, 0, 1, false), 60, 1, true).
351 AddItem(nil, 0, 1, false)
352
353 c.UI.Pages.AddPage("files", centerFlex, true, true)
354 c.UI.App.Draw()
355
356 return res, err
357 }
358
359 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
360 newsText := string(t.GetField(fieldData).Data)
361 newsText = strings.ReplaceAll(newsText, "\r", "\n")
362
363 newsTextView := tview.NewTextView().
364 SetText(newsText).
365 SetDoneFunc(func(key tcell.Key) {
366 c.UI.Pages.SwitchToPage(serverUIPage)
367 c.UI.App.SetFocus(c.UI.chatInput)
368 })
369 newsTextView.SetBorder(true).SetTitle("News")
370
371 c.UI.Pages.AddPage("news", newsTextView, true, true)
372 // c.UI.Pages.SwitchToPage("news")
373 // c.UI.App.SetFocus(newsTextView)
374 c.UI.App.Draw()
375
376 return res, err
377 }
378
379 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
380 newUser := User{
381 ID: t.GetField(fieldUserID).Data,
382 Name: string(t.GetField(fieldUserName).Data),
383 Icon: t.GetField(fieldUserIconID).Data,
384 Flags: t.GetField(fieldUserFlags).Data,
385 }
386
387 // Possible cases:
388 // user is new to the server
389 // user is already on the server but has a new name
390
391 var oldName string
392 var newUserList []User
393 updatedUser := false
394 for _, u := range c.UserList {
395 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
396 if bytes.Equal(newUser.ID, u.ID) {
397 oldName = u.Name
398 u.Name = newUser.Name
399 if u.Name != newUser.Name {
400 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
401 }
402 updatedUser = true
403 }
404 newUserList = append(newUserList, u)
405 }
406
407 if !updatedUser {
408 newUserList = append(newUserList, newUser)
409 }
410
411 c.UserList = newUserList
412
413 c.renderUserList()
414
415 return res, err
416 }
417
418 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
419 exitUser := t.GetField(fieldUserID).Data
420
421 var newUserList []User
422 for _, u := range c.UserList {
423 if !bytes.Equal(exitUser, u.ID) {
424 newUserList = append(newUserList, u)
425 }
426 }
427
428 c.UserList = newUserList
429
430 c.renderUserList()
431
432 return res, err
433 }
434
435 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
436 var users []User
437 for _, field := range t.Fields {
438 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
439 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
440 // field type. Probably a good idea to do everywhere.
441 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
442 u, err := ReadUser(field.Data)
443 if err != nil {
444 return res, err
445 }
446 users = append(users, *u)
447 }
448 }
449 c.UserList = users
450
451 c.renderUserList()
452
453 return res, err
454 }
455
456 func (c *Client) renderUserList() {
457 c.UI.userList.Clear()
458 for _, u := range c.UserList {
459 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
460 if flagBitmap.Bit(userFlagAdmin) == 1 {
461 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
462 } else {
463 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
464 }
465 // TODO: fade if user is away
466 }
467 }
468
469 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
470 if c.pref.EnableBell {
471 fmt.Println("\a")
472 }
473
474 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
475
476 return res, err
477 }
478
479 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
480 c.UserAccess = t.GetField(fieldUserAccess).Data
481
482 return res, err
483 }
484
485 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
486 agreement := string(t.GetField(fieldData).Data)
487 agreement = strings.ReplaceAll(agreement, "\r", "\n")
488
489 agreeModal := tview.NewModal().
490 SetText(agreement).
491 AddButtons([]string{"Agree", "Disagree"}).
492 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
493 if buttonIndex == 0 {
494 res = append(res,
495 *NewTransaction(
496 tranAgreed, nil,
497 NewField(fieldUserName, []byte(c.pref.Username)),
498 NewField(fieldUserIconID, c.pref.IconBytes()),
499 NewField(fieldUserFlags, []byte{0x00, 0x00}),
500 NewField(fieldOptions, []byte{0x00, 0x00}),
501 ),
502 )
503 c.UI.Pages.HidePage("agreement")
504 c.UI.App.SetFocus(c.UI.chatInput)
505 } else {
506 _ = c.Disconnect()
507 c.UI.Pages.SwitchToPage("home")
508 }
509 },
510 )
511
512 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
513
514 return res, err
515 }
516
517 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
518 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
519 errMsg := string(t.GetField(fieldError).Data)
520 errModal := tview.NewModal()
521 errModal.SetText(errMsg)
522 errModal.AddButtons([]string{"Oh no"})
523 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
524 c.UI.Pages.RemovePage("errModal")
525 })
526 c.UI.Pages.RemovePage("joinServer")
527 c.UI.Pages.AddPage("errModal", errModal, false, true)
528
529 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
530
531 c.Logger.Error(string(t.GetField(fieldError).Data))
532 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
533 }
534 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
535 c.UI.App.SetFocus(c.UI.chatInput)
536
537 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
538 c.Logger.Errorw("err", "err", err)
539 }
540 return res, err
541 }
542
543 // JoinServer connects to a Hotline server and completes the login flow
544 func (c *Client) JoinServer(address, login, passwd string) error {
545 // Establish TCP connection to server
546 if err := c.connect(address); err != nil {
547 return err
548 }
549
550 // Send handshake sequence
551 if err := c.Handshake(); err != nil {
552 return err
553 }
554
555 // Authenticate (send tranLogin 107)
556 if err := c.LogIn(login, passwd); err != nil {
557 return err
558 }
559
560 // start keepalive go routine
561 go func() { _ = c.keepalive() }()
562
563 return nil
564 }
565
566 func (c *Client) keepalive() error {
567 for {
568 time.Sleep(300 * time.Second)
569 _ = c.Send(*NewTransaction(tranKeepAlive, nil))
570 c.Logger.Infow("Sent keepalive ping")
571 }
572 }
573
574 // connect establishes a connection with a Server by sending handshake sequence
575 func (c *Client) connect(address string) error {
576 var err error
577 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
578 if err != nil {
579 return err
580 }
581 return nil
582 }
583
584 var ClientHandshake = []byte{
585 0x54, 0x52, 0x54, 0x50, // TRTP
586 0x48, 0x4f, 0x54, 0x4c, // HOTL
587 0x00, 0x01,
588 0x00, 0x02,
589 }
590
591 var ServerHandshake = []byte{
592 0x54, 0x52, 0x54, 0x50, // TRTP
593 0x00, 0x00, 0x00, 0x00, // ErrorCode
594 }
595
596 func (c *Client) Handshake() error {
597 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
598 // Sub-protocol ID 4 User defined
599 // Version 2 1 Currently 1
600 // Sub-version 2 User defined
601 if _, err := c.Connection.Write(ClientHandshake); err != nil {
602 return fmt.Errorf("handshake write err: %s", err)
603 }
604
605 replyBuf := make([]byte, 8)
606 _, err := c.Connection.Read(replyBuf)
607 if err != nil {
608 return err
609 }
610
611 if bytes.Equal(replyBuf, ServerHandshake) {
612 return nil
613 }
614
615 // In the case of an error, client and server close the connection.
616 return fmt.Errorf("handshake response err: %s", err)
617 }
618
619 func (c *Client) LogIn(login string, password string) error {
620 return c.Send(
621 *NewTransaction(
622 tranLogin, nil,
623 NewField(fieldUserName, []byte(c.pref.Username)),
624 NewField(fieldUserIconID, c.pref.IconBytes()),
625 NewField(fieldUserLogin, negateString([]byte(login))),
626 NewField(fieldUserPassword, negateString([]byte(password))),
627 ),
628 )
629 }
630
631 func (c *Client) Send(t Transaction) error {
632 requestNum := binary.BigEndian.Uint16(t.Type)
633 tID := binary.BigEndian.Uint32(t.ID)
634
635 // handler := TransactionHandlers[requestNum]
636
637 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
638 if t.IsReply == 0 {
639 c.activeTasks[tID] = &t
640 }
641
642 var n int
643 var err error
644 b, err := t.MarshalBinary()
645 if err != nil {
646 return err
647 }
648 if n, err = c.Connection.Write(b); err != nil {
649 return err
650 }
651 c.Logger.Debugw("Sent Transaction",
652 "IsReply", t.IsReply,
653 "type", requestNum,
654 "sentBytes", n,
655 )
656 return nil
657 }
658
659 func (c *Client) HandleTransaction(t *Transaction) error {
660 var origT Transaction
661 if t.IsReply == 1 {
662 requestID := binary.BigEndian.Uint32(t.ID)
663 origT = *c.activeTasks[requestID]
664 t.Type = origT.Type
665 }
666
667 requestNum := binary.BigEndian.Uint16(t.Type)
668 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
669
670 if handler, ok := c.Handlers[requestNum]; ok {
671 outT, _ := handler.Handle(c, t)
672 for _, t := range outT {
673 c.Send(t)
674 }
675 } else {
676 c.Logger.Errorw(
677 "Unimplemented transaction type received",
678 "RequestID", requestNum,
679 "TransactionID", t.ID,
680 )
681 }
682
683 return nil
684 }
685
686 func (c *Client) Disconnect() error {
687 return c.Connection.Close()
688 }