diff --git a/app/Services/Wire/AgentResult.php b/app/Services/Wire/AgentResult.php new file mode 100644 index 0000000000..6b4538b69d --- /dev/null +++ b/app/Services/Wire/AgentResult.php @@ -0,0 +1,16 @@ +ip; + $port = (int) config('constants.agent.port', 9000); + $token = (string) data_get($server, 'settings.agent_token', config('constants.agent.token', '')); + + $address = str_contains($host, ':') ? "tcp://[{$host}]:{$port}" : "tcp://{$host}:{$port}"; + + $stream = @stream_socket_client( + $address, + $errno, + $errstr, + (float) config('constants.agent.connection_timeout', 10), + STREAM_CLIENT_CONNECT, + ); + + if ($stream === false) { + throw new WireProtocolException("Unable to reach agent at {$address}: {$errstr} ({$errno})."); + } + + try { + return self::exchange($stream, $token, $command, $timeout); + } finally { + fclose($stream); + } + } + + /** + * Perform the handshake-then-exec exchange over an already-open stream. + * + * @param resource $stream A connected, blocking stream resource. + * + * @throws WireProtocolException on a malformed/unexpected frame or timeout + */ + public static function exchange($stream, string $token, string $command, int $timeout): AgentResult + { + $streamId = 1; + $deadline = microtime(true) + max(1, $timeout); + $buffer = ''; + + self::write($stream, WireProtocol::encodeJson(WireProtocol::HELLO_TYPE, $streamId, [ + 'version' => WireProtocol::VERSION, + 'token' => $token, + 'features' => ['exec'], + ])); + + $welcome = self::read($stream, $buffer, $deadline); + + if ($welcome->isType(WireProtocol::ERROR_TYPE)) { + throw new WireProtocolException('Agent rejected handshake: '.self::errorMessage($welcome)); + } + + if (! $welcome->isType(WireProtocol::WELCOME_TYPE)) { + throw new WireProtocolException("Expected WELCOME frame, received type {$welcome->type}."); + } + + self::write($stream, WireProtocol::encodeJson(WireProtocol::EXEC_TYPE, $streamId, [ + 'command' => $command, + 'timeout' => $timeout, + ])); + + $stdout = ''; + $stderr = ''; + $exitCode = null; + + while ($exitCode === null) { + $frame = self::read($stream, $buffer, $deadline); + + switch ($frame->type) { + case WireProtocol::STDOUT_TYPE: + $stdout .= $frame->payload; + break; + case WireProtocol::STDERR_TYPE: + $stderr .= $frame->payload; + break; + case WireProtocol::EXIT_TYPE: + $exitCode = (int) ($frame->json()['code'] ?? 0); + break; + case WireProtocol::PING_TYPE: + self::write($stream, WireProtocol::encode(WireProtocol::PONG_TYPE, $frame->streamId)); + break; + case WireProtocol::ERROR_TYPE: + throw new WireProtocolException('Agent error: '.self::errorMessage($frame)); + default: + throw new WireProtocolException("Unexpected frame type {$frame->type} during execution."); + } + } + + return new AgentResult($exitCode, $stdout, $stderr); + } + + /** + * Read bytes until one complete frame can be decoded, then consume it. + * + * @param resource $stream + * + * @throws WireProtocolException on timeout or a closed connection + */ + private static function read($stream, string &$buffer, float $deadline): WireFrame + { + while (true) { + $decoded = WireProtocol::tryDecode($buffer); + + if ($decoded !== null) { + $buffer = substr($buffer, $decoded['length']); + + return $decoded['frame']; + } + + $remaining = $deadline - microtime(true); + + if ($remaining <= 0) { + throw new WireProtocolException('Timed out waiting for an agent frame.'); + } + + stream_set_timeout($stream, (int) ceil($remaining), (int) (fmod($remaining, 1) * 1_000_000)); + $chunk = fread($stream, self::READ_CHUNK); + + if ($chunk === false || ($chunk === '' && feof($stream))) { + throw new WireProtocolException('Agent connection closed before a complete frame arrived.'); + } + + $buffer .= $chunk; + } + } + + /** + * Write a full frame, looping until every byte is flushed. + * + * @param resource $stream + * + * @throws WireProtocolException when the connection cannot accept the bytes + */ + private static function write($stream, string $frame): void + { + $offset = 0; + $total = strlen($frame); + + while ($offset < $total) { + $written = fwrite($stream, substr($frame, $offset)); + + if ($written === false || $written === 0) { + throw new WireProtocolException('Failed to write frame to agent connection.'); + } + + $offset += $written; + } + } + + private static function errorMessage(WireFrame $frame): string + { + try { + return (string) ($frame->json()['message'] ?? 'unknown error'); + } catch (WireProtocolException) { + return 'unknown error'; + } + } +} diff --git a/app/Services/Wire/WireFrame.php b/app/Services/Wire/WireFrame.php new file mode 100644 index 0000000000..65cd1baa12 --- /dev/null +++ b/app/Services/Wire/WireFrame.php @@ -0,0 +1,43 @@ +type === $type; + } + + /** + * Decode the payload as JSON. + * + * @return array + * + * @throws WireProtocolException when the payload is not a valid JSON object + */ + public function json(): array + { + $decoded = json_decode($this->payload, true); + + if (! is_array($decoded)) { + throw new WireProtocolException('Frame payload is not a JSON object.'); + } + + return $decoded; + } +} diff --git a/app/Services/Wire/WireProtocol.php b/app/Services/Wire/WireProtocol.php new file mode 100644 index 0000000000..2efedad768 --- /dev/null +++ b/app/Services/Wire/WireProtocol.php @@ -0,0 +1,186 @@ + agent: handshake offer. JSON {version, token, features}. */ + public const HELLO_TYPE = 0x01; + + /** Agent -> control plane: handshake accept. JSON {version, agent, os, features}. */ + public const WELCOME_TYPE = 0x02; + + /** Control plane -> agent: run a command. JSON {command, timeout}. */ + public const EXEC_TYPE = 0x10; + + /** Agent -> control plane: a chunk of stdout. Raw bytes. */ + public const STDOUT_TYPE = 0x11; + + /** Agent -> control plane: a chunk of stderr. Raw bytes. */ + public const STDERR_TYPE = 0x12; + + /** Agent -> control plane: command finished. JSON {code}. */ + public const EXIT_TYPE = 0x13; + + /** Either direction: keepalive probe. Empty payload. */ + public const PING_TYPE = 0x20; + + /** Either direction: keepalive response. Empty payload. */ + public const PONG_TYPE = 0x21; + + /** Either direction: protocol or execution error. JSON {code, message}. */ + public const ERROR_TYPE = 0x7F; + + /** + * Encode a frame to its on-the-wire byte string. + * + * @throws WireProtocolException on out-of-range header fields or oversized payload + */ + public static function encode(int $type, int $streamId, string $payload = '', int $flags = 0): string + { + if ($type < 0 || $type > 0xFF) { + throw new WireProtocolException("Frame type out of range: {$type}."); + } + + if ($flags < 0 || $flags > 0xFFFF) { + throw new WireProtocolException("Frame flags out of range: {$flags}."); + } + + if ($streamId < 0) { + throw new WireProtocolException("Stream id must not be negative: {$streamId}."); + } + + $length = strlen($payload); + + if ($length > self::MAX_PAYLOAD) { + throw new WireProtocolException("Payload of {$length} bytes exceeds the ".self::MAX_PAYLOAD.' byte limit.'); + } + + $header = self::MAGIC + .pack('C', self::VERSION) + .pack('C', $type) + .pack('n', $flags) + .pack('J', $streamId) + .pack('N', $length) + .pack('N', self::checksum($payload)); + + return $header.$payload; + } + + /** + * Encode a frame whose payload is a JSON-encoded object. + * + * @param array $payload + */ + public static function encodeJson(int $type, int $streamId, array $payload = [], int $flags = 0): string + { + return self::encode($type, $streamId, json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), $flags); + } + + /** + * Try to decode a single frame from the head of a byte buffer. + * + * Returns null when the buffer does not yet contain a complete frame (the + * caller should read more bytes and retry). Returns an array shaped as + * ['frame' => WireFrame, 'length' => int] on success, where 'length' is the + * number of bytes the frame consumed — slice them off before decoding next. + * + * @return array{frame: WireFrame, length: int}|null + * + * @throws WireProtocolException on a corrupt frame (bad magic, version, size or CRC) + */ + public static function tryDecode(string $buffer): ?array + { + if (strlen($buffer) < self::HEADER_SIZE) { + return null; + } + + if (substr($buffer, 0, 4) !== self::MAGIC) { + throw new WireProtocolException('Invalid frame magic; not a CWP frame.'); + } + + $header = unpack('Cversion/Ctype/nflags/Jstream/Nlength/Ncrc', substr($buffer, 4, self::HEADER_SIZE - 4)); + + if ($header['version'] !== self::VERSION) { + throw new WireProtocolException("Unsupported CWP version: {$header['version']}."); + } + + $length = $header['length']; + + if ($length > self::MAX_PAYLOAD) { + throw new WireProtocolException("Declared payload of {$length} bytes exceeds the ".self::MAX_PAYLOAD.' byte limit.'); + } + + $total = self::HEADER_SIZE + $length; + + if (strlen($buffer) < $total) { + return null; + } + + $payload = substr($buffer, self::HEADER_SIZE, $length); + + if (self::checksum($payload) !== $header['crc']) { + throw new WireProtocolException('Frame CRC32 mismatch; payload is corrupt.'); + } + + return [ + 'frame' => new WireFrame($header['type'], $header['stream'], $payload, $header['flags']), + 'length' => $total, + ]; + } + + /** + * CRC32 (crc32b) of the payload as an unsigned 32-bit integer. + * + * Masked to 32 bits so the value matches zlib's crc32() on the agent side + * regardless of PHP's integer width. + */ + private static function checksum(string $payload): int + { + return crc32($payload) & 0xFFFFFFFF; + } +} diff --git a/app/Services/Wire/WireProtocolException.php b/app/Services/Wire/WireProtocolException.php new file mode 100644 index 0000000000..c6f753ffd6 --- /dev/null +++ b/app/Services/Wire/WireProtocolException.php @@ -0,0 +1,11 @@ +exitCode !== 0) { + excludeCertainErrors($result->stderr, $result->exitCode); + } + + $output = trim($result->stdout); + + return $output === '' ? null : sanitize_utf8_text($output); +} + function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) { $ignoredErrors = collect([ diff --git a/config/constants.php b/config/constants.php index 7504b6ba86..d283c5e1ed 100644 --- a/config/constants.php +++ b/config/constants.php @@ -78,6 +78,17 @@ 'retry_multiplier' => env('SSH_RETRY_MULTIPLIER', 2), ], + 'agent' => [ + // Coolify Wire Protocol (CWP) transport. When enabled, command execution + // on a server can flow over the agent daemon instead of spawning ssh. + // Disabled by default so the SSH transport stays the default everywhere. + 'enabled' => env('COOLIFY_AGENT_ENABLED', false), + 'protocol_version' => 1, + 'port' => env('COOLIFY_AGENT_PORT', 9000), + 'token' => env('COOLIFY_AGENT_TOKEN'), + 'connection_timeout' => env('COOLIFY_AGENT_CONNECTION_TIMEOUT', 10), + ], + 'invitation' => [ 'link' => [ 'base_url' => '/invitations/', diff --git a/docker/coolify-agent/Makefile b/docker/coolify-agent/Makefile new file mode 100644 index 0000000000..780a59c7ba --- /dev/null +++ b/docker/coolify-agent/Makefile @@ -0,0 +1,11 @@ +CC ?= cc +CFLAGS ?= -O2 -Wall -Wextra -std=c11 +LDLIBS = -lz + +coolify-agent: coolify-agent.c + $(CC) $(CFLAGS) -o $@ coolify-agent.c $(LDLIBS) + +clean: + rm -f coolify-agent + +.PHONY: clean diff --git a/docker/coolify-agent/README.md b/docker/coolify-agent/README.md new file mode 100644 index 0000000000..34ec120a30 --- /dev/null +++ b/docker/coolify-agent/README.md @@ -0,0 +1,75 @@ +# Coolify Wire Protocol (CWP) & Agent + +CWP is a compact binary protocol for **control-plane → agent (server-to-server)** +communication. It replaces spawning an `ssh` process per command: the control +plane keeps a connection to the agent daemon and exchanges binary frames to run +commands and stream their output. + +This directory contains the reference **C agent daemon** (`coolify-agent.c`). +The control-plane side lives in PHP under `app/Services/Wire/` +(`WireProtocol`, the codec; `CoolifyAgentClient`, the transport). + +## Frame format + +Every frame is a fixed **24-byte header** followed by an opcode-specific payload. +All multi-byte integers are **big-endian** (network byte order). + +| Offset | Size | Field | Description | +| ------ | ---- | ----------- | --------------------------------------------- | +| 0 | 4 | `MAGIC` | ASCII `CWP1` | +| 4 | 1 | `VERSION` | Protocol major version (currently `1`) | +| 5 | 1 | `TYPE` | Opcode (see below) | +| 6 | 2 | `FLAGS` | Reserved bit-flags | +| 8 | 8 | `STREAM_ID` | Correlates request / response / stream frames | +| 16 | 4 | `LENGTH` | Payload length in bytes | +| 20 | 4 | `CRC32` | CRC32 (`crc32b`/zlib) of the payload | +| 24 | N | `PAYLOAD` | `N = LENGTH` bytes | + +Payloads of structured frames (`HELLO`, `WELCOME`, `EXEC`, `EXIT`, `ERROR`) are +UTF-8 JSON objects. Stream frames (`STDOUT`, `STDERR`) carry **raw bytes** so +binary command output is forwarded without base64 inflation. A single payload is +capped at 16 MiB; larger output is delivered across multiple stream frames. + +## Opcodes + +| Type | Name | Direction | Payload | +| ------ | --------- | --------------- | ------------------------------------ | +| `0x01` | `HELLO` | control → agent | `{"version","token","features"}` | +| `0x02` | `WELCOME` | agent → control | `{"version","agent","features"}` | +| `0x10` | `EXEC` | control → agent | `{"command","timeout"}` | +| `0x11` | `STDOUT` | agent → control | raw bytes | +| `0x12` | `STDERR` | agent → control | raw bytes | +| `0x13` | `EXIT` | agent → control | `{"code"}` | +| `0x20` | `PING` | either | empty | +| `0x21` | `PONG` | either | empty | +| `0x7F` | `ERROR` | either | `{"code","message"}` | + +## Exchange + +1. Control plane connects and sends `HELLO` with its shared `token`. +2. Agent validates the token and replies `WELCOME` (or `ERROR` and closes). +3. Control plane sends `EXEC` with the command. +4. Agent streams `STDOUT` / `STDERR` frames, then a final `EXIT` with the code. +5. `PING` / `PONG` may be sent at any time to keep the connection alive. + +## Build & run + +```bash +make # produces ./coolify-agent (needs zlib) +COOLIFY_AGENT_TOKEN=secret ./coolify-agent --port 9000 +``` + +## Enabling the transport in Coolify + +The transport is **opt-in**; the SSH transport stays the default. Configure via +`config/constants.php` (`agent.*`) / environment: + +```dotenv +COOLIFY_AGENT_ENABLED=true +COOLIFY_AGENT_PORT=9000 +COOLIFY_AGENT_TOKEN=secret +``` + +and set `use_agent` (and optionally `agent_token`) on the server's settings. +When enabled, `instant_remote_process()` and +`instant_remote_process_with_timeout()` route commands over CWP instead of SSH. diff --git a/docker/coolify-agent/coolify-agent.c b/docker/coolify-agent/coolify-agent.c new file mode 100644 index 0000000000..bec5350820 --- /dev/null +++ b/docker/coolify-agent/coolify-agent.c @@ -0,0 +1,413 @@ +/* + * coolify-agent — reference C daemon for the Coolify Wire Protocol (CWP) v1. + * + * This is the server (agent) side of the control-plane <-> agent transport that + * replaces per-command `ssh` invocations. It listens on a TCP port, performs a + * token-authenticated handshake, then executes a single command per connection + * and streams stdout/stderr/exit back as CWP frames. + * + * Frame layout (24-byte header, all integers big-endian): + * + * 0 4 MAGIC "CWP1" + * 4 1 VERSION 1 + * 5 1 TYPE opcode + * 6 2 FLAGS reserved + * 8 8 STREAM_ID request correlation id + * 16 4 LENGTH payload length + * 20 4 CRC32 crc32 of payload (zlib crc32 == PHP crc32b) + * 24 N PAYLOAD + * + * Build: make (links against zlib for crc32) + * Run: COOLIFY_AGENT_TOKEN=secret ./coolify-agent --port 9000 + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CWP_MAGIC "CWP1" +#define CWP_VERSION 1 +#define CWP_HEADER_SIZE 24 +#define CWP_MAX_PAYLOAD (16u * 1024u * 1024u) + +#define T_HELLO 0x01 +#define T_WELCOME 0x02 +#define T_EXEC 0x10 +#define T_STDOUT 0x11 +#define T_STDERR 0x12 +#define T_EXIT 0x13 +#define T_PING 0x20 +#define T_PONG 0x21 +#define T_ERROR 0x7F + +static uint16_t rd_u16(const unsigned char *p) { return (uint16_t)((p[0] << 8) | p[1]); } +static uint32_t rd_u32(const unsigned char *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3]; +} +static uint64_t rd_u64(const unsigned char *p) +{ + uint64_t v = 0; + for (int i = 0; i < 8; i++) { + v = (v << 8) | p[i]; + } + return v; +} +static void wr_u16(unsigned char *p, uint16_t v) { p[0] = v >> 8; p[1] = v & 0xFF; } +static void wr_u32(unsigned char *p, uint32_t v) +{ + p[0] = v >> 24; p[1] = (v >> 16) & 0xFF; p[2] = (v >> 8) & 0xFF; p[3] = v & 0xFF; +} +static void wr_u64(unsigned char *p, uint64_t v) +{ + for (int i = 7; i >= 0; i--) { p[i] = v & 0xFF; v >>= 8; } +} + +/* Read exactly n bytes; returns 0 on success, -1 on EOF/error. */ +static int read_full(int fd, unsigned char *buf, size_t n) +{ + size_t off = 0; + while (off < n) { + ssize_t r = read(fd, buf + off, n - off); + if (r == 0) return -1; + if (r < 0) { + if (errno == EINTR) continue; + return -1; + } + off += (size_t)r; + } + return 0; +} + +/* Write exactly n bytes; returns 0 on success, -1 on error. */ +static int write_full(int fd, const unsigned char *buf, size_t n) +{ + size_t off = 0; + while (off < n) { + ssize_t w = write(fd, buf + off, n - off); + if (w < 0) { + if (errno == EINTR) continue; + return -1; + } + off += (size_t)w; + } + return 0; +} + +/* Send a single frame. payload may be NULL when len == 0. */ +static int send_frame(int fd, uint8_t type, uint64_t stream_id, const unsigned char *payload, uint32_t len) +{ + unsigned char header[CWP_HEADER_SIZE]; + memcpy(header, CWP_MAGIC, 4); + header[4] = CWP_VERSION; + header[5] = type; + wr_u16(header + 6, 0); + wr_u64(header + 8, stream_id); + wr_u32(header + 16, len); + + uLong crc = crc32(0L, Z_NULL, 0); + if (len > 0) crc = crc32(crc, payload, len); + wr_u32(header + 20, (uint32_t)crc); + + if (write_full(fd, header, CWP_HEADER_SIZE) != 0) return -1; + if (len > 0 && write_full(fd, payload, len) != 0) return -1; + return 0; +} + +static int send_json(int fd, uint8_t type, uint64_t stream_id, const char *json) +{ + return send_frame(fd, type, stream_id, (const unsigned char *)json, (uint32_t)strlen(json)); +} + +/* + * Read one frame. Allocates *payload (caller frees) when len > 0. Returns 0 on + * success, -1 on connection close, -2 on a protocol violation. + */ +static int recv_frame(int fd, uint8_t *type, uint64_t *stream_id, unsigned char **payload, uint32_t *len) +{ + unsigned char header[CWP_HEADER_SIZE]; + if (read_full(fd, header, CWP_HEADER_SIZE) != 0) return -1; + + if (memcmp(header, CWP_MAGIC, 4) != 0) return -2; + if (header[4] != CWP_VERSION) return -2; + + *type = header[5]; + *stream_id = rd_u64(header + 8); + uint32_t plen = rd_u32(header + 16); + uint32_t crc = rd_u32(header + 20); + + if (plen > CWP_MAX_PAYLOAD) return -2; + + unsigned char *buf = NULL; + if (plen > 0) { + buf = malloc(plen); + if (!buf) return -2; + if (read_full(fd, buf, plen) != 0) { free(buf); return -1; } + uLong got = crc32(crc32(0L, Z_NULL, 0), buf, plen); + if ((uint32_t)got != crc) { free(buf); return -2; } + } + + *payload = buf; + *len = plen; + return 0; +} + +/* Append a UTF-8 encoding of codepoint cp to out at *pos. */ +static void utf8_append(char *out, size_t *pos, unsigned int cp) +{ + if (cp < 0x80) { + out[(*pos)++] = (char)cp; + } else if (cp < 0x800) { + out[(*pos)++] = (char)(0xC0 | (cp >> 6)); + out[(*pos)++] = (char)(0x80 | (cp & 0x3F)); + } else { + out[(*pos)++] = (char)(0xE0 | (cp >> 12)); + out[(*pos)++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[(*pos)++] = (char)(0x80 | (cp & 0x3F)); + } +} + +/* + * Extract the string value for "key" from a flat JSON object payload, decoding + * standard escapes. Writes a NUL-terminated result into out (capacity outcap). + * Returns 0 on success, -1 if the key is absent or the buffer is too small. + */ +static int json_string(const unsigned char *payload, uint32_t len, const char *key, char *out, size_t outcap) +{ + char needle[64]; + int nl = snprintf(needle, sizeof(needle), "\"%s\"", key); + if (nl < 0 || (size_t)nl >= sizeof(needle)) return -1; + + const char *hay = (const char *)payload; + const char *p = NULL; + for (uint32_t i = 0; i + (uint32_t)nl <= len; i++) { + if (memcmp(hay + i, needle, (size_t)nl) == 0) { p = hay + i + nl; break; } + } + if (!p) return -1; + + const char *end = hay + len; + while (p < end && (*p == ' ' || *p == ':' || *p == '\t' || *p == '\n' || *p == '\r')) p++; + if (p >= end || *p != '"') return -1; + p++; + + size_t pos = 0; + while (p < end && *p != '"') { + if (pos + 4 >= outcap) return -1; /* leave room for multibyte + NUL */ + if (*p == '\\' && p + 1 < end) { + p++; + switch (*p) { + case 'n': out[pos++] = '\n'; break; + case 't': out[pos++] = '\t'; break; + case 'r': out[pos++] = '\r'; break; + case 'b': out[pos++] = '\b'; break; + case 'f': out[pos++] = '\f'; break; + case '/': out[pos++] = '/'; break; + case '\\': out[pos++] = '\\'; break; + case '"': out[pos++] = '"'; break; + case 'u': { + if (p + 4 >= end) return -1; + char hex[5] = {p[1], p[2], p[3], p[4], 0}; + unsigned int cp = (unsigned int)strtoul(hex, NULL, 16); + utf8_append(out, &pos, cp); + p += 4; + break; + } + default: out[pos++] = *p; break; + } + p++; + } else { + out[pos++] = *p++; + } + } + if (p >= end) return -1; + out[pos] = '\0'; + return 0; +} + +/* Run the command, streaming stdout/stderr frames, then an EXIT frame. */ +static void run_command(int fd, uint64_t stream_id, const char *command) +{ + int outp[2], errp[2]; + if (pipe(outp) != 0 || pipe(errp) != 0) { + send_json(fd, T_ERROR, stream_id, "{\"code\":1,\"message\":\"pipe failed\"}"); + return; + } + + pid_t pid = fork(); + if (pid < 0) { + send_json(fd, T_ERROR, stream_id, "{\"code\":1,\"message\":\"fork failed\"}"); + return; + } + + if (pid == 0) { + dup2(outp[1], STDOUT_FILENO); + dup2(errp[1], STDERR_FILENO); + close(outp[0]); close(outp[1]); + close(errp[0]); close(errp[1]); + execl("/bin/sh", "sh", "-c", command, (char *)NULL); + _exit(127); + } + + close(outp[1]); + close(errp[1]); + + int open_fds = 2; + unsigned char chunk[65536]; + while (open_fds > 0) { + fd_set rfds; + FD_ZERO(&rfds); + int maxfd = -1; + if (outp[0] >= 0) { FD_SET(outp[0], &rfds); if (outp[0] > maxfd) maxfd = outp[0]; } + if (errp[0] >= 0) { FD_SET(errp[0], &rfds); if (errp[0] > maxfd) maxfd = errp[0]; } + if (maxfd < 0) break; + + int ready = select(maxfd + 1, &rfds, NULL, NULL, NULL); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + + if (outp[0] >= 0 && FD_ISSET(outp[0], &rfds)) { + ssize_t r = read(outp[0], chunk, sizeof(chunk)); + if (r > 0) { + send_frame(fd, T_STDOUT, stream_id, chunk, (uint32_t)r); + } else { + close(outp[0]); outp[0] = -1; open_fds--; + } + } + if (errp[0] >= 0 && FD_ISSET(errp[0], &rfds)) { + ssize_t r = read(errp[0], chunk, sizeof(chunk)); + if (r > 0) { + send_frame(fd, T_STDERR, stream_id, chunk, (uint32_t)r); + } else { + close(errp[0]); errp[0] = -1; open_fds--; + } + } + } + + int status = 0; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} + int code = WIFEXITED(status) ? WEXITSTATUS(status) : (WIFSIGNALED(status) ? 128 + WTERMSIG(status) : 1); + + char exit_payload[32]; + snprintf(exit_payload, sizeof(exit_payload), "{\"code\":%d}", code); + send_json(fd, T_EXIT, stream_id, exit_payload); +} + +/* Handle a single client connection through its full lifecycle. */ +static void handle_connection(int fd, const char *expected_token) +{ + uint8_t type; + uint64_t stream_id; + unsigned char *payload = NULL; + uint32_t len = 0; + + if (recv_frame(fd, &type, &stream_id, &payload, &len) != 0) return; + if (type != T_HELLO) { + send_json(fd, T_ERROR, stream_id, "{\"code\":1,\"message\":\"expected HELLO\"}"); + free(payload); + return; + } + + char token[1024] = ""; + json_string(payload, len, "token", token, sizeof(token)); + free(payload); + payload = NULL; + + if (expected_token && expected_token[0] != '\0' && strcmp(token, expected_token) != 0) { + send_json(fd, T_ERROR, stream_id, "{\"code\":401,\"message\":\"invalid token\"}"); + return; + } + + send_json(fd, T_WELCOME, stream_id, "{\"version\":1,\"agent\":\"coolify-agent\",\"features\":[\"exec\"]}"); + + /* Service requests until the peer disconnects. */ + while (recv_frame(fd, &type, &stream_id, &payload, &len) == 0) { + if (type == T_PING) { + send_frame(fd, T_PONG, stream_id, NULL, 0); + } else if (type == T_EXEC) { + char *command = malloc(len + 1 > 4096 ? len + 1 : 4096); + if (!command) { free(payload); break; } + if (json_string(payload, len, "command", command, len + 1 > 4096 ? len + 1 : 4096) == 0) { + run_command(fd, stream_id, command); + } else { + send_json(fd, T_ERROR, stream_id, "{\"code\":1,\"message\":\"missing command\"}"); + } + free(command); + } else { + send_json(fd, T_ERROR, stream_id, "{\"code\":1,\"message\":\"unexpected frame\"}"); + } + free(payload); + payload = NULL; + } +} + +static void reap_children(int sig) +{ + (void)sig; + while (waitpid(-1, NULL, WNOHANG) > 0) {} +} + +int main(int argc, char **argv) +{ + int port = 9000; + const char *token = getenv("COOLIFY_AGENT_TOKEN"); + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) { + port = atoi(argv[++i]); + } else if (strcmp(argv[i], "--token") == 0 && i + 1 < argc) { + token = argv[++i]; + } + } + + signal(SIGPIPE, SIG_IGN); + signal(SIGCHLD, reap_children); + + int srv = socket(AF_INET, SOCK_STREAM, 0); + if (srv < 0) { perror("socket"); return 1; } + + int yes = 1; + setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons((uint16_t)port); + + if (bind(srv, (struct sockaddr *)&addr, sizeof(addr)) != 0) { perror("bind"); return 1; } + if (listen(srv, 64) != 0) { perror("listen"); return 1; } + + fprintf(stderr, "coolify-agent listening on port %d (CWP v%d)\n", port, CWP_VERSION); + + for (;;) { + int client = accept(srv, NULL, NULL); + if (client < 0) { + if (errno == EINTR) continue; + perror("accept"); + continue; + } + + pid_t pid = fork(); + if (pid == 0) { + close(srv); + handle_connection(client, token); + close(client); + _exit(0); + } + close(client); + } +} diff --git a/tests/Unit/Wire/CoolifyAgentClientTest.php b/tests/Unit/Wire/CoolifyAgentClientTest.php new file mode 100644 index 0000000000..e9d5b43940 --- /dev/null +++ b/tests/Unit/Wire/CoolifyAgentClientTest.php @@ -0,0 +1,105 @@ + + */ + function drainAgentFrames($peer): array + { + stream_set_blocking($peer, false); + $buffer = ''; + while (($chunk = fread($peer, 65536)) !== false && $chunk !== '') { + $buffer .= $chunk; + } + + $frames = []; + while (($decoded = WireProtocol::tryDecode($buffer)) !== null) { + $frames[] = $decoded['frame']; + $buffer = substr($buffer, $decoded['length']); + } + + return $frames; + } +} + +it('completes the handshake and reassembles streamed output', function () { + [$client, $agent] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + // Queue the agent's replies up front so the synchronous client can read them. + fwrite($agent, WireProtocol::encodeJson(WireProtocol::WELCOME_TYPE, 1, ['version' => 1, 'agent' => 'coolify-agent'])); + fwrite($agent, WireProtocol::encode(WireProtocol::STDOUT_TYPE, 1, 'hello ')); + fwrite($agent, WireProtocol::encode(WireProtocol::STDOUT_TYPE, 1, 'world')); + fwrite($agent, WireProtocol::encode(WireProtocol::STDERR_TYPE, 1, 'a warning')); + fwrite($agent, WireProtocol::encodeJson(WireProtocol::EXIT_TYPE, 1, ['code' => 0])); + + $result = CoolifyAgentClient::exchange($client, 'secret-token', 'echo hi', 5); + + expect($result->exitCode)->toBe(0) + ->and($result->stdout)->toBe('hello world') + ->and($result->stderr)->toBe('a warning'); + + $sent = drainAgentFrames($agent); + + expect($sent[0]->type)->toBe(WireProtocol::HELLO_TYPE) + ->and($sent[0]->json()['token'])->toBe('secret-token') + ->and($sent[1]->type)->toBe(WireProtocol::EXEC_TYPE) + ->and($sent[1]->json()['command'])->toBe('echo hi'); + + fclose($client); + fclose($agent); +}); + +it('replies to a PING with a PONG while waiting for output', function () { + [$client, $agent] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + fwrite($agent, WireProtocol::encodeJson(WireProtocol::WELCOME_TYPE, 1, ['version' => 1])); + fwrite($agent, WireProtocol::encode(WireProtocol::PING_TYPE, 99)); + fwrite($agent, WireProtocol::encode(WireProtocol::STDOUT_TYPE, 1, 'done')); + fwrite($agent, WireProtocol::encodeJson(WireProtocol::EXIT_TYPE, 1, ['code' => 0])); + + $result = CoolifyAgentClient::exchange($client, '', 'true', 5); + + expect($result->stdout)->toBe('done'); + + $sent = drainAgentFrames($agent); + $pongs = array_filter($sent, fn ($frame) => $frame->type === WireProtocol::PONG_TYPE); + + expect($pongs)->not->toBeEmpty(); + + fclose($client); + fclose($agent); +}); + +it('maps a non-zero exit code from the agent', function () { + [$client, $agent] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + fwrite($agent, WireProtocol::encodeJson(WireProtocol::WELCOME_TYPE, 1, ['version' => 1])); + fwrite($agent, WireProtocol::encode(WireProtocol::STDERR_TYPE, 1, 'boom')); + fwrite($agent, WireProtocol::encodeJson(WireProtocol::EXIT_TYPE, 1, ['code' => 137])); + + $result = CoolifyAgentClient::exchange($client, '', 'false', 5); + + expect($result->exitCode)->toBe(137) + ->and($result->stderr)->toBe('boom'); + + fclose($client); + fclose($agent); +}); + +it('throws when the agent rejects the handshake', function () { + [$client, $agent] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + fwrite($agent, WireProtocol::encodeJson(WireProtocol::ERROR_TYPE, 1, ['code' => 401, 'message' => 'invalid token'])); + + expect(fn () => CoolifyAgentClient::exchange($client, 'wrong', 'echo hi', 5)) + ->toThrow(WireProtocolException::class); + + fclose($client); + fclose($agent); +}); diff --git a/tests/Unit/Wire/WireProtocolTest.php b/tests/Unit/Wire/WireProtocolTest.php new file mode 100644 index 0000000000..11d04199ec --- /dev/null +++ b/tests/Unit/Wire/WireProtocolTest.php @@ -0,0 +1,135 @@ +toBe('CWP1'); + + $header = unpack('Cversion/Ctype/nflags/Jstream/Nlength/Ncrc', substr($frame, 4, WireProtocol::HEADER_SIZE - 4)); + + expect($header['version'])->toBe(WireProtocol::VERSION) + ->and($header['type'])->toBe(WireProtocol::EXEC_TYPE) + ->and($header['flags'])->toBe(0) + ->and($header['stream'])->toBe(0x0102030405060708) + ->and($header['length'])->toBe(5) + ->and($header['crc'])->toBe(crc32('hello')) + ->and(substr($frame, WireProtocol::HEADER_SIZE))->toBe('hello'); +}); + +it('round-trips a structured frame through encode and decode', function () { + $frame = WireProtocol::encode(WireProtocol::STDOUT_TYPE, 7, 'output', 3); + + $decoded = WireProtocol::tryDecode($frame); + + expect($decoded['length'])->toBe(strlen($frame)) + ->and($decoded['frame']->type)->toBe(WireProtocol::STDOUT_TYPE) + ->and($decoded['frame']->streamId)->toBe(7) + ->and($decoded['frame']->flags)->toBe(3) + ->and($decoded['frame']->payload)->toBe('output'); +}); + +it('round-trips an empty payload', function () { + $frame = WireProtocol::encode(WireProtocol::PING_TYPE, 42); + + $decoded = WireProtocol::tryDecode($frame); + + expect($decoded['frame']->type)->toBe(WireProtocol::PING_TYPE) + ->and($decoded['frame']->payload)->toBe('') + ->and($decoded['frame']->streamId)->toBe(42); +}); + +it('preserves raw binary stream payloads byte for byte', function () { + $binary = random_bytes(512)."\x00\xff\x10\n"; + $frame = WireProtocol::encode(WireProtocol::STDERR_TYPE, 1, $binary); + + $decoded = WireProtocol::tryDecode($frame); + + expect($decoded['frame']->payload)->toBe($binary); +}); + +it('encodes and decodes JSON payloads', function () { + $frame = WireProtocol::encodeJson(WireProtocol::EXIT_TYPE, 9, ['code' => 137]); + + $decoded = WireProtocol::tryDecode($frame); + + expect($decoded['frame']->json())->toBe(['code' => 137]); +}); + +it('preserves a 64-bit stream id larger than 32 bits', function () { + $streamId = 0x1_0000_0001; + $frame = WireProtocol::encode(WireProtocol::EXEC_TYPE, $streamId); + + $decoded = WireProtocol::tryDecode($frame); + + expect($decoded['frame']->streamId)->toBe($streamId); +}); + +it('returns null when the buffer is shorter than a header', function () { + $frame = WireProtocol::encode(WireProtocol::EXEC_TYPE, 1, 'data'); + + expect(WireProtocol::tryDecode(substr($frame, 0, WireProtocol::HEADER_SIZE - 1)))->toBeNull(); +}); + +it('returns null when the payload has not fully arrived', function () { + $frame = WireProtocol::encode(WireProtocol::EXEC_TYPE, 1, 'partial-payload'); + + expect(WireProtocol::tryDecode(substr($frame, 0, WireProtocol::HEADER_SIZE + 4)))->toBeNull(); +}); + +it('consumes exactly one frame from a buffer holding two', function () { + $first = WireProtocol::encode(WireProtocol::STDOUT_TYPE, 1, 'first'); + $second = WireProtocol::encode(WireProtocol::STDERR_TYPE, 1, 'second'); + + $decoded = WireProtocol::tryDecode($first.$second); + + expect($decoded['length'])->toBe(strlen($first)) + ->and($decoded['frame']->payload)->toBe('first'); + + $rest = substr($first.$second, $decoded['length']); + $next = WireProtocol::tryDecode($rest); + + expect($next['frame']->payload)->toBe('second'); +}); + +it('throws when the magic prefix is wrong', function () { + $frame = 'XXXX'.substr(WireProtocol::encode(WireProtocol::EXEC_TYPE, 1, 'x'), 4); + + expect(fn () => WireProtocol::tryDecode($frame))->toThrow(WireProtocolException::class); +}); + +it('throws on an unsupported protocol version', function () { + $frame = WireProtocol::encode(WireProtocol::EXEC_TYPE, 1, 'x'); + $frame[4] = chr(2); + + expect(fn () => WireProtocol::tryDecode($frame))->toThrow(WireProtocolException::class); +}); + +it('throws when the payload CRC does not match', function () { + $frame = WireProtocol::encode(WireProtocol::STDOUT_TYPE, 1, 'corruptme'); + $frame[WireProtocol::HEADER_SIZE] = chr(ord($frame[WireProtocol::HEADER_SIZE]) ^ 0xFF); + + expect(fn () => WireProtocol::tryDecode($frame))->toThrow(WireProtocolException::class); +}); + +it('throws when a declared payload length exceeds the limit', function () { + $header = 'CWP1' + .pack('C', WireProtocol::VERSION) + .pack('C', WireProtocol::EXEC_TYPE) + .pack('n', 0) + .pack('J', 1) + .pack('N', WireProtocol::MAX_PAYLOAD + 1) + .pack('N', 0); + + expect(fn () => WireProtocol::tryDecode($header))->toThrow(WireProtocolException::class); +}); + +it('rejects an out-of-range frame type', function () { + expect(fn () => WireProtocol::encode(0x100, 1, ''))->toThrow(WireProtocolException::class); +}); + +it('rejects a negative stream id', function () { + expect(fn () => WireProtocol::encode(WireProtocol::EXEC_TYPE, -1, ''))->toThrow(WireProtocolException::class); +});