โ† All libraries

CandyWish

๐Ÿ” CandyWish

SSH server middleware framework

port of wish ssh middleware force-command multi-tenant

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.

Install

composer require sugarcraft/candy-wish

Two transports

What the supervisor PHP process does internally is pluggable via the Transport abstraction:

Quickstart โ€” in-process bash

// /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();

Quickstart โ€” host-sshd legacy

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();

What's in the box

InProcessTransport (default)candy-pty supervisor โ€” allocates a PTY, spawns child with controllingTerminal:true, pumps bytes, forwards SIGWINCH. Lets you mount any cmd / shell / editor.
HostSshdTransport (legacy)Pre-PTY-upgrade behaviour. Middleware run inline against sshd's PTY directly. Zero subprocess overhead.
Spawn middleware (InProcess only)Terminal โ€” produces cmd[] from Session via factory. Drives runChild() on the active transport.
BubbleTea middleware (HostSshd only)Terminal โ€” mounts a SugarCraft Program inline reading STDIN/STDOUT. Transport-aware: throws clearly under InProcess.
Logger / Auth / RateLimitTransport-agnostic. Same composition surface under either mode.
Custom middlewareAnything implementing Middleware::handle(Context $ctx, Session $session, callable $next): void.

Source & demos

Try the quickstart โ†’

API

ClassMethodDescription
Servernew()Create a new SSH server
Serveruse(Middleware)Add middleware to the pipeline
ServerwithTransport(Transport)Override the active transport (default: InProcessTransport)
ServerwithKeepalive(int)Add keepalive middleware with the given interval
Serverserve(?Session)Start the server (builds root Context automatically)
Contextbackground()Root context โ€” never done, not cancelable
ContextwithValue(string $k, mixed $v)Return a new context with $k โ†’ $v attached
ContextwithDeadline(DateTimeImmutable)Return a new cancelable context that is done when deadline passes
ContextwithCancelable()Return a new cancelable context (call cancel() to trigger)
Contextcancel(?Throwable)Mark the context as cancelled
Contextdone(): boolTrue when cancelled or deadline-exceeded
Contexterr(): ?ThrowableCancellationException, DeadlineExceededException, or null
Contextvalue(string $k): mixedWalk parent chain looking for $k; null if not found
Sessionuser, term, cols, rows, clientHost, clientPort, serverHost, serverPort, tty, command, lang, sessionId, authMethod, keyFingerprint, clientVersion, serverVersionSession metadata (sessionId/authMethod/keyFingerprint/clientVersion/serverVersion populated by withProtocolMetadata() after SSH handshake)
SessionwithProtocolMetadata(string $sessionId, string $authMethod, ?string $keyFingerprint, string $clientVersion, string $serverVersion): selfReturn a new Session with protocol-handshake metadata attached (called by transports after SSH handshake)
SessionisInteractive(): boolTrue when a TTY is attached
SessiontoLogContext(): arrayArray of session fields for logging
Middlewarehandle(Context, Session, callable): void|PromiseInterfaceAll middleware receive Context first, Session second; may return void (sync) or PromiseInterface (async โ€” transport awaits before continuing chain)
AsyncMiddlewareextends MiddlewareAbstract base for promise-returning middleware โ€” override handleAsync() to perform async I/O (LDAP, OAuth, database auth); 30-second timeout enforced via await()
Spawnnew(callable)Terminal middleware for subprocess spawning (InProcess only)
BubbleTeanew(callable)Terminal middleware for Program mount (HostSshd only)
Authnew(array $users, array $keyFingerprints)Username and/or key allowlist middleware
PasswordAuthnew(callable, ?resource)Validates (user, password) against callback; reads `SSH_PASSWORD` env var
CertificateAuthnew(callable, bool $required, ?resource)Validates X.509 cert from `SSL_CLIENT_CERT` / `SSH_CLIENT_CERT` env vars
AuthMethodsnew(list<string>, ?resource)Advertises auth methods via `SSH_AUTH_METHODS` STDOUT banner; stores list in Context
KeyboardInteractivenew(list<array{prompt:string,echo?:bool}>, ?callable, ?resources)Challenge-response prompts (RFC 4256); reads responses from STDIN
Subsystemnew()Terminal โ€” parses subsystem <name> from Session::command, dispatches to registered SubsystemHandler; non-subsystem requests pass through to $next
SubsystemHandlerhandle(Context, Session): voidInterface โ€” implement to handle a named subsystem request
SftpStubnew()Example SubsystemHandler impl โ€” stub demonstrating wiring; not a real SFTP server
RateLimitnew(string $statePath, int $burst, float $ratePerSec)Per-IP token-bucket rate limiter
Loggernew(string|resource|null)JSON one-line logger to file or stderr
Keepalivenew(int $intervalSeconds)Sends SSH keepalive messages at the given interval
InProcessTransportnew(?PtySystem, ?ChannelHandler)Default transport โ€” PTY supervisor + candy-pty; optional custom ChannelHandler
HostSshdTransportnew()Legacy transport โ€” inline middleware, Program on STDIN/STDOUT
ChannelHandlerhandlePtyReq / handleWindowChange / handleShell / handleExec / handleSignal / handleEnv / handleBreakInterface for custom channel-level message handling (InProcessTransport only)
ChannelMsgabstract baseBase for all SSH channel messages (RFC 4254)
DefaultChannelHandlernew(?ChildSpawner, ?Session)Default impl โ€” tracks PTY dims, env vars, drives ChildSpawner on shell/exec
PtyReqMsgwantPty, term, cols, rows, widthPx, heightPxPTY allocation request
WindowChangeMsgcols, rows, widthPx, heightPxTerminal resize (SIGWINCH equivalent)
ShellMsgwantShell, subsystemLogin-shell request
ExecMsgcommandExec request โ€” raw command string
SignalMsgsignalNameSignal delivery (SIGINT, SIGTERM, etc.)
EnvMsgname, valueEnvironment variable request
BreakMsg(empty)Break request
CancellationExceptionextends RuntimeExceptionThrown when Context is cancelled
DeadlineExceededExceptionextends RuntimeExceptionThrown when Context deadline passes

Demos.

VHS-recorded GIFs of every example shipped with the library. Regenerated automatically on every push that touches the source.

Middleware pipeline

Middleware pipeline

Logger โ†’ RateLimit โ†’ hello against a synthetic Session.
Spawn bash

Spawn bash (in-process)

InProcessTransport spawning /bin/bash -l via candy-pty. Inner shell runs echo / uname / stty size / exit.