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.

Joe Tannenbaum

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:

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:

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 in

  • Run 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
3import (
4 "context"
5 "errors"
6 "flag"
7 "fmt"
8 "os"
9 "os/signal"
10 "syscall"
11 "time"
13 ""
14 ""
15 ""
16 ""
19func main() {
20 var host = flag.String("host", "", "Host address for SSH server to listen")
21 var port = flag.Int("port", 23234, "Port for SSH server to listen")
23 flag.Parse()
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 }
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 <- nil
43 }
44 }()
46 <-done
47 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 }

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
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"
17 ""
18 ""
19 ""
20 ""
21 ""
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})))
30func main() {
31 var host = flag.String("host", "", "Host address for SSH server to listen")
32 var port = flag.Int("port", 23234, "Port for SSH server to listen")
34 flag.Parse()
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()
44 cmd := exec.Command("php", "lab/resume.php")
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 }
53 go func() {
54 for win := range winCh {
55 setWinsize(f, win.Width, win.Height)
56 }
57 }()
59 go func() {
60 io.Copy(f, s) // stdin
61 }()
62 io.Copy(s, f) // stdout
63 cmd.Wait()
64 } else {
65 io.WriteString(s, "No PTY requested.\n")
66 s.Exit(1)
67 }
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 }
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 <- nil
87 }
88 }()
90 <-done
91 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 }

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:


2Description=My App

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 -port 22

1# need to run this every time you change the unit file
2sudo systemctl daemon-reload
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.

(Excellent) syntax highlighting provided by Torchlight