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