]> git.r.bdr.sh - rbdr/mobius/blame_incremental - hotline/client.go
Bump CI Golang version
[rbdr/mobius] / hotline / client.go
... / ...
CommitLineData
1package hotline
2
3import (
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
23const (
24 trackerListPage = "trackerList"
25 serverUIPage = "serverUI"
26)
27
28//go:embed banners/*.txt
29var bannerDir embed.FS
30
31type Bookmark struct {
32 Name string `yaml:"Name"`
33 Addr string `yaml:"Addr"`
34 Login string `yaml:"Login"`
35 Password string `yaml:"Password"`
36}
37
38type 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
46func (cp *ClientPrefs) IconBytes() []byte {
47 iconBytes := make([]byte, 2)
48 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
49 return iconBytes
50}
51
52func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) {
53 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
54}
55
56func 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
70type 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
90type ClientHandler func(context.Context, *Client, *Transaction) ([]Transaction, error)
91
92func (c *Client) HandleFunc(transactionID uint16, handler ClientHandler) {
93 c.Handlers[transactionID] = handler
94}
95
96func 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
107func 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
127type DebugBuffer struct {
128 TextView *tview.TextView
129}
130
131func (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
136func (db *DebugBuffer) Sync() error {
137 return nil
138}
139
140func 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
149type ClientTransaction struct {
150 Name string
151 Handler func(*Client, *Transaction) ([]Transaction, error)
152}
153
154func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 return ch.Handler(cc, t)
156}
157
158type ClientTHandler interface {
159 Handle(*Client, *Transaction) ([]Transaction, error)
160}
161
162var 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
178func 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
210func (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
238func 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
326func 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
346func 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
384func 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
401func 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
422func (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
435func 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
445func 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
451func 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
483func 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
510func (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
533const keepaliveInterval = 300 * time.Second
534
535func (c *Client) keepalive() error {
536 for {
537 time.Sleep(keepaliveInterval)
538 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
539 }
540}
541
542var ClientHandshake = []byte{
543 0x54, 0x52, 0x54, 0x50, // TRTP
544 0x48, 0x4f, 0x54, 0x4c, // HOTL
545 0x00, 0x01,
546 0x00, 0x02,
547}
548
549var ServerHandshake = []byte{
550 0x54, 0x52, 0x54, 0x50, // TRTP
551 0x00, 0x00, 0x00, 0x00, // ErrorCode
552}
553
554func (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
577func (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, negateString([]byte(login))),
584 NewField(FieldUserPassword, negateString([]byte(password))),
585 ),
586 )
587}
588
589func (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
614func (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, _ := handler(ctx, c, t)
629 for _, t := range outT {
630 if err := c.Send(t); err != nil {
631 return err
632 }
633 }
634 } else {
635 c.Logger.Debug(
636 "Unimplemented transaction type",
637 "IsReply", t.IsReply,
638 "type", binary.BigEndian.Uint16(t.Type),
639 )
640 }
641
642 return nil
643}
644
645func (c *Client) Disconnect() error {
646 return c.Connection.Close()
647}
648
649
650func (c *Client) HandleTransactions(ctx context.Context) error {
651 // Create a new scanner for parsing incoming bytes into transaction tokens
652 scanner := bufio.NewScanner(c.Connection)
653 scanner.Split(transactionScanner)
654
655 // Scan for new transactions and handle them as they come in.
656 for scanner.Scan() {
657 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
658 // scanner re-uses the buffer for subsequent scans.
659 buf := make([]byte, len(scanner.Bytes()))
660 copy(buf, scanner.Bytes())
661
662 var t Transaction
663 _, err := t.Write(buf)
664 if err != nil {
665 break
666 }
667
668 if err := c.HandleTransaction(ctx, &t); err != nil {
669 c.Logger.Error("Error handling transaction", "err", err)
670 }
671 }
672
673 if scanner.Err() == nil {
674 return scanner.Err()
675 }
676 return nil
677}