repos / pico

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

commit
cf2f80c
parent
aea7552
author
Eric Bower
date
2025-03-17 12:21:46 -0400 EDT
feat(tui): tuns
8 files changed,  +469, -8
M .env.example
+2, -0
1@@ -146,3 +146,5 @@ PIPE_PROM_PORT=9222
2 PIPE_DOMAIN=pipe.dev.pico.sh:3001
3 PIPE_PROTOCOL=http
4 PIPE_DEBUG=1
5+
6+TUNS_CONSOLE_SECRET=
M pkg/apps/pico/config.go
+5, -3
 1@@ -7,10 +7,12 @@ import (
 2 
 3 func NewConfigSite(service string) *shared.ConfigSite {
 4 	dbURL := utils.GetEnv("DATABASE_URL", "")
 5+	tuns := utils.GetEnv("TUNS_CONSOLE_SECRET", "")
 6 
 7 	return &shared.ConfigSite{
 8-		DbURL:  dbURL,
 9-		Space:  "pico",
10-		Logger: shared.CreateLogger(service),
11+		DbURL:      dbURL,
12+		Space:      "pico",
13+		Logger:     shared.CreateLogger(service),
14+		TunsSecret: tuns,
15 	}
16 }
M pkg/shared/config.go
+1, -0
1@@ -51,6 +51,7 @@ type ConfigSite struct {
2 	MaxAssetSize       int64
3 	MaxSpecialFileSize int64
4 	Logger             *slog.Logger
5+	TunsSecret         string
6 }
7 
8 func NewConfigSite() *ConfigSite {
M pkg/tui/analytics.go
+1, -1
 1@@ -22,12 +22,12 @@ type AnalyticsPage struct {
 2 
 3 	sites     []*db.VisitUrl
 4 	features  []*db.FeatureFlag
 5-	leftPane  list.Dynamic
 6 	err       error
 7 	stats     map[string]*db.SummaryVisits
 8 	selected  string
 9 	interval  string
10 	focus     string
11+	leftPane  list.Dynamic
12 	rightPane *Pager
13 }
14 
M pkg/tui/logs.go
+5, -4
 1@@ -27,19 +27,16 @@ type LogsPage struct {
 2 
 3 	input    *TextInput
 4 	list     *list.Dynamic
 5-	logs     []*LogLine
 6 	filtered []int
 7+	logs     []*LogLine
 8 	ctx      context.Context
 9 	done     context.CancelFunc
10 }
11 
12 func NewLogsPage(shrd *SharedModel) *LogsPage {
13-	ctx, cancel := context.WithCancel(shrd.Session.Context())
14 	page := &LogsPage{
15 		shared: shrd,
16 		input:  NewTextInput("filter logs"),
17-		ctx:    ctx,
18-		done:   cancel,
19 	}
20 	page.list = &list.Dynamic{Builder: page.getWidget, DisableEventHandlers: true}
21 	return page
22@@ -71,6 +68,7 @@ func (m *LogsPage) filterLogs() {
23 		m.filtered = append(m.filtered, idx)
24 	}
25 
26+	// scroll to bottom
27 	if len(m.filtered) > 0 {
28 		m.list.SetCursor(uint(len(m.filtered) - 1))
29 	}
30@@ -125,6 +123,9 @@ func (m *LogsPage) getWidget(i uint, cursor uint) vxfw.Widget {
31 }
32 
33 func (m *LogsPage) connectToLogs() error {
34+	ctx, cancel := context.WithCancel(m.shared.Session.Context())
35+	m.ctx = ctx
36+	m.done = cancel
37 	conn := shared.NewPicoPipeClient()
38 	drain, err := pipeLogger.ReadLogs(m.ctx, m.shared.Logger, conn)
39 	if err != nil {
M pkg/tui/menu.go
+1, -0
1@@ -13,6 +13,7 @@ var menuChoices = []string{
2 	"tokens",
3 	"logs",
4 	"analytics",
5+	"tuns",
6 	"pico+",
7 	"chat",
8 }
A pkg/tui/tuns.go
+453, -0
  1@@ -0,0 +1,453 @@
  2+package tui
  3+
  4+import (
  5+	"bufio"
  6+	"context"
  7+	"encoding/json"
  8+	"fmt"
  9+	"math"
 10+	"net/http"
 11+	"sort"
 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/shared"
 20+	"github.com/picosh/utils/pipe"
 21+)
 22+
 23+type RouteListener struct {
 24+	HttpListeners map[string][]string `json:"httpListeners"`
 25+	Listeners     map[string]string   `json:"listeners"`
 26+	TcpAliases    map[string]string   `json:"tcpAliases"`
 27+}
 28+
 29+type TunsClient struct {
 30+	RemoteAddr        string        `json:"remoteAddr"`
 31+	User              string        `json:"user"`
 32+	Version           string        `json:"version"`
 33+	Session           string        `json:"session"`
 34+	Pubkey            string        `json:"pubKey"`
 35+	PubkeyFingerprint string        `json:"pubKeyFingerprint"`
 36+	Listeners         []string      `json:"listeners"`
 37+	RouteListeners    RouteListener `json:"routeListeners"`
 38+}
 39+
 40+type TunsClientApi struct {
 41+	Clients map[string]*TunsClient `json:"clients"`
 42+	Status  bool                   `json:"status"`
 43+}
 44+
 45+type TunsClientSimple struct {
 46+	TunType           string
 47+	TunAddress        string
 48+	RemoteAddr        string
 49+	User              string
 50+	PubkeyFingerprint string
 51+}
 52+
 53+type ResultLog struct {
 54+	ServerID        string    `json:"server_id"`
 55+	User            string    `json:"user"`
 56+	UserId          string    `json:"user_id"`
 57+	CurrentTime     string    `json:"current_time"`
 58+	StartTime       time.Time `json:"start_time"`
 59+	StartTimePretty string    `json:"start_time_pretty"`
 60+	RequestTime     string    `json:"request_time"`
 61+	RequestIP       string    `json:"request_ip"`
 62+	RequestMethod   string    `json:"request_method"`
 63+	// RequestURL         string              `json:"request_url"`
 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+	TunnelType         string              `json:"tunnel_type"`
 72+	ConnectionType     string              `json:"connection_type"`
 73+	TunnelAddrs        []string            `json:"tunnel_addrs"`
 74+}
 75+
 76+type ResultLogLineLoaded struct {
 77+	Line ResultLog
 78+}
 79+
 80+type TunsLoaded struct{}
 81+
 82+type TunsPage struct {
 83+	shared *SharedModel
 84+
 85+	err       error
 86+	tuns      []TunsClientSimple
 87+	selected  string
 88+	focus     string
 89+	leftPane  list.Dynamic
 90+	rightPane *Pager
 91+	logs      []*ResultLog
 92+	logList   list.Dynamic
 93+	ctx       context.Context
 94+	done      context.CancelFunc
 95+}
 96+
 97+func NewTunsPage(shrd *SharedModel) *TunsPage {
 98+	m := &TunsPage{
 99+		shared: shrd,
100+
101+		rightPane: NewPager(),
102+	}
103+	m.leftPane = list.Dynamic{DrawCursor: true, Builder: m.getLeftWidget}
104+	m.logList = list.Dynamic{DrawCursor: true, Builder: m.getLogWidget}
105+	return m
106+}
107+
108+func (m *TunsPage) getLeftWidget(i uint, cursor uint) vxfw.Widget {
109+	if int(i) >= len(m.tuns) {
110+		return nil
111+	}
112+
113+	site := m.tuns[i]
114+	txt := text.New(site.TunAddress)
115+	txt.Softwrap = false
116+	return txt
117+}
118+
119+func (m *TunsPage) getLogWidget(i uint, cursor uint) vxfw.Widget {
120+	if int(i) >= len(m.logs) {
121+		return nil
122+	}
123+
124+	log := m.logs[i]
125+	codestyle := vaxis.Style{Foreground: red}
126+	if log.ResponseCode >= 200 && log.ResponseCode < 300 {
127+		codestyle = vaxis.Style{Foreground: green}
128+	}
129+	if log.ResponseCode >= 300 && log.ResponseCode < 400 {
130+		codestyle = vaxis.Style{Foreground: oj}
131+	}
132+	txt := richtext.New([]vaxis.Segment{
133+		{Text: log.ResponseStatus + " ", Style: codestyle},
134+		{Text: log.RequestTime + " "},
135+		{Text: log.RequestIP + " "},
136+		{Text: log.RequestMethod + " ", Style: vaxis.Style{Foreground: purp}},
137+		{Text: log.OriginalRequestURI},
138+	})
139+	txt.Softwrap = false
140+	return txt
141+}
142+
143+func (m *TunsPage) connectToLogs() error {
144+	ctx, cancel := context.WithCancel(m.shared.Session.Context())
145+	m.ctx = ctx
146+	m.done = cancel
147+	conn := shared.NewPicoPipeClient()
148+	drain, err := pipe.Sub(m.ctx, m.shared.Logger, conn, "sub tuns-result-drain -k")
149+	if err != nil {
150+		return err
151+	}
152+
153+	scanner := bufio.NewScanner(drain)
154+	scanner.Buffer(make([]byte, 32*1024), 32*1024)
155+	for scanner.Scan() {
156+		line := scanner.Text()
157+		var parsedData ResultLog
158+
159+		err := json.Unmarshal([]byte(line), &parsedData)
160+		if err != nil {
161+			m.shared.Logger.Error("json unmarshal", "err", err, "line", line)
162+			continue
163+		}
164+
165+		// user := parsedData.User
166+		// userId := parsedData.UserId
167+		// if user == m.shared.User.Name || userId == m.shared.User.ID {
168+		m.shared.App.PostEvent(ResultLogLineLoaded{parsedData})
169+		// }
170+	}
171+
172+	return nil
173+}
174+
175+func (m *TunsPage) Footer() []Shortcut {
176+	short := []Shortcut{
177+		{Shortcut: "j/k", Text: "choose"},
178+		{Shortcut: "tab", Text: "focus"},
179+		{Shortcut: "r", Text: "reload"},
180+	}
181+	return short
182+}
183+
184+func (m *TunsPage) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) {
185+	switch msg := ev.(type) {
186+	case PageIn:
187+		go m.fetchTuns()
188+		go func() {
189+			_ = m.connectToLogs()
190+		}()
191+		m.focus = "page"
192+		return vxfw.FocusWidgetCmd(m), nil
193+	case PageOut:
194+		m.selected = ""
195+		m.err = nil
196+		m.done()
197+	case ResultLogLineLoaded:
198+		m.logs = append(m.logs, &msg.Line)
199+		// scroll to bottom
200+		if len(m.logs) > 0 {
201+			m.logList.SetCursor(uint(len(m.logs) - 1))
202+		}
203+		return vxfw.RedrawCmd{}, nil
204+	case TunsLoaded:
205+		m.focus = "tuns"
206+		return vxfw.BatchCmd([]vxfw.Command{
207+			vxfw.FocusWidgetCmd(&m.leftPane),
208+			vxfw.RedrawCmd{},
209+		}), nil
210+	case vaxis.Key:
211+		if msg.Matches(vaxis.KeyEnter) {
212+			m.selected = m.tuns[m.leftPane.Cursor()].TunAddress
213+			return vxfw.RedrawCmd{}, nil
214+		}
215+		if msg.Matches('r') {
216+			go m.fetchTuns()
217+			return nil, nil
218+		}
219+		if msg.Matches(vaxis.KeyTab) {
220+			var cmd vxfw.Widget
221+			if m.focus == "tuns" && m.selected != "" {
222+				m.focus = "details"
223+				cmd = m.rightPane
224+			} else if m.focus == "details" {
225+				m.focus = "tuns"
226+				cmd = &m.leftPane
227+			} else if m.focus == "requests" {
228+				m.focus = "tuns"
229+				cmd = &m.leftPane
230+			} else if m.focus == "page" {
231+				m.focus = "tuns"
232+				cmd = &m.leftPane
233+			}
234+			return vxfw.BatchCmd([]vxfw.Command{
235+				vxfw.FocusWidgetCmd(cmd),
236+				vxfw.RedrawCmd{},
237+			}), nil
238+		}
239+	}
240+	return nil, nil
241+}
242+
243+func (m *TunsPage) focusBorder(brd *Border) {
244+	focus := m.focus
245+	if focus == brd.Label {
246+		brd.Style = vaxis.Style{Foreground: oj}
247+	} else {
248+		brd.Style = vaxis.Style{Foreground: purp}
249+	}
250+}
251+
252+func (m *TunsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
253+	root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
254+	leftPaneW := float32(ctx.Max.Width) * 0.35
255+
256+	var wdgt vxfw.Widget = text.New("No tunnels found")
257+	if len(m.tuns) > 0 {
258+		wdgt = &m.leftPane
259+	}
260+
261+	leftPane := NewBorder(wdgt)
262+	leftPane.Label = "tuns"
263+	m.focusBorder(leftPane)
264+	leftSurf, _ := leftPane.Draw(vxfw.DrawContext{
265+		Characters: ctx.Characters,
266+		Max: vxfw.Size{
267+			Width:  uint16(leftPaneW),
268+			Height: ctx.Max.Height - 1,
269+		},
270+	})
271+
272+	root.AddChild(0, 0, leftSurf)
273+
274+	rightPaneW := float32(ctx.Max.Width) * 0.65
275+	rightCtx := vxfw.DrawContext{
276+		Characters: vaxis.Characters,
277+		Max: vxfw.Size{
278+			Width:  uint16(rightPaneW) - 2,
279+			Height: ctx.Max.Height - 1,
280+		},
281+	}
282+
283+	if m.selected == "" {
284+		rightWdgt := richtext.New([]vaxis.Segment{
285+			{Text: "This is the pico tuns viewer which will allow users to view all of their tunnels.\n\n"},
286+			{Text: "Tuns is a pico+ only feature.\n\n", Style: vaxis.Style{Foreground: oj}},
287+			{Text: "Select a site on the left to view its request logs."},
288+		})
289+		brd := NewBorder(rightWdgt)
290+		brd.Label = "details"
291+		rightSurf, _ := brd.Draw(rightCtx)
292+		root.AddChild(int(leftPaneW), 0, rightSurf)
293+	} else {
294+		rightSurf := vxfw.NewSurface(uint16(rightPaneW), math.MaxUint16, m)
295+		ah := 0
296+
297+		data := m.findSelected()
298+		if data != nil {
299+			kv := NewKv([]Kv{
300+				{Key: "type", Value: data.TunType},
301+				{Key: "addr", Value: data.TunAddress},
302+				{Key: "client-addr", Value: data.RemoteAddr},
303+				{Key: "pubkey", Value: data.PubkeyFingerprint},
304+			})
305+			brd := NewBorder(kv)
306+			brd.Label = "info"
307+
308+			detailSurf, _ := brd.Draw(rightCtx)
309+			rightSurf.AddChild(0, ah, detailSurf)
310+			ah += int(detailSurf.Size.Height)
311+		}
312+
313+		brd := NewBorder(&m.logList)
314+		brd.Label = "requests"
315+		m.focusBorder(brd)
316+		surf, _ := brd.Draw(vxfw.DrawContext{
317+			Characters: vaxis.Characters,
318+			Max: vxfw.Size{
319+				Width:  uint16(rightPaneW) - 4,
320+				Height: 15,
321+			},
322+		})
323+		rightSurf.AddChild(0, ah, surf)
324+
325+		m.rightPane.Surface = rightSurf
326+		rightPane := NewBorder(m.rightPane)
327+		rightPane.Label = "details"
328+		m.focusBorder(rightPane)
329+		pagerSurf, _ := rightPane.Draw(rightCtx)
330+
331+		root.AddChild(int(leftPaneW), 0, pagerSurf)
332+	}
333+
334+	if m.err != nil {
335+		txt := text.New(m.err.Error())
336+		txt.Style = vaxis.Style{Foreground: red}
337+		surf, _ := txt.Draw(createDrawCtx(ctx, 1))
338+		root.AddChild(0, int(ctx.Max.Height-1), surf)
339+	}
340+
341+	return root, nil
342+}
343+
344+func (m *TunsPage) findSelected() *TunsClientSimple {
345+	for _, client := range m.tuns {
346+		if client.TunAddress == m.selected {
347+			return &client
348+		}
349+	}
350+	return nil
351+}
352+
353+func fetch(fqdn, auth string) (map[string]*TunsClient, error) {
354+	mapper := map[string]*TunsClient{}
355+	url := fmt.Sprintf("https://%s/_sish/api/clients?x-authorization=%s", fqdn, auth)
356+	resp, err := http.Get(url)
357+	if err != nil {
358+		return mapper, err
359+	}
360+	defer resp.Body.Close()
361+
362+	var data TunsClientApi
363+	err = json.NewDecoder(resp.Body).Decode(&data)
364+	if err != nil {
365+		return mapper, err
366+	}
367+
368+	return data.Clients, nil
369+}
370+
371+func (m *TunsPage) fetchTuns() {
372+	tMap, err := fetch("tuns.sh", m.shared.Cfg.TunsSecret)
373+	if err != nil {
374+		m.err = err
375+		return
376+	}
377+	nMap, err := fetch("nue.tuns.sh", m.shared.Cfg.TunsSecret)
378+	if err != nil {
379+		m.err = err
380+		return
381+	}
382+
383+	ls := []TunsClientSimple{}
384+	for _, val := range tMap {
385+		for k := range val.RouteListeners.HttpListeners {
386+			ls = append(ls, TunsClientSimple{
387+				TunType:           "http",
388+				TunAddress:        k,
389+				RemoteAddr:        val.RemoteAddr,
390+				User:              val.User,
391+				PubkeyFingerprint: val.PubkeyFingerprint,
392+			})
393+		}
394+
395+		for k := range val.RouteListeners.TcpAliases {
396+			ls = append(ls, TunsClientSimple{
397+				TunType:           "tcp-alias",
398+				TunAddress:        k,
399+				RemoteAddr:        val.RemoteAddr,
400+				User:              val.User,
401+				PubkeyFingerprint: val.PubkeyFingerprint,
402+			})
403+		}
404+
405+		for k := range val.RouteListeners.Listeners {
406+			ls = append(ls, TunsClientSimple{
407+				TunType:           "tcp",
408+				TunAddress:        k,
409+				RemoteAddr:        val.RemoteAddr,
410+				User:              val.User,
411+				PubkeyFingerprint: val.PubkeyFingerprint,
412+			})
413+		}
414+	}
415+
416+	for _, val := range nMap {
417+		for k := range val.RouteListeners.HttpListeners {
418+			ls = append(ls, TunsClientSimple{
419+				TunType:           "http",
420+				TunAddress:        k,
421+				RemoteAddr:        val.RemoteAddr,
422+				User:              val.User,
423+				PubkeyFingerprint: val.PubkeyFingerprint,
424+			})
425+		}
426+
427+		for k := range val.RouteListeners.TcpAliases {
428+			ls = append(ls, TunsClientSimple{
429+				TunType:           "tcp-alias",
430+				TunAddress:        k,
431+				RemoteAddr:        val.RemoteAddr,
432+				User:              val.User,
433+				PubkeyFingerprint: val.PubkeyFingerprint,
434+			})
435+		}
436+
437+		for k := range val.RouteListeners.Listeners {
438+			ls = append(ls, TunsClientSimple{
439+				TunType:           "tcp",
440+				TunAddress:        k,
441+				RemoteAddr:        val.RemoteAddr,
442+				User:              val.User,
443+				PubkeyFingerprint: val.PubkeyFingerprint,
444+			})
445+		}
446+	}
447+
448+	sort.Slice(ls, func(i, j int) bool {
449+		return ls[i].TunAddress < ls[j].TunAddress
450+	})
451+
452+	m.tuns = ls
453+	m.shared.App.PostEvent(TunsLoaded{})
454+}
M pkg/tui/ui.go
+1, -0
1@@ -337,6 +337,7 @@ func NewTui(opts vaxis.Options, shrd *SharedModel) {
2 		"logs":       NewLogsPage(shrd),
3 		"analytics":  NewAnalyticsPage(shrd),
4 		"chat":       NewChatPage(shrd),
5+		"tuns":       NewTunsPage(shrd),
6 	}
7 	root := &App{
8 		shared: shrd,