From 25f29e2dc4b766dd4daa860b904fe4e80350890a Mon Sep 17 00:00:00 2001 From: dcorral Date: Thu, 6 Nov 2025 23:31:41 +0100 Subject: [PATCH] Implement all together --- ...-compose.yml => docker-compose.example.yml | 9 +- go.mod | 37 +- go.sum | 75 ++- main.go | 428 +++++++++--------- 4 files changed, 316 insertions(+), 233 deletions(-) rename docker-compose.yml => docker-compose.example.yml (53%) diff --git a/docker-compose.yml b/docker-compose.example.yml similarity index 53% rename from docker-compose.yml rename to docker-compose.example.yml index 1a6c8d2..97e8afe 100644 --- a/docker-compose.yml +++ b/docker-compose.example.yml @@ -1,15 +1,10 @@ services: ssh-server: - build: . + image: dcorral3/go-ssh-server-command:latest ports: - "22:22" cap_add: - SYS_CHROOT environment: - - COMMAND=/app/tui - volumes: - - ssh_host_key:/app/host_key + - PORT=22 # Change to any port for internal port listen restart: unless-stopped - -volumes: - ssh_host_key: diff --git a/go.mod b/go.mod index 821e185..3217a73 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,40 @@ module sshserver go 1.24.0 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/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 +) diff --git a/go.sum b/go.sum index 1d6f359..848c84c 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,79 @@ -github.com/creack/pty/v2 v2.0.1 h1:RDY1VY5b+7m2mfPsugucOYPIxMp+xal5ZheSyVzUA+k= -github.com/creack/pty/v2 v2.0.1/go.mod h1:2dSssKp3b86qYEMwA/FPwc3ff+kYpDdQI8osU8J7gxQ= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +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/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/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/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= diff --git a/main.go b/main.go index 17e5273..daca39b 100644 --- a/main.go +++ b/main.go @@ -1,250 +1,236 @@ package main import ( + "context" "crypto/ed25519" "crypto/rand" - "encoding/binary" "encoding/pem" - "io" - "log" + "errors" + "log/slog" "net" "os" - "os/exec" - "golang.org/x/crypto/ssh" - "golang.org/x/term" - "github.com/creack/pty/v2" + "os/signal" + "syscall" + "time" + + 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() { - if os.Getenv("COMMAND") == "" { - log.Fatal("COMMAND environment variable must be set") - } - config := &ssh.ServerConfig{ - Config: ssh.Config{ - KeyExchanges: []string{"mlkem768x25519-sha256", "curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group16-sha512"}, - }, - NoClientAuth: true, - } - 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) - } - } else { - _, key, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - log.Fatal(err) - } - signer, err = ssh.NewSignerFromKey(key) - if err != nil { - log.Fatal(err) - } - block, err := ssh.MarshalPrivateKey(key, "") - if err != nil { - log.Fatal("Failed to marshal host key:", err) - } - privateKeyBytes := pem.EncodeToMemory(block) - if err := os.WriteFile(keyFile, privateKeyBytes, 0600); err != nil { - 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) +type Theme struct { + Bg lipgloss.Color + 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 +} + +func gruvboxDark() Theme { + return Theme{ + Bg: lipgloss.Color("#282828"), + 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"), } } -func handleConn(conn net.Conn, config *ssh.ServerConfig) { - sshConn, chans, reqs, err := ssh.NewServerConn(conn, config) - if err != nil { - 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() +type model struct { + showWelcome bool + focus int + showHelp bool + theme Theme + blink bool + blinkCount int } -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 +const numBoxes = 4 - 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) - } +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 } - 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() { - defer func() { - ptmx.Close() - channel.Close() - }() - // Bidirectional copy with error handling - done := make(chan error, 2) - go func() { done <- io.Copy(channel, ptmx) }() - go func() { done <- io.Copy(ptmx, channel) }() - <-done // Wait for one to finish - cmd.Process.Signal(os.Interrupt) // Graceful shutdown - <-done - }() - req.Reply(true, nil) - // Wait for cmd to finish after PTY setup - go func() { - cmd.Wait() - channel.SendRequest("exit-status", false, []byte{0}) // Send exit status - }() - return // PTY session started, no more requests - case "window-change": - // Handle resizes post-PTY allocation - if ptyAllocated && ptmx != nil { - 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") + 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 { - req.Reply(false, nil) + m.focus -= 2 } - case "exec": - // Handle exec separately if no PTY - if ptyAllocated { - req.Reply(false, nil) - continue + case "k", "up": + if m.focus >= 2 { + m.focus -= 2 + } else { + m.focus += 2 } - req.Reply(true, nil) - command := string(req.Payload[4:]) - runCommand(channel, command) - return - default: - req.Reply(false, nil) + case "?": + m.showHelp = true + case "q", "esc": + return m, tea.Quit } } + return m, nil } -func runCommand(channel ssh.Channel, command string) { - defer channel.Close() - cmd := exec.Command("/bin/sh", "-c", command) - cmd.Env = []string{"PATH=/bin"} - cmd.Dir = "/" - stdin, err := cmd.StdinPipe() - if err != nil { - log.Println("StdinPipe error:", err) - return +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 } - stdout, err := cmd.StdoutPipe() - if err != nil { - log.Println("StdoutPipe error:", err) - return + 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", + ) } - stderr, err := cmd.StderrPipe() - if err != nil { - log.Println("StderrPipe error:", err) - return + // 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))) } - 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() + 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) + if err != nil { + log.Error("Failed to generate host key", "error", err) + os.Exit(1) + } + block, err := gossh.MarshalPrivateKey(key, "") + if err != nil { + log.Error("Failed to marshal host key", "error", err) + os.Exit(1) + } + privateKeyBytes := pem.EncodeToMemory(block) + log.Info("Generated host key") + + ctx, cancel := context.WithCancel(context.Background()) + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-signalChan + cancel() + }() + + port := os.Getenv("PORT") + if port == "" { + port = "22" + } + + s, err := wish.NewServer( + wish.WithAddress(net.JoinHostPort("0.0.0.0", port)), + wish.WithHostKeyPEM(privateKeyBytes), + 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() { + if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Error("Could not start server", "error", err) + cancel() + } + }() + + <-ctx.Done() + s.Shutdown(ctx) + slog.Info("Shutting down server") +} + +func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { + return model{ + showWelcome: true, + focus: 0, + showHelp: false, + theme: gruvboxDark(), + blink: false, + blinkCount: 0, + }, []tea.ProgramOption{tea.WithAltScreen()} +} \ No newline at end of file