package main import ( "context" "crypto/ed25519" "crypto/rand" "encoding/pem" "errors" "log/slog" "net" "os" "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" ) 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"), } } 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 { 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) 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) { // Check if PTY is available ptyReq, _, _ := s.Pty() if ptyReq.Term == "" { // No PTY, handle as command execution s.Write([]byte("This server requires an interactive terminal (PTY).\nPlease connect with: ssh -t user@host\n")) s.Exit(1) return nil, nil } return model{ showWelcome: true, focus: 0, showHelp: false, theme: gruvboxDark(), blink: false, blinkCount: 0, }, []tea.ProgramOption{tea.WithAltScreen()} }