]> git.r.bdr.sh - rbdr/mobius/blob - hotline/client.go
24df7c35aa8caddf1c6084d96d2860dc1883bb0e
[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 }
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 if err := decoder.Decode(&prefs); err != nil {
65 return nil, err
66 }
67 return &prefs, nil
68 }
69
70 type Client struct {
71 cfgPath string
72 DebugBuf *DebugBuffer
73 Connection net.Conn
74 Login *[]byte
75 Password *[]byte
76 Flags *[]byte
77 ID *[]byte
78 Version []byte
79 UserAccess []byte
80 filePath []string
81 UserList []User
82 Logger *zap.SugaredLogger
83 activeTasks map[uint32]*Transaction
84 serverName string
85
86 pref *ClientPrefs
87
88 Handlers map[uint16]clientTHandler
89
90 UI *UI
91
92 Inbox chan *Transaction
93 }
94
95 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
96 c := &Client{
97 cfgPath: cfgPath,
98 Logger: logger,
99 activeTasks: make(map[uint32]*Transaction),
100 Handlers: clientHandlers,
101 }
102 c.UI = NewUI(c)
103
104 prefs, err := readConfig(cfgPath)
105 if err != nil {
106 fmt.Printf("unable to read config file %s", cfgPath)
107 os.Exit(1)
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 exists 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 handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
242 fTree := tview.NewTreeView().SetTopLevel(1)
243 root := tview.NewTreeNode("Root")
244 fTree.SetRoot(root).SetCurrentNode(root)
245 fTree.SetBorder(true).SetTitle("| Files |")
246 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
247 switch event.Key() {
248 case tcell.KeyEscape:
249 c.UI.Pages.RemovePage("files")
250 c.filePath = []string{}
251 case tcell.KeyEnter:
252 selectedNode := fTree.GetCurrentNode()
253
254 if selectedNode.GetText() == "<- Back" {
255 c.filePath = c.filePath[:len(c.filePath)-1]
256 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
257
258 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
259 c.UI.HLClient.Logger.Errorw("err", "err", err)
260 }
261 return event
262 }
263
264 entry := selectedNode.GetReference().(*FileNameWithInfo)
265
266 if bytes.Equal(entry.Type[:], []byte("fldr")) {
267 c.Logger.Infow("get new directory listing", "name", string(entry.name))
268
269 c.filePath = append(c.filePath, string(entry.name))
270 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
271
272 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
273 c.UI.HLClient.Logger.Errorw("err", "err", err)
274 }
275 } else {
276 // TODO: initiate file download
277 c.Logger.Infow("download file", "name", string(entry.name))
278 }
279 }
280
281 return event
282 })
283
284 if len(c.filePath) > 0 {
285 node := tview.NewTreeNode("<- Back")
286 root.AddChild(node)
287 }
288
289 for _, f := range t.Fields {
290 var fn FileNameWithInfo
291 err = fn.UnmarshalBinary(f.Data)
292 if err != nil {
293 return nil, nil
294 }
295
296 if bytes.Equal(fn.Type[:], []byte("fldr")) {
297 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
298 node.SetReference(&fn)
299 root.AddChild(node)
300 } else {
301 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
302
303 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
304 node.SetReference(&fn)
305 root.AddChild(node)
306 }
307
308 }
309
310 centerFlex := tview.NewFlex().
311 AddItem(nil, 0, 1, false).
312 AddItem(tview.NewFlex().
313 SetDirection(tview.FlexRow).
314 AddItem(nil, 0, 1, false).
315 AddItem(fTree, 20, 1, true).
316 AddItem(nil, 0, 1, false), 60, 1, true).
317 AddItem(nil, 0, 1, false)
318
319 c.UI.Pages.AddPage("files", centerFlex, true, true)
320 c.UI.App.Draw()
321
322 return res, err
323 }
324
325 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
326 newsText := string(t.GetField(fieldData).Data)
327 newsText = strings.ReplaceAll(newsText, "\r", "\n")
328
329 newsTextView := tview.NewTextView().
330 SetText(newsText).
331 SetDoneFunc(func(key tcell.Key) {
332 c.UI.Pages.SwitchToPage(serverUIPage)
333 c.UI.App.SetFocus(c.UI.chatInput)
334 })
335 newsTextView.SetBorder(true).SetTitle("News")
336
337 c.UI.Pages.AddPage("news", newsTextView, true, true)
338 // c.UI.Pages.SwitchToPage("news")
339 // c.UI.App.SetFocus(newsTextView)
340 c.UI.App.Draw()
341
342 return res, err
343 }
344
345 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
346 newUser := User{
347 ID: t.GetField(fieldUserID).Data,
348 Name: string(t.GetField(fieldUserName).Data),
349 Icon: t.GetField(fieldUserIconID).Data,
350 Flags: t.GetField(fieldUserFlags).Data,
351 }
352
353 // Possible cases:
354 // user is new to the server
355 // user is already on the server but has a new name
356
357 var oldName string
358 var newUserList []User
359 updatedUser := false
360 for _, u := range c.UserList {
361 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
362 if bytes.Equal(newUser.ID, u.ID) {
363 oldName = u.Name
364 u.Name = newUser.Name
365 if u.Name != newUser.Name {
366 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
367 }
368 updatedUser = true
369 }
370 newUserList = append(newUserList, u)
371 }
372
373 if !updatedUser {
374 newUserList = append(newUserList, newUser)
375 }
376
377 c.UserList = newUserList
378
379 c.renderUserList()
380
381 return res, err
382 }
383
384 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
385 exitUser := t.GetField(fieldUserID).Data
386
387 var newUserList []User
388 for _, u := range c.UserList {
389 if !bytes.Equal(exitUser, u.ID) {
390 newUserList = append(newUserList, u)
391 }
392 }
393
394 c.UserList = newUserList
395
396 c.renderUserList()
397
398 return res, err
399 }
400
401 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
402
403 func (c *Client) ReadLoop() error {
404 tranBuff := make([]byte, 0)
405 tReadlen := 0
406 // Infinite loop where take action on incoming client requests until the connection is closed
407 for {
408 buf := make([]byte, readBuffSize)
409 tranBuff = tranBuff[tReadlen:]
410
411 readLen, err := c.Connection.Read(buf)
412 if err != nil {
413 return err
414 }
415 tranBuff = append(tranBuff, buf[:readLen]...)
416
417 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
418 // into a slice of transactions
419 var transactions []Transaction
420 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
421 c.Logger.Errorw("Error handling transaction", "err", err)
422 }
423
424 // iterate over all of the transactions that were parsed from the byte slice and handle them
425 for _, t := range transactions {
426 if err := c.HandleTransaction(&t); err != nil {
427 c.Logger.Errorw("Error handling transaction", "err", err)
428 }
429 }
430 }
431 }
432
433 func (c *Client) GetTransactions() error {
434 tranBuff := make([]byte, 0)
435 tReadlen := 0
436
437 buf := make([]byte, readBuffSize)
438 tranBuff = tranBuff[tReadlen:]
439
440 readLen, err := c.Connection.Read(buf)
441 if err != nil {
442 return err
443 }
444 tranBuff = append(tranBuff, buf[:readLen]...)
445
446 return nil
447 }
448
449 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
450 var users []User
451 for _, field := range t.Fields {
452 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
453 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
454 // field type. Probably a good idea to do everywhere.
455 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
456 u, err := ReadUser(field.Data)
457 if err != nil {
458 return res, err
459 }
460 users = append(users, *u)
461 }
462 }
463 c.UserList = users
464
465 c.renderUserList()
466
467 return res, err
468 }
469
470 func (c *Client) renderUserList() {
471 c.UI.userList.Clear()
472 for _, u := range c.UserList {
473 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
474 if flagBitmap.Bit(userFlagAdmin) == 1 {
475 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
476 } else {
477 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
478 }
479 // TODO: fade if user is away
480 }
481 }
482
483 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
484 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
485
486 return res, err
487 }
488
489 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
490 c.UserAccess = t.GetField(fieldUserAccess).Data
491
492 return res, err
493 }
494
495 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
496 agreement := string(t.GetField(fieldData).Data)
497 agreement = strings.ReplaceAll(agreement, "\r", "\n")
498
499 agreeModal := tview.NewModal().
500 SetText(agreement).
501 AddButtons([]string{"Agree", "Disagree"}).
502 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
503 if buttonIndex == 0 {
504 res = append(res,
505 *NewTransaction(
506 tranAgreed, nil,
507 NewField(fieldUserName, []byte(c.pref.Username)),
508 NewField(fieldUserIconID, c.pref.IconBytes()),
509 NewField(fieldUserFlags, []byte{0x00, 0x00}),
510 NewField(fieldOptions, []byte{0x00, 0x00}),
511 ),
512 )
513 c.UI.Pages.HidePage("agreement")
514 c.UI.App.SetFocus(c.UI.chatInput)
515 } else {
516 _ = c.Disconnect()
517 c.UI.Pages.SwitchToPage("home")
518 }
519 },
520 )
521
522 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
523
524 return res, err
525 }
526
527 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
528 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
529 errMsg := string(t.GetField(fieldError).Data)
530 errModal := tview.NewModal()
531 errModal.SetText(errMsg)
532 errModal.AddButtons([]string{"Oh no"})
533 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
534 c.UI.Pages.RemovePage("errModal")
535 })
536 c.UI.Pages.RemovePage("joinServer")
537 c.UI.Pages.AddPage("errModal", errModal, false, true)
538
539 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
540
541 c.Logger.Error(string(t.GetField(fieldError).Data))
542 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
543 }
544 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
545 c.UI.App.SetFocus(c.UI.chatInput)
546
547 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
548 c.Logger.Errorw("err", "err", err)
549 }
550 return res, err
551 }
552
553 // JoinServer connects to a Hotline server and completes the login flow
554 func (c *Client) JoinServer(address, login, passwd string) error {
555 // Establish TCP connection to server
556 if err := c.connect(address); err != nil {
557 return err
558 }
559
560 // Send handshake sequence
561 if err := c.Handshake(); err != nil {
562 return err
563 }
564
565 // Authenticate (send tranLogin 107)
566 if err := c.LogIn(login, passwd); err != nil {
567 return err
568 }
569
570 // start keepalive go routine
571 go func() { _ = c.keepalive() }()
572
573 return nil
574 }
575
576 func (c *Client) keepalive() error {
577 for {
578 time.Sleep(300 * time.Second)
579 _ = c.Send(*NewTransaction(tranKeepAlive, nil))
580 c.Logger.Infow("Sent keepalive ping")
581 }
582 }
583
584 // connect establishes a connection with a Server by sending handshake sequence
585 func (c *Client) connect(address string) error {
586 var err error
587 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
588 if err != nil {
589 return err
590 }
591 return nil
592 }
593
594 var ClientHandshake = []byte{
595 0x54, 0x52, 0x54, 0x50, // TRTP
596 0x48, 0x4f, 0x54, 0x4c, // HOTL
597 0x00, 0x01,
598 0x00, 0x02,
599 }
600
601 var ServerHandshake = []byte{
602 0x54, 0x52, 0x54, 0x50, // TRTP
603 0x00, 0x00, 0x00, 0x00, // ErrorCode
604 }
605
606 func (c *Client) Handshake() error {
607 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
608 // Sub-protocol ID 4 User defined
609 // Version 2 1 Currently 1
610 // Sub-version 2 User defined
611 if _, err := c.Connection.Write(ClientHandshake); err != nil {
612 return fmt.Errorf("handshake write err: %s", err)
613 }
614
615 replyBuf := make([]byte, 8)
616 _, err := c.Connection.Read(replyBuf)
617 if err != nil {
618 return err
619 }
620
621 if bytes.Equal(replyBuf, ServerHandshake) {
622 return nil
623 }
624
625 // In the case of an error, client and server close the connection.
626 return fmt.Errorf("handshake response err: %s", err)
627 }
628
629 func (c *Client) LogIn(login string, password string) error {
630 return c.Send(
631 *NewTransaction(
632 tranLogin, nil,
633 NewField(fieldUserName, []byte(c.pref.Username)),
634 NewField(fieldUserIconID, c.pref.IconBytes()),
635 NewField(fieldUserLogin, negateString([]byte(login))),
636 NewField(fieldUserPassword, negateString([]byte(password))),
637 NewField(fieldVersion, []byte{0, 2}),
638 ),
639 )
640 }
641
642 func (c *Client) Send(t Transaction) error {
643 requestNum := binary.BigEndian.Uint16(t.Type)
644 tID := binary.BigEndian.Uint32(t.ID)
645
646 // handler := TransactionHandlers[requestNum]
647
648 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
649 if t.IsReply == 0 {
650 c.activeTasks[tID] = &t
651 }
652
653 var n int
654 var err error
655 b, err := t.MarshalBinary()
656 if err != nil {
657 return err
658 }
659 if n, err = c.Connection.Write(b); err != nil {
660 return err
661 }
662 c.Logger.Debugw("Sent Transaction",
663 "IsReply", t.IsReply,
664 "type", requestNum,
665 "sentBytes", n,
666 )
667 return nil
668 }
669
670 func (c *Client) HandleTransaction(t *Transaction) error {
671 var origT Transaction
672 if t.IsReply == 1 {
673 requestID := binary.BigEndian.Uint32(t.ID)
674 origT = *c.activeTasks[requestID]
675 t.Type = origT.Type
676 }
677
678 requestNum := binary.BigEndian.Uint16(t.Type)
679 c.Logger.Infow(
680 "Received Transaction",
681 "RequestType", requestNum,
682 )
683
684 if handler, ok := c.Handlers[requestNum]; ok {
685 outT, _ := handler.Handle(c, t)
686 for _, t := range outT {
687 c.Send(t)
688 }
689 } else {
690 c.Logger.Errorw(
691 "Unimplemented transaction type received",
692 "RequestID", requestNum,
693 "TransactionID", t.ID,
694 )
695 }
696
697 return nil
698 }
699
700 func (c *Client) Disconnect() error {
701 return c.Connection.Close()
702 }