Implement all together
This commit is contained in:
@@ -1,15 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
ssh-server:
|
ssh-server:
|
||||||
build: .
|
image: dcorral3/go-ssh-server-command:latest
|
||||||
ports:
|
ports:
|
||||||
- "22:22"
|
- "22:22"
|
||||||
cap_add:
|
cap_add:
|
||||||
- SYS_CHROOT
|
- SYS_CHROOT
|
||||||
environment:
|
environment:
|
||||||
- COMMAND=/app/tui
|
- PORT=22 # Change to any port for internal port listen
|
||||||
volumes:
|
|
||||||
- ssh_host_key:/app/host_key
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
ssh_host_key:
|
|
||||||
37
go.mod
37
go.mod
@@ -3,9 +3,40 @@ module sshserver
|
|||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/creack/pty/v2 v2.0.1
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/charmbracelet/log v0.4.2
|
||||||
|
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
|
||||||
|
github.com/charmbracelet/wish v1.4.7
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
golang.org/x/term v0.36.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.37.0 // indirect
|
require (
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/keygen v0.5.3 // 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/conpty v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
|
||||||
|
github.com/charmbracelet/x/input v0.3.4 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.0 // indirect
|
||||||
|
github.com/creack/pty v1.1.21 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0 // 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/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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
75
go.sum
75
go.sum
@@ -1,8 +1,79 @@
|
|||||||
github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
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/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/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4=
|
||||||
|
github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk=
|
||||||
|
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/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||||
|
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||||
|
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
|
||||||
|
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
|
||||||
|
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
|
||||||
|
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
|
||||||
|
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/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||||
|
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
|
github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0=
|
||||||
|
github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
|
||||||
|
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||||
|
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
|
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/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
|
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/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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
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=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
|
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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
430
main.go
430
main.go
@@ -1,250 +1,236 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io"
|
"errors"
|
||||||
"log"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/signal"
|
||||||
"golang.org/x/crypto/ssh"
|
"syscall"
|
||||||
"golang.org/x/term"
|
"time"
|
||||||
"github.com/creack/pty/v2"
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/log"
|
||||||
|
"github.com/charmbracelet/ssh"
|
||||||
|
"github.com/charmbracelet/wish"
|
||||||
|
"github.com/charmbracelet/wish/activeterm"
|
||||||
|
"github.com/charmbracelet/wish/bubbletea"
|
||||||
|
"github.com/charmbracelet/wish/logging"
|
||||||
|
"github.com/charmbracelet/wish/recover"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
type Theme struct {
|
||||||
if os.Getenv("COMMAND") == "" {
|
Bg lipgloss.Color
|
||||||
log.Fatal("COMMAND environment variable must be set")
|
Fg lipgloss.Color
|
||||||
|
Red lipgloss.Color
|
||||||
|
Green lipgloss.Color
|
||||||
|
Yellow lipgloss.Color
|
||||||
|
Blue lipgloss.Color
|
||||||
|
Purple lipgloss.Color
|
||||||
|
Aqua lipgloss.Color
|
||||||
|
Orange lipgloss.Color
|
||||||
|
Gray lipgloss.Color
|
||||||
}
|
}
|
||||||
config := &ssh.ServerConfig{
|
|
||||||
Config: ssh.Config{
|
func gruvboxDark() Theme {
|
||||||
KeyExchanges: []string{"mlkem768x25519-sha256", "curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group16-sha512"},
|
return Theme{
|
||||||
},
|
Bg: lipgloss.Color("#282828"),
|
||||||
NoClientAuth: true,
|
Fg: lipgloss.Color("#ebdbb2"),
|
||||||
|
Red: lipgloss.Color("#cc241d"),
|
||||||
|
Green: lipgloss.Color("#98971a"),
|
||||||
|
Yellow: lipgloss.Color("#d79921"),
|
||||||
|
Blue: lipgloss.Color("#458588"),
|
||||||
|
Purple: lipgloss.Color("#b16286"),
|
||||||
|
Aqua: lipgloss.Color("#689d6a"),
|
||||||
|
Orange: lipgloss.Color("#d65d0e"),
|
||||||
|
Gray: lipgloss.Color("#928374"),
|
||||||
}
|
}
|
||||||
var signer ssh.Signer
|
|
||||||
keyFile := "/app/host_key"
|
|
||||||
if data, err := os.ReadFile(keyFile); err == nil {
|
|
||||||
signer, err = ssh.ParsePrivateKey(data)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to parse existing host key:", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
showWelcome bool
|
||||||
|
focus int
|
||||||
|
showHelp bool
|
||||||
|
theme Theme
|
||||||
|
blink bool
|
||||||
|
blinkCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
const numBoxes = 4
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd {
|
||||||
|
return tea.Tick(time.Millisecond*70, func(t time.Time) tea.Msg {
|
||||||
|
return tickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type tickMsg time.Time
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tickMsg:
|
||||||
|
m.blinkCount++
|
||||||
|
m.blink = (m.blinkCount / 3) % 2 == 0
|
||||||
|
return m, tea.Tick(time.Millisecond*70, func(t time.Time) tea.Msg {
|
||||||
|
return tickMsg(t)
|
||||||
|
})
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if m.showWelcome {
|
||||||
|
m.showWelcome = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.showHelp {
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "esc", "?", "enter", "backspace":
|
||||||
|
m.showHelp = false
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch msg.String() {
|
||||||
|
case "h", "left":
|
||||||
|
m.focus = (m.focus + 3) % numBoxes
|
||||||
|
case "l", "right":
|
||||||
|
m.focus = (m.focus + 1) % numBoxes
|
||||||
|
case "j", "down":
|
||||||
|
if m.focus < 2 {
|
||||||
|
m.focus += 2
|
||||||
} else {
|
} else {
|
||||||
|
m.focus -= 2
|
||||||
|
}
|
||||||
|
case "k", "up":
|
||||||
|
if m.focus >= 2 {
|
||||||
|
m.focus -= 2
|
||||||
|
} else {
|
||||||
|
m.focus += 2
|
||||||
|
}
|
||||||
|
case "?":
|
||||||
|
m.showHelp = true
|
||||||
|
case "q", "esc":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
if m.showWelcome {
|
||||||
|
fg := lipgloss.NewStyle().Foreground(m.theme.Fg).Background(m.theme.Bg)
|
||||||
|
accentFg := lipgloss.NewStyle().Foreground(m.theme.Blue).Background(m.theme.Bg)
|
||||||
|
if m.blink {
|
||||||
|
accentFg = lipgloss.NewStyle().Foreground(m.theme.Bg).Background(m.theme.Bg)
|
||||||
|
}
|
||||||
|
welcome := fg.Render("Welcome to ") +
|
||||||
|
lipgloss.NewStyle().Foreground(m.theme.Orange).Background(m.theme.Bg).Render("dcorral.com") +
|
||||||
|
fg.Render("!") + "\n" +
|
||||||
|
fg.Render("press ") + accentFg.Render("any key") + fg.Render(" to continue")
|
||||||
|
return welcome
|
||||||
|
}
|
||||||
|
if m.showHelp {
|
||||||
|
return lipgloss.NewStyle().Foreground(m.theme.Fg).Background(m.theme.Bg).Render(
|
||||||
|
"Navigation:\n" +
|
||||||
|
"h / ←: Move left\n" +
|
||||||
|
"l / →: Move right\n" +
|
||||||
|
"j / ↓: Move down\n" +
|
||||||
|
"k / ↑: Move up\n" +
|
||||||
|
"? : Show help\n" +
|
||||||
|
"q / Esc: Quit",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Render the boxes
|
||||||
|
boxes := make([]string, numBoxes)
|
||||||
|
for i := range boxes {
|
||||||
|
var style lipgloss.Style
|
||||||
|
if i == m.focus {
|
||||||
|
style = lipgloss.NewStyle().Background(m.theme.Blue).Foreground(m.theme.Bg).Border(lipgloss.NormalBorder()).Padding(0, 1)
|
||||||
|
} else {
|
||||||
|
style = lipgloss.NewStyle().Background(m.theme.Bg).Foreground(m.theme.Fg).Border(lipgloss.NormalBorder()).Padding(0, 1)
|
||||||
|
}
|
||||||
|
boxes[i] = style.Render("Box " + string(rune('0'+i)))
|
||||||
|
}
|
||||||
|
grid := lipgloss.JoinHorizontal(lipgloss.Top, boxes[0], " ", boxes[1]) + "\n" +
|
||||||
|
lipgloss.JoinHorizontal(lipgloss.Top, boxes[2], " ", boxes[3]) + "\n" +
|
||||||
|
lipgloss.NewStyle().Foreground(m.theme.Fg).Background(m.theme.Bg).Render("Use hjkl or arrows to navigate, ? for help, q to quit")
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Generate host key
|
||||||
_, key, err := ed25519.GenerateKey(rand.Reader)
|
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Error("Failed to generate host key", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
signer, err = ssh.NewSignerFromKey(key)
|
block, err := gossh.MarshalPrivateKey(key, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Error("Failed to marshal host key", "error", err)
|
||||||
}
|
os.Exit(1)
|
||||||
block, err := ssh.MarshalPrivateKey(key, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Failed to marshal host key:", err)
|
|
||||||
}
|
}
|
||||||
privateKeyBytes := pem.EncodeToMemory(block)
|
privateKeyBytes := pem.EncodeToMemory(block)
|
||||||
if err := os.WriteFile(keyFile, privateKeyBytes, 0600); err != nil {
|
log.Info("Generated host key")
|
||||||
log.Fatal("Failed to save host key:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.AddHostKey(signer)
|
|
||||||
listener, err := net.Listen("tcp", ":22")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
log.Println("SSH server listening on :22")
|
|
||||||
for {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Accept error:", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go handleConn(conn, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleConn(conn net.Conn, config *ssh.ServerConfig) {
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
|
signalChan := make(chan os.Signal, 1)
|
||||||
if err != nil {
|
signal.Notify(signalChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||||
log.Println("ServerConn error:", err)
|
|
||||||
conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if acm, ok := sshConn.Conn.(ssh.AlgorithmsConnMetadata); ok {
|
|
||||||
log.Println("Negotiated KEX:", acm.Algorithms().KeyExchange)
|
|
||||||
}
|
|
||||||
log.Println("New connection from", sshConn.RemoteAddr(), "user", sshConn.User())
|
|
||||||
go ssh.DiscardRequests(reqs)
|
|
||||||
for newChannel := range chans {
|
|
||||||
if newChannel.ChannelType() != "session" {
|
|
||||||
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
channel, requests, err := newChannel.Accept()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Channel accept error:", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go handleChannel(channel, requests)
|
|
||||||
}
|
|
||||||
sshConn.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleChannel(channel ssh.Channel, requests <-chan *ssh.Request) {
|
|
||||||
defer channel.Close()
|
|
||||||
var ptmx *os.File
|
|
||||||
var termWidth, termHeight uint32 = 80, 24
|
|
||||||
var clientTerm string
|
|
||||||
var ptyAllocated bool // Track if PTY is allocated
|
|
||||||
|
|
||||||
for req := range requests {
|
|
||||||
switch req.Type {
|
|
||||||
case "env":
|
|
||||||
// Handle environment requests (e.g., client TERM propagation)
|
|
||||||
if len(req.Payload) >= 7 && string(req.Payload[:7]) == "TERM=\x00" {
|
|
||||||
termLen := int(req.Payload[7])
|
|
||||||
if len(req.Payload) >= 8+termLen {
|
|
||||||
clientTerm = string(req.Payload[8 : 8+termLen])
|
|
||||||
log.Println("Client TERM from env:", clientTerm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.Reply(true, nil)
|
|
||||||
case "pty-req":
|
|
||||||
// Allocate PTY early on pty-req, before shell/exec
|
|
||||||
if ptyAllocated {
|
|
||||||
req.Reply(false, nil)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(req.Payload) >= 4 {
|
|
||||||
termLen := binary.BigEndian.Uint32(req.Payload[0:4])
|
|
||||||
if len(req.Payload) >= int(4+termLen+16) {
|
|
||||||
clientTerm = string(req.Payload[4 : 4+termLen])
|
|
||||||
log.Println("Client TERM from pty-req:", clientTerm)
|
|
||||||
cols := binary.BigEndian.Uint32(req.Payload[4+termLen : 4+termLen+4])
|
|
||||||
rows := binary.BigEndian.Uint32(req.Payload[4+termLen+4 : 4+termLen+8])
|
|
||||||
if cols > 0 {
|
|
||||||
termWidth = cols
|
|
||||||
}
|
|
||||||
if rows > 0 {
|
|
||||||
termHeight = rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Set default TERM if not provided (TUI-compatible)
|
|
||||||
if clientTerm == "" {
|
|
||||||
clientTerm = "xterm-256color"
|
|
||||||
}
|
|
||||||
command := os.Getenv("COMMAND")
|
|
||||||
if command == "" {
|
|
||||||
command = "/app/tui"
|
|
||||||
}
|
|
||||||
cmd := exec.Command(command)
|
|
||||||
envTerm := "TERM=" + clientTerm
|
|
||||||
cmd.Env = []string{"PATH=/bin", envTerm}
|
|
||||||
cmd.Dir = "/"
|
|
||||||
var err error
|
|
||||||
ptmx, err = pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(termWidth), Rows: uint16(termHeight)})
|
|
||||||
if err != nil {
|
|
||||||
log.Println("PTY start error:", err)
|
|
||||||
req.Reply(false, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Make raw mode on master (server side)
|
|
||||||
if _, err := term.MakeRaw(int(ptmx.Fd())); err != nil {
|
|
||||||
log.Println("MakeRaw master error:", err)
|
|
||||||
}
|
|
||||||
// Note: Slave (cmd side) is already raw via pty.Start, but ensure via setsid if needed
|
|
||||||
ptyAllocated = true
|
|
||||||
// Start I/O bridging immediately
|
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
<-signalChan
|
||||||
ptmx.Close()
|
cancel()
|
||||||
channel.Close()
|
|
||||||
}()
|
}()
|
||||||
// Bidirectional copy with error handling
|
|
||||||
done := make(chan error, 2)
|
port := os.Getenv("PORT")
|
||||||
go func() { done <- io.Copy(channel, ptmx) }()
|
if port == "" {
|
||||||
go func() { done <- io.Copy(ptmx, channel) }()
|
port = "22"
|
||||||
<-done // Wait for one to finish
|
}
|
||||||
cmd.Process.Signal(os.Interrupt) // Graceful shutdown
|
|
||||||
<-done
|
s, err := wish.NewServer(
|
||||||
}()
|
wish.WithAddress(net.JoinHostPort("0.0.0.0", port)),
|
||||||
req.Reply(true, nil)
|
wish.WithHostKeyPEM(privateKeyBytes),
|
||||||
// Wait for cmd to finish after PTY setup
|
wish.WithMiddleware(
|
||||||
|
recover.Middleware(
|
||||||
|
bubbletea.Middleware(teaHandler),
|
||||||
|
activeterm.Middleware(),
|
||||||
|
logging.Middleware(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
wish.WithKeyboardInteractiveAuth(
|
||||||
|
func(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not start server", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Starting SSH server", "port", port)
|
||||||
go func() {
|
go func() {
|
||||||
cmd.Wait()
|
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
||||||
channel.SendRequest("exit-status", false, []byte{0}) // Send exit status
|
log.Error("Could not start server", "error", err)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
return // PTY session started, no more requests
|
|
||||||
case "window-change":
|
<-ctx.Done()
|
||||||
// Handle resizes post-PTY allocation
|
s.Shutdown(ctx)
|
||||||
if ptyAllocated && ptmx != nil {
|
slog.Info("Shutting down server")
|
||||||
width := binary.BigEndian.Uint32(req.Payload)
|
|
||||||
height := binary.BigEndian.Uint32(req.Payload[4:])
|
|
||||||
if width > 0 {
|
|
||||||
termWidth = width
|
|
||||||
}
|
|
||||||
if height > 0 {
|
|
||||||
termHeight = height
|
|
||||||
}
|
|
||||||
pty.Setsize(ptmx, &pty.Winsize{Cols: uint16(termWidth), Rows: uint16(termHeight)})
|
|
||||||
}
|
|
||||||
req.Reply(true, nil)
|
|
||||||
case "shell":
|
|
||||||
// Only handle if no PTY (fallback, but PTY should be allocated first)
|
|
||||||
if !ptyAllocated {
|
|
||||||
req.Reply(true, nil)
|
|
||||||
// Fallback to original shell logic if needed
|
|
||||||
log.Println("Shell requested without PTY")
|
|
||||||
} else {
|
|
||||||
req.Reply(false, nil)
|
|
||||||
}
|
|
||||||
case "exec":
|
|
||||||
// Handle exec separately if no PTY
|
|
||||||
if ptyAllocated {
|
|
||||||
req.Reply(false, nil)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
req.Reply(true, nil)
|
|
||||||
command := string(req.Payload[4:])
|
|
||||||
runCommand(channel, command)
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
req.Reply(false, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCommand(channel ssh.Channel, command string) {
|
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
defer channel.Close()
|
return model{
|
||||||
cmd := exec.Command("/bin/sh", "-c", command)
|
showWelcome: true,
|
||||||
cmd.Env = []string{"PATH=/bin"}
|
focus: 0,
|
||||||
cmd.Dir = "/"
|
showHelp: false,
|
||||||
stdin, err := cmd.StdinPipe()
|
theme: gruvboxDark(),
|
||||||
if err != nil {
|
blink: false,
|
||||||
log.Println("StdinPipe error:", err)
|
blinkCount: 0,
|
||||||
return
|
}, []tea.ProgramOption{tea.WithAltScreen()}
|
||||||
}
|
}
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("StdoutPipe error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stderr, err := cmd.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
log.Println("StderrPipe error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
log.Println("Start error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() { io.Copy(stdin, channel); stdin.Close() }()
|
|
||||||
go func() { io.Copy(channel, stdout); stdout.Close() }()
|
|
||||||
go func() { io.Copy(channel, stderr); stderr.Close() }()
|
|
||||||
cmd.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user