]> git.r.bdr.sh - rbdr/mobius/blob - hotline/client.go
Add support for account login rename
[rbdr/mobius] / hotline / client.go
1 package hotline
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "embed"
8 "encoding/binary"
9 "errors"
10 "fmt"
11 "github.com/gdamore/tcell/v2"
12 "github.com/rivo/tview"
13 "gopkg.in/yaml.v3"
14 "log/slog"
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) {
53 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
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 UserAccess []byte
75 filePath []string
76 UserList []User
77 Logger *slog.Logger
78 activeTasks map[uint32]*Transaction
79 serverName string
80
81 Pref *ClientPrefs
82
83 Handlers map[uint16]ClientHandler
84
85 UI *UI
86
87 Inbox chan *Transaction
88 }
89
90 type ClientHandler func(context.Context, *Client, *Transaction) ([]Transaction, error)
91
92 func (c *Client) HandleFunc(transactionID uint16, handler ClientHandler) {
93 c.Handlers[transactionID] = handler
94 }
95
96 func NewClient(username string, logger *slog.Logger) *Client {
97 c := &Client{
98 Logger: logger,
99 activeTasks: make(map[uint32]*Transaction),
100 Handlers: make(map[uint16]ClientHandler),
101 }
102 c.Pref = &ClientPrefs{Username: username}
103
104 return c
105 }
106
107 func NewUIClient(cfgPath string, logger *slog.Logger) *Client {
108 c := &Client{
109 cfgPath: cfgPath,
110 Logger: logger,
111 activeTasks: make(map[uint32]*Transaction),
112 Handlers: clientHandlers,
113 }
114 c.UI = NewUI(c)
115
116 prefs, err := readConfig(cfgPath)
117 if err != nil {
118 logger.Error(fmt.Sprintf("unable to read config file %s\n", cfgPath))
119 os.Exit(1)
120 }
121 c.Pref = prefs
122
123 return c
124 }
125
126 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
127 type DebugBuffer struct {
128 TextView *tview.TextView
129 }
130
131 func (db *DebugBuffer) Write(p []byte) (int, error) {
132 return db.TextView.Write(p)
133 }
134
135 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
136 func (db *DebugBuffer) Sync() error {
137 return nil
138 }
139
140 func randomBanner() string {
141 rand.Seed(time.Now().UnixNano())
142
143 bannerFiles, _ := bannerDir.ReadDir("banners")
144 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
145
146 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
147 }
148
149 type ClientTransaction struct {
150 Name string
151 Handler func(*Client, *Transaction) ([]Transaction, error)
152 }
153
154 func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 return ch.Handler(cc, t)
156 }
157
158 type ClientTHandler interface {
159 Handle(*Client, *Transaction) ([]Transaction, error)
160 }
161
162 var clientHandlers = map[uint16]ClientHandler{
163 TranChatMsg: handleClientChatMsg,
164 TranLogin: handleClientTranLogin,
165 TranShowAgreement: handleClientTranShowAgreement,
166 TranUserAccess: handleClientTranUserAccess,
167 TranGetUserNameList: handleClientGetUserNameList,
168 TranNotifyChangeUser: handleNotifyChangeUser,
169 TranNotifyDeleteUser: handleNotifyDeleteUser,
170 TranGetMsgs: handleGetMsgs,
171 TranGetFileNameList: handleGetFileNameList,
172 TranServerMsg: handleTranServerMsg,
173 TranKeepAlive: func(ctx context.Context, client *Client, transaction *Transaction) (t []Transaction, err error) {
174 return t, err
175 },
176 }
177
178 func handleTranServerMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
179 now := time.Now().Format(time.RFC850)
180
181 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
182 msg += "\n\nAt " + now
183 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
184
185 msgBox := tview.NewTextView().SetScrollable(true)
186 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
187 msgBox.SetTitle(title).SetBorder(true)
188 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
189 switch event.Key() {
190 case tcell.KeyEscape:
191 c.UI.Pages.RemovePage("serverMsgModal" + now)
192 }
193 return event
194 })
195
196 centeredFlex := tview.NewFlex().
197 AddItem(nil, 0, 1, false).
198 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
199 AddItem(nil, 0, 1, false).
200 AddItem(msgBox, 0, 2, true).
201 AddItem(nil, 0, 1, false), 0, 2, true).
202 AddItem(nil, 0, 1, false)
203
204 c.UI.Pages.AddPage("serverMsgModal"+now, centeredFlex, true, true)
205 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
206
207 return res, err
208 }
209
210 func (c *Client) showErrMsg(msg string) {
211 t := time.Now().Format(time.RFC850)
212
213 title := "| Error |"
214
215 msgBox := tview.NewTextView().SetScrollable(true)
216 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
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" + t)
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"+t, centeredFlex, true, true)
235 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
236 }
237
238 func handleGetFileNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
239 if t.IsError() {
240 c.showErrMsg(string(t.GetField(FieldError).Data))
241 return res, err
242 }
243
244 fTree := tview.NewTreeView().SetTopLevel(1)
245 root := tview.NewTreeNode("Root")
246 fTree.SetRoot(root).SetCurrentNode(root)
247 fTree.SetBorder(true).SetTitle("| Files |")
248 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
249 switch event.Key() {
250 case tcell.KeyEscape:
251 c.UI.Pages.RemovePage("files")
252 c.filePath = []string{}
253 case tcell.KeyEnter:
254 selectedNode := fTree.GetCurrentNode()
255
256 if selectedNode.GetText() == "<- Back" {
257 c.filePath = c.filePath[:len(c.filePath)-1]
258 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
259
260 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
261 c.UI.HLClient.Logger.Error("err", "err", err)
262 }
263 return event
264 }
265
266 entry := selectedNode.GetReference().(*FileNameWithInfo)
267
268 if bytes.Equal(entry.Type[:], []byte("fldr")) {
269 c.Logger.Info("get new directory listing", "name", string(entry.name))
270
271 c.filePath = append(c.filePath, string(entry.name))
272 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
273
274 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
275 c.UI.HLClient.Logger.Error("err", "err", err)
276 }
277 } else {
278 // TODO: initiate file download
279 c.Logger.Info("download file", "name", string(entry.name))
280 }
281 }
282
283 return event
284 })
285
286 if len(c.filePath) > 0 {
287 node := tview.NewTreeNode("<- Back")
288 root.AddChild(node)
289 }
290
291 for _, f := range t.Fields {
292 var fn FileNameWithInfo
293 err = fn.UnmarshalBinary(f.Data)
294 if err != nil {
295 return nil, nil
296 }
297
298 if bytes.Equal(fn.Type[:], []byte("fldr")) {
299 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
300 node.SetReference(&fn)
301 root.AddChild(node)
302 } else {
303 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
304
305 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
306 node.SetReference(&fn)
307 root.AddChild(node)
308 }
309 }
310
311 centerFlex := tview.NewFlex().
312 AddItem(nil, 0, 1, false).
313 AddItem(tview.NewFlex().
314 SetDirection(tview.FlexRow).
315 AddItem(nil, 0, 1, false).
316 AddItem(fTree, 20, 1, true).
317 AddItem(nil, 0, 1, false), 60, 1, true).
318 AddItem(nil, 0, 1, false)
319
320 c.UI.Pages.AddPage("files", centerFlex, true, true)
321 c.UI.App.Draw()
322
323 return res, err
324 }
325
326 func handleGetMsgs(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
327 newsText := string(t.GetField(FieldData).Data)
328 newsText = strings.ReplaceAll(newsText, "\r", "\n")
329
330 newsTextView := tview.NewTextView().
331 SetText(newsText).
332 SetDoneFunc(func(key tcell.Key) {
333 c.UI.Pages.SwitchToPage(serverUIPage)
334 c.UI.App.SetFocus(c.UI.chatInput)
335 })
336 newsTextView.SetBorder(true).SetTitle("News")
337
338 c.UI.Pages.AddPage("news", newsTextView, true, true)
339 // c.UI.Pages.SwitchToPage("news")
340 // c.UI.App.SetFocus(newsTextView)
341 c.UI.App.Draw()
342
343 return res, err
344 }
345
346 func handleNotifyChangeUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) {
347 newUser := User{
348 ID: t.GetField(FieldUserID).Data,
349 Name: string(t.GetField(FieldUserName).Data),
350 Icon: t.GetField(FieldUserIconID).Data,
351 Flags: t.GetField(FieldUserFlags).Data,
352 }
353
354 // Possible cases:
355 // user is new to the server
356 // user is already on the server but has a new name
357
358 var oldName string
359 var newUserList []User
360 updatedUser := false
361 for _, u := range c.UserList {
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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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.Error("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 }
540 }
541
542 var ClientHandshake = []byte{
543 0x54, 0x52, 0x54, 0x50, // TRTP
544 0x48, 0x4f, 0x54, 0x4c, // HOTL
545 0x00, 0x01,
546 0x00, 0x02,
547 }
548
549 var ServerHandshake = []byte{
550 0x54, 0x52, 0x54, 0x50, // TRTP
551 0x00, 0x00, 0x00, 0x00, // ErrorCode
552 }
553
554 func (c *Client) Handshake() error {
555 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
556 // Sub-protocol ID 4 User defined
557 // Version 2 1 Currently 1
558 // Sub-version 2 User defined
559 if _, err := c.Connection.Write(ClientHandshake); err != nil {
560 return fmt.Errorf("handshake write err: %s", err)
561 }
562
563 replyBuf := make([]byte, 8)
564 _, err := c.Connection.Read(replyBuf)
565 if err != nil {
566 return err
567 }
568
569 if bytes.Equal(replyBuf, ServerHandshake) {
570 return nil
571 }
572
573 // In the case of an error, client and server close the connection.
574 return fmt.Errorf("handshake response err: %s", err)
575 }
576
577 func (c *Client) LogIn(login string, password string) error {
578 return c.Send(
579 *NewTransaction(
580 TranLogin, nil,
581 NewField(FieldUserName, []byte(c.Pref.Username)),
582 NewField(FieldUserIconID, c.Pref.IconBytes()),
583 NewField(FieldUserLogin, encodeString([]byte(login))),
584 NewField(FieldUserPassword, encodeString([]byte(password))),
585 ),
586 )
587 }
588
589 func (c *Client) Send(t Transaction) error {
590 requestNum := binary.BigEndian.Uint16(t.Type)
591
592 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
593 if t.IsReply == 0 {
594 c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
595 }
596
597 b, err := t.MarshalBinary()
598 if err != nil {
599 return err
600 }
601
602 var n int
603 if n, err = c.Connection.Write(b); err != nil {
604 return err
605 }
606 c.Logger.Debug("Sent Transaction",
607 "IsReply", t.IsReply,
608 "type", requestNum,
609 "sentBytes", n,
610 )
611 return nil
612 }
613
614 func (c *Client) HandleTransaction(ctx context.Context, t *Transaction) error {
615 var origT Transaction
616 if t.IsReply == 1 {
617 requestID := binary.BigEndian.Uint32(t.ID)
618 origT = *c.activeTasks[requestID]
619 t.Type = origT.Type
620 }
621
622 if handler, ok := c.Handlers[binary.BigEndian.Uint16(t.Type)]; ok {
623 c.Logger.Debug(
624 "Received transaction",
625 "IsReply", t.IsReply,
626 "type", binary.BigEndian.Uint16(t.Type),
627 )
628 outT, err := handler(ctx, c, t)
629 if err != nil {
630 c.Logger.Error("error handling transaction", "err", err)
631 }
632 for _, t := range outT {
633 if err := c.Send(t); err != nil {
634 return err
635 }
636 }
637 } else {
638 c.Logger.Debug(
639 "Unimplemented transaction type",
640 "IsReply", t.IsReply,
641 "type", binary.BigEndian.Uint16(t.Type),
642 )
643 }
644
645 return nil
646 }
647
648 func (c *Client) Disconnect() error {
649 return c.Connection.Close()
650 }
651
652
653 func (c *Client) HandleTransactions(ctx context.Context) error {
654 // Create a new scanner for parsing incoming bytes into transaction tokens
655 scanner := bufio.NewScanner(c.Connection)
656 scanner.Split(transactionScanner)
657
658 // Scan for new transactions and handle them as they come in.
659 for scanner.Scan() {
660 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
661 // scanner re-uses the buffer for subsequent scans.
662 buf := make([]byte, len(scanner.Bytes()))
663 copy(buf, scanner.Bytes())
664
665 var t Transaction
666 _, err := t.Write(buf)
667 if err != nil {
668 break
669 }
670
671 if err := c.HandleTransaction(ctx, &t); err != nil {
672 c.Logger.Error("Error handling transaction", "err", err)
673 }
674 }
675
676 if scanner.Err() == nil {
677 return scanner.Err()
678 }
679 return nil
680 }