Creating SSH Apps with Charm Wish and Laravel Prompts
Building PHP CLI apps with Laravel Prompts is easy, but how can we share them? Charm to the rescue! Charm Wish is an easy-to-use SSH server that allows users to securely log into your server and use your CLI app.
Some backstory:
Spatie (🐐) recently published a package that allows you to generate PDFs from Laravel Blade files. I had previously created my work resume using PHP and a hand-rolled PDF generator, but it wasn't great.
So I decided to give the package a shot and, surprising no one, it worked perfectly.
When I shared it on Twitter (I'll never call it X), someone made a comment that got my gears turning:
Suprised you didn't do a prompts cv.
— Nigel James (@njames) January 4, 2024
Also https://t.co/ux8ivUodoM is a thing
Re-creating my resume as a terminal app was super enticing, but how would I share it? Then I remembered about Charm Wish, a simple way to create SSH apps using Go.
Side note: If you don't know about Charm, you should. They are dope and make dope things. I want to learn more Go just to use their packages. I also want to preface this article and say that I'm not great at Go. Yet.
What We're Doing
We're going to set up Charm Wish to allow users to SSH into our server and use a TUI built on the Laravel Prompts rendering engine.
Note: This is going to be a high level overview. I'm not going to dive deep into setting up Go if you haven't yet, or how I built the TUI specifically. This is more a quick guide on how to set up an SSH app that runs a PHP script on login.
Want to see this in action? Check it out:
> ssh https://t.co/Jw0e1qkGRj@charmcli Wish 🤝 @laravelphp Prompts
— joetannenbaum (@joetannenbaum) January 11, 2024
The Prompts TUI
If you'd like to see the relevant code, it's here:
Setting Up Charm Wish
Here's what we need to do:
Change the port for OpenSSH on the server so that users don't have to specify the port when logging in
Set up the Go script to create the Wish SSH server
Execute
php labs/resume.php
when a user logs inRun the script with
systemd
so that it remains running in the background and restarts on failure
Let's get into it.
Change the OpenSSH Port
Thankfully, this is an easy one. On your server:
1sudo vim /etc/ssh/sshd_config
Look for the line that starts with Port
. It may be commented out, go ahead and uncomment it. Change it to whatever number you'd like (and is available), for example 2234. Then restart the service:
1sudo service ssh restart
Now you want to update your firewall rules to ensure that the port is not blocked. Depending on which firewall you are using, this may be different for you. For ufw:
1sudo ufw allow [port_number]/tcp
Important: Before you log out of the server or close that terminal tab, open a new terminal and make sure you can access your server via SSH. If it doesn't work you will be locked out of your server, so remaining logged in in the original tab will allow you to remedy any issues.
Now when you want to log into your server via OpenSSH, you'll have to specify the port if you weren't already.
If you're using an SSH config, you can simply specify it using the Port
option. If you're logging in via the CLI, you can use the -p
argument.
If you're using Laravel Forge on this server, make sure you change the port that Forge connects to the server with under Settings > Server Settings > SSH Port.
Writing the Go Script
Our basic Wish server looks like this (gosh Charm makes this so easy):
1package main 2 3import ( 4 "context" 5 "errors" 6 "flag" 7 "fmt" 8 "os" 9 "os/signal"10 "syscall"11 "time"12 13 "github.com/charmbracelet/log"14 "github.com/charmbracelet/ssh"15 "github.com/charmbracelet/wish"16 "github.com/charmbracelet/wish/logging"17)18 19func main() {20 var host = flag.String("host", "127.0.0.1", "Host address for SSH server to listen")21 var port = flag.Int("port", 23234, "Port for SSH server to listen")22 23 flag.Parse()24 25 s, err := wish.NewServer(26 wish.WithAddress(fmt.Sprintf("%s:%d", *host, *port)),27 wish.WithHostKeyPath(".ssh/term_info_ed25519"),28 wish.WithMiddleware(29 logging.Middleware(),30 ),31 )32 if err != nil {33 log.Error("could not start server", "error", err)34 }35 36 done := make(chan os.Signal, 1)37 signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)38 log.Info("Starting SSH server", "host", host, "port", port)39 go func() {40 if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {41 log.Error("could not start server", "error", err)42 done <- nil43 }44 }()45 46 <-done47 log.Info("Stopping SSH server")48 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)49 defer func() { cancel() }()50 if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {51 log.Error("could not stop server", "error", err)52 }53}
The Wish server works by writing middleware, straight from the docs:
"Wish middlewares are analogous to those in several HTTP frameworks. They are essentially SSH handlers that you can use to do specific tasks, and then call the next middleware.
Notice that middlewares are composed from first to last, which means the last one is executed first."
We actually already have a middleware here, the logging middleware that comes with Wish and shows who is connecting and for how long and prints it to the terminal.
Let's add middleware to execute our PHP script:
1package main 2 3
4import ( 5 "context" 6 "errors" 7 "flag" 8 "fmt" 9 "io"10 "os"11 "os/exec"12 "os/signal"13 "syscall"14 "time"15 "unsafe"16 17 "github.com/charmbracelet/log"18 "github.com/charmbracelet/ssh"19 "github.com/charmbracelet/wish"20 "github.com/charmbracelet/wish/logging"21 "github.com/creack/pty"22)23 24 25func setWinsize(f *os.File, w, h int) {26 syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),27 uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))28}29 30func main() {31 var host = flag.String("host", "127.0.0.1", "Host address for SSH server to listen")32 var port = flag.Int("port", 23234, "Port for SSH server to listen")33 34 flag.Parse()35 36 s, err := wish.NewServer(37 wish.WithAddress(fmt.Sprintf("%s:%d", *host, *port)),38 wish.WithHostKeyPath(".ssh/term_info_ed25519"),39 wish.WithMiddleware(40 func(h ssh.Handler) ssh.Handler { 41 return func(s ssh.Session) {42 ptyReq, winCh, isPty := s.Pty()43 44 cmd := exec.Command("php", "lab/resume.php")45 46 if isPty {47 cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))48 f, err := pty.Start(cmd)49 if err != nil {50 panic(err)51 }52 53 go func() {54 for win := range winCh {55 setWinsize(f, win.Width, win.Height)56 }57 }()58 59 go func() {60 io.Copy(f, s) // stdin61 }()62 io.Copy(s, f) // stdout63 cmd.Wait()64 } else {65 io.WriteString(s, "No PTY requested.\n")66 s.Exit(1)67 }68 69 h(s)70 }71 }, 72 logging.Middleware(),73 ),74 )75
76 if err != nil {77 log.Error("could not start server", "error", err)78 }79 80 done := make(chan os.Signal, 1)81 signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)82 log.Info("Starting SSH server", "host", host, "port", port)83 go func() {84 if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {85 log.Error("could not start server", "error", err)86 done <- nil87 }88 }()89 90 <-done91 log.Info("Stopping SSH server")92 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)93 defer func() { cancel() }()94 if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {95 log.Error("could not stop server", "error", err)96 }97 98}
And that's it! That's our Wish server. So simple. Now let's build it for Linux:
1env GOOS=linux GOARCH=amd64 go build main.go
Getting SystemD Set Up
Once the scripts are on your server, let's have them run in the background using systemd
. First, let's create a unit. Straight from the Wish docs:
/etc/systemd/system/myapp.service
:
1[Unit] 2Description=My App 3After=network.target 4 5[Service] 6Type=simple 7User=myapp 8Group=myapp 9WorkingDirectory=/home/myapp/10ExecStart=/usr/bin/myapp11Restart=on-failure12 13[Install]14WantedBy=multi-user.target
For our Wish script, we allowed the port and host to be specified via arguments to make local dev easy. When you specify the ExecStart config, make sure you add them:
/path/to/your/wish/server/main-host 0.0.0.0 -port 22
1# need to run this every time you change the unit file2sudo systemctl daemon-reload3 4# start/restart/stop/etc:5sudo systemctl start myapp
Wrapping Up
Now, if you SSH into your server, you should see your PHP script running! I hope you're as excited about the prospect of this as I am. Expect to see many more SSH apps coming your way.