- commit
- cf2f80c
- parent
- aea7552
- author
- Eric Bower
- date
- 2025-03-17 12:21:46 -0400 EDT
feat(tui): tuns
8 files changed,
+469,
-8
+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=
+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 }
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 {
+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
+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 {
1@@ -13,6 +13,7 @@ var menuChoices = []string{
2 "tokens",
3 "logs",
4 "analytics",
5+ "tuns",
6 "pico+",
7 "chat",
8 }
+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+}
+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,