afaztech / linops
asynchronous PHP library for Linux server management over SSH
Requires
- php: >=8.2
- amphp/amp: ^3.0
- amphp/byte-stream: ^2.0
- amphp/pipeline: ^1.0
- amphp/process: ^2.0
- amphp/sync: ^2.0
- phpseclib/phpseclib: ^3.0
- revolt/event-loop: ^1.0
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
asynchronous PHP library for Linux server management over SSH.
LinOps gives you a single, type-safe API for inspecting and operating Linux
servers — locally or over SSH — with first-class support for streaming output,
interactive sessions, SFTP transfers, control panel detection, firewall
detection, and running under sudo. Blocking phpseclib calls are isolated in
dedicated worker processes so your application's event loop stays responsive
under load.
Features
- SSH & local execution through the same API (
ServerConfig::local()for the current host,ServerConfigfor remote). - Host-key verification before credentials are sent (
STRICT,TOFU,INSECUREpolicies). - Async multi-server pool with bounded concurrency
(
ServerPool::collect()preserves input order,ServerPool::stream()yields results as they complete). - System inspection — host info, CPU load, memory, disks with inode usage, hardware inventory (CPU, memory modules, disks, SMART health, BIOS, motherboard).
- Network inspection — addresses (IPv4/IPv6, subnet, broadcast, private flags), routes, DNS, interface traffic rates.
- Service management — list, status, and explicit restart.
- Control-panel detection — cPanel/WHM, Plesk, DirectAdmin, HestiaCP, ISPConfig, Virtualmin/Webmin, CloudPanel, CyberPanel, aaPanel, 1Panel, FASTPANEL, VestaCP, Sentora, Froxlor, Webuzo, Enhance.
- Firewall detection with precedence (CSF > firewalld > ufw > nftables >
iptables), plus a
getActiveFirewall()helper. - Process & port inspection —
psandss/netstat. - Log & login history —
last,lastb,tail -f,journalctl -fwith a bounded follow window. - SFTP — atomic uploads and downloads (temp + rename), plus size-guarded
readFile(). - Three output modes for command execution —
CAPTURE,STREAM,INTERACTIVE— selected viaOutputMode. - PTY-aware streaming that forwards stdin to interactive prompts (e.g.
yum's[y/N]) and never splits the completion marker across chunks. openShell()for a pure interactive session, likessh user@host.- Sudo support via
ServerConfig(sudo: true, sudoUser: 'www-data'), usingsudo -n(requires NOPASSWD in sudoers). - Secrets are never leaked —
__debugInfo(),getIdentifier(), andgetConnectionKey()redact passwords, keys, and passphrases.
Requirements
- PHP 8.2 or newer
- Extensions:
ext-json,ext-filter,ext-hash - Composer
- A remote SSH server with an interactive shell (for streaming / PTY features) or SFTP subsystem enabled (for file transfers)
Installation
composer require afaztech/linops
Quick start
<?php use AfazTech\LinOps\LinOps; use AfazTech\LinOps\ServerConfig; use AfazTech\LinOps\Support\HostKeyPolicy; $config = new ServerConfig( host: '10.0.0.10', port: 22, username: 'deploy', privateKey: '/home/deploy/.ssh/id_ed25519', hostKeyPolicy: HostKeyPolicy::TOFU, // store on first use ); $linops = new LinOps($config); $host = $linops->getHostInfo(); echo $host->hostname, ' / ', $host->distributionId, "\n"; $load = $linops->getCpuLoad(1.0); echo 'CPU usage: ', $load->usagePercent, "%\n"; foreach ($linops->getDisks() as $disk) { printf("%s mounted at %s — %.1f%% used\n", $disk->filesystem, $disk->mountPoint, $disk->usagePercent); } $linops->close();
Core concepts
ServerConfig
Immutable configuration for one server (SSH or local). Validated in the constructor: timeouts must be positive, credentials must be explicit, and local mode skips SSH-specific validation.
$config = new ServerConfig( host: '10.0.0.10', port: 22, username: 'root', password: 'secret', // OR privateKey, not both connectionTimeout: 10.0, commandTimeout: 30.0, hostKeyPolicy: HostKeyPolicy::STRICT, knownHostsFile: null, // defaults to ~/.ssh/known_hosts label: 'web-01', // used in results and logs sudo: false, // wrap commands with `sudo -n` sudoUser: null, // e.g. 'www-data' ); // Local execution — no SSH, no credentials. $local = ServerConfig::local();
Host key policies
STRICT— the host key must already be inknown_hostsand match exactly. This is the default and is what you want in production.TOFU— trust on first use: unknown hosts are written toknown_hosts, and subsequent connections still enforce an exact match.INSECURE— do not verify. Only use for isolated test environments.ServerConfig::local()selects this automatically because there is no remote host key to verify.
Sudo
When sudo: true is set, every command dispatched through CommandExecutor
and through executeCommand(STREAM|INTERACTIVE) is wrapped as
sudo -n <command> (or sudo -n -u <sudoUser> <command>). -n means no
password prompt is ever attempted, so the command fails fast if the sudoers
entry lacks NOPASSWD. openShell() is not wrapped — use
sudo -i inside the shell if you need elevation.
LinOps
A facade bound to a single ServerConfig. Lazily creates a
WorkerPoolTransport with one worker process; you can inject your own pool.
ServerPool
Fans a callback out across many servers with bounded concurrency:
use AfazTech\LinOps\ServerPool; $pool = new ServerPool(concurrency: 20); // Collect all results (input order preserved by default). $results = $pool->collect($servers, function (ServerConfig $server) { return (new LinOps($server))->getHostInfo()->toArray(); }); // Stream results as they complete (completion order). foreach ($pool->stream($servers, function (ServerConfig $server) { return (new LinOps($server))->getCpuLoad()->toArray(); }) as $index => $result) { if ($result->success) { printf("[%d] %s => %s\n", $index, $result->serverIdentifier, json_encode($result->data)); } }
Inspecting a server
// System $linops->getHostInfo(): HostInfo $linops->getCpuLoad(float $sampleInterval = 1.0): CpuLoadInfo $linops->getMemory(): MemoryInfo $linops->getDisks(?array $mountPoints = null, bool $includePseudo = false): list<DiskInfo> $linops->getDiskUsage(...): list<array<string, mixed>> // df-style raw bytes $linops->getHardwareInfo(): HardwareInfo $linops->needsReboot(): bool // Network $linops->getAddresses(): list<NetworkAddressInfo> $linops->getRoutes(): list<RouteInfo> $linops->getDnsConfig(): DnsConfigInfo $linops->getTraffic(?array $interfaces = null, float $sampleInterval = 1.0): list<InterfaceTrafficInfo> // Control panels & firewalls $linops->detectControlPanels(): list<ControlPanelInfo> $linops->detectFirewalls(): list<FirewallInfo> $linops->getActiveFirewall(): ?FirewallInfo // Processes & ports $linops->listProcesses(): list<ProcessInfo> $linops->listListeningPorts(): list<ListeningPort> // Logs & logins $linops->lastLogins(int $limit = 20): list<LoginRecord> $linops->getFailedLogins(int $limit = 20): list<LoginRecord> $linops->tailFile(string $path, float $duration = 60.0): string $linops->journalFollow(string $unit, float $duration = 60.0): string // Services $linops->listServices(?string $pattern = null): list<ServiceStatus> $linops->getServiceStatus(string $service): ServiceStatus $linops->restartService(string $service): ServiceRestartResult
Every inspection method returns an immutable result object with a toArray()
helper. Failures are represented either by an empty list, or by fields such as
supported: false / error: "..." on the result — never by a thrown
exception unless the connection itself is broken.
SFTP
use AfazTech\LinOps\Result\SftpTransferResult; // Upload (atomic: writes to <remote>.tmp.XXXX then mv -f). $result = $linops->upload('/tmp/build.tar.gz', '/opt/app/build.tar.gz'); if (!$result->success) { fwrite(STDERR, $result->error . "\n"); } else { echo "Uploaded {$result->bytesTransferred} bytes in {$result->duration}s\n"; } // Download (atomic: writes to <local>.tmp.XXXX then rename). $linops->download('/var/log/syslog', '/tmp/syslog'); // Read into memory with a hard size cap (default 10 MiB). $content = $linops->readFile('/etc/nginx/nginx.conf', 64 * 1024);
readFile() performs an SFTP stat() before transferring any bytes, so
oversized files raise a LinOpsException and never enter PHP memory.
In local mode, all three methods fall back to plain file copies and reads.
SFTP always uses its own SSH connection, separate from the exec channel.
Reusing a long-lived SSH2 object for SFTP does not work reliably in
phpseclib3 and produces silent "SFTP put failed" errors — LinOps opens a
dedicated SFTP session lazily on first use, and verifies the host key before
authenticating there too.
Command execution
use AfazTech\LinOps\OutputMode; // Blocking, returns full output. $result = $linops->execute('uptime'); echo $result->stdout; $results = $linops->executeMany(['uptime', 'id', 'df -h']);
For long-running or interactive commands, use executeCommand():
// CAPTURE — buffer and return. $result = $linops->executeCommand('systemctl status nginx', OutputMode::CAPTURE); // STREAM — real-time chunks; stdout/stderr in the result are null. $linops->executeCommand( 'journalctl -u nginx -f', OutputMode::STREAM, function (string $stream, string $data) { fwrite($stream === 'stderr' ? STDERR : STDOUT, $data); }, timeout: 30.0, ); // INTERACTIVE — forward stdin. If STDIN is a TTY it is used automatically. $linops->executeCommand( 'yum update', OutputMode::INTERACTIVE, function (string $stream, string $data) { fwrite($stream === 'stderr' ? STDERR : STDOUT, $data); }, timeout: 3600.0, );
Fallback behaviour
If the server refuses the PTY shell channel during STREAM or INTERACTIVE,
LinOps does not silently retry via exec(). That would re-run the
command on a fresh connection (dangerous for non-idempotent commands such as
yum update) and would drop the interactive capability without warning. You
can opt in explicitly:
$linops->executeCommand( 'yum update -y', OutputMode::STREAM, $onChunk, allowFallback: true, // only safe for idempotent commands );
Even when fallback is enabled, a WorkerException is thrown if the PTY write
fails. If the connection dies after the command has started, LinOps does not
retry — the exit code is -1 and a message is streamed to stderr.
Interactive shell
$linops->openShell( onChunk: function (string $stream, string $data) { fwrite($stream === 'stderr' ? STDERR : STDOUT, $data); }, timeout: 3600.0, );
openShell() opens a PTY shell with no command, giving you the equivalent of
ssh user@host. There is no exec fallback: if the server refuses the shell
channel, a WorkerException is thrown. For elevation, type sudo -i inside
the shell.
For raw-mode keystroke forwarding (Ctrl+C, Tab, arrow keys) see the
accompanying run-yum.php and run-yum-shell.php scripts — they switch the
local terminal into raw mode before calling executeCommand(INTERACTIVE) /
openShell().
Security notes
- Host key verified before credentials.
SshConnectorcallsgetServerPublicHostKey()and runs the host key throughHostKeyVerifierbeforelogin(), so a spoofed server cannot capture your password or key material. - Secrets are redacted.
ServerConfig::__debugInfo()returns***forpassword,privateKey, andprivateKeyPassphrase.getConnectionKey()is a SHA-256 of host/port/username/mode only. - No sudo password handling.
sudo -nis used exclusively. If your sudoers policy requires a password, useopenShell()and type it interactively. - SFTP is subject to the same host-key policy. The dedicated SFTP
connection goes through the same
SshConnectorand verification path. - Fallback is opt-in. Silent
exec()fallback is disabled by default and must be requested viaallowFallback: true.
Architecture
┌──────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Application │───▶│ LinOps / ServerPool│───▶│ CommandExecutor │
└──────────────┘ └─────────────────────┘ └──────────────────────┘
│
┌──────────────────────────────┴──────────────┐
▼ ▼
┌─────────────────────┐ ┌───────────────────┐
│ WorkerPoolTransport │ │ LocalCommandRunner│
└─────────────────────┘ └───────────────────┘
│
▼
┌─────────────────────┐ JSON over stdio
│ worker.php │◀────────────────────┐
│ (blocking phpseclib)│ │
└─────────────────────┘ │
▲ │
┌────────┴─────────┐ │
│ streaming-worker │─────────────────────────┘
│ (PTY + exec) │
└──────────────────┘
- Blocking phpseclib work is confined to isolated worker processes, one per connection key.
WorkerPoolTransportuses a globalLocalSemaphorefor total concurrency and aLocalMutexper server so two requests never interleave on the same worker's stdin/stdout.- Workers are evicted with LRU semantics; a worker currently serving a request is never evicted.
- On cancellation,
WorkerHandle::readLine()invalidates the worker and kills the process so a subsequent request cannot read a stale response.
License
MIT. See LICENSE.