]> git.r.bdr.sh - rbdr/mobius/blob - hotline/client.go
Handler tracker list errors
[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 "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 EnableBell bool `yaml:"EnableBell"`
43 }
44
45 func (cp *ClientPrefs) IconBytes() []byte {
46 iconBytes := make([]byte, 2)
47 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
48 return iconBytes
49 }
50
51 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) {
52 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
53 }
54
55 func readConfig(cfgPath string) (*ClientPrefs, error) {
56 fh, err := os.Open(cfgPath)
57 if err != nil {
58 return nil, err
59 }
60
61 prefs := ClientPrefs{}
62 decoder := yaml.NewDecoder(fh)
63 if err := decoder.Decode(&prefs); err != nil {
64 return nil, err
65 }
66 return &prefs, nil
67 }
68
69 type Client struct {
70 cfgPath string
71 DebugBuf *DebugBuffer
72 Connection net.Conn
73 UserAccess []byte
74 filePath []string
75 UserList []User
76 Logger *zap.SugaredLogger
77 activeTasks map[uint32]*Transaction
78 serverName string
79
80 Pref *ClientPrefs
81
82 Handlers map[uint16]ClientHandler
83
84 UI *UI
85
86 Inbox chan *Transaction
87 }
88
89 type ClientHandler func(*Client, *Transaction) ([]Transaction, error)
90
91 func (c *Client) HandleFunc(transactionID uint16, handler ClientHandler) {
92 c.Handlers[transactionID] = handler
93 }
94
95 func NewClient(username string, logger *zap.SugaredLogger) *Client {
96 c := &Client{
97 Logger: logger,
98 activeTasks: make(map[uint32]*Transaction),
99 Handlers: make(map[uint16]ClientHandler),
100 }
101 c.Pref = &ClientPrefs{Username: username}
102
103 return c
104 }
105
106 func NewUIClient(cfgPath string, logger *zap.SugaredLogger) *Client {
107 c := &Client{
108 cfgPath: cfgPath,
109 Logger: logger,
110 activeTasks: make(map[uint32]*Transaction),
111 Handlers: clientHandlers,
112 }
113 c.UI = NewUI(c)
114
115 prefs, err := readConfig(cfgPath)
116 if err != nil {
117 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
118 }
119 c.Pref = prefs
120
121 return c
122 }
123
124 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
125 type DebugBuffer struct {
126 TextView *tview.TextView
127 }
128
129 func (db *DebugBuffer) Write(p []byte) (int, error) {
130 return db.TextView.Write(p)
131 }
132
133 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
134 func (db *DebugBuffer) Sync() error {
135 return nil
136 }
137
138 func randomBanner() string {
139 rand.Seed(time.Now().UnixNano())
140
141 bannerFiles, _ := bannerDir.ReadDir("banners")
142 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
143
144 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
145 }
146
147 type ClientTransaction struct {
148 Name string
149 Handler func(*Client, *Transaction) ([]Transaction, error)
150 }
151
152 func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
153 return ch.Handler(cc, t)
154 }
155
156 type ClientTHandler interface {
157 Handle(*Client, *Transaction) ([]Transaction, error)
158 }
159
160 var clientHandlers = map[uint16]ClientHandler{
161 TranChatMsg: handleClientChatMsg,
162 TranLogin: handleClientTranLogin,
163 TranShowAgreement: handleClientTranShowAgreement,
164 TranUserAccess: handleClientTranUserAccess,
165 TranGetUserNameList: handleClientGetUserNameList,
166 TranNotifyChangeUser: handleNotifyChangeUser,
167 TranNotifyDeleteUser: handleNotifyDeleteUser,
168 TranGetMsgs: handleGetMsgs,
169 TranGetFileNameList: handleGetFileNameList,
170 TranServerMsg: handleTranServerMsg,
171 TranKeepAlive: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
172 return t, err
173 },
174 }
175
176 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
177 now := time.Now().Format(time.RFC850)
178
179 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
180 msg += "\n\nAt " + now
181 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
182
183 msgBox := tview.NewTextView().SetScrollable(true)
184 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
185 msgBox.SetTitle(title).SetBorder(true)
186 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
187 switch event.Key() {
188 case tcell.KeyEscape:
189 c.UI.Pages.RemovePage("serverMsgModal" + now)
190 }
191 return event
192 })
193
194 centeredFlex := tview.NewFlex().
195 AddItem(nil, 0, 1, false).
196 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
197 AddItem(nil, 0, 1, false).
198 AddItem(msgBox, 0, 2, true).
199 AddItem(nil, 0, 1, false), 0, 2, true).
200 AddItem(nil, 0, 1, false)
201
202 c.UI.Pages.AddPage("serverMsgModal"+now, centeredFlex, true, true)
203 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
204
205 return res, err
206 }
207
208 func (c *Client) showErrMsg(msg string) {
209 t := time.Now().Format(time.RFC850)
210
211 title := "| Error |"
212
213 msgBox := tview.NewTextView().SetScrollable(true)
214 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
215 msgBox.SetTitle(title).SetBorder(true)
216 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
217 switch event.Key() {
218 case tcell.KeyEscape:
219 c.UI.Pages.RemovePage("serverMsgModal" + t)
220 }
221 return event
222 })
223
224 centeredFlex := tview.NewFlex().
225 AddItem(nil, 0, 1, false).
226 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
227 AddItem(nil, 0, 1, false).
228 AddItem(msgBox, 0, 2, true).
229 AddItem(nil, 0, 1, false), 0, 2, true).
230 AddItem(nil, 0, 1, false)
231
232 c.UI.Pages.AddPage("serverMsgModal"+t, centeredFlex, true, true)
233 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
234 }
235
236 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
237 if t.IsError() {
238 c.showErrMsg(string(t.GetField(FieldError).Data))
239 c.Logger.Infof("Error: %s", t.GetField(FieldError).Data)
240 return res, err
241 }
242
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 err = fn.UnmarshalBinary(f.Data)
293 if err != nil {
294 return nil, nil
295 }
296
297 if bytes.Equal(fn.Type[:], []byte("fldr")) {
298 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
299 node.SetReference(&fn)
300 root.AddChild(node)
301 } else {
302 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
303
304 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
305 node.SetReference(&fn)
306 root.AddChild(node)
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 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
402 var users []User
403 for _, field := range t.Fields {
404 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
405 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
406 // field type. Probably a good idea to do everywhere.
407 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
408 u, err := ReadUser(field.Data)
409 if err != nil {
410 return res, err
411 }
412 users = append(users, *u)
413 }
414 }
415 c.UserList = users
416
417 c.renderUserList()
418
419 return res, err
420 }
421
422 func (c *Client) renderUserList() {
423 c.UI.userList.Clear()
424 for _, u := range c.UserList {
425 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
426 if flagBitmap.Bit(UserFlagAdmin) == 1 {
427 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
428 } else {
429 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
430 }
431 // TODO: fade if user is away
432 }
433 }
434
435 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
436 if c.Pref.EnableBell {
437 fmt.Println("\a")
438 }
439
440 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
441
442 return res, err
443 }
444
445 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
446 c.UserAccess = t.GetField(FieldUserAccess).Data
447
448 return res, err
449 }
450
451 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
452 agreement := string(t.GetField(FieldData).Data)
453 agreement = strings.ReplaceAll(agreement, "\r", "\n")
454
455 agreeModal := tview.NewModal().
456 SetText(agreement).
457 AddButtons([]string{"Agree", "Disagree"}).
458 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
459 if buttonIndex == 0 {
460 res = append(res,
461 *NewTransaction(
462 TranAgreed, nil,
463 NewField(FieldUserName, []byte(c.Pref.Username)),
464 NewField(FieldUserIconID, c.Pref.IconBytes()),
465 NewField(FieldUserFlags, []byte{0x00, 0x00}),
466 NewField(FieldOptions, []byte{0x00, 0x00}),
467 ),
468 )
469 c.UI.Pages.HidePage("agreement")
470 c.UI.App.SetFocus(c.UI.chatInput)
471 } else {
472 _ = c.Disconnect()
473 c.UI.Pages.SwitchToPage("home")
474 }
475 },
476 )
477
478 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
479
480 return res, err
481 }
482
483 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
484 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
485 errMsg := string(t.GetField(FieldError).Data)
486 errModal := tview.NewModal()
487 errModal.SetText(errMsg)
488 errModal.AddButtons([]string{"Oh no"})
489 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
490 c.UI.Pages.RemovePage("errModal")
491 })
492 c.UI.Pages.RemovePage("joinServer")
493 c.UI.Pages.AddPage("errModal", errModal, false, true)
494
495 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
496
497 c.Logger.Error(string(t.GetField(FieldError).Data))
498 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
499 }
500 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
501 c.UI.App.SetFocus(c.UI.chatInput)
502
503 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
504 c.Logger.Errorw("err", "err", err)
505 }
506 return res, err
507 }
508
509 // JoinServer connects to a Hotline server and completes the login flow
510 func (c *Client) Connect(address, login, passwd string) (err error) {
511 // Establish TCP connection to server
512 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
513 if err != nil {
514 return err
515 }
516
517 // Send handshake sequence
518 if err := c.Handshake(); err != nil {
519 return err
520 }
521
522 // Authenticate (send TranLogin 107)
523 if err := c.LogIn(login, passwd); err != nil {
524 return err
525 }
526
527 // start keepalive go routine
528 go func() { _ = c.keepalive() }()
529
530 return nil
531 }
532
533 const keepaliveInterval = 300 * time.Second
534
535 func (c *Client) keepalive() error {
536 for {
537 time.Sleep(keepaliveInterval)
538 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
539 c.Logger.Debugw("Sent keepalive ping")
540 }
541 }
542
543 var ClientHandshake = []byte{
544 0x54, 0x52, 0x54, 0x50, // TRTP
545 0x48, 0x4f, 0x54, 0x4c, // HOTL
546 0x00, 0x01,
547 0x00, 0x02,
548 }
549
550 var ServerHandshake = []byte{
551 0x54, 0x52, 0x54, 0x50, // TRTP
552 0x00, 0x00, 0x00, 0x00, // ErrorCode
553 }
554
555 func (c *Client) Handshake() error {
556 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
557 // Sub-protocol ID 4 User defined
558 // Version 2 1 Currently 1
559 // Sub-version 2 User defined
560 if _, err := c.Connection.Write(ClientHandshake); err != nil {
561 return fmt.Errorf("handshake write err: %s", err)
562 }
563
564 replyBuf := make([]byte, 8)
565 _, err := c.Connection.Read(replyBuf)
566 if err != nil {
567 return err
568 }
569
570 if bytes.Equal(replyBuf, ServerHandshake) {
571 return nil
572 }
573
574 // In the case of an error, client and server close the connection.
575 return fmt.Errorf("handshake response err: %s", err)
576 }
577
578 func (c *Client) LogIn(login string, password string) error {
579 return c.Send(
580 *NewTransaction(
581 TranLogin, nil,
582 NewField(FieldUserName, []byte(c.Pref.Username)),
583 NewField(FieldUserIconID, c.Pref.IconBytes()),
584 NewField(FieldUserLogin, negateString([]byte(login))),
585 NewField(FieldUserPassword, negateString([]byte(password))),
586 ),
587 )
588 }
589
590 func (c *Client) Send(t Transaction) error {
591 requestNum := binary.BigEndian.Uint16(t.Type)
592
593 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
594 if t.IsReply == 0 {
595 c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
596 }
597
598 b, err := t.MarshalBinary()
599 if err != nil {
600 return err
601 }
602
603 var n int
604 if n, err = c.Connection.Write(b); err != nil {
605 return err
606 }
607 c.Logger.Debugw("Sent Transaction",
608 "IsReply", t.IsReply,
609 "type", requestNum,
610 "sentBytes", n,
611 )
612 return nil
613 }
614
615 func (c *Client) HandleTransaction(t *Transaction) error {
616 var origT Transaction
617 if t.IsReply == 1 {
618 requestID := binary.BigEndian.Uint32(t.ID)
619 origT = *c.activeTasks[requestID]
620 t.Type = origT.Type
621 }
622
623 if handler, ok := c.Handlers[binary.BigEndian.Uint16(t.Type)]; ok {
624 outT, _ := handler(c, t)
625 for _, t := range outT {
626 if err := c.Send(t); err != nil {
627 return err
628 }
629 }
630 } else {
631 c.Logger.Debugw(
632 "Unimplemented transaction type received",
633 "RequestID", t.Type,
634 "TransactionID", t.ID,
635 )
636 }
637
638 return nil
639 }
640
641 func (c *Client) Disconnect() error {
642 return c.Connection.Close()
643 }
644
645 func (c *Client) HandleTransactions() error {
646 // Create a new scanner for parsing incoming bytes into transaction tokens
647 scanner := bufio.NewScanner(c.Connection)
648 scanner.Split(transactionScanner)
649
650 // Scan for new transactions and handle them as they come in.
651 for scanner.Scan() {
652 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
653 // scanner re-uses the buffer for subsequent scans.
654 buf := make([]byte, len(scanner.Bytes()))
655 copy(buf, scanner.Bytes())
656
657 var t Transaction
658 _, err := t.Write(buf)
659 if err != nil {
660 break
661 }
662 if err := c.HandleTransaction(&t); err != nil {
663 c.Logger.Errorw("Error handling transaction", "err", err)
664 }
665 }
666
667 if scanner.Err() == nil {
668 return scanner.Err()
669 }
670 return nil
671 }