]> git.r.bdr.sh - rbdr/mobius/blob - hotline/client.go
Ran 'golangci-lint run -E gocritic,whitespace --fix' 🤞
[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 centerFlex := tview.NewFlex().
323 AddItem(nil, 0, 1, false).
324 AddItem(tview.NewFlex().
325 SetDirection(tview.FlexRow).
326 AddItem(nil, 0, 1, false).
327 AddItem(fTree, 20, 1, true).
328 AddItem(nil, 0, 1, false), 60, 1, true).
329 AddItem(nil, 0, 1, false)
330
331 c.UI.Pages.AddPage("files", centerFlex, true, true)
332 c.UI.App.Draw()
333
334 return res, err
335 }
336
337 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
338 newsText := string(t.GetField(FieldData).Data)
339 newsText = strings.ReplaceAll(newsText, "\r", "\n")
340
341 newsTextView := tview.NewTextView().
342 SetText(newsText).
343 SetDoneFunc(func(key tcell.Key) {
344 c.UI.Pages.SwitchToPage(serverUIPage)
345 c.UI.App.SetFocus(c.UI.chatInput)
346 })
347 newsTextView.SetBorder(true).SetTitle("News")
348
349 c.UI.Pages.AddPage("news", newsTextView, true, true)
350 // c.UI.Pages.SwitchToPage("news")
351 // c.UI.App.SetFocus(newsTextView)
352 c.UI.App.Draw()
353
354 return res, err
355 }
356
357 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
358 newUser := User{
359 ID: t.GetField(FieldUserID).Data,
360 Name: string(t.GetField(FieldUserName).Data),
361 Icon: t.GetField(FieldUserIconID).Data,
362 Flags: t.GetField(FieldUserFlags).Data,
363 }
364
365 // Possible cases:
366 // user is new to the server
367 // user is already on the server but has a new name
368
369 var oldName string
370 var newUserList []User
371 updatedUser := false
372 for _, u := range c.UserList {
373 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
374 if bytes.Equal(newUser.ID, u.ID) {
375 oldName = u.Name
376 u.Name = newUser.Name
377 if u.Name != newUser.Name {
378 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
379 }
380 updatedUser = true
381 }
382 newUserList = append(newUserList, u)
383 }
384
385 if !updatedUser {
386 newUserList = append(newUserList, newUser)
387 }
388
389 c.UserList = newUserList
390
391 c.renderUserList()
392
393 return res, err
394 }
395
396 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
397 exitUser := t.GetField(FieldUserID).Data
398
399 var newUserList []User
400 for _, u := range c.UserList {
401 if !bytes.Equal(exitUser, u.ID) {
402 newUserList = append(newUserList, u)
403 }
404 }
405
406 c.UserList = newUserList
407
408 c.renderUserList()
409
410 return res, err
411 }
412
413 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
414 var users []User
415 for _, field := range t.Fields {
416 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
417 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
418 // field type. Probably a good idea to do everywhere.
419 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
420 u, err := ReadUser(field.Data)
421 if err != nil {
422 return res, err
423 }
424 users = append(users, *u)
425 }
426 }
427 c.UserList = users
428
429 c.renderUserList()
430
431 return res, err
432 }
433
434 func (c *Client) renderUserList() {
435 c.UI.userList.Clear()
436 for _, u := range c.UserList {
437 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
438 if flagBitmap.Bit(userFlagAdmin) == 1 {
439 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
440 } else {
441 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
442 }
443 // TODO: fade if user is away
444 }
445 }
446
447 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
448 if c.Pref.EnableBell {
449 fmt.Println("\a")
450 }
451
452 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
453
454 return res, err
455 }
456
457 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
458 c.UserAccess = t.GetField(FieldUserAccess).Data
459
460 return res, err
461 }
462
463 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
464 agreement := string(t.GetField(FieldData).Data)
465 agreement = strings.ReplaceAll(agreement, "\r", "\n")
466
467 agreeModal := tview.NewModal().
468 SetText(agreement).
469 AddButtons([]string{"Agree", "Disagree"}).
470 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
471 if buttonIndex == 0 {
472 res = append(res,
473 *NewTransaction(
474 TranAgreed, nil,
475 NewField(FieldUserName, []byte(c.Pref.Username)),
476 NewField(FieldUserIconID, c.Pref.IconBytes()),
477 NewField(FieldUserFlags, []byte{0x00, 0x00}),
478 NewField(FieldOptions, []byte{0x00, 0x00}),
479 ),
480 )
481 c.UI.Pages.HidePage("agreement")
482 c.UI.App.SetFocus(c.UI.chatInput)
483 } else {
484 _ = c.Disconnect()
485 c.UI.Pages.SwitchToPage("home")
486 }
487 },
488 )
489
490 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
491
492 return res, err
493 }
494
495 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
496 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
497 errMsg := string(t.GetField(FieldError).Data)
498 errModal := tview.NewModal()
499 errModal.SetText(errMsg)
500 errModal.AddButtons([]string{"Oh no"})
501 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
502 c.UI.Pages.RemovePage("errModal")
503 })
504 c.UI.Pages.RemovePage("joinServer")
505 c.UI.Pages.AddPage("errModal", errModal, false, true)
506
507 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
508
509 c.Logger.Error(string(t.GetField(FieldError).Data))
510 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
511 }
512 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
513 c.UI.App.SetFocus(c.UI.chatInput)
514
515 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
516 c.Logger.Errorw("err", "err", err)
517 }
518 return res, err
519 }
520
521 // JoinServer connects to a Hotline server and completes the login flow
522 func (c *Client) Connect(address, login, passwd string) (err error) {
523 // Establish TCP connection to server
524 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
525 if err != nil {
526 return err
527 }
528
529 // Send handshake sequence
530 if err := c.Handshake(); err != nil {
531 return err
532 }
533
534 // Authenticate (send TranLogin 107)
535 if err := c.LogIn(login, passwd); err != nil {
536 return err
537 }
538
539 // start keepalive go routine
540 go func() { _ = c.keepalive() }()
541
542 return nil
543 }
544
545 const keepaliveInterval = 300 * time.Second
546
547 func (c *Client) keepalive() error {
548 for {
549 time.Sleep(keepaliveInterval)
550 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
551 c.Logger.Debugw("Sent keepalive ping")
552 }
553 }
554
555 var ClientHandshake = []byte{
556 0x54, 0x52, 0x54, 0x50, // TRTP
557 0x48, 0x4f, 0x54, 0x4c, // HOTL
558 0x00, 0x01,
559 0x00, 0x02,
560 }
561
562 var ServerHandshake = []byte{
563 0x54, 0x52, 0x54, 0x50, // TRTP
564 0x00, 0x00, 0x00, 0x00, // ErrorCode
565 }
566
567 func (c *Client) Handshake() error {
568 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
569 // Sub-protocol ID 4 User defined
570 // Version 2 1 Currently 1
571 // Sub-version 2 User defined
572 if _, err := c.Connection.Write(ClientHandshake); err != nil {
573 return fmt.Errorf("handshake write err: %s", err)
574 }
575
576 replyBuf := make([]byte, 8)
577 _, err := c.Connection.Read(replyBuf)
578 if err != nil {
579 return err
580 }
581
582 if bytes.Equal(replyBuf, ServerHandshake) {
583 return nil
584 }
585
586 // In the case of an error, client and server close the connection.
587 return fmt.Errorf("handshake response err: %s", err)
588 }
589
590 func (c *Client) LogIn(login string, password string) error {
591 return c.Send(
592 *NewTransaction(
593 TranLogin, nil,
594 NewField(FieldUserName, []byte(c.Pref.Username)),
595 NewField(FieldUserIconID, c.Pref.IconBytes()),
596 NewField(FieldUserLogin, negateString([]byte(login))),
597 NewField(FieldUserPassword, negateString([]byte(password))),
598 ),
599 )
600 }
601
602 func (c *Client) Send(t Transaction) error {
603 requestNum := binary.BigEndian.Uint16(t.Type)
604 tID := binary.BigEndian.Uint32(t.ID)
605
606 // handler := TransactionHandlers[requestNum]
607
608 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
609 if t.IsReply == 0 {
610 c.activeTasks[tID] = &t
611 }
612
613 var n int
614 var err error
615 b, err := t.MarshalBinary()
616 if err != nil {
617 return err
618 }
619 if n, err = c.Connection.Write(b); err != nil {
620 return err
621 }
622 c.Logger.Debugw("Sent Transaction",
623 "IsReply", t.IsReply,
624 "type", requestNum,
625 "sentBytes", n,
626 )
627 return nil
628 }
629
630 func (c *Client) HandleTransaction(t *Transaction) error {
631 var origT Transaction
632 if t.IsReply == 1 {
633 requestID := binary.BigEndian.Uint32(t.ID)
634 origT = *c.activeTasks[requestID]
635 t.Type = origT.Type
636 }
637
638 requestNum := binary.BigEndian.Uint16(t.Type)
639 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
640
641 if handler, ok := c.Handlers[requestNum]; ok {
642 outT, _ := handler(c, t)
643 for _, t := range outT {
644 c.Send(t)
645 }
646 } else {
647 c.Logger.Debugw(
648 "Unimplemented transaction type received",
649 "RequestID", requestNum,
650 "TransactionID", t.ID,
651 )
652 }
653
654 return nil
655 }
656
657 func (c *Client) Disconnect() error {
658 return c.Connection.Close()
659 }
660
661 func (c *Client) HandleTransactions() error {
662 // Create a new scanner for parsing incoming bytes into transaction tokens
663 scanner := bufio.NewScanner(c.Connection)
664 scanner.Split(transactionScanner)
665
666 // Scan for new transactions and handle them as they come in.
667 for scanner.Scan() {
668 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
669 // scanner re-uses the buffer for subsequent scans.
670 buf := make([]byte, len(scanner.Bytes()))
671 copy(buf, scanner.Bytes())
672
673 var t Transaction
674 _, err := t.Write(buf)
675 if err != nil {
676 break
677 }
678 if err := c.HandleTransaction(&t); err != nil {
679 c.Logger.Errorw("Error handling transaction", "err", err)
680 }
681 }
682
683 if scanner.Err() == nil {
684 return scanner.Err()
685 }
686 return nil
687 }