feat(tui): update interaction layer to Bubble Tea TUI
All checks were successful
renovate / renovate (push) Successful in 27s
Docker Build and Push / build-and-push (push) Successful in 3m49s

This commit is contained in:
2025-12-29 21:55:39 +07:00
parent a7d9b2ab8a
commit 85f21e7698
9 changed files with 549 additions and 451 deletions

20
go.mod
View File

@@ -4,17 +4,37 @@ go 1.24.4
require ( require (
github.com/caddyserver/certmagic v0.25.0 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/joho/godotenv v1.5.1
github.com/libdns/cloudflare v0.2.2 github.com/libdns/cloudflare v0.2.2
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
) )
require ( 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/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/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.1 // 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/mholt/acmez/v3 v3.1.3 // indirect
github.com/miekg/dns v1.1.68 // 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 github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect

51
go.sum
View File

@@ -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 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic=
github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= 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 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= 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 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= 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 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc=
github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= 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 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= 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= 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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 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 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=

View File

@@ -330,7 +330,6 @@ func Handler(conn net.Conn) {
return return
} }
cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr()) cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr())
cw.SetInteraction(sshSession.GetInteraction())
forwardRequest(cw, reqhf, sshSession) forwardRequest(cw, reqhf, sshSession)
return return
} }

View File

@@ -104,7 +104,6 @@ func HandlerTLS(conn net.Conn) {
return return
} }
cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr()) cw := NewCustomWriter(conn, dstReader, conn.RemoteAddr())
cw.SetInteraction(sshSession.GetInteraction())
forwardRequest(cw, reqhf, sshSession) forwardRequest(cw, reqhf, sshSession)
return return
} }

View File

@@ -59,7 +59,6 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
var rawPortToBind uint32 var rawPortToBind uint32
if err := binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil { if err := binary.Read(reader, binary.BigEndian, &rawPortToBind); err != nil {
log.Println("Failed to read port from payload:", err) 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) err := req.Reply(false, nil)
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) log.Println("Failed to reply to request:", err)
@@ -73,7 +72,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
} }
if rawPortToBind > 65535 { 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) err := req.Reply(false, nil)
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) log.Println("Failed to reply to request:", err)
@@ -89,7 +88,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
portToBind := uint16(rawPortToBind) portToBind := uint16(rawPortToBind)
if isBlockedPort(portToBind) { 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) err := req.Reply(false, nil)
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) log.Println("Failed to reply to request:", err)
@@ -110,7 +109,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
unassign, success := portUtil.Default.GetUnassignedPort() unassign, success := portUtil.Default.GetUnassignedPort()
portToBind = unassign portToBind = unassign
if !success { if !success {
s.interaction.SendMessage("No available port\r\n") log.Println("No available port")
err := req.Reply(false, nil) err := req.Reply(false, nil)
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) log.Println("Failed to reply to request:", err)
@@ -123,7 +122,7 @@ func (s *SSHSession) HandleTCPIPForward(req *ssh.Request) {
return return
} }
} else if isUse, isExist := portUtil.Default.GetPortStatus(portToBind); isExist && isUse { } 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) err := req.Reply(false, nil)
if err != nil { if err != nil {
log.Println("Failed to reply to request:", err) 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.SetType(types.HTTP)
s.forwarder.SetForwardedPort(portToBind) s.forwarder.SetForwardedPort(portToBind)
s.slugManager.Set(slug) s.slugManager.Set(slug)
s.interaction.SendMessage("\033[H\033[2J") log.Printf("HTTP tunnel established: %s://%s.%s", protocol, slug, domain)
s.interaction.ShowWelcomeMessage()
s.interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to %s://%s.%s\r\n", protocol, slug, domain))
s.lifecycle.SetStatus(types.RUNNING) s.lifecycle.SetStatus(types.RUNNING)
s.interaction.HandleUserInput() s.interaction.Start()
} }
func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) { func (s *SSHSession) HandleTCPForward(req *ssh.Request, addr string, portToBind uint16) {
log.Printf("Requested forwarding on %s:%d", addr, portToBind) log.Printf("Requested forwarding on %s:%d", addr, portToBind)
listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", portToBind)) listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", portToBind))
if err != nil { 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 { if setErr := portUtil.Default.SetPortStatus(portToBind, false); setErr != nil {
log.Printf("Failed to reset port status: %v", setErr) 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.SetType(types.TCP)
s.forwarder.SetListener(listener) s.forwarder.SetListener(listener)
s.forwarder.SetForwardedPort(portToBind) s.forwarder.SetForwardedPort(portToBind)
s.interaction.SendMessage("\033[H\033[2J") log.Printf("TCP tunnel established: tcp://%s:%d", utils.Getenv("DOMAIN", "localhost"), s.forwarder.GetForwardedPort())
s.interaction.ShowWelcomeMessage()
s.interaction.SendMessage(fmt.Sprintf("Forwarding your traffic to tcp://%s:%d \r\n", utils.Getenv("DOMAIN", "localhost"), s.forwarder.GetForwardedPort()))
s.lifecycle.SetStatus(types.RUNNING) s.lifecycle.SetStatus(types.RUNNING)
go s.forwarder.AcceptTCPConnections() go s.forwarder.AcceptTCPConnections()
s.interaction.HandleUserInput() s.interaction.Start()
} }
func generateUniqueSlug() string { func generateUniqueSlug() string {

View File

@@ -1,9 +1,8 @@
package interaction package interaction
import ( import (
"bytes" "context"
"fmt" "fmt"
"io"
"log" "log"
"strings" "strings"
"time" "time"
@@ -11,6 +10,13 @@ import (
"tunnel_pls/types" "tunnel_pls/types"
"tunnel_pls/utils" "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" "golang.org/x/crypto/ssh"
) )
@@ -19,20 +25,10 @@ type Lifecycle interface {
} }
type Controller interface { type Controller interface {
SendMessage(message string)
HandleUserInput()
HandleCommand(command string)
HandleSlugEditMode(char byte)
HandleSlugSave()
HandleSlugCancel()
HandleSlugUpdateError()
ShowWelcomeMessage()
DisplaySlugEditor()
SetChannel(channel ssh.Channel) SetChannel(channel ssh.Channel)
SetLifecycle(lifecycle Lifecycle) SetLifecycle(lifecycle Lifecycle)
SetSlugModificator(func(oldSlug, newSlug string) bool) SetSlugModificator(func(oldSlug, newSlug string) bool)
WaitForKeyPress() Start()
ShowForwardingMessage()
} }
type Forwarder interface { type Forwarder interface {
@@ -42,32 +38,58 @@ type Forwarder interface {
} }
type Interaction struct { type Interaction struct {
inputLength int
commandBuffer *bytes.Buffer
interactiveMode bool
interactionType types.InteractionType
editSlug string
channel ssh.Channel channel ssh.Channel
slugManager slug.Manager slugManager slug.Manager
forwarder Forwarder forwarder Forwarder
lifecycle Lifecycle lifecycle Lifecycle
pendingExit bool
updateClientSlug func(oldSlug, newSlug string) 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 { func NewInteraction(slugManager slug.Manager, forwarder Forwarder) *Interaction {
ctx, cancel := context.WithCancel(context.Background())
return &Interaction{ return &Interaction{
inputLength: 0,
commandBuffer: bytes.NewBuffer(make([]byte, 0, 20)),
interactiveMode: false,
interactionType: "",
editSlug: "",
channel: nil, channel: nil,
slugManager: slugManager, slugManager: slugManager,
forwarder: forwarder, forwarder: forwarder,
lifecycle: nil, lifecycle: nil,
pendingExit: false,
updateClientSlug: nil, updateClientSlug: nil,
program: nil,
ctx: ctx,
cancel: cancel,
} }
} }
@@ -79,414 +101,439 @@ func (i *Interaction) SetChannel(channel ssh.Channel) {
i.channel = 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) { func (i *Interaction) SetSlugModificator(modificator func(oldSlug, newSlug string) bool) {
i.updateClientSlug = modificator i.updateClientSlug = modificator
} }
func (i *Interaction) WaitForKeyPress() { func (i *Interaction) Stop() {
keyBuf := make([]byte, 1) if i.cancel != nil {
for { i.cancel()
_, err := i.channel.Read(keyBuf)
if err == nil {
break
} }
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 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
}
}
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
}
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 m, nil
}
func (m model) helpView() string {
return "\n" + m.help.ShortHelpView([]key.Binding{
m.keymap.command,
m.keymap.quit,
})
}
func (m model) View() string {
if m.quitting {
return ""
}
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 { if err != nil {
log.Printf("Error reading keypress: %v", err) log.Printf("Cannot close tea: %s \n", err)
break
} }
i.program.Kill()
i.program = nil
if err := m.interaction.lifecycle.Close(); err != nil {
log.Printf("Cannot close session: %s \n", err)
} }
} }
func calculateBoxWidth(contentLine string) int { func buildURL(protocol, subdomain, domain string) string {
boxWidth := len(contentLine) + paddingRight + 1 return fmt.Sprintf("%s://%s.%s", protocol, subdomain, domain)
if boxWidth < minBoxWidth {
boxWidth = minBoxWidth
}
return boxWidth
} }
func centerText(text string, width int) string { func generateRandomSubdomain() string {
padding := (width - len(text)) / 2 return utils.GenerateRandomString(20)
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
}
}
return true
}
func isValidSlugChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'
}
func isForbiddenSlug(slug string) bool {
for _, s := range forbiddenSlugs {
if slug == s {
return true
}
}
return false
} }

View File

@@ -11,10 +11,6 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type Interaction interface {
SendMessage(string)
}
type Forwarder interface { type Forwarder interface {
Close() error Close() error
GetTunnelType() types.TunnelType GetTunnelType() types.TunnelType
@@ -25,18 +21,16 @@ type Lifecycle struct {
status types.Status status types.Status
conn ssh.Conn conn ssh.Conn
channel ssh.Channel channel ssh.Channel
interaction Interaction
forwarder Forwarder forwarder Forwarder
slugManager slug.Manager slugManager slug.Manager
unregisterClient func(slug string) 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{ return &Lifecycle{
status: "", status: "",
conn: conn, conn: conn,
channel: nil, channel: nil,
interaction: interaction,
forwarder: forwarder, forwarder: forwarder,
slugManager: slugManager, slugManager: slugManager,
unregisterClient: nil, unregisterClient: nil,

View File

@@ -1,7 +1,6 @@
package session package session
import ( import (
"fmt"
"log" "log"
"sync" "sync"
"time" "time"
@@ -53,7 +52,7 @@ func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan
slugManager := slug.NewManager() slugManager := slug.NewManager()
forwarderManager := forwarder.NewForwarder(slugManager) forwarderManager := forwarder.NewForwarder(slugManager)
interactionManager := interaction.NewInteraction(slugManager, forwarderManager) interactionManager := interaction.NewInteraction(slugManager, forwarderManager)
lifecycleManager := lifecycle.NewLifecycle(conn, interactionManager, forwarderManager, slugManager) lifecycleManager := lifecycle.NewLifecycle(conn, forwarderManager, slugManager)
interactionManager.SetLifecycle(lifecycleManager) interactionManager.SetLifecycle(lifecycleManager)
interactionManager.SetSlugModificator(updateClientSlug) interactionManager.SetSlugModificator(updateClientSlug)
@@ -80,7 +79,7 @@ func New(conn *ssh.ServerConn, forwardingReq <-chan *ssh.Request, sshChan <-chan
tcpipReq := session.waitForTCPIPForward(forwardingReq) tcpipReq := session.waitForTCPIPForward(forwardingReq)
if tcpipReq == nil { 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 { if err := session.lifecycle.Close(); err != nil {
log.Printf("failed to close session: %v", err) log.Printf("failed to close session: %v", err)
} }

View File

@@ -15,12 +15,6 @@ const (
TCP TunnelType = "TCP" TCP TunnelType = "TCP"
) )
type InteractionType string
const (
Slug InteractionType = "SLUG"
)
var BadGatewayResponse = []byte("HTTP/1.1 502 Bad Gateway\r\n" + var BadGatewayResponse = []byte("HTTP/1.1 502 Bad Gateway\r\n" +
"Content-Length: 11\r\n" + "Content-Length: 11\r\n" +
"Content-Type: text/plain\r\n\r\n" + "Content-Type: text/plain\r\n\r\n" +