From 85f21e7698bd1b5400e52d7b73fae73beadd97f7 Mon Sep 17 00:00:00 2001 From: bagas Date: Mon, 29 Dec 2025 21:55:39 +0700 Subject: [PATCH] feat(tui): update interaction layer to Bubble Tea TUI --- go.mod | 20 + go.sum | 51 ++ server/http.go | 1 - server/https.go | 1 - session/handler.go | 23 +- session/interaction/interaction.go | 885 +++++++++++++++-------------- session/lifecycle/lifecycle.go | 8 +- session/session.go | 5 +- types/types.go | 6 - 9 files changed, 549 insertions(+), 451 deletions(-) diff --git a/go.mod b/go.mod index 6c7621d..11dbda1 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,37 @@ go 1.24.4 require ( github.com/caddyserver/certmagic v0.25.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/joho/godotenv v1.5.1 github.com/libdns/cloudflare v0.2.2 golang.org/x/crypto v0.46.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libdns/libdns v1.1.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mholt/acmez/v3 v3.1.3 // indirect github.com/miekg/dns v1.1.68 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect diff --git a/go.sum b/go.sum index fb988d1..98717ab 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,74 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc= github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= @@ -38,12 +85,16 @@ go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= diff --git a/server/http.go b/server/http.go index 1f08123..4cdaaf5 100644 --- a/server/http.go +++ b/server/http.go @@ -330,7 +330,6 @@ func Handler(conn net.Conn) { return } cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr()) - cw.SetInteraction(sshSession.GetInteraction()) forwardRequest(cw, reqhf, sshSession) return } diff --git a/server/https.go b/server/https.go index 2a09c91..50342d2 100644 --- a/server/https.go +++ b/server/https.go @@ -104,7 +104,6 @@ func HandlerTLS(conn net.Conn) { return } cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr()) - cw.SetInteraction(sshSession.GetInteraction()) forwardRequest(cw, reqhf, sshSession) return } diff --git a/session/handler.go b/session/handler.go index 04b1c87..2ef2c44 100644 --- a/session/handler.go +++ b/session/handler.go @@ -59,7 +59,6 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) { var rawPortToBind uint32 if err := binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil { log.Println("Failed to read port from payload:", err) - s.interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port. (02) \r\n", rawPortToBind)) err := req.Reply(false, nil) if err != nil { log.Println("Failed to reply to request:", err) @@ -73,7 +72,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) { } if rawPortToBind > 65535 { - s.interaction.SendMessage(fmt.Sprintf("Port %d is larger then allowed port of 65535. (02)\r\n", rawPortToBind)) + log.Printf("Port %d is larger than allowed port of 65535", rawPortToBind) err := req.Reply(false, nil) if err != nil { log.Println("Failed to reply to request:", err) @@ -89,7 +88,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) { portToBind := uint16(rawPortToBind) if isBlockedPort(portToBind) { - s.interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port. (02)\r\n", portToBind)) + log.Printf("Port %d is blocked or restricted", portToBind) err := req.Reply(false, nil) if err != nil { log.Println("Failed to reply to request:", err) @@ -110,7 +109,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) { unassign, success := portUtil.Default.GetUnassignedPort() portToBind = unassign if !success { - s.interaction.SendMessage("No available port\r\n") + log.Println("No available port") err := req.Reply(false, nil) if err != nil { log.Println("Failed to reply to request:", err) @@ -123,7 +122,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) { return } } else if isUse, isExist := portUtil.Default.GetPortStatus(portToBind); isExist && isUse { - s.interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port. (03)\r\n", portToBind)) + log.Printf("Port %d is already in use or restricted", portToBind) err := req.Reply(false, nil) if err != nil { log.Println("Failed to reply to request:", err) @@ -196,18 +195,16 @@ func (s *SSHSession) HandleHTTPForward(req *ssh.Request, portToBind uint16) { s.forwarder.SetType(types.HTTP) s.forwarder.SetForwardedPort(portToBind) s.slugManager.Set(slug) - s.interaction.SendMessage("\033[H\033[2J") - s.interaction.ShowWelcomeMessage() - s.interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to %s://%s.%s\r\n", protocol, slug, domain)) + log.Printf("HTTP tunnel established: %s://%s.%s", protocol, slug, domain) s.lifecycle.SetStatus(types.RUNNING) - s.interaction.HandleUserInput() + s.interaction.Start() } func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) { log.Printf("Requested forwarding on %s:%d", addr, portToBind) listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", portToBind)) if err != nil { - s.interaction.SendMessage(fmt.Sprintf("Port %d is already in use or restricted. Please choose a different port.\r\n", portToBind)) + log.Printf("Port %d is already in use or restricted", portToBind) if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil { log.Printf("Failed to reset port status: %v", setErr) } @@ -256,12 +253,10 @@ func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind s.forwarder.SetType(types.TCP) s.forwarder.SetListener(listener) s.forwarder.SetForwardedPort(portToBind) - s.interaction.SendMessage("\033[H\033[2J") - s.interaction.ShowWelcomeMessage() - s.interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to tcp://%s:%d \r\n", utils.Getenv("DOMAIN", "localhost"), s.forwarder.GetForwardedPort())) + log.Printf("TCP tunnel established: tcp://%s:%d", utils.Getenv("DOMAIN", "localhost"), s.forwarder.GetForwardedPort()) s.lifecycle.SetStatus(types.RUNNING) go s.forwarder.AcceptTCPConnections() - s.interaction.HandleUserInput() + s.interaction.Start() } func generateUniqueSlug() string { diff --git a/session/interaction/interaction.go b/session/interaction/interaction.go index e20577c..a258fe0 100644 --- a/session/interaction/interaction.go +++ b/session/interaction/interaction.go @@ -1,9 +1,8 @@ package interaction import ( - "bytes" + "context" "fmt" - "io" "log" "strings" "time" @@ -11,6 +10,13 @@ import ( "tunnel_pls/types" "tunnel_pls/utils" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "golang.org/x/crypto/ssh" ) @@ -19,20 +25,10 @@ type Lifecycle interface { } type Controller interface { - SendMessage(message string) - HandleUserInput() - HandleCommand(command string) - HandleSlugEditMode(char byte) - HandleSlugSave() - HandleSlugCancel() - HandleSlugUpdateError() - ShowWelcomeMessage() - DisplaySlugEditor() SetChannel(channel ssh.Channel) SetLifecycle(lifecycle Lifecycle) SetSlugModificator(func(oldSlug, newSlug string) bool) - WaitForKeyPress() - ShowForwardingMessage() + Start() } type Forwarder interface { @@ -42,32 +38,58 @@ type Forwarder interface { } type Interaction struct { - inputLength int - commandBuffer *bytes.Buffer - interactiveMode bool - interactionType types.InteractionType - editSlug string channel ssh.Channel slugManager slug.Manager forwarder Forwarder lifecycle Lifecycle - pendingExit bool updateClientSlug func(oldSlug, newSlug string) bool + program *tea.Program + ctx context.Context + cancel context.CancelFunc } +type commandItem struct { + name string + desc string +} + +type model struct { + tunnelURL string + domain string + protocol string + tunnelType types.TunnelType + port uint16 + keymap keymap + help help.Model + quitting bool + showingCommands bool + editingSlug bool + showingComingSoon bool + commandList list.Model + slugInput textinput.Model + slugError string + interaction *Interaction +} + +type keymap struct { + quit key.Binding + command key.Binding + random key.Binding +} + +type tickMsg time.Time + func NewInteraction(slugManager slug.Manager, forwarder Forwarder) *Interaction { + ctx, cancel := context.WithCancel(context.Background()) return &Interaction{ - inputLength: 0, - commandBuffer: bytes.NewBuffer(make([]byte, 0, 20)), - interactiveMode: false, - interactionType: "", - editSlug: "", channel: nil, slugManager: slugManager, forwarder: forwarder, lifecycle: nil, - pendingExit: false, updateClientSlug: nil, + program: nil, + ctx: ctx, + cancel: cancel, } } @@ -79,414 +101,439 @@ func (i *Interaction) SetChannel(channel ssh.Channel) { i.channel = channel } -func (i *Interaction) SendMessage(message string) { - if i.channel == nil { - log.Printf("channel is nil") - } - - _, err := i.channel.Write([]byte(message)) - if err != nil && err != io.EOF { - log.Printf("error writing to channel: %s", err) - } - return -} - -func (i *Interaction) HandleUserInput() { - buf := make([]byte, 1) - i.interactiveMode = false - - for { - n, err := i.channel.Read(buf) - if err != nil { - i.handleReadError(err) - break - } - - if n > 0 { - i.processCharacter(buf[0]) - } - } -} - -func (i *Interaction) handleReadError(err error) { - if err != io.EOF { - log.Printf("Error reading from client: %s", err) - } -} - -func (i *Interaction) processCharacter(char byte) { - if i.interactiveMode { - i.handleInteractiveMode(char) - return - } - - if i.handleExitSequence(char) { - return - } - - i.SendMessage(string(char)) - i.handleNonInteractiveInput(char) -} - -func (i *Interaction) handleInteractiveMode(char byte) { - switch i.interactionType { - case types.Slug: - i.HandleSlugEditMode(char) - } -} - -func (i *Interaction) handleExitSequence(char byte) bool { - if char == ctrlC { - if i.pendingExit { - i.SendMessage("Closing connection...\r\n") - if err := i.lifecycle.Close(); err != nil { - log.Printf("failed to close session: %v", err) - } - return true - } - i.SendMessage("Please press Ctrl+C again to disconnect.\r\n") - i.pendingExit = true - return true - } - - if i.pendingExit && char != ctrlC { - i.pendingExit = false - i.SendMessage("Operation canceled.\r\n") - } - - return false -} - -func (i *Interaction) handleNonInteractiveInput(char byte) { - switch { - case char == backspaceChar || char == deleteChar: - i.handleBackspace() - case char == forwardSlash: - i.handleCommandStart() - case i.commandBuffer.Len() > 0: - i.handleCommandInput(char) - case char == enterChar: - i.SendMessage(clearLine) - default: - i.inputLength++ - } -} - -func (i *Interaction) handleBackspace() { - if i.inputLength > 0 { - i.SendMessage(backspaceSeq) - } - if i.commandBuffer.Len() > 0 { - i.commandBuffer.Truncate(i.commandBuffer.Len() - 1) - } -} - -func (i *Interaction) handleCommandStart() { - i.commandBuffer.Reset() - i.commandBuffer.WriteByte(forwardSlash) -} - -func (i *Interaction) handleCommandInput(char byte) { - if char == enterChar { - i.SendMessage(clearLine) - i.HandleCommand(i.commandBuffer.String()) - return - } - i.commandBuffer.WriteByte(char) - i.inputLength++ -} - -func (i *Interaction) HandleSlugEditMode(char byte) { - switch { - case char == enterChar: - i.HandleSlugSave() - case char == escapeChar || char == ctrlC: - i.HandleSlugCancel() - case char == backspaceChar || char == deleteChar: - i.handleSlugBackspace() - case char >= minPrintableChar && char <= maxPrintableChar: - i.appendToSlug(char) - } -} - -func (i *Interaction) handleSlugBackspace() { - if len(i.editSlug) > 0 { - i.editSlug = i.editSlug[:len(i.editSlug)-1] - i.refreshSlugDisplay() - } -} - -func (i *Interaction) appendToSlug(char byte) { - if len(i.editSlug) < maxSlugLength { - i.editSlug += string(char) - i.refreshSlugDisplay() - } -} - -func (i *Interaction) refreshSlugDisplay() { - domain := utils.Getenv("DOMAIN", "localhost") - i.SendMessage(clearToLineEnd) - i.SendMessage("➤ " + i.editSlug + "." + domain) -} - -func (i *Interaction) HandleSlugSave() { - i.SendMessage(clearScreen) - - switch { - case isForbiddenSlug(i.editSlug): - i.showForbiddenSlugMessage() - case !isValidSlug(i.editSlug): - i.showInvalidSlugMessage() - default: - i.updateSlug() - } - - i.WaitForKeyPress() - i.returnToMainScreen() -} - -func (i *Interaction) updateSlug() { - oldSlug := i.slugManager.Get() - newSlug := i.editSlug - - if !i.updateClientSlug(oldSlug, newSlug) { - i.HandleSlugUpdateError() - return - } - - domain := utils.Getenv("DOMAIN", "localhost") - i.SendMessage("\r\n\r\n✅ SUBDOMAIN UPDATED ✅\r\n\r\n") - i.SendMessage("Your new address is: " + newSlug + "." + domain + "\r\n\r\n") - i.SendMessage("Press any key to continue...\r\n") -} - -func (i *Interaction) showForbiddenSlugMessage() { - i.SendMessage("\r\n\r\n❌ FORBIDDEN SUBDOMAIN ❌\r\n\r\n") - i.SendMessage("This subdomain is not allowed.\r\n") - i.SendMessage("Please try a different subdomain.\r\n\r\n") - i.SendMessage("Press any key to continue...\r\n") -} - -func (i *Interaction) showInvalidSlugMessage() { - i.SendMessage("\r\n\r\n❌ INVALID SUBDOMAIN ❌\r\n\r\n") - i.SendMessage("Use only lowercase letters, numbers, and hyphens.\r\n") - i.SendMessage(fmt.Sprintf("Length must be %d-%d characters and cannot start or end with a hyphen.\r\n\r\n", minSlugLength, maxSlugLength)) - i.SendMessage("Press any key to continue...\r\n") -} - -func (i *Interaction) returnToMainScreen() { - i.SendMessage(clearScreen) - i.ShowWelcomeMessage() - i.ShowForwardingMessage() - i.interactiveMode = false - i.commandBuffer.Reset() -} - -func (i *Interaction) HandleSlugCancel() { - i.SendMessage(clearScreen) - i.SendMessage("\r\n\r\n⚠️ SUBDOMAIN EDIT CANCELLED ⚠️\r\n\r\n") - i.SendMessage("Press any key to continue...\r\n") - - i.interactiveMode = false - i.interactionType = "" - i.WaitForKeyPress() - - i.SendMessage(clearScreen) - i.ShowWelcomeMessage() - i.ShowForwardingMessage() -} - -func (i *Interaction) HandleSlugUpdateError() { - i.SendMessage("\r\n\r\n❌ SERVER ERROR ❌\r\n\r\n") - i.SendMessage("Failed to update subdomain. You will be disconnected in 5 seconds.\r\n\r\n") - - for countdown := 5; countdown > 0; countdown-- { - i.SendMessage(fmt.Sprintf("Disconnecting in %d...\r\n", countdown)) - time.Sleep(1 * time.Second) - } - - if err := i.lifecycle.Close(); err != nil { - log.Printf("failed to close session: %v", err) - } -} - -func (i *Interaction) HandleCommand(command string) { - handlers := map[string]func(){ - "/bye": i.handleByeCommand, - "/help": i.handleHelpCommand, - "/clear": i.handleClearCommand, - "/slug": i.handleSlugCommand, - } - - if handler, exists := handlers[command]; exists { - handler() - } else { - i.SendMessage("Unknown command\r\n") - } - - i.commandBuffer.Reset() -} - -func (i *Interaction) handleByeCommand() { - i.SendMessage("Closing connection...\r\n") - if err := i.lifecycle.Close(); err != nil { - log.Printf("failed to close session: %v", err) - } -} - -func (i *Interaction) handleHelpCommand() { - i.SendMessage("\r\nAvailable commands: /bye, /help, /clear, /slug\r\n") -} - -func (i *Interaction) handleClearCommand() { - i.SendMessage(clearScreen) - i.ShowWelcomeMessage() - i.ShowForwardingMessage() -} - -func (i *Interaction) handleSlugCommand() { - if i.forwarder.GetTunnelType() != types.HTTP { - i.SendMessage(fmt.Sprintf("\r\n%s tunnels cannot have custom subdomains\r\n", i.forwarder.GetTunnelType())) - return - } - - i.interactiveMode = true - i.interactionType = types.Slug - i.editSlug = i.slugManager.Get() - i.SendMessage(clearScreen) - i.DisplaySlugEditor() - - domain := utils.Getenv("DOMAIN", "localhost") - i.SendMessage("➤ " + i.editSlug + "." + domain) -} - -func (i *Interaction) ShowForwardingMessage() { - domain := utils.Getenv("DOMAIN", "localhost") - - if i.forwarder.GetTunnelType() == types.HTTP { - protocol := "http" - if utils.Getenv("TLS_ENABLED", "false") == "true" { - protocol = "https" - } - i.SendMessage(fmt.Sprintf("Forwarding your traffic to %s://%s.%s \r\n", protocol, i.slugManager.Get(), domain)) - } else { - i.SendMessage(fmt.Sprintf("Forwarding your traffic to tcp://%s:%d \r\n", domain, i.forwarder.GetForwardedPort())) - } -} - -func (i *Interaction) ShowWelcomeMessage() { - asciiArt := []string{ - ` _______ _ _____ _ `, - `|__ __| | | | __ \| | `, - ` | |_ _ _ __ _ __ ___| | | |__) | |___ `, - ` | | | | | '_ \| '_ \ / _ \ | | ___/| / __|`, - ` | | |_| | | | | | | | __/ | | | | \__ \`, - ` |_|\__,_|_| |_|_| |_|\___|_| |_| |_|___/`, - ``, - ` "Tunnel Pls" - Project by Bagas`, - ` https://fossy.my.id`, - ``, - ` Welcome to Tunnel! Available commands:`, - ` - '/bye' : Exit the tunnel`, - ` - '/help' : Show this help message`, - ` - '/clear' : Clear the current line`, - ` - '/slug' : Set custom subdomain`, - } - - for _, line := range asciiArt { - i.SendMessage("\r\n" + line) - } - i.SendMessage("\r\n\r\n") -} - -func (i *Interaction) DisplaySlugEditor() { - domain := utils.Getenv("DOMAIN", "localhost") - fullDomain := i.slugManager.Get() + "." + domain - - contentLine := " ║ Current: " + fullDomain - boxWidth := calculateBoxWidth(contentLine) - - box := buildSlugEditorBox(boxWidth, fullDomain) - i.SendMessage("\r\n\r\n" + box + "\r\n\r\n") -} - -func buildSlugEditorBox(boxWidth int, fullDomain string) string { - topBorder := " ╔" + strings.Repeat("═", boxWidth-4) + "╗\r\n" - title := centerText("SUBDOMAIN EDITOR", boxWidth-4) - header := " ║" + title + "║\r\n" - midBorder := " ╠" + strings.Repeat("═", boxWidth-4) + "╣\r\n" - emptyLine := " ║" + strings.Repeat(" ", boxWidth-4) + "║\r\n" - - currentLineContent := fmt.Sprintf(" ║ Current: %s", fullDomain) - currentLine := currentLineContent + strings.Repeat(" ", boxWidth-len(currentLineContent)+1) + "║\r\n" - - saveCancel := " ║ [Enter] Save | [Esc] Cancel" + strings.Repeat(" ", boxWidth-35) + "║\r\n" - bottomBorder := " ╚" + strings.Repeat("═", boxWidth-4) + "╝\r\n" - - return topBorder + header + midBorder + emptyLine + currentLine + emptyLine + emptyLine + midBorder + saveCancel + bottomBorder -} - func (i *Interaction) SetSlugModificator(modificator func(oldSlug, newSlug string) bool) { i.updateClientSlug = modificator } -func (i *Interaction) WaitForKeyPress() { - keyBuf := make([]byte, 1) - for { - _, err := i.channel.Read(keyBuf) - if err == nil { - break +func (i *Interaction) Stop() { + if i.cancel != nil { + i.cancel() + } + if i.program != nil { + i.program.Kill() + i.program = nil + } +} + +func (i commandItem) FilterValue() string { return i.name } +func (i commandItem) Title() string { return i.name } +func (i commandItem) Description() string { return i.desc } + +func tickCmd(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m model) Init() tea.Cmd { + return tea.Batch(textinput.Blink, tea.WindowSize()) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tickMsg: + m.showingComingSoon = false + return m, tea.Batch(tea.ClearScreen, textinput.Blink) + + case tea.WindowSizeMsg: + m.commandList.SetWidth(msg.Width) + m.commandList.SetHeight(msg.Height - 4) + return m, nil + + case tea.QuitMsg: + m.quitting = true + return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit) + + case tea.KeyMsg: + if m.showingComingSoon { + m.showingComingSoon = false + return m, tea.Batch(tea.ClearScreen, textinput.Blink) } - if err != nil { - log.Printf("Error reading keypress: %v", err) - break + + if m.editingSlug { + switch msg.String() { + case "esc": + m.editingSlug = false + m.slugError = "" + return m, tea.Batch(tea.ClearScreen, textinput.Blink) + case "enter": + inputValue := m.slugInput.Value() + m.interaction.updateClientSlug(m.interaction.slugManager.Get(), inputValue) + m.tunnelURL = buildURL(m.protocol, inputValue, m.domain) + m.editingSlug = false + m.slugError = "" + return m, tea.Batch(tea.ClearScreen, textinput.Blink) + case "ctrl+c": + m.editingSlug = false + m.slugError = "" + return m, tea.Batch(tea.ClearScreen, textinput.Blink) + default: + if key.Matches(msg, m.keymap.random) { + newSubdomain := generateRandomSubdomain() + m.slugInput.SetValue(newSubdomain) + m.slugError = "" + m.slugInput, cmd = m.slugInput.Update(msg) + return m, cmd + } + m.slugError = "" + m.slugInput, cmd = m.slugInput.Update(msg) + return m, cmd + } } - } -} -func calculateBoxWidth(contentLine string) int { - boxWidth := len(contentLine) + paddingRight + 1 - if boxWidth < minBoxWidth { - boxWidth = minBoxWidth - } - return boxWidth -} + if m.showingCommands { + switch { + case key.Matches(msg, m.keymap.quit): + m.showingCommands = false + return m, tea.Batch(tea.ClearScreen, textinput.Blink) + case msg.String() == "enter": + selectedItem := m.commandList.SelectedItem() + if selectedItem != nil { + item := selectedItem.(commandItem) + if item.name == "slug" { + m.showingCommands = false + m.editingSlug = true + m.slugInput.SetValue(m.interaction.slugManager.Get()) + m.slugInput.Focus() + return m, tea.Batch(tea.ClearScreen, textinput.Blink) + } else if item.name == "tunnel-type" { + m.showingCommands = false + m.showingComingSoon = true + return m, tea.Batch(tickCmd(5*time.Second), tea.ClearScreen, textinput.Blink) + } + m.showingCommands = false + return m, nil + } + case msg.String() == "esc": + m.showingCommands = false + return m, tea.Batch(tea.ClearScreen, textinput.Blink) + } + m.commandList, cmd = m.commandList.Update(msg) + return m, cmd + } -func centerText(text string, width int) string { - padding := (width - len(text)) / 2 - if padding < 0 { - padding = 0 - } - return strings.Repeat(" ", padding) + text + strings.Repeat(" ", width-len(text)-padding) -} - -func isValidSlug(slug string) bool { - if len(slug) < minSlugLength || len(slug) > maxSlugLength { - return false - } - - if slug[0] == '-' || slug[len(slug)-1] == '-' { - return false - } - - for _, c := range slug { - if !isValidSlugChar(byte(c)) { - return false + switch { + case key.Matches(msg, m.keymap.quit): + m.quitting = true + return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit) + case key.Matches(msg, m.keymap.command): + m.showingCommands = true + return m, tea.Batch(tea.ClearScreen, textinput.Blink) } } - return true + return m, nil } -func isValidSlugChar(c byte) bool { - return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' +func (m model) helpView() string { + return "\n" + m.help.ShortHelpView([]key.Binding{ + m.keymap.command, + m.keymap.quit, + }) } -func isForbiddenSlug(slug string) bool { - for _, s := range forbiddenSlugs { - if slug == s { - return true - } +func (m model) View() string { + if m.quitting { + return "" } - return false + + if m.showingComingSoon { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")). + PaddingTop(1). + PaddingBottom(1) + + messageBoxStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#1A1A2E")). + Bold(true). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7D56F4")). + Padding(1, 3). + MarginTop(2). + MarginBottom(2). + Align(lipgloss.Center) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Italic(true). + MarginTop(1) + + var b strings.Builder + b.WriteString("\n\n") + b.WriteString(titleStyle.Render("⏳ Coming Soon")) + b.WriteString("\n\n") + b.WriteString(messageBoxStyle.Render("🚀 This feature is coming very soon!\n Stay tuned for updates.")) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("This message will disappear in 5 seconds or press any key...")) + + return b.String() + } + + if m.editingSlug { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")). + PaddingTop(1). + PaddingBottom(1) + + instructionStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")). + MarginTop(1) + + inputBoxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7D56F4")). + Padding(1, 2). + MarginTop(2). + MarginBottom(2) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Italic(true). + MarginTop(1) + + errorBoxStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF0000")). + Background(lipgloss.Color("#3D0000")). + Bold(true). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#FF0000")). + Padding(0, 2). + MarginTop(1). + MarginBottom(1) + + rulesBoxStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FAFAFA")). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7D56F4")). + Padding(0, 2). + MarginTop(1). + MarginBottom(1) + + var b strings.Builder + b.WriteString(titleStyle.Render("🔧 Edit Subdomain")) + b.WriteString("\n\n") + + if m.tunnelType != types.HTTP { + warningBoxStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFA500")). + Background(lipgloss.Color("#3D2000")). + Bold(true). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#FFA500")). + Padding(1, 2). + MarginTop(2). + MarginBottom(2) + + b.WriteString(warningBoxStyle.Render("⚠️ TCP tunnels cannot have custom subdomains. Only HTTP/HTTPS tunnels support subdomain customization. ")) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render("Press Enter or Esc to go back")) + return b.String() + } + + rulesContent := "📋 Rules: \n\t• 3-20 chars \n\t• a-z, 0-9, - \n\t• No leading/trailing -" + b.WriteString(rulesBoxStyle.Render(rulesContent)) + b.WriteString("\n") + + b.WriteString(instructionStyle.Render("Enter your custom subdomain:")) + b.WriteString("\n") + + if m.slugError != "" { + errorInputBoxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#FF0000")). + Padding(1, 2). + MarginTop(2). + MarginBottom(1) + b.WriteString(errorInputBoxStyle.Render(m.slugInput.View())) + b.WriteString("\n") + b.WriteString(errorBoxStyle.Render("❌ " + m.slugError)) + b.WriteString("\n") + } else { + b.WriteString(inputBoxStyle.Render(m.slugInput.View())) + b.WriteString("\n") + } + + previewURL := buildURL(m.protocol, m.slugInput.Value(), m.domain) + previewStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")). + Italic(true). + Width(80) + b.WriteString(previewStyle.Render(fmt.Sprintf("Preview: %s", previewURL))) + b.WriteString("\n") + + b.WriteString(helpStyle.Render("Press Enter to save • CTRL+R for random • Esc to cancel")) + + return b.String() + } + + if m.showingCommands { + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")). + PaddingTop(1). + PaddingBottom(1) + + helpStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666666")). + Italic(true). + MarginTop(1) + + var b strings.Builder + b.WriteString("\n") + b.WriteString(titleStyle.Render("⚡ Commands")) + b.WriteString("\n\n") + b.WriteString(m.commandList.View()) + b.WriteString("\n") + b.WriteString(helpStyle.Render("↑/↓ Navigate • Enter Select • Esc Cancel")) + + return b.String() + } + + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")). + PaddingTop(1). + PaddingBottom(1) + + subtitleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Italic(true) + + urlStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#04B575")). + Underline(true) + + sectionTitleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FAFAFA")). + MarginTop(1). + MarginBottom(1) + + forwardingStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#04B575")). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#04B575")). + Padding(0, 2). + MarginTop(1). + MarginBottom(1) + + var b strings.Builder + + b.WriteString(titleStyle.Render("🚇 Tunnel Pls")) + b.WriteString("\n") + b.WriteString(subtitleStyle.Render("Project by Bagas")) + b.WriteString("\n") + b.WriteString(urlStyle.Render("https://fossy.my.id")) + b.WriteString("\n\n") + + b.WriteString(sectionTitleStyle.Render("Welcome to Tunnel!")) + b.WriteString("\n") + + b.WriteString("\n") + forwardingText := fmt.Sprintf("🌐 Forwarding your traffic to:\n %s", m.tunnelURL) + b.WriteString(forwardingStyle.Render(forwardingText)) + + b.WriteString(m.helpView()) + + return b.String() +} + +func (i *Interaction) Start() { + lipgloss.SetColorProfile(termenv.TrueColor) + + domain := utils.Getenv("DOMAIN", "localhost") + protocol := "http" + if utils.Getenv("TLS_ENABLED", "false") == "true" { + protocol = "https" + } + + tunnelType := i.forwarder.GetTunnelType() + port := i.forwarder.GetForwardedPort() + + var tunnelURL string + if tunnelType == types.HTTP { + tunnelURL = buildURL(protocol, i.slugManager.Get(), domain) + } else { + tunnelURL = fmt.Sprintf("tcp://%s:%d", domain, port) + } + + items := []list.Item{ + commandItem{name: "slug", desc: "Set custom subdomain"}, + commandItem{name: "tunnel-type", desc: "Change tunnel type (Coming Soon)"}, + } + + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = true + delegate.SetHeight(2) + + commandList := list.New(items, delegate, 80, 20) + commandList.Title = "Select a command" + commandList.SetShowStatusBar(false) + commandList.SetFilteringEnabled(false) + commandList.SetShowHelp(false) + + ti := textinput.New() + ti.Placeholder = "my-custom-slug" + ti.CharLimit = 20 + ti.Width = 50 + + m := model{ + tunnelURL: tunnelURL, + domain: domain, + protocol: protocol, + tunnelType: tunnelType, + port: port, + commandList: commandList, + slugInput: ti, + interaction: i, + keymap: keymap{ + quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + command: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "commands"), + ), + random: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "random"), + ), + }, + help: help.New(), + } + + i.program = tea.NewProgram( + m, + tea.WithInput(i.channel), + tea.WithOutput(i.channel), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + tea.WithoutSignals(), + tea.WithoutSignalHandler(), + ) + + _, err := i.program.Run() + if err != nil { + log.Printf("Cannot close tea: %s \n", err) + } + i.program.Kill() + i.program = nil + if err := m.interaction.lifecycle.Close(); err != nil { + log.Printf("Cannot close session: %s \n", err) + } +} + +func buildURL(protocol, subdomain, domain string) string { + return fmt.Sprintf("%s://%s.%s", protocol, subdomain, domain) +} + +func generateRandomSubdomain() string { + return utils.GenerateRandomString(20) } diff --git a/session/lifecycle/lifecycle.go b/session/lifecycle/lifecycle.go index 8ba1ac6..5917ba0 100644 --- a/session/lifecycle/lifecycle.go +++ b/session/lifecycle/lifecycle.go @@ -11,10 +11,6 @@ import ( "golang.org/x/crypto/ssh" ) -type Interaction interface { - SendMessage(string) -} - type Forwarder interface { Close() error GetTunnelType() types.TunnelType @@ -25,18 +21,16 @@ type Lifecycle struct { status types.Status conn ssh.Conn channel ssh.Channel - interaction Interaction forwarder Forwarder slugManager slug.Manager unregisterClient func(slug string) } -func NewLifecycle(conn ssh.Conn, interaction Interaction, forwarder Forwarder, slugManager slug.Manager) *Lifecycle { +func NewLifecycle(conn ssh.Conn, forwarder Forwarder, slugManager slug.Manager) *Lifecycle { return &Lifecycle{ status: "", conn: conn, channel: nil, - interaction: interaction, forwarder: forwarder, slugManager: slugManager, unregisterClient: nil, diff --git a/session/session.go b/session/session.go index 7f50e2d..deb48bd 100644 --- a/session/session.go +++ b/session/session.go @@ -1,7 +1,6 @@ package session import ( - "fmt" "log" "sync" "time" @@ -53,7 +52,7 @@ func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan slugManager := slug.NewManager() forwarderManager := forwarder.NewForwarder(slugManager) interactionManager := interaction.NewInteraction(slugManager, forwarderManager) - lifecycleManager := lifecycle.NewLifecycle(conn, interactionManager, forwarderManager, slugManager) + lifecycleManager := lifecycle.NewLifecycle(conn, forwarderManager, slugManager) interactionManager.SetLifecycle(lifecycleManager) interactionManager.SetSlugModificator(updateClientSlug) @@ -80,7 +79,7 @@ func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan tcpipReq := session.waitForTCPIPForward(forwardingReq) if tcpipReq == nil { - session.interaction.SendMessage(fmt.Sprintf("Port forwarding request not received.\r\nEnsure you ran the correct command with -R flag.\r\nExample: ssh %s -p %s -R 80:localhost:3000\r\nFor more details, visit https://tunnl.live.\r\n\r\n", utils.Getenv("DOMAIN", "localhost"), utils.Getenv("PORT", "2200"))) + log.Printf("Port forwarding request not received. Ensure you ran the correct command with -R flag. Example: ssh %s -p %s -R 80:localhost:3000", utils.Getenv("DOMAIN", "localhost"), utils.Getenv("PORT", "2200")) if err := session.lifecycle.Close(); err != nil { log.Printf("failed to close session: %v", err) } diff --git a/types/types.go b/types/types.go index e0fc74b..f909da5 100644 --- a/types/types.go +++ b/types/types.go @@ -15,12 +15,6 @@ const ( TCP TunnelType = "TCP" ) -type InteractionType string - -const ( - Slug InteractionType = "SLUG" -) - var BadGatewayResponse = []byte("HTTP/1.1 502 Bad Gateway\r\n" + "Content-Length: 11\r\n" + "Content-Type: text/plain\r\n\r\n" +