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