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