Antonio Mika
·
2025-04-16
tuns.go
1package tui
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "math"
9 "net/http"
10 "sort"
11 "sync"
12 "time"
13
14 "git.sr.ht/~rockorager/vaxis"
15 "git.sr.ht/~rockorager/vaxis/vxfw"
16 "git.sr.ht/~rockorager/vaxis/vxfw/list"
17 "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
18 "git.sr.ht/~rockorager/vaxis/vxfw/text"
19 "github.com/picosh/pico/pkg/db"
20 "github.com/picosh/pico/pkg/shared"
21 "github.com/picosh/utils/pipe"
22)
23
24type RouteListener struct {
25 HttpListeners map[string][]string `json:"httpListeners"`
26 Listeners map[string]string `json:"listeners"`
27 TcpAliases map[string]string `json:"tcpAliases"`
28}
29
30type TunsClient struct {
31 RemoteAddr string `json:"remoteAddr"`
32 User string `json:"user"`
33 Version string `json:"version"`
34 Session string `json:"session"`
35 Pubkey string `json:"pubKey"`
36 PubkeyFingerprint string `json:"pubKeyFingerprint"`
37 Listeners []string `json:"listeners"`
38 RouteListeners RouteListener `json:"routeListeners"`
39}
40
41type TunsClientApi struct {
42 Clients map[string]*TunsClient `json:"clients"`
43 Status bool `json:"status"`
44}
45
46type TunsClientSimple struct {
47 TunType string
48 TunAddress string
49 RemoteAddr string
50 User string
51 PubkeyFingerprint string
52}
53
54type ResultLog struct {
55 ServerID string `json:"server_id"`
56 User string `json:"user"`
57 UserId string `json:"user_id"`
58 CurrentTime string `json:"current_time"`
59 StartTime time.Time `json:"start_time"`
60 StartTimePretty string `json:"start_time_pretty"`
61 RequestTime string `json:"request_time"`
62 RequestIP string `json:"request_ip"`
63 RequestMethod string `json:"request_method"`
64 OriginalRequestURI string `json:"original_request_uri"`
65 RequestHeaders map[string][]string `json:"request_headers"`
66 RequestBody string `json:"request_body"`
67 ResponseHeaders map[string][]string `json:"response_headers"`
68 ResponseBody string `json:"response_body"`
69 ResponseCode int `json:"response_code"`
70 ResponseStatus string `json:"response_status"`
71 TunnelID string `json:"tunnel_id"`
72 TunnelType string `json:"tunnel_type"`
73 ConnectionType string `json:"connection_type"`
74 // RequestURL string `json:"request_url"`
75}
76
77type ResultLogLineLoaded struct {
78 Line ResultLog
79}
80
81type TunsLoaded struct{}
82
83type EventLogsLoaded struct{}
84
85type TunsPage struct {
86 shared *SharedModel
87
88 loading bool
89 err error
90 tuns []TunsClientSimple
91 selected string
92 focus string
93 leftPane list.Dynamic
94 rightPane *Pager
95 logs []*ResultLog
96 logList list.Dynamic
97 ctx context.Context
98 done context.CancelFunc
99 isAdmin bool
100 eventLogs []*db.TunsEventLog
101 eventLogList list.Dynamic
102
103 mu sync.RWMutex
104}
105
106func NewTunsPage(shrd *SharedModel) *TunsPage {
107 m := &TunsPage{
108 shared: shrd,
109
110 rightPane: NewPager(),
111 }
112 m.leftPane = list.Dynamic{DrawCursor: true, Builder: m.getLeftWidget}
113 m.logList = list.Dynamic{DrawCursor: true, Builder: m.getLogWidget}
114 m.eventLogList = list.Dynamic{DrawCursor: true, Builder: m.getEventLogWidget}
115 return m
116}
117
118func (m *TunsPage) getLeftWidget(i uint, cursor uint) vxfw.Widget {
119 if int(i) >= len(m.tuns) {
120 return nil
121 }
122
123 site := m.tuns[i]
124 txt := text.New(site.TunAddress)
125 txt.Softwrap = false
126 return txt
127}
128
129func (m *TunsPage) getLogWidget(i uint, cursor uint) vxfw.Widget {
130 if int(i) >= len(m.logs) {
131 return nil
132 }
133
134 log := m.logs[i]
135 codestyle := vaxis.Style{Foreground: red}
136 if log.ResponseCode >= 200 && log.ResponseCode < 300 {
137 codestyle = vaxis.Style{Foreground: green}
138 }
139 if log.ResponseCode >= 300 && log.ResponseCode < 400 {
140 codestyle = vaxis.Style{Foreground: oj}
141 }
142 txt := richtext.New([]vaxis.Segment{
143 {Text: log.CurrentTime + " "},
144 {Text: log.ResponseStatus + " ", Style: codestyle},
145 {Text: log.RequestTime + " "},
146 {Text: log.RequestIP + " "},
147 {Text: log.RequestMethod + " ", Style: vaxis.Style{Foreground: purp}},
148 {Text: log.OriginalRequestURI},
149 })
150 txt.Softwrap = false
151 return txt
152}
153
154func (m *TunsPage) getEventLogWidget(i uint, cursor uint) vxfw.Widget {
155 m.mu.RLock()
156 defer m.mu.RUnlock()
157 if int(i) >= len(m.eventLogs) {
158 return nil
159 }
160
161 log := m.eventLogs[i]
162 style := vaxis.Style{Foreground: green}
163 if log.EventType == "disconnect" {
164 style = vaxis.Style{Foreground: red}
165 }
166 txt := richtext.New([]vaxis.Segment{
167 {Text: log.CreatedAt.Format(time.RFC3339) + " "},
168 {Text: log.EventType + " ", Style: style},
169 {Text: log.RemoteAddr},
170 })
171 txt.Softwrap = false
172 return txt
173}
174
175func (m *TunsPage) connectToLogs() error {
176 ctx, cancel := context.WithCancel(m.shared.Session.Context())
177 defer cancel()
178
179 m.ctx = ctx
180 m.done = cancel
181
182 conn := shared.NewPicoPipeClient()
183 drain, err := pipe.Sub(ctx, m.shared.Logger, conn, "sub tuns-result-drain -k")
184 if err != nil {
185 return err
186 }
187
188 scanner := bufio.NewScanner(drain)
189 scanner.Buffer(make([]byte, 32*1024), 32*1024)
190 for scanner.Scan() {
191 line := scanner.Text()
192 var parsedData ResultLog
193
194 err := json.Unmarshal([]byte(line), &parsedData)
195 if err != nil {
196 m.shared.Logger.Error("json unmarshal", "err", err, "line", line)
197 continue
198 }
199
200 if parsedData.TunnelType == "tcp" || parsedData.TunnelType == "sni" {
201 newTunID, err := shared.ParseTunsTCP(parsedData.TunnelID, parsedData.ServerID)
202 if err != nil {
203 m.shared.Logger.Error("parse tun addr", "err", err)
204 } else {
205 parsedData.TunnelID = newTunID
206 }
207 }
208
209 user := parsedData.User
210 userId := parsedData.UserId
211 isUser := user == m.shared.User.Name || userId == m.shared.User.ID
212
213 m.mu.RLock()
214 selected := m.selected
215 m.mu.RUnlock()
216 if (m.isAdmin || isUser) && parsedData.TunnelID == selected {
217 m.shared.App.PostEvent(ResultLogLineLoaded{parsedData})
218 }
219 }
220
221 if err := scanner.Err(); err != nil {
222 m.shared.Logger.Error("scanner error", "err", err)
223 return err
224 }
225
226 return nil
227}
228
229func (m *TunsPage) Footer() []Shortcut {
230 short := []Shortcut{
231 {Shortcut: "j/k", Text: "choose"},
232 {Shortcut: "tab", Text: "focus"},
233 }
234 return short
235}
236
237func (m *TunsPage) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) {
238 switch msg := ev.(type) {
239 case PageIn:
240 m.loading = true
241 ff, _ := m.shared.Dbpool.FindFeature(m.shared.User.ID, "admin")
242 if ff != nil {
243 m.isAdmin = true
244 }
245 go m.fetchTuns()
246 go func() {
247 _ = m.connectToLogs()
248 }()
249 m.focus = "page"
250 return vxfw.FocusWidgetCmd(m), nil
251 case PageOut:
252 m.mu.Lock()
253 m.selected = ""
254 m.logs = []*ResultLog{}
255 m.err = nil
256 m.mu.Unlock()
257 m.done()
258 case ResultLogLineLoaded:
259 m.logs = append(m.logs, &msg.Line)
260 // scroll to bottom
261 if len(m.logs) > 0 {
262 m.logList.SetCursor(uint(len(m.logs) - 1))
263 }
264 return vxfw.RedrawCmd{}, nil
265 case EventLogsLoaded:
266 return vxfw.RedrawCmd{}, nil
267 case TunsLoaded:
268 m.focus = "tuns"
269 return vxfw.BatchCmd([]vxfw.Command{
270 vxfw.FocusWidgetCmd(&m.leftPane),
271 vxfw.RedrawCmd{},
272 }), nil
273 case vaxis.Key:
274 if msg.Matches(vaxis.KeyEnter) {
275 m.mu.Lock()
276 m.selected = m.tuns[m.leftPane.Cursor()].TunAddress
277 m.logs = []*ResultLog{}
278 m.eventLogs = []*db.TunsEventLog{}
279 m.mu.Unlock()
280 go m.fetchEventLogs()
281 return vxfw.RedrawCmd{}, nil
282 }
283 if msg.Matches(vaxis.KeyTab) {
284 var cmd vxfw.Widget
285 if m.focus == "tuns" && m.selected != "" {
286 m.focus = "details"
287 cmd = m.rightPane
288 } else if m.focus == "details" {
289 m.focus = "tuns"
290 cmd = &m.leftPane
291 } else if m.focus == "requests" {
292 m.focus = "tuns"
293 cmd = &m.leftPane
294 } else if m.focus == "page" {
295 m.focus = "tuns"
296 cmd = &m.leftPane
297 }
298 return vxfw.BatchCmd([]vxfw.Command{
299 vxfw.FocusWidgetCmd(cmd),
300 vxfw.RedrawCmd{},
301 }), nil
302 }
303 }
304 return nil, nil
305}
306
307func (m *TunsPage) focusBorder(brd *Border) {
308 focus := m.focus
309 if focus == brd.Label {
310 brd.Style = vaxis.Style{Foreground: oj}
311 } else {
312 brd.Style = vaxis.Style{Foreground: purp}
313 }
314}
315
316func (m *TunsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
317 root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
318 leftPaneW := float32(ctx.Max.Width) * 0.35
319
320 var wdgt vxfw.Widget = text.New("No tunnels found")
321 if len(m.tuns) > 0 {
322 wdgt = &m.leftPane
323 }
324
325 if m.loading {
326 wdgt = text.New("Loading ...")
327 }
328
329 leftPane := NewBorder(wdgt)
330 leftPane.Label = "tuns"
331 m.focusBorder(leftPane)
332 leftSurf, _ := leftPane.Draw(vxfw.DrawContext{
333 Characters: ctx.Characters,
334 Max: vxfw.Size{
335 Width: uint16(leftPaneW),
336 Height: ctx.Max.Height - 1,
337 },
338 })
339
340 root.AddChild(0, 0, leftSurf)
341
342 rightPaneW := float32(ctx.Max.Width) * 0.65
343 rightCtx := vxfw.DrawContext{
344 Characters: vaxis.Characters,
345 Max: vxfw.Size{
346 Width: uint16(rightPaneW) - 2,
347 Height: ctx.Max.Height - 1,
348 },
349 }
350
351 if m.selected == "" {
352 rightWdgt := richtext.New([]vaxis.Segment{
353 {Text: "This is the pico tuns viewer which will allow users to view all of their tunnels.\n\n"},
354 {Text: "Tuns is a pico+ only feature.\n\n", Style: vaxis.Style{Foreground: oj}},
355 {Text: "Select a site on the left to view its request logs."},
356 })
357 brd := NewBorder(rightWdgt)
358 brd.Label = "details"
359 rightSurf, _ := brd.Draw(rightCtx)
360 root.AddChild(int(leftPaneW), 0, rightSurf)
361 } else {
362 rightSurf := vxfw.NewSurface(uint16(rightPaneW), math.MaxUint16, m)
363 ah := 0
364
365 data := m.findSelected()
366 if data != nil {
367 kv := NewKv([]Kv{
368 {Key: "type", Value: data.TunType},
369 {Key: "addr", Value: data.TunAddress},
370 {Key: "client-addr", Value: data.RemoteAddr},
371 {Key: "pubkey", Value: data.PubkeyFingerprint},
372 })
373 brd := NewBorder(kv)
374 brd.Label = "info"
375
376 detailSurf, _ := brd.Draw(rightCtx)
377 rightSurf.AddChild(0, ah, detailSurf)
378 ah += int(detailSurf.Size.Height)
379 }
380
381 brd := NewBorder(&m.logList)
382 brd.Label = "requests"
383 m.focusBorder(brd)
384 surf, _ := brd.Draw(vxfw.DrawContext{
385 Characters: vaxis.Characters,
386 Max: vxfw.Size{
387 Width: uint16(rightPaneW) - 4,
388 Height: 15,
389 },
390 })
391 rightSurf.AddChild(0, ah, surf)
392 ah += int(surf.Size.Height)
393
394 brd = NewBorder(&m.eventLogList)
395 brd.Label = "conn events"
396 m.focusBorder(brd)
397 surf, _ = brd.Draw(vxfw.DrawContext{
398 Characters: vaxis.Characters,
399 Max: vxfw.Size{
400 Width: uint16(rightPaneW) - 4,
401 Height: 15,
402 },
403 })
404 rightSurf.AddChild(0, ah, surf)
405
406 m.rightPane.Surface = rightSurf
407 rightPane := NewBorder(m.rightPane)
408 rightPane.Label = "details"
409 m.focusBorder(rightPane)
410 pagerSurf, _ := rightPane.Draw(rightCtx)
411
412 root.AddChild(int(leftPaneW), 0, pagerSurf)
413 }
414
415 m.mu.RLock()
416 if m.err != nil {
417 txt := text.New(m.err.Error())
418 txt.Style = vaxis.Style{Foreground: red}
419 surf, _ := txt.Draw(createDrawCtx(ctx, 1))
420 root.AddChild(0, int(ctx.Max.Height-1), surf)
421 }
422 m.mu.RUnlock()
423
424 return root, nil
425}
426
427func (m *TunsPage) findSelected() *TunsClientSimple {
428 for _, client := range m.tuns {
429 if client.TunAddress == m.selected {
430 return &client
431 }
432 }
433 return nil
434}
435
436func fetch(fqdn, auth string) (map[string]*TunsClient, error) {
437 mapper := map[string]*TunsClient{}
438 url := fmt.Sprintf("https://%s/_sish/api/clients?x-authorization=%s", fqdn, auth)
439 resp, err := http.Get(url)
440 if err != nil {
441 return mapper, err
442 }
443 defer resp.Body.Close()
444
445 var data TunsClientApi
446 err = json.NewDecoder(resp.Body).Decode(&data)
447 if err != nil {
448 return mapper, err
449 }
450
451 return data.Clients, nil
452}
453
454func (m *TunsPage) fetchEventLogs() {
455 logs, err := m.shared.Dbpool.FindTunsEventLogsByAddr(m.shared.User.ID, m.selected)
456 if err != nil {
457 m.mu.Lock()
458 defer m.mu.Unlock()
459 m.err = err
460 return
461 }
462
463 m.mu.Lock()
464 defer m.mu.Unlock()
465 m.eventLogs = logs
466}
467
468func (m *TunsPage) fetchTuns() {
469 tMap, err := fetch("tuns.sh", m.shared.Cfg.TunsSecret)
470 if err != nil {
471 m.err = err
472 return
473 }
474 nMap, err := fetch("nue.tuns.sh", m.shared.Cfg.TunsSecret)
475 if err != nil {
476 m.err = err
477 return
478 }
479
480 ls := []TunsClientSimple{}
481 for _, val := range tMap {
482 if !m.isAdmin && val.User != m.shared.User.Name {
483 continue
484 }
485
486 for k := range val.RouteListeners.HttpListeners {
487 ls = append(ls, TunsClientSimple{
488 TunType: "http",
489 TunAddress: k,
490 RemoteAddr: val.RemoteAddr,
491 User: val.User,
492 PubkeyFingerprint: val.PubkeyFingerprint,
493 })
494 }
495
496 for k := range val.RouteListeners.TcpAliases {
497 ls = append(ls, TunsClientSimple{
498 TunType: "alias",
499 TunAddress: k,
500 RemoteAddr: val.RemoteAddr,
501 User: val.User,
502 PubkeyFingerprint: val.PubkeyFingerprint,
503 })
504 }
505
506 for k := range val.RouteListeners.Listeners {
507 tunAddr, err := shared.ParseTunsTCP(k, "tuns.sh")
508 if err != nil {
509 m.shared.Session.Logger.Info("parse tun addr", "err", err)
510 tunAddr = k
511 }
512
513 ls = append(ls, TunsClientSimple{
514 TunType: "tcp",
515 TunAddress: tunAddr,
516 RemoteAddr: val.RemoteAddr,
517 User: val.User,
518 PubkeyFingerprint: val.PubkeyFingerprint,
519 })
520 }
521 }
522
523 for _, val := range nMap {
524 if !m.isAdmin && val.User != m.shared.User.Name {
525 continue
526 }
527
528 for k := range val.RouteListeners.HttpListeners {
529 ls = append(ls, TunsClientSimple{
530 TunType: "http",
531 TunAddress: k,
532 RemoteAddr: val.RemoteAddr,
533 User: val.User,
534 PubkeyFingerprint: val.PubkeyFingerprint,
535 })
536 }
537
538 for k := range val.RouteListeners.TcpAliases {
539 ls = append(ls, TunsClientSimple{
540 TunType: "alias",
541 TunAddress: k,
542 RemoteAddr: val.RemoteAddr,
543 User: val.User,
544 PubkeyFingerprint: val.PubkeyFingerprint,
545 })
546 }
547
548 for k := range val.RouteListeners.Listeners {
549 tunAddr, err := shared.ParseTunsTCP(k, "nue.tuns.sh")
550 if err != nil {
551 m.shared.Session.Logger.Info("parse tun addr", "err", err)
552 tunAddr = k
553 }
554
555 ls = append(ls, TunsClientSimple{
556 TunType: "tcp",
557 TunAddress: tunAddr,
558 RemoteAddr: val.RemoteAddr,
559 User: val.User,
560 PubkeyFingerprint: val.PubkeyFingerprint,
561 })
562 }
563 }
564
565 sort.Slice(ls, func(i, j int) bool {
566 return ls[i].TunAddress < ls[j].TunAddress
567 })
568
569 m.tuns = ls
570 m.loading = false
571 m.shared.App.PostEvent(TunsLoaded{})
572}