]> git.r.bdr.sh - rbdr/mobius/blob - hotline/client.go
Align file size column
[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.v2"
14 "math/big"
15 "math/rand"
16 "net"
17 "os"
18 "strings"
19 "time"
20 )
21
22 const (
23 trackerListPage = "trackerList"
24 )
25
26 //go:embed banners/*.txt
27 var bannerDir embed.FS
28
29 type Bookmark struct {
30 Name string `yaml:"Name"`
31 Addr string `yaml:"Addr"`
32 Login string `yaml:"Login"`
33 Password string `yaml:"Password"`
34 }
35
36 type ClientPrefs struct {
37 Username string `yaml:"Username"`
38 IconID int `yaml:"IconID"`
39 Bookmarks []Bookmark `yaml:"Bookmarks"`
40 Tracker string `yaml:"Tracker"`
41 }
42
43 func (cp *ClientPrefs) IconBytes() []byte {
44 iconBytes := make([]byte, 2)
45 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
46 return iconBytes
47 }
48
49 func readConfig(cfgPath string) (*ClientPrefs, error) {
50 fh, err := os.Open(cfgPath)
51 if err != nil {
52 return nil, err
53 }
54
55 prefs := ClientPrefs{}
56 decoder := yaml.NewDecoder(fh)
57 decoder.SetStrict(true)
58 if err := decoder.Decode(&prefs); err != nil {
59 return nil, err
60 }
61 return &prefs, nil
62 }
63
64 type Client struct {
65 cfgPath string
66 DebugBuf *DebugBuffer
67 Connection net.Conn
68 Login *[]byte
69 Password *[]byte
70 Flags *[]byte
71 ID *[]byte
72 Version []byte
73 UserAccess []byte
74 filePath []string
75 UserList []User
76 Logger *zap.SugaredLogger
77 activeTasks map[uint32]*Transaction
78
79 pref *ClientPrefs
80
81 Handlers map[uint16]clientTHandler
82
83 UI *UI
84
85 outbox chan *Transaction
86 Inbox chan *Transaction
87 }
88
89 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
90 c := &Client{
91 cfgPath: cfgPath,
92 Logger: logger,
93 activeTasks: make(map[uint32]*Transaction),
94 Handlers: clientHandlers,
95 }
96 c.UI = NewUI(c)
97
98 prefs, err := readConfig(cfgPath)
99 if err != nil {
100 fmt.Printf("unable to read config file %s", cfgPath)
101 os.Exit(1)
102 }
103 c.pref = prefs
104
105 return c
106 }
107
108 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
109 type DebugBuffer struct {
110 TextView *tview.TextView
111 }
112
113 func (db *DebugBuffer) Write(p []byte) (int, error) {
114 return db.TextView.Write(p)
115 }
116
117 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
118 func (db *DebugBuffer) Sync() error {
119 return nil
120 }
121
122 func randomBanner() string {
123 rand.Seed(time.Now().UnixNano())
124
125 bannerFiles, _ := bannerDir.ReadDir("banners")
126 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
127
128 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
129 }
130
131 type clientTransaction struct {
132 Name string
133 Handler func(*Client, *Transaction) ([]Transaction, error)
134 }
135
136 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
137 return ch.Handler(cc, t)
138 }
139
140 type clientTHandler interface {
141 Handle(*Client, *Transaction) ([]Transaction, error)
142 }
143
144 type mockClientHandler struct {
145 mock.Mock
146 }
147
148 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
149 args := mh.Called(cc, t)
150 return args.Get(0).([]Transaction), args.Error(1)
151 }
152
153 var clientHandlers = map[uint16]clientTHandler{
154 // Server initiated
155 tranChatMsg: clientTransaction{
156 Name: "tranChatMsg",
157 Handler: handleClientChatMsg,
158 },
159 tranLogin: clientTransaction{
160 Name: "tranLogin",
161 Handler: handleClientTranLogin,
162 },
163 tranShowAgreement: clientTransaction{
164 Name: "tranShowAgreement",
165 Handler: handleClientTranShowAgreement,
166 },
167 tranUserAccess: clientTransaction{
168 Name: "tranUserAccess",
169 Handler: handleClientTranUserAccess,
170 },
171 tranGetUserNameList: clientTransaction{
172 Name: "tranGetUserNameList",
173 Handler: handleClientGetUserNameList,
174 },
175 tranNotifyChangeUser: clientTransaction{
176 Name: "tranNotifyChangeUser",
177 Handler: handleNotifyChangeUser,
178 },
179 tranNotifyDeleteUser: clientTransaction{
180 Name: "tranNotifyDeleteUser",
181 Handler: handleNotifyDeleteUser,
182 },
183 tranGetMsgs: clientTransaction{
184 Name: "tranNotifyDeleteUser",
185 Handler: handleGetMsgs,
186 },
187 tranGetFileNameList: clientTransaction{
188 Name: "tranGetFileNameList",
189 Handler: handleGetFileNameList,
190 },
191 }
192
193 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
194 fTree := tview.NewTreeView().SetTopLevel(1)
195 root := tview.NewTreeNode("Root")
196 fTree.SetRoot(root).SetCurrentNode(root)
197 fTree.SetBorder(true).SetTitle("| Files |")
198 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
199 switch event.Key() {
200 case tcell.KeyEscape:
201 c.UI.Pages.RemovePage("files")
202 c.filePath = []string{}
203 case tcell.KeyEnter:
204 selectedNode := fTree.GetCurrentNode()
205
206 if selectedNode.GetText() == "<- Back" {
207 c.filePath = c.filePath[:len(c.filePath)-1]
208 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
209
210 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
211 c.UI.HLClient.Logger.Errorw("err", "err", err)
212 }
213 return event
214 }
215
216 entry := selectedNode.GetReference().(*FileNameWithInfo)
217
218 if bytes.Equal(entry.Type, []byte("fldr")) {
219 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
220
221 c.filePath = append(c.filePath, string(entry.Name))
222 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
223
224 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
225 c.UI.HLClient.Logger.Errorw("err", "err", err)
226 }
227 } else {
228 // TODO: initiate file download
229 c.Logger.Infow("download file", "name", string(entry.Name))
230 }
231 }
232
233 return event
234 })
235
236 if len(c.filePath) > 0 {
237 node := tview.NewTreeNode("<- Back")
238 root.AddChild(node)
239 }
240
241 var fileList []FileNameWithInfo
242 for _, f := range t.Fields {
243 var fn FileNameWithInfo
244 _, _ = fn.Read(f.Data)
245 fileList = append(fileList, fn)
246
247 if bytes.Equal(fn.Type, []byte("fldr")) {
248 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
249 node.SetReference(&fn)
250 root.AddChild(node)
251 } else {
252 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
253
254 node := tview.NewTreeNode(fmt.Sprintf(" %-30s %15v KB", fn.Name, size))
255 node.SetReference(&fn)
256 root.AddChild(node)
257 }
258
259 }
260
261 centerFlex := tview.NewFlex().
262 AddItem(nil, 0, 1, false).
263 AddItem(tview.NewFlex().
264 SetDirection(tview.FlexRow).
265 AddItem(nil, 0, 1, false).
266 AddItem(fTree, 20, 1, true).
267 AddItem(nil, 0, 1, false), 60, 1, true).
268 AddItem(nil, 0, 1, false)
269
270 c.UI.Pages.AddPage("files", centerFlex, true, true)
271 c.UI.App.Draw()
272
273 return res, err
274 }
275
276 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
277 newsText := string(t.GetField(fieldData).Data)
278 newsText = strings.ReplaceAll(newsText, "\r", "\n")
279
280 newsTextView := tview.NewTextView().
281 SetText(newsText).
282 SetDoneFunc(func(key tcell.Key) {
283 c.UI.Pages.SwitchToPage("serverUI")
284 c.UI.App.SetFocus(c.UI.chatInput)
285 })
286 newsTextView.SetBorder(true).SetTitle("News")
287
288 c.UI.Pages.AddPage("news", newsTextView, true, true)
289 //c.UI.Pages.SwitchToPage("news")
290 //c.UI.App.SetFocus(newsTextView)
291 c.UI.App.Draw()
292
293 return res, err
294 }
295
296 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
297 newUser := User{
298 ID: t.GetField(fieldUserID).Data,
299 Name: string(t.GetField(fieldUserName).Data),
300 Icon: t.GetField(fieldUserIconID).Data,
301 Flags: t.GetField(fieldUserFlags).Data,
302 }
303
304 // Possible cases:
305 // user is new to the server
306 // user is already on the server but has a new name
307
308 var oldName string
309 var newUserList []User
310 updatedUser := false
311 for _, u := range c.UserList {
312 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
313 if bytes.Equal(newUser.ID, u.ID) {
314 oldName = u.Name
315 u.Name = newUser.Name
316 if u.Name != newUser.Name {
317 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
318 }
319 updatedUser = true
320 }
321 newUserList = append(newUserList, u)
322 }
323
324 if !updatedUser {
325 newUserList = append(newUserList, newUser)
326 }
327
328 c.UserList = newUserList
329
330 c.renderUserList()
331
332 return res, err
333 }
334
335 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
336 exitUser := t.GetField(fieldUserID).Data
337
338 var newUserList []User
339 for _, u := range c.UserList {
340 if !bytes.Equal(exitUser, u.ID) {
341 newUserList = append(newUserList, u)
342 }
343 }
344
345 c.UserList = newUserList
346
347 c.renderUserList()
348
349 return res, err
350 }
351
352 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
353
354 func (c *Client) ReadLoop() error {
355 tranBuff := make([]byte, 0)
356 tReadlen := 0
357 // Infinite loop where take action on incoming client requests until the connection is closed
358 for {
359 buf := make([]byte, readBuffSize)
360 tranBuff = tranBuff[tReadlen:]
361
362 readLen, err := c.Connection.Read(buf)
363 if err != nil {
364 return err
365 }
366 tranBuff = append(tranBuff, buf[:readLen]...)
367
368 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
369 // into a slice of transactions
370 var transactions []Transaction
371 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
372 c.Logger.Errorw("Error handling transaction", "err", err)
373 }
374
375 // iterate over all of the transactions that were parsed from the byte slice and handle them
376 for _, t := range transactions {
377 if err := c.HandleTransaction(&t); err != nil {
378 c.Logger.Errorw("Error handling transaction", "err", err)
379 }
380 }
381 }
382 }
383
384 func (c *Client) GetTransactions() error {
385 tranBuff := make([]byte, 0)
386 tReadlen := 0
387
388 buf := make([]byte, readBuffSize)
389 tranBuff = tranBuff[tReadlen:]
390
391 readLen, err := c.Connection.Read(buf)
392 if err != nil {
393 return err
394 }
395 tranBuff = append(tranBuff, buf[:readLen]...)
396
397 return nil
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 c.UI.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.Logger.Debug("show agreement page")
474 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
475 c.UI.Pages.ShowPage("agreement ")
476 c.UI.App.Draw()
477
478 return res, err
479 }
480
481 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
482 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
483 errMsg := string(t.GetField(fieldError).Data)
484 errModal := tview.NewModal()
485 errModal.SetText(errMsg)
486 errModal.AddButtons([]string{"Oh no"})
487 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
488 c.UI.Pages.RemovePage("errModal")
489 })
490 c.UI.Pages.RemovePage("joinServer")
491 c.UI.Pages.AddPage("errModal", errModal, false, true)
492
493 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
494
495 c.Logger.Error(string(t.GetField(fieldError).Data))
496 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
497 }
498 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
499 c.UI.App.SetFocus(c.UI.chatInput)
500
501 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
502 c.Logger.Errorw("err", "err", err)
503 }
504 return res, err
505 }
506
507 // JoinServer connects to a Hotline server and completes the login flow
508 func (c *Client) JoinServer(address, login, passwd string) error {
509 // Establish TCP connection to server
510 if err := c.connect(address); err != nil {
511 return err
512 }
513
514 // Send handshake sequence
515 if err := c.Handshake(); err != nil {
516 return err
517 }
518
519 // Authenticate (send tranLogin 107)
520 if err := c.LogIn(login, passwd); err != nil {
521 return err
522 }
523
524 return nil
525 }
526
527 // connect establishes a connection with a Server by sending handshake sequence
528 func (c *Client) connect(address string) error {
529 var err error
530 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
531 if err != nil {
532 return err
533 }
534 return nil
535 }
536
537 var ClientHandshake = []byte{
538 0x54, 0x52, 0x54, 0x50, // TRTP
539 0x48, 0x4f, 0x54, 0x4c, // HOTL
540 0x00, 0x01,
541 0x00, 0x02,
542 }
543
544 var ServerHandshake = []byte{
545 0x54, 0x52, 0x54, 0x50, // TRTP
546 0x00, 0x00, 0x00, 0x00, // ErrorCode
547 }
548
549 func (c *Client) Handshake() error {
550 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
551 //Sub-protocol ID 4 User defined
552 //Version 2 1 Currently 1
553 //Sub-version 2 User defined
554 if _, err := c.Connection.Write(ClientHandshake); err != nil {
555 return fmt.Errorf("handshake write err: %s", err)
556 }
557
558 replyBuf := make([]byte, 8)
559 _, err := c.Connection.Read(replyBuf)
560 if err != nil {
561 return err
562 }
563
564 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
565 return nil
566 }
567
568 // In the case of an error, client and server close the connection.
569 return fmt.Errorf("handshake response err: %s", err)
570 }
571
572 func (c *Client) LogIn(login string, password string) error {
573 return c.Send(
574 *NewTransaction(
575 tranLogin, nil,
576 NewField(fieldUserName, []byte(c.pref.Username)),
577 NewField(fieldUserIconID, c.pref.IconBytes()),
578 NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))),
579 NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))),
580 NewField(fieldVersion, []byte{0, 2}),
581 ),
582 )
583 }
584
585 func (c *Client) Send(t Transaction) error {
586 requestNum := binary.BigEndian.Uint16(t.Type)
587 tID := binary.BigEndian.Uint32(t.ID)
588
589 //handler := TransactionHandlers[requestNum]
590
591 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
592 if t.IsReply == 0 {
593 c.activeTasks[tID] = &t
594 }
595
596 var n int
597 var err error
598 if n, err = c.Connection.Write(t.Payload()); err != nil {
599 return err
600 }
601 c.Logger.Debugw("Sent Transaction",
602 "IsReply", t.IsReply,
603 "type", requestNum,
604 "sentBytes", n,
605 )
606 return nil
607 }
608
609 func (c *Client) HandleTransaction(t *Transaction) error {
610 var origT Transaction
611 if t.IsReply == 1 {
612 requestID := binary.BigEndian.Uint32(t.ID)
613 origT = *c.activeTasks[requestID]
614 t.Type = origT.Type
615 }
616
617 requestNum := binary.BigEndian.Uint16(t.Type)
618 c.Logger.Infow(
619 "Received Transaction",
620 "RequestType", requestNum,
621 )
622
623 if handler, ok := c.Handlers[requestNum]; ok {
624 outT, _ := handler.Handle(c, t)
625 for _, t := range outT {
626 c.Send(t)
627 }
628 } else {
629 c.Logger.Errorw(
630 "Unimplemented transaction type received",
631 "RequestID", requestNum,
632 "TransactionID", t.ID,
633 )
634 }
635
636 return nil
637 }
638
639 func (c *Client) Connected() bool {
640 // c.Agreed == true &&
641 if c.UserAccess != nil {
642 return true
643 }
644 return false
645 }
646
647 func (c *Client) Disconnect() error {
648 err := c.Connection.Close()
649 if err != nil {
650 return err
651 }
652 return nil
653 }