SSH server middleware framework
Build TUIs anyone can ssh user@host to run. Leans on the host's OpenSSH daemon (ForceCommand) rather than re-implementing the SSH wire protocol. sshd handles auth, ciphers, host keys, fail2ban, audit logs.
composer require sugarcraft/candy-wish
What the supervisor PHP process does internally is pluggable via the Transport abstraction:
Spawn.BubbleTea) mounts a SugarCraft Program directly on the supervisor's STDIN/STDOUT. Pin via Server::withTransport(new HostSshdTransport()). Use this for inline-STDIN-reading middleware or zero-subprocess Program mounts.// /opt/wish/server.php โ invoked by sshd ForceCommand
require '/opt/wish/vendor/autoload.php';
use SugarCraft\Wish\Server;
use SugarCraft\Wish\Middleware\{Logger, Auth, RateLimit, Spawn};
use SugarCraft\Wish\Session;
Server::new()
->use(new Logger('/var/log/wish.jsonl'))
->use(new Auth(users: ['alice', 'bob']))
->use(new Spawn(fn (Session $s) => [
'cmd' => ['/bin/bash', '-l'],
'env' => [
'TERM' => $s->term, 'USER' => $s->user, 'HOME' => "/home/{$s->user}",
'PATH' => '/usr/local/bin:/usr/bin:/bin',
],
]))
->serve();
use SugarCraft\Wish\Transport\HostSshdTransport;
use SugarCraft\Wish\Middleware\BubbleTea;
Server::new()
->withTransport(new HostSshdTransport())
->use(new Logger('/var/log/wish.jsonl'))
->use(new Auth(users: ['alice', 'bob']))
->use(new BubbleTea(fn($session) => new MyApp($session)))
->serve();
Middleware::handle(Context $ctx, Session $session, callable $next): void.| Class | Method | Description |
|---|---|---|
| Server | new() | Create a new SSH server |
| Server | use(Middleware) | Add middleware to the pipeline |
| Server | withTransport(Transport) | Override the active transport (default: InProcessTransport) |
| Server | withKeepalive(int) | Add keepalive middleware with the given interval |
| Server | serve(?Session) | Start the server (builds root Context automatically) |
| Context | background() | Root context โ never done, not cancelable |
| Context | withValue(string $k, mixed $v) | Return a new context with $k โ $v attached |
| Context | withDeadline(DateTimeImmutable) | Return a new cancelable context that is done when deadline passes |
| Context | withCancelable() | Return a new cancelable context (call cancel() to trigger) |
| Context | cancel(?Throwable) | Mark the context as cancelled |
| Context | done(): bool | True when cancelled or deadline-exceeded |
| Context | err(): ?Throwable | CancellationException, DeadlineExceededException, or null |
| Context | value(string $k): mixed | Walk parent chain looking for $k; null if not found |
| Session | user, term, cols, rows, clientHost, clientPort, serverHost, serverPort, tty, command, lang, sessionId, authMethod, keyFingerprint, clientVersion, serverVersion | Session metadata (sessionId/authMethod/keyFingerprint/clientVersion/serverVersion populated by withProtocolMetadata() after SSH handshake) |
| Session | withProtocolMetadata(string $sessionId, string $authMethod, ?string $keyFingerprint, string $clientVersion, string $serverVersion): self | Return a new Session with protocol-handshake metadata attached (called by transports after SSH handshake) |
| Session | isInteractive(): bool | True when a TTY is attached |
| Session | toLogContext(): array | Array of session fields for logging |
| Middleware | handle(Context, Session, callable): void|PromiseInterface | All middleware receive Context first, Session second; may return void (sync) or PromiseInterface (async โ transport awaits before continuing chain) |
| AsyncMiddleware | extends Middleware | Abstract base for promise-returning middleware โ override handleAsync() to perform async I/O (LDAP, OAuth, database auth); 30-second timeout enforced via await() |
| Spawn | new(callable) | Terminal middleware for subprocess spawning (InProcess only) |
| BubbleTea | new(callable) | Terminal middleware for Program mount (HostSshd only) |
| Auth | new(array $users, array $keyFingerprints) | Username and/or key allowlist middleware |
| PasswordAuth | new(callable, ?resource) | Validates (user, password) against callback; reads `SSH_PASSWORD` env var |
| CertificateAuth | new(callable, bool $required, ?resource) | Validates X.509 cert from `SSL_CLIENT_CERT` / `SSH_CLIENT_CERT` env vars |
| AuthMethods | new(list<string>, ?resource) | Advertises auth methods via `SSH_AUTH_METHODS` STDOUT banner; stores list in Context |
| KeyboardInteractive | new(list<array{prompt:string,echo?:bool}>, ?callable, ?resources) | Challenge-response prompts (RFC 4256); reads responses from STDIN |
| Subsystem | new() | Terminal โ parses subsystem <name> from Session::command, dispatches to registered SubsystemHandler; non-subsystem requests pass through to $next |
| SubsystemHandler | handle(Context, Session): void | Interface โ implement to handle a named subsystem request |
| SftpStub | new() | Example SubsystemHandler impl โ stub demonstrating wiring; not a real SFTP server |
| RateLimit | new(string $statePath, int $burst, float $ratePerSec) | Per-IP token-bucket rate limiter |
| Logger | new(string|resource|null) | JSON one-line logger to file or stderr |
| Keepalive | new(int $intervalSeconds) | Sends SSH keepalive messages at the given interval |
| InProcessTransport | new(?PtySystem, ?ChannelHandler) | Default transport โ PTY supervisor + candy-pty; optional custom ChannelHandler |
| HostSshdTransport | new() | Legacy transport โ inline middleware, Program on STDIN/STDOUT |
| ChannelHandler | handlePtyReq / handleWindowChange / handleShell / handleExec / handleSignal / handleEnv / handleBreak | Interface for custom channel-level message handling (InProcessTransport only) |
| ChannelMsg | abstract base | Base for all SSH channel messages (RFC 4254) |
| DefaultChannelHandler | new(?ChildSpawner, ?Session) | Default impl โ tracks PTY dims, env vars, drives ChildSpawner on shell/exec |
| PtyReqMsg | wantPty, term, cols, rows, widthPx, heightPx | PTY allocation request |
| WindowChangeMsg | cols, rows, widthPx, heightPx | Terminal resize (SIGWINCH equivalent) |
| ShellMsg | wantShell, subsystem | Login-shell request |
| ExecMsg | command | Exec request โ raw command string |
| SignalMsg | signalName | Signal delivery (SIGINT, SIGTERM, etc.) |
| EnvMsg | name, value | Environment variable request |
| BreakMsg | (empty) | Break request |
| CancellationException | extends RuntimeException | Thrown when Context is cancelled |
| DeadlineExceededException | extends RuntimeException | Thrown when Context deadline passes |
VHS-recorded GIFs of every example shipped with the library. Regenerated automatically on every push that touches the source.
/bin/bash -l via candy-pty. Inner shell runs echo / uname / stty size / exit.