repos / pico

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

pico / pkg / tui
Eric Bower  ·  2025-05-25

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			cursor := int(m.leftPane.Cursor())
277			if cursor >= len(m.tuns) {
278				return nil, nil
279			}
280			m.selected = m.tuns[m.leftPane.Cursor()].TunAddress
281			m.logs = []*ResultLog{}
282			m.eventLogs = []*db.TunsEventLog{}
283			m.mu.Unlock()
284			go m.fetchEventLogs()
285			return vxfw.RedrawCmd{}, nil
286		}
287		if msg.Matches(vaxis.KeyTab) {
288			var cmd vxfw.Widget
289			if m.focus == "tuns" && m.selected != "" {
290				m.focus = "details"
291				cmd = m.rightPane
292			} else if m.focus == "details" {
293				m.focus = "tuns"
294				cmd = &m.leftPane
295			} else if m.focus == "requests" {
296				m.focus = "tuns"
297				cmd = &m.leftPane
298			} else if m.focus == "page" {
299				m.focus = "tuns"
300				cmd = &m.leftPane
301			}
302			return vxfw.BatchCmd([]vxfw.Command{
303				vxfw.FocusWidgetCmd(cmd),
304				vxfw.RedrawCmd{},
305			}), nil
306		}
307	}
308	return nil, nil
309}
310
311func (m *TunsPage) focusBorder(brd *Border) {
312	focus := m.focus
313	if focus == brd.Label {
314		brd.Style = vaxis.Style{Foreground: oj}
315	} else {
316		brd.Style = vaxis.Style{Foreground: purp}
317	}
318}
319
320func (m *TunsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
321	root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
322	leftPaneW := float32(ctx.Max.Width) * 0.35
323
324	var wdgt vxfw.Widget = text.New("No tunnels found")
325	if len(m.tuns) > 0 {
326		wdgt = &m.leftPane
327	}
328
329	if m.loading {
330		wdgt = text.New("Loading ...")
331	}
332
333	leftPane := NewBorder(wdgt)
334	leftPane.Label = "tuns"
335	m.focusBorder(leftPane)
336	leftSurf, _ := leftPane.Draw(vxfw.DrawContext{
337		Characters: ctx.Characters,
338		Max: vxfw.Size{
339			Width:  uint16(leftPaneW),
340			Height: ctx.Max.Height - 1,
341		},
342	})
343
344	root.AddChild(0, 0, leftSurf)
345
346	rightPaneW := float32(ctx.Max.Width) * 0.65
347	rightCtx := vxfw.DrawContext{
348		Characters: vaxis.Characters,
349		Max: vxfw.Size{
350			Width:  uint16(rightPaneW) - 2,
351			Height: ctx.Max.Height - 1,
352		},
353	}
354
355	if m.selected == "" {
356		rightWdgt := richtext.New([]vaxis.Segment{
357			{Text: "This is the pico tuns viewer which will allow users to view all of their tunnels.\n\n"},
358			{Text: "Tuns is a pico+ only feature.\n\n", Style: vaxis.Style{Foreground: oj}},
359			{Text: "Select a site on the left to view its request logs."},
360		})
361		brd := NewBorder(rightWdgt)
362		brd.Label = "details"
363		rightSurf, _ := brd.Draw(rightCtx)
364		root.AddChild(int(leftPaneW), 0, rightSurf)
365	} else {
366		rightSurf := vxfw.NewSurface(uint16(rightPaneW), math.MaxUint16, m)
367		ah := 0
368
369		data := m.findSelected()
370		if data != nil {
371			kv := NewKv([]Kv{
372				{Key: "type", Value: data.TunType},
373				{Key: "addr", Value: data.TunAddress},
374				{Key: "client-addr", Value: data.RemoteAddr},
375				{Key: "pubkey", Value: data.PubkeyFingerprint},
376			})
377			brd := NewBorder(kv)
378			brd.Label = "info"
379
380			detailSurf, _ := brd.Draw(rightCtx)
381			rightSurf.AddChild(0, ah, detailSurf)
382			ah += int(detailSurf.Size.Height)
383		}
384
385		brd := NewBorder(&m.logList)
386		brd.Label = "requests"
387		m.focusBorder(brd)
388		surf, _ := brd.Draw(vxfw.DrawContext{
389			Characters: vaxis.Characters,
390			Max: vxfw.Size{
391				Width:  uint16(rightPaneW) - 4,
392				Height: 15,
393			},
394		})
395		rightSurf.AddChild(0, ah, surf)
396		ah += int(surf.Size.Height)
397
398		brd = NewBorder(&m.eventLogList)
399		brd.Label = "conn events"
400		m.focusBorder(brd)
401		surf, _ = brd.Draw(vxfw.DrawContext{
402			Characters: vaxis.Characters,
403			Max: vxfw.Size{
404				Width:  uint16(rightPaneW) - 4,
405				Height: 15,
406			},
407		})
408		rightSurf.AddChild(0, ah, surf)
409
410		m.rightPane.Surface = rightSurf
411		rightPane := NewBorder(m.rightPane)
412		rightPane.Label = "details"
413		m.focusBorder(rightPane)
414		pagerSurf, _ := rightPane.Draw(rightCtx)
415
416		root.AddChild(int(leftPaneW), 0, pagerSurf)
417	}
418
419	m.mu.RLock()
420	if m.err != nil {
421		txt := text.New(m.err.Error())
422		txt.Style = vaxis.Style{Foreground: red}
423		surf, _ := txt.Draw(createDrawCtx(ctx, 1))
424		root.AddChild(0, int(ctx.Max.Height-1), surf)
425	}
426	m.mu.RUnlock()
427
428	return root, nil
429}
430
431func (m *TunsPage) findSelected() *TunsClientSimple {
432	for _, client := range m.tuns {
433		if client.TunAddress == m.selected {
434			return &client
435		}
436	}
437	return nil
438}
439
440func fetch(fqdn, auth string) (map[string]*TunsClient, error) {
441	mapper := map[string]*TunsClient{}
442	url := fmt.Sprintf("https://%s/_sish/api/clients?x-authorization=%s", fqdn, auth)
443	resp, err := http.Get(url)
444	if err != nil {
445		return mapper, err
446	}
447	defer func() {
448		_ = resp.Body.Close()
449	}()
450
451	var data TunsClientApi
452	err = json.NewDecoder(resp.Body).Decode(&data)
453	if err != nil {
454		return mapper, err
455	}
456
457	return data.Clients, nil
458}
459
460func (m *TunsPage) fetchEventLogs() {
461	logs, err := m.shared.Dbpool.FindTunsEventLogsByAddr(m.shared.User.ID, m.selected)
462	if err != nil {
463		m.mu.Lock()
464		defer m.mu.Unlock()
465		m.err = err
466		return
467	}
468
469	m.mu.Lock()
470	defer m.mu.Unlock()
471	m.eventLogs = logs
472}
473
474func (m *TunsPage) fetchTuns() {
475	tMap, err := fetch("tuns.sh", m.shared.Cfg.TunsSecret)
476	if err != nil {
477		m.err = err
478		return
479	}
480	nMap, err := fetch("nue.tuns.sh", m.shared.Cfg.TunsSecret)
481	if err != nil {
482		m.err = err
483		return
484	}
485
486	ls := []TunsClientSimple{}
487	for _, val := range tMap {
488		if !m.isAdmin && val.User != m.shared.User.Name {
489			continue
490		}
491
492		for k := range val.RouteListeners.HttpListeners {
493			ls = append(ls, TunsClientSimple{
494				TunType:           "http",
495				TunAddress:        k,
496				RemoteAddr:        val.RemoteAddr,
497				User:              val.User,
498				PubkeyFingerprint: val.PubkeyFingerprint,
499			})
500		}
501
502		for k := range val.RouteListeners.TcpAliases {
503			ls = append(ls, TunsClientSimple{
504				TunType:           "alias",
505				TunAddress:        k,
506				RemoteAddr:        val.RemoteAddr,
507				User:              val.User,
508				PubkeyFingerprint: val.PubkeyFingerprint,
509			})
510		}
511
512		for k := range val.RouteListeners.Listeners {
513			tunAddr, err := shared.ParseTunsTCP(k, "tuns.sh")
514			if err != nil {
515				m.shared.Session.Logger.Info("parse tun addr", "err", err)
516				tunAddr = k
517			}
518
519			ls = append(ls, TunsClientSimple{
520				TunType:           "tcp",
521				TunAddress:        tunAddr,
522				RemoteAddr:        val.RemoteAddr,
523				User:              val.User,
524				PubkeyFingerprint: val.PubkeyFingerprint,
525			})
526		}
527	}
528
529	for _, val := range nMap {
530		if !m.isAdmin && val.User != m.shared.User.Name {
531			continue
532		}
533
534		for k := range val.RouteListeners.HttpListeners {
535			ls = append(ls, TunsClientSimple{
536				TunType:           "http",
537				TunAddress:        k,
538				RemoteAddr:        val.RemoteAddr,
539				User:              val.User,
540				PubkeyFingerprint: val.PubkeyFingerprint,
541			})
542		}
543
544		for k := range val.RouteListeners.TcpAliases {
545			ls = append(ls, TunsClientSimple{
546				TunType:           "alias",
547				TunAddress:        k,
548				RemoteAddr:        val.RemoteAddr,
549				User:              val.User,
550				PubkeyFingerprint: val.PubkeyFingerprint,
551			})
552		}
553
554		for k := range val.RouteListeners.Listeners {
555			tunAddr, err := shared.ParseTunsTCP(k, "nue.tuns.sh")
556			if err != nil {
557				m.shared.Session.Logger.Info("parse tun addr", "err", err)
558				tunAddr = k
559			}
560
561			ls = append(ls, TunsClientSimple{
562				TunType:           "tcp",
563				TunAddress:        tunAddr,
564				RemoteAddr:        val.RemoteAddr,
565				User:              val.User,
566				PubkeyFingerprint: val.PubkeyFingerprint,
567			})
568		}
569	}
570
571	sort.Slice(ls, func(i, j int) bool {
572		return ls[i].TunAddress < ls[j].TunAddress
573	})
574
575	m.tuns = ls
576	m.loading = false
577	m.shared.App.PostEvent(TunsLoaded{})
578}