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