repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / tui
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}