diff --git a/.github/workflows/cwp-agent.yml b/.github/workflows/cwp-agent.yml new file mode 100644 index 0000000000..868997bf59 --- /dev/null +++ b/.github/workflows/cwp-agent.yml @@ -0,0 +1,38 @@ +name: CWP Agent + +on: + pull_request: + paths: + - "coolify-agent/**" + - ".github/workflows/cwp-agent.yml" + push: + branches: + - v4.x + paths: + - "coolify-agent/**" + - ".github/workflows/cwp-agent.yml" + +jobs: + cwp-agent: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + cc: [gcc, clang] + + steps: + - uses: actions/checkout@v4 + + - name: Install compiler + run: | + sudo apt-get update + sudo apt-get install -y ${{ matrix.cc }} + + - name: Build and test + run: | + make -C coolify-agent CC=${{ matrix.cc }} test + make -C coolify-agent CC=${{ matrix.cc }} agent + + - name: Fuzz frame parser + if: matrix.cc == 'clang' + run: make -C coolify-agent CC=clang FUZZ_ARGS=-max_total_time=600 fuzz-smoke diff --git a/coolify-agent/Makefile b/coolify-agent/Makefile new file mode 100644 index 0000000000..79a77d99ac --- /dev/null +++ b/coolify-agent/Makefile @@ -0,0 +1,63 @@ +CC ?= cc +PYTHON ?= python3 + +CFLAGS ?= -std=c99 -Wall -Wextra -Werror -O2 +FREESTANDING_CFLAGS ?= -std=c99 -Wall -Wextra -Werror -Os -ffreestanding -fno-builtin +INCLUDES := -Iinclude -Isrc +BUILD_DIR := build +FUZZ_ARGS ?= -runs=100 + +PROTOCOL_SRCS := \ + src/string.c \ + src/protocol/crc32c.c \ + src/protocol/frame.c \ + src/protocol/handshake.c \ + src/protocol/message.c \ + src/protocol/stream.c + +.PHONY: all agent fixtures fuzz-corpus test fuzz-smoke clean + +all: test + +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +agent: $(BUILD_DIR) +ifeq ($(shell uname -s),Linux) + $(CC) $(FREESTANDING_CFLAGS) $(INCLUDES) -nostdlib -static src/main.c $(PROTOCOL_SRCS) -o $(BUILD_DIR)/coolify-agent + test "$$(wc -c < $(BUILD_DIR)/coolify-agent)" -lt 512000 +else + $(CC) $(FREESTANDING_CFLAGS) $(INCLUDES) -c src/main.c -o $(BUILD_DIR)/main.o +endif + +fixtures: + $(PYTHON) tools/generate_hex_fixtures.py + +fuzz-corpus: fixtures + $(PYTHON) tools/generate_fuzz_corpus.py + +$(BUILD_DIR)/test_crc32c: tests/test_crc32c.c src/protocol/crc32c.c | $(BUILD_DIR) + $(CC) $(CFLAGS) $(INCLUDES) tests/test_crc32c.c src/protocol/crc32c.c -o $@ + +$(BUILD_DIR)/test_frame: tests/test_frame.c $(PROTOCOL_SRCS) | $(BUILD_DIR) + $(CC) $(CFLAGS) $(INCLUDES) tests/test_frame.c $(PROTOCOL_SRCS) -o $@ + +$(BUILD_DIR)/test_protocol: tests/test_protocol.c $(PROTOCOL_SRCS) | $(BUILD_DIR) + $(CC) $(CFLAGS) $(INCLUDES) tests/test_protocol.c $(PROTOCOL_SRCS) -o $@ + +test: fixtures $(BUILD_DIR)/test_crc32c $(BUILD_DIR)/test_frame $(BUILD_DIR)/test_protocol + $(BUILD_DIR)/test_crc32c + $(BUILD_DIR)/test_frame + $(BUILD_DIR)/test_protocol + for fixture in tests/hex_fixtures/*.hex; do $(PYTHON) tools/cwp_dump.py "$$fixture" >/dev/null; done + +fuzz-smoke: fuzz-corpus $(BUILD_DIR) + @if printf '%s\n' 'int LLVMFuzzerTestOneInput(const unsigned char *d, unsigned long s){(void)d;(void)s;return 0;}' | $(CC) -x c - -fsanitize=fuzzer -o $(BUILD_DIR)/fuzzer-check >/dev/null 2>&1; then \ + $(CC) $(CFLAGS) $(INCLUDES) -fsanitize=fuzzer,address tests/fuzz/fuzz_frame_parser.c $(PROTOCOL_SRCS) -o $(BUILD_DIR)/fuzz_frame_parser; \ + $(BUILD_DIR)/fuzz_frame_parser $(FUZZ_ARGS) tests/fuzz/corpus; \ + else \ + echo "libFuzzer runtime unavailable for $(CC); skipping fuzz smoke"; \ + fi + +clean: + rm -rf $(BUILD_DIR) diff --git a/coolify-agent/SPEC.md b/coolify-agent/SPEC.md new file mode 100644 index 0000000000..40a4b00daa --- /dev/null +++ b/coolify-agent/SPEC.md @@ -0,0 +1,95 @@ +# Coolify Wire Protocol v1 + +This document is the byte-level contract implemented by the initial `coolify-agent` frame layer. + +## Frame Format + +All multi-byte integers are big-endian. + +| Offset | Size | Field | +| ---: | ---: | --- | +| 0 | 1 | Magic, always `0xC0` | +| 1 | 1 | Version, currently `0x01` | +| 2 | 2 | Frame type | +| 4 | 4 | Stream ID | +| 8 | 4 | Payload length, max `16,777,215` bytes | +| 12 | 4 | Sequence number | +| 16 | 8 | Timestamp in microseconds since Unix epoch | +| 24 | 4 | Header CRC32C over bytes `0..23` | +| 28 | N | Payload bytes | +| 28 + N | 4 | Payload CRC32C over payload bytes, omitted when payload length is zero | + +Zero-payload frames are exactly 28 bytes. Payload frames are `32 + payload_length` bytes. + +## Frame Types + +The initial constants cover these ranges: + +- `0x0001..0x000A`: handshake, keepalive, ACK, ERROR, stream lifecycle +- `0x0100..0x0103`: command execution +- `0x0200..0x0205`: file transfer +- `0x0300..0x030A`: container operations and events +- `0x0400..0x0403`: deployment lifecycle +- `0x0500..0x0502`: server stats, health, alerts +- `0x0600..0x0602`: proxy configuration +- `0xFF00`: extension frame + +`0xFFFF` is reserved and must never be sent. + +## Payload Encoding + +- Strings are `uint16 length` followed by UTF-8 bytes, not null-terminated. +- Arrays are prefixed with a `uint32` element count. +- Booleans are one byte: `0x00` for false, `0x01` for true. +- Percentages use fixed-point integers multiplied by 100. + +Implemented payload builders: + +- `HANDSHAKE_INIT` +- `CMD_EXEC_REQUEST` +- `ERROR` + +The fixture generator also emits sample payloads for: + +- `CONTAINER_LIST_RESPONSE` +- `SERVER_STATS` + +## CRC32C + +CWP uses CRC32C Castagnoli, not IEEE CRC32. The software implementation uses the reflected polynomial `0x82F63B78`. The hardware path uses x86 SSE4.2 CRC32 instructions when available and falls back to software otherwise. + +Known vector: + +```text +crc32c("123456789") = 0xE3069283 +``` + +## Stream Rules + +- Stream `0` is the control stream. +- Odd stream IDs are control-plane initiated. +- Even stream IDs are agent initiated. +- At most 256 streams may be open per connection. +- The initial per-stream window is 1 MiB. + +## Fixtures + +`tests/hex_fixtures` contains canonical frames generated by `tools/generate_hex_fixtures.py`: + +- `handshake_init.hex` +- `cmd_exec_request.hex` +- `container_list_response.hex` +- `server_stats.hex` +- `error_frame.hex` + +Each fixture includes real header and payload CRC32C values. The Python decoder and C parser both verify those checksums. + +## Verification + +```bash +make -C coolify-agent test +make -C coolify-agent agent +make -C coolify-agent fuzz-smoke +``` + +On Linux, `make agent` builds a freestanding static binary with `-nostdlib -static` and checks the binary is smaller than 500KB. On platforms without a libFuzzer runtime, `fuzz-smoke` reports a skip instead of failing local development. diff --git a/coolify-agent/include/cwp.h b/coolify-agent/include/cwp.h new file mode 100644 index 0000000000..2f8522a634 --- /dev/null +++ b/coolify-agent/include/cwp.h @@ -0,0 +1,114 @@ +#ifndef CWP_H +#define CWP_H + +#define CWP_MAGIC 0xC0u +#define CWP_VERSION 0x01u +#define CWP_HEADER_LEN 28u +#define CWP_PAYLOAD_CRC_LEN 4u +#define CWP_MAX_PAYLOAD_LEN 16777215u +#define CWP_MAX_STREAMS 256u +#define CWP_CONTROL_STREAM_ID 0u + +#define CWP_OK 0 +#define CWP_ERR_UNKNOWN -1 +#define CWP_ERR_PROTOCOL -2 +#define CWP_ERR_AUTHENTICATION_FAILED -3 +#define CWP_ERR_STREAM_LIMIT_EXCEEDED -4 +#define CWP_ERR_PAYLOAD_TOO_LARGE -5 +#define CWP_ERR_INVALID_FRAME_TYPE -6 +#define CWP_ERR_STREAM_NOT_FOUND -7 +#define CWP_ERR_CRC_MISMATCH -8 +#define CWP_ERR_TIMEOUT -9 +#define CWP_ERR_COMMAND_FAILED -10 +#define CWP_ERR_FILE_NOT_FOUND -11 +#define CWP_ERR_PERMISSION_DENIED -12 +#define CWP_ERR_CONTAINER_NOT_FOUND -13 +#define CWP_ERR_DOCKER_ERROR -14 +#define CWP_ERR_INTERNAL -15 +#define CWP_ERR_SHORT_BUFFER -16 + +enum cwp_frame_type { + CWP_HANDSHAKE_INIT = 0x0001, + CWP_HANDSHAKE_RESPONSE = 0x0002, + CWP_HANDSHAKE_COMPLETE = 0x0003, + CWP_PING = 0x0004, + CWP_PONG = 0x0005, + CWP_ACK = 0x0006, + CWP_ERROR = 0x0007, + CWP_STREAM_OPEN = 0x0008, + CWP_STREAM_CLOSE = 0x0009, + CWP_STREAM_RESET = 0x000A, + CWP_CMD_EXEC_REQUEST = 0x0100, + CWP_CMD_EXEC_STDOUT = 0x0101, + CWP_CMD_EXEC_STDERR = 0x0102, + CWP_CMD_EXEC_EXIT = 0x0103, + CWP_FILE_UPLOAD_START = 0x0200, + CWP_FILE_UPLOAD_CHUNK = 0x0201, + CWP_FILE_UPLOAD_COMPLETE = 0x0202, + CWP_FILE_DOWNLOAD_REQUEST = 0x0203, + CWP_FILE_DOWNLOAD_CHUNK = 0x0204, + CWP_FILE_DOWNLOAD_COMPLETE = 0x0205, + CWP_CONTAINER_LIST = 0x0300, + CWP_CONTAINER_LIST_RESPONSE = 0x0301, + CWP_CONTAINER_INSPECT = 0x0302, + CWP_CONTAINER_INSPECT_RESPONSE = 0x0303, + CWP_CONTAINER_START = 0x0304, + CWP_CONTAINER_STOP = 0x0305, + CWP_CONTAINER_REMOVE = 0x0306, + CWP_CONTAINER_LOGS_START = 0x0307, + CWP_CONTAINER_LOGS_DATA = 0x0308, + CWP_CONTAINER_LOGS_STOP = 0x0309, + CWP_CONTAINER_EVENT = 0x030A, + CWP_DEPLOY_START = 0x0400, + CWP_DEPLOY_PROGRESS = 0x0401, + CWP_DEPLOY_LOG = 0x0402, + CWP_DEPLOY_COMPLETE = 0x0403, + CWP_SERVER_STATS = 0x0500, + CWP_SERVER_HEALTH = 0x0501, + CWP_SERVER_ALERT = 0x0502, + CWP_PROXY_CONFIG_PUSH = 0x0600, + CWP_PROXY_CONFIG_ACK = 0x0601, + CWP_PROXY_RELOAD = 0x0602, + CWP_EXTENSION = 0xFF00, + CWP_RESERVED = 0xFFFF +}; + +enum cwp_container_status { + CWP_CONTAINER_CREATED = 0, + CWP_CONTAINER_RUNNING = 1, + CWP_CONTAINER_PAUSED = 2, + CWP_CONTAINER_RESTARTING = 3, + CWP_CONTAINER_REMOVING = 4, + CWP_CONTAINER_EXITED = 5, + CWP_CONTAINER_DEAD = 6 +}; + +enum cwp_container_event_type { + CWP_CONTAINER_EVENT_START = 0, + CWP_CONTAINER_EVENT_STOP = 1, + CWP_CONTAINER_EVENT_DIE = 2, + CWP_CONTAINER_EVENT_OOM = 3, + CWP_CONTAINER_EVENT_PAUSE = 4, + CWP_CONTAINER_EVENT_UNPAUSE = 5, + CWP_CONTAINER_EVENT_RESTART = 6 +}; + +enum cwp_error_code { + CWP_ERROR_UNKNOWN = 0x00000001, + CWP_ERROR_PROTOCOL = 0x00000002, + CWP_ERROR_AUTHENTICATION_FAILED = 0x00000003, + CWP_ERROR_STREAM_LIMIT_EXCEEDED = 0x00000004, + CWP_ERROR_PAYLOAD_TOO_LARGE = 0x00000005, + CWP_ERROR_INVALID_FRAME_TYPE = 0x00000006, + CWP_ERROR_STREAM_NOT_FOUND = 0x00000007, + CWP_ERROR_CRC_MISMATCH = 0x00000008, + CWP_ERROR_TIMEOUT = 0x00000009, + CWP_ERROR_COMMAND_FAILED = 0x0000000A, + CWP_ERROR_FILE_NOT_FOUND = 0x0000000B, + CWP_ERROR_PERMISSION_DENIED = 0x0000000C, + CWP_ERROR_CONTAINER_NOT_FOUND = 0x0000000D, + CWP_ERROR_DOCKER_ERROR = 0x0000000E, + CWP_ERROR_INTERNAL = 0x0000000F +}; + +#endif diff --git a/coolify-agent/src/main.c b/coolify-agent/src/main.c new file mode 100644 index 0000000000..6df5b3696b --- /dev/null +++ b/coolify-agent/src/main.c @@ -0,0 +1,20 @@ +#include "../include/cwp.h" +#include "syscall.h" + +#if defined(__linux__) && defined(__x86_64__) +void _start(void) +{ + static const char banner[] = "coolify-agent cwp skeleton\n"; + + (void)cwp_syscall3(CWP_SYS_WRITE, 1, (long)banner, sizeof(banner) - 1u); + (void)cwp_syscall1(CWP_SYS_EXIT, 0); + + for (;;) { + } +} +#else +int main(void) +{ + return CWP_OK; +} +#endif diff --git a/coolify-agent/src/protocol/byte_order.h b/coolify-agent/src/protocol/byte_order.h new file mode 100644 index 0000000000..b6521a4d6c --- /dev/null +++ b/coolify-agent/src/protocol/byte_order.h @@ -0,0 +1,57 @@ +#ifndef CWP_BYTE_ORDER_H +#define CWP_BYTE_ORDER_H + +#include "../types.h" + +static inline void cwp_write_u16(uint8_t *out, uint16_t value) +{ + out[0] = (uint8_t)(value >> 8); + out[1] = (uint8_t)value; +} + +static inline void cwp_write_u32(uint8_t *out, uint32_t value) +{ + out[0] = (uint8_t)(value >> 24); + out[1] = (uint8_t)(value >> 16); + out[2] = (uint8_t)(value >> 8); + out[3] = (uint8_t)value; +} + +static inline void cwp_write_u64(uint8_t *out, uint64_t value) +{ + out[0] = (uint8_t)(value >> 56); + out[1] = (uint8_t)(value >> 48); + out[2] = (uint8_t)(value >> 40); + out[3] = (uint8_t)(value >> 32); + out[4] = (uint8_t)(value >> 24); + out[5] = (uint8_t)(value >> 16); + out[6] = (uint8_t)(value >> 8); + out[7] = (uint8_t)value; +} + +static inline uint16_t cwp_read_u16(const uint8_t *in) +{ + return ((uint16_t)in[0] << 8) | (uint16_t)in[1]; +} + +static inline uint32_t cwp_read_u32(const uint8_t *in) +{ + return ((uint32_t)in[0] << 24) | + ((uint32_t)in[1] << 16) | + ((uint32_t)in[2] << 8) | + (uint32_t)in[3]; +} + +static inline uint64_t cwp_read_u64(const uint8_t *in) +{ + return ((uint64_t)in[0] << 56) | + ((uint64_t)in[1] << 48) | + ((uint64_t)in[2] << 40) | + ((uint64_t)in[3] << 32) | + ((uint64_t)in[4] << 24) | + ((uint64_t)in[5] << 16) | + ((uint64_t)in[6] << 8) | + (uint64_t)in[7]; +} + +#endif diff --git a/coolify-agent/src/protocol/crc32c.c b/coolify-agent/src/protocol/crc32c.c new file mode 100644 index 0000000000..3a04b8b67a --- /dev/null +++ b/coolify-agent/src/protocol/crc32c.c @@ -0,0 +1,115 @@ +#include "crc32c.h" + +#define CWP_CRC32C_POLY_REFLECTED 0x82F63B78u + +uint32_t cwp_crc32c(uint32_t crc, const uint8_t *buf, size_t len) +{ + if (cwp_crc32c_hw_available()) { + return cwp_crc32c_hw(crc, buf, len); + } + + return cwp_crc32c_sw(crc, buf, len); +} + +uint32_t cwp_crc32c_sw(uint32_t crc, const uint8_t *buf, size_t len) +{ + crc = ~crc; + + for (size_t i = 0; i < len; i++) { + crc ^= (uint32_t)buf[i]; + + for (uint8_t bit = 0; bit < 8; bit++) { + uint32_t mask = 0u - (crc & 1u); + crc = (crc >> 1) ^ (CWP_CRC32C_POLY_REFLECTED & mask); + } + } + + return ~crc; +} + +int cwp_crc32c_hw_available(void) +{ +#if defined(__x86_64__) || defined(__i386__) + uint32_t eax; + uint32_t ebx; + uint32_t ecx; + uint32_t edx; + + eax = 1u; + +#if defined(__x86_64__) + __asm__ volatile( + "cpuid" + : "+a"(eax), "=b"(ebx), "=c"(ecx), "=d"(edx)); +#else + __asm__ volatile( + "pushl %%ebx\n\t" + "cpuid\n\t" + "movl %%ebx, %1\n\t" + "popl %%ebx" + : "+a"(eax), "=r"(ebx), "=c"(ecx), "=d"(edx)); +#endif + + (void)ebx; + (void)edx; + + return (ecx & (1u << 20)) != 0u; +#else + return 0; +#endif +} + +uint32_t cwp_crc32c_hw(uint32_t crc, const uint8_t *buf, size_t len) +{ +#if defined(__x86_64__) + crc = ~crc; + + while (len >= 8u) { + uint64_t value = + ((uint64_t)buf[0]) | + ((uint64_t)buf[1] << 8) | + ((uint64_t)buf[2] << 16) | + ((uint64_t)buf[3] << 24) | + ((uint64_t)buf[4] << 32) | + ((uint64_t)buf[5] << 40) | + ((uint64_t)buf[6] << 48) | + ((uint64_t)buf[7] << 56); + __asm__ volatile("crc32q %1, %0" : "+r"(crc) : "r"(value)); + buf += 8; + len -= 8; + } + + while (len > 0u) { + uint8_t value = *buf; + __asm__ volatile("crc32b %1, %0" : "+r"(crc) : "rm"(value)); + buf++; + len--; + } + + return ~crc; +#elif defined(__i386__) + crc = ~crc; + + while (len >= 4u) { + uint32_t value = + ((uint32_t)buf[0]) | + ((uint32_t)buf[1] << 8) | + ((uint32_t)buf[2] << 16) | + ((uint32_t)buf[3] << 24); + __asm__ volatile("crc32l %1, %0" : "+r"(crc) : "r"(value)); + buf += 4; + len -= 4; + } + + while (len > 0u) { + uint8_t value = *buf; + __asm__ volatile("crc32b %1, %0" : "+r"(crc) : "rm"(value)); + buf++; + len--; + } + + return ~crc; +#else + return cwp_crc32c_sw(crc, buf, len); +#endif +} diff --git a/coolify-agent/src/protocol/crc32c.h b/coolify-agent/src/protocol/crc32c.h new file mode 100644 index 0000000000..993b1ab50b --- /dev/null +++ b/coolify-agent/src/protocol/crc32c.h @@ -0,0 +1,11 @@ +#ifndef CWP_CRC32C_H +#define CWP_CRC32C_H + +#include "../types.h" + +uint32_t cwp_crc32c(uint32_t crc, const uint8_t *buf, size_t len); +uint32_t cwp_crc32c_sw(uint32_t crc, const uint8_t *buf, size_t len); +uint32_t cwp_crc32c_hw(uint32_t crc, const uint8_t *buf, size_t len); +int cwp_crc32c_hw_available(void); + +#endif diff --git a/coolify-agent/src/protocol/frame.c b/coolify-agent/src/protocol/frame.c new file mode 100644 index 0000000000..3f1b2135a6 --- /dev/null +++ b/coolify-agent/src/protocol/frame.c @@ -0,0 +1,157 @@ +#include "frame.h" + +#include "byte_order.h" +#include "crc32c.h" + +size_t cwp_frame_wire_len(uint32_t payload_length) +{ + size_t len = CWP_HEADER_LEN + (size_t)payload_length; + + if (payload_length > 0u) { + len += CWP_PAYLOAD_CRC_LEN; + } + + return len; +} + +int cwp_frame_serialize(const struct cwp_frame *frame, uint8_t *out, size_t out_len, size_t *written) +{ + uint32_t header_crc; + size_t needed; + + if (frame == CWP_NULL || out == CWP_NULL || written == CWP_NULL) { + return CWP_ERR_INTERNAL; + } + + if (frame->payload_length > CWP_MAX_PAYLOAD_LEN) { + return CWP_ERR_PAYLOAD_TOO_LARGE; + } + + if (!cwp_frame_type_known(frame->frame_type)) { + return CWP_ERR_INVALID_FRAME_TYPE; + } + + if (frame->payload_length > 0u && frame->payload == CWP_NULL) { + return CWP_ERR_PROTOCOL; + } + + needed = cwp_frame_wire_len(frame->payload_length); + if (out_len < needed) { + return CWP_ERR_SHORT_BUFFER; + } + + out[0] = CWP_MAGIC; + out[1] = CWP_VERSION; + cwp_write_u16(out + 2, frame->frame_type); + cwp_write_u32(out + 4, frame->stream_id); + cwp_write_u32(out + 8, frame->payload_length); + cwp_write_u32(out + 12, frame->sequence_number); + cwp_write_u64(out + 16, frame->timestamp_us); + + header_crc = cwp_crc32c(0u, out, 24u); + cwp_write_u32(out + 24, header_crc); + + for (uint32_t i = 0; i < frame->payload_length; i++) { + out[CWP_HEADER_LEN + i] = frame->payload[i]; + } + + if (frame->payload_length > 0u) { + uint32_t payload_crc = cwp_crc32c(0u, frame->payload, frame->payload_length); + cwp_write_u32(out + CWP_HEADER_LEN + frame->payload_length, payload_crc); + } + + *written = needed; + return CWP_OK; +} + +int cwp_frame_parse(const uint8_t *wire, size_t wire_len, struct cwp_frame *frame, size_t *consumed) +{ + uint32_t payload_length; + size_t needed; + uint32_t expected_header_crc; + uint32_t actual_header_crc; + + if (wire == CWP_NULL || frame == CWP_NULL || consumed == CWP_NULL) { + return CWP_ERR_INTERNAL; + } + + if (wire_len < CWP_HEADER_LEN) { + return CWP_ERR_SHORT_BUFFER; + } + + if (wire[0] != CWP_MAGIC || wire[1] != CWP_VERSION) { + return CWP_ERR_PROTOCOL; + } + + frame->frame_type = cwp_read_u16(wire + 2); + if (!cwp_frame_type_known(frame->frame_type)) { + return CWP_ERR_INVALID_FRAME_TYPE; + } + + payload_length = cwp_read_u32(wire + 8); + if (payload_length > CWP_MAX_PAYLOAD_LEN) { + return CWP_ERR_PAYLOAD_TOO_LARGE; + } + + needed = cwp_frame_wire_len(payload_length); + if (wire_len < needed) { + return CWP_ERR_SHORT_BUFFER; + } + + expected_header_crc = cwp_read_u32(wire + 24); + actual_header_crc = cwp_crc32c(0u, wire, 24u); + if (expected_header_crc != actual_header_crc) { + return CWP_ERR_CRC_MISMATCH; + } + + if (payload_length > 0u) { + uint32_t expected_payload_crc = cwp_read_u32(wire + CWP_HEADER_LEN + payload_length); + uint32_t actual_payload_crc = cwp_crc32c(0u, wire + CWP_HEADER_LEN, payload_length); + + if (expected_payload_crc != actual_payload_crc) { + return CWP_ERR_CRC_MISMATCH; + } + } + + frame->stream_id = cwp_read_u32(wire + 4); + frame->payload_length = payload_length; + frame->sequence_number = cwp_read_u32(wire + 12); + frame->timestamp_us = cwp_read_u64(wire + 16); + frame->payload = payload_length > 0u ? wire + CWP_HEADER_LEN : CWP_NULL; + *consumed = needed; + + return CWP_OK; +} + +int cwp_frame_type_known(uint16_t frame_type) +{ + if (frame_type >= CWP_HANDSHAKE_INIT && frame_type <= CWP_STREAM_RESET) { + return 1; + } + + if (frame_type >= CWP_CMD_EXEC_REQUEST && frame_type <= CWP_CMD_EXEC_EXIT) { + return 1; + } + + if (frame_type >= CWP_FILE_UPLOAD_START && frame_type <= CWP_FILE_DOWNLOAD_COMPLETE) { + return 1; + } + + if (frame_type >= CWP_CONTAINER_LIST && frame_type <= CWP_CONTAINER_EVENT) { + return 1; + } + + if (frame_type >= CWP_DEPLOY_START && frame_type <= CWP_DEPLOY_COMPLETE) { + return 1; + } + + if (frame_type >= CWP_SERVER_STATS && frame_type <= CWP_SERVER_ALERT) { + return 1; + } + + if (frame_type >= CWP_PROXY_CONFIG_PUSH && frame_type <= CWP_PROXY_RELOAD) { + return 1; + } + + return frame_type == CWP_EXTENSION; +} diff --git a/coolify-agent/src/protocol/frame.h b/coolify-agent/src/protocol/frame.h new file mode 100644 index 0000000000..297c0b6d2d --- /dev/null +++ b/coolify-agent/src/protocol/frame.h @@ -0,0 +1,21 @@ +#ifndef CWP_FRAME_H +#define CWP_FRAME_H + +#include "../../include/cwp.h" +#include "../types.h" + +struct cwp_frame { + uint16_t frame_type; + uint32_t stream_id; + uint32_t payload_length; + uint32_t sequence_number; + uint64_t timestamp_us; + const uint8_t *payload; +}; + +size_t cwp_frame_wire_len(uint32_t payload_length); +int cwp_frame_serialize(const struct cwp_frame *frame, uint8_t *out, size_t out_len, size_t *written); +int cwp_frame_parse(const uint8_t *wire, size_t wire_len, struct cwp_frame *frame, size_t *consumed); +int cwp_frame_type_known(uint16_t frame_type); + +#endif diff --git a/coolify-agent/src/protocol/handshake.c b/coolify-agent/src/protocol/handshake.c new file mode 100644 index 0000000000..34a1dc53e9 --- /dev/null +++ b/coolify-agent/src/protocol/handshake.c @@ -0,0 +1,53 @@ +#include "handshake.h" + +#include "../../include/cwp.h" + +void cwp_handshake_init(struct cwp_handshake *handshake, uint64_t nonce) +{ + if (handshake == CWP_NULL) { + return; + } + + handshake->state = CWP_HANDSHAKE_STATE_INIT_SENT; + handshake->nonce = nonce; + handshake->session_id = 0u; +} + +int cwp_handshake_mark_response(struct cwp_handshake *handshake) +{ + if (handshake == CWP_NULL) { + return CWP_ERR_INTERNAL; + } + + if (handshake->state != CWP_HANDSHAKE_STATE_INIT_SENT) { + handshake->state = CWP_HANDSHAKE_STATE_FAILED; + return CWP_ERR_PROTOCOL; + } + + handshake->state = CWP_HANDSHAKE_STATE_RESPONSE_RECEIVED; + return CWP_OK; +} + +int cwp_handshake_complete(struct cwp_handshake *handshake, uint64_t session_id) +{ + if (handshake == CWP_NULL) { + return CWP_ERR_INTERNAL; + } + + if (handshake->state != CWP_HANDSHAKE_STATE_RESPONSE_RECEIVED) { + handshake->state = CWP_HANDSHAKE_STATE_FAILED; + return CWP_ERR_PROTOCOL; + } + + handshake->session_id = session_id; + handshake->state = CWP_HANDSHAKE_STATE_ESTABLISHED; + + return CWP_OK; +} + +void cwp_handshake_fail(struct cwp_handshake *handshake) +{ + if (handshake != CWP_NULL) { + handshake->state = CWP_HANDSHAKE_STATE_FAILED; + } +} diff --git a/coolify-agent/src/protocol/handshake.h b/coolify-agent/src/protocol/handshake.h new file mode 100644 index 0000000000..9eb2b1a990 --- /dev/null +++ b/coolify-agent/src/protocol/handshake.h @@ -0,0 +1,25 @@ +#ifndef CWP_HANDSHAKE_H +#define CWP_HANDSHAKE_H + +#include "../types.h" + +enum cwp_handshake_state { + CWP_HANDSHAKE_STATE_NEW = 0, + CWP_HANDSHAKE_STATE_INIT_SENT = 1, + CWP_HANDSHAKE_STATE_RESPONSE_RECEIVED = 2, + CWP_HANDSHAKE_STATE_ESTABLISHED = 3, + CWP_HANDSHAKE_STATE_FAILED = 4 +}; + +struct cwp_handshake { + enum cwp_handshake_state state; + uint64_t nonce; + uint64_t session_id; +}; + +void cwp_handshake_init(struct cwp_handshake *handshake, uint64_t nonce); +int cwp_handshake_mark_response(struct cwp_handshake *handshake); +int cwp_handshake_complete(struct cwp_handshake *handshake, uint64_t session_id); +void cwp_handshake_fail(struct cwp_handshake *handshake); + +#endif diff --git a/coolify-agent/src/protocol/message.c b/coolify-agent/src/protocol/message.c new file mode 100644 index 0000000000..c28206aa93 --- /dev/null +++ b/coolify-agent/src/protocol/message.c @@ -0,0 +1,203 @@ +#include "message.h" + +#include "../../include/cwp.h" +#include "byte_order.h" + +int cwp_writer_init(struct cwp_buf_writer *writer, uint8_t *buf, size_t capacity) +{ + if (writer == CWP_NULL || buf == CWP_NULL) { + return CWP_ERR_INTERNAL; + } + + writer->buf = buf; + writer->capacity = capacity; + writer->len = 0u; + + return CWP_OK; +} + +int cwp_write_bytes(struct cwp_buf_writer *writer, const uint8_t *bytes, size_t len) +{ + if (writer == CWP_NULL || (len > 0u && bytes == CWP_NULL)) { + return CWP_ERR_INTERNAL; + } + + if (writer->capacity - writer->len < len) { + return CWP_ERR_SHORT_BUFFER; + } + + for (size_t i = 0; i < len; i++) { + writer->buf[writer->len + i] = bytes[i]; + } + + writer->len += len; + return CWP_OK; +} + +int cwp_write_string(struct cwp_buf_writer *writer, const char *value, size_t len) +{ + int result; + + if (len > 65535u) { + return CWP_ERR_PAYLOAD_TOO_LARGE; + } + + result = cwp_write_uint16(writer, (uint16_t)len); + if (result != CWP_OK) { + return result; + } + + return cwp_write_bytes(writer, (const uint8_t *)value, len); +} + +int cwp_write_uint16(struct cwp_buf_writer *writer, uint16_t value) +{ + uint8_t bytes[2]; + cwp_write_u16(bytes, value); + return cwp_write_bytes(writer, bytes, 2u); +} + +int cwp_write_uint32(struct cwp_buf_writer *writer, uint32_t value) +{ + uint8_t bytes[4]; + cwp_write_u32(bytes, value); + return cwp_write_bytes(writer, bytes, 4u); +} + +int cwp_write_uint64(struct cwp_buf_writer *writer, uint64_t value) +{ + uint8_t bytes[8]; + cwp_write_u64(bytes, value); + return cwp_write_bytes(writer, bytes, 8u); +} + +int cwp_build_handshake_init( + uint8_t *out, + size_t out_len, + size_t *written, + const uint8_t server_id[16], + const uint8_t team_id[16], + uint64_t nonce, + const char *agent_version_required, + size_t agent_version_required_len) +{ + struct cwp_buf_writer writer; + int result; + + result = cwp_writer_init(&writer, out, out_len); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_uint16(&writer, CWP_VERSION); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_bytes(&writer, server_id, 16u); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_bytes(&writer, team_id, 16u); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_uint64(&writer, nonce); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_string(&writer, agent_version_required, agent_version_required_len); + if (result != CWP_OK) { + return result; + } + + *written = writer.len; + return CWP_OK; +} + +int cwp_build_cmd_exec_request( + uint8_t *out, + size_t out_len, + size_t *written, + const char *command, + size_t command_len, + const char *working_directory, + size_t working_directory_len, + uint32_t timeout_seconds) +{ + struct cwp_buf_writer writer; + int result; + + result = cwp_writer_init(&writer, out, out_len); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_string(&writer, command, command_len); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_string(&writer, working_directory, working_directory_len); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_uint32(&writer, timeout_seconds); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_uint16(&writer, 0u); + if (result != CWP_OK) { + return result; + } + + *written = writer.len; + return CWP_OK; +} + +int cwp_build_error_payload( + uint8_t *out, + size_t out_len, + size_t *written, + uint32_t error_code, + const char *message, + size_t message_len, + uint32_t ref_stream_id, + uint32_t ref_sequence) +{ + struct cwp_buf_writer writer; + int result; + + result = cwp_writer_init(&writer, out, out_len); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_uint32(&writer, error_code); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_string(&writer, message, message_len); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_uint32(&writer, ref_stream_id); + if (result != CWP_OK) { + return result; + } + + result = cwp_write_uint32(&writer, ref_sequence); + if (result != CWP_OK) { + return result; + } + + *written = writer.len; + return CWP_OK; +} diff --git a/coolify-agent/src/protocol/message.h b/coolify-agent/src/protocol/message.h new file mode 100644 index 0000000000..45546562c8 --- /dev/null +++ b/coolify-agent/src/protocol/message.h @@ -0,0 +1,46 @@ +#ifndef CWP_MESSAGE_H +#define CWP_MESSAGE_H + +#include "../types.h" + +struct cwp_buf_writer { + uint8_t *buf; + size_t capacity; + size_t len; +}; + +int cwp_writer_init(struct cwp_buf_writer *writer, uint8_t *buf, size_t capacity); +int cwp_write_bytes(struct cwp_buf_writer *writer, const uint8_t *bytes, size_t len); +int cwp_write_string(struct cwp_buf_writer *writer, const char *value, size_t len); +int cwp_write_uint16(struct cwp_buf_writer *writer, uint16_t value); +int cwp_write_uint32(struct cwp_buf_writer *writer, uint32_t value); +int cwp_write_uint64(struct cwp_buf_writer *writer, uint64_t value); +int cwp_build_handshake_init( + uint8_t *out, + size_t out_len, + size_t *written, + const uint8_t server_id[16], + const uint8_t team_id[16], + uint64_t nonce, + const char *agent_version_required, + size_t agent_version_required_len); +int cwp_build_cmd_exec_request( + uint8_t *out, + size_t out_len, + size_t *written, + const char *command, + size_t command_len, + const char *working_directory, + size_t working_directory_len, + uint32_t timeout_seconds); +int cwp_build_error_payload( + uint8_t *out, + size_t out_len, + size_t *written, + uint32_t error_code, + const char *message, + size_t message_len, + uint32_t ref_stream_id, + uint32_t ref_sequence); + +#endif diff --git a/coolify-agent/src/protocol/stream.c b/coolify-agent/src/protocol/stream.c new file mode 100644 index 0000000000..8f89986a06 --- /dev/null +++ b/coolify-agent/src/protocol/stream.c @@ -0,0 +1,90 @@ +#include "stream.h" + +void cwp_stream_table_init(struct cwp_stream_table *table) +{ + if (table == CWP_NULL) { + return; + } + + for (uint32_t i = 0u; i < CWP_MAX_STREAMS; i++) { + table->streams[i].stream_id = 0u; + table->streams[i].next_sequence = 1u; + table->streams[i].window_available = 1024u * 1024u; + table->streams[i].open = 0u; + } + + table->streams[0].stream_id = CWP_CONTROL_STREAM_ID; + table->streams[0].open = 1u; +} + +int cwp_stream_open(struct cwp_stream_table *table, uint32_t stream_id) +{ + if (table == CWP_NULL) { + return CWP_ERR_INTERNAL; + } + + if (cwp_stream_find(table, stream_id) != CWP_NULL) { + return CWP_ERR_PROTOCOL; + } + + for (uint32_t i = 0u; i < CWP_MAX_STREAMS; i++) { + if (!table->streams[i].open) { + table->streams[i].stream_id = stream_id; + table->streams[i].next_sequence = 1u; + table->streams[i].window_available = 1024u * 1024u; + table->streams[i].open = 1u; + return CWP_OK; + } + } + + return CWP_ERR_STREAM_LIMIT_EXCEEDED; +} + +int cwp_stream_close(struct cwp_stream_table *table, uint32_t stream_id) +{ + struct cwp_stream *stream = cwp_stream_find(table, stream_id); + + if (stream == CWP_NULL) { + return CWP_ERR_STREAM_NOT_FOUND; + } + + if (stream_id == CWP_CONTROL_STREAM_ID) { + return CWP_ERR_PROTOCOL; + } + + stream->open = 0u; + return CWP_OK; +} + +struct cwp_stream *cwp_stream_find(struct cwp_stream_table *table, uint32_t stream_id) +{ + if (table == CWP_NULL) { + return CWP_NULL; + } + + for (uint32_t i = 0u; i < CWP_MAX_STREAMS; i++) { + if (table->streams[i].open && table->streams[i].stream_id == stream_id) { + return &table->streams[i]; + } + } + + return CWP_NULL; +} + +int cwp_stream_next_sequence(struct cwp_stream_table *table, uint32_t stream_id, uint32_t *sequence) +{ + struct cwp_stream *stream = cwp_stream_find(table, stream_id); + + if (sequence == CWP_NULL) { + return CWP_ERR_INTERNAL; + } + + if (stream == CWP_NULL) { + return CWP_ERR_STREAM_NOT_FOUND; + } + + *sequence = stream->next_sequence; + stream->next_sequence++; + + return CWP_OK; +} diff --git a/coolify-agent/src/protocol/stream.h b/coolify-agent/src/protocol/stream.h new file mode 100644 index 0000000000..f84726d4c4 --- /dev/null +++ b/coolify-agent/src/protocol/stream.h @@ -0,0 +1,24 @@ +#ifndef CWP_STREAM_H +#define CWP_STREAM_H + +#include "../../include/cwp.h" +#include "../types.h" + +struct cwp_stream { + uint32_t stream_id; + uint32_t next_sequence; + uint32_t window_available; + uint8_t open; +}; + +struct cwp_stream_table { + struct cwp_stream streams[CWP_MAX_STREAMS]; +}; + +void cwp_stream_table_init(struct cwp_stream_table *table); +int cwp_stream_open(struct cwp_stream_table *table, uint32_t stream_id); +int cwp_stream_close(struct cwp_stream_table *table, uint32_t stream_id); +struct cwp_stream *cwp_stream_find(struct cwp_stream_table *table, uint32_t stream_id); +int cwp_stream_next_sequence(struct cwp_stream_table *table, uint32_t stream_id, uint32_t *sequence); + +#endif diff --git a/coolify-agent/src/string.c b/coolify-agent/src/string.c new file mode 100644 index 0000000000..f085b527db --- /dev/null +++ b/coolify-agent/src/string.c @@ -0,0 +1,49 @@ +#include "string.h" + +void *cwp_memcpy(void *dest, const void *src, size_t len) +{ + uint8_t *out = (uint8_t *)dest; + const uint8_t *in = (const uint8_t *)src; + + for (size_t i = 0; i < len; i++) { + out[i] = in[i]; + } + + return dest; +} + +void *cwp_memset(void *dest, int value, size_t len) +{ + uint8_t *out = (uint8_t *)dest; + + for (size_t i = 0; i < len; i++) { + out[i] = (uint8_t)value; + } + + return dest; +} + +int cwp_memcmp(const void *left, const void *right, size_t len) +{ + const uint8_t *left_bytes = (const uint8_t *)left; + const uint8_t *right_bytes = (const uint8_t *)right; + + for (size_t i = 0; i < len; i++) { + if (left_bytes[i] != right_bytes[i]) { + return (int)left_bytes[i] - (int)right_bytes[i]; + } + } + + return 0; +} + +size_t cwp_strlen(const char *value) +{ + size_t len = 0; + + while (value[len] != 0) { + len++; + } + + return len; +} diff --git a/coolify-agent/src/string.h b/coolify-agent/src/string.h new file mode 100644 index 0000000000..28d174fae7 --- /dev/null +++ b/coolify-agent/src/string.h @@ -0,0 +1,11 @@ +#ifndef CWP_STRING_H +#define CWP_STRING_H + +#include "types.h" + +void *cwp_memcpy(void *dest, const void *src, size_t len); +void *cwp_memset(void *dest, int value, size_t len); +int cwp_memcmp(const void *left, const void *right, size_t len); +size_t cwp_strlen(const char *value); + +#endif diff --git a/coolify-agent/src/syscall.h b/coolify-agent/src/syscall.h new file mode 100644 index 0000000000..a2705a695a --- /dev/null +++ b/coolify-agent/src/syscall.h @@ -0,0 +1,37 @@ +#ifndef CWP_SYSCALL_H +#define CWP_SYSCALL_H + +#include "types.h" + +#if defined(__linux__) && defined(__x86_64__) +#define CWP_SYS_WRITE 1 +#define CWP_SYS_EXIT 60 + +static inline long cwp_syscall1(long nr, long a1) +{ + long ret; + + __asm__ volatile( + "syscall" + : "=a"(ret) + : "a"(nr), "D"(a1) + : "rcx", "r11", "memory"); + + return ret; +} + +static inline long cwp_syscall3(long nr, long a1, long a2, long a3) +{ + long ret; + + __asm__ volatile( + "syscall" + : "=a"(ret) + : "a"(nr), "D"(a1), "S"(a2), "d"(a3) + : "rcx", "r11", "memory"); + + return ret; +} +#endif + +#endif diff --git a/coolify-agent/src/types.h b/coolify-agent/src/types.h new file mode 100644 index 0000000000..bd73296d3d --- /dev/null +++ b/coolify-agent/src/types.h @@ -0,0 +1,15 @@ +#ifndef CWP_TYPES_H +#define CWP_TYPES_H + +typedef unsigned char uint8_t; +typedef unsigned short uint16_t; +typedef unsigned int uint32_t; +typedef unsigned long long uint64_t; +typedef signed int int32_t; +typedef unsigned long size_t; + +#ifndef CWP_NULL +#define CWP_NULL ((void *)0) +#endif + +#endif diff --git a/coolify-agent/tests/fuzz/fuzz_frame_parser.c b/coolify-agent/tests/fuzz/fuzz_frame_parser.c new file mode 100644 index 0000000000..b13c9cc02e --- /dev/null +++ b/coolify-agent/tests/fuzz/fuzz_frame_parser.c @@ -0,0 +1,10 @@ +#include "../../src/protocol/frame.h" + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + struct cwp_frame frame; + size_t consumed = 0u; + + (void)cwp_frame_parse(data, size, &frame, &consumed); + return 0; +} diff --git a/coolify-agent/tests/hex_fixtures/cmd_exec_request.hex b/coolify-agent/tests/hex_fixtures/cmd_exec_request.hex new file mode 100644 index 0000000000..a82419d2d5 --- /dev/null +++ b/coolify-agent/tests/hex_fixtures/cmd_exec_request.hex @@ -0,0 +1,4 @@ +c0 01 01 00 00 00 00 01 00 00 00 1b 00 00 00 01 +00 06 2b 5e 8f 3c a0 00 b7 78 33 f6 00 09 64 6f +63 6b 65 72 20 70 73 00 08 2f 73 72 76 2f 61 70 +70 00 00 00 1e 00 00 5a d9 0b 64 diff --git a/coolify-agent/tests/hex_fixtures/container_list_response.hex b/coolify-agent/tests/hex_fixtures/container_list_response.hex new file mode 100644 index 0000000000..f6ec0048c6 --- /dev/null +++ b/coolify-agent/tests/hex_fixtures/container_list_response.hex @@ -0,0 +1,8 @@ +c0 01 03 01 00 00 00 02 00 00 00 5a 00 00 00 01 +00 06 2b 5e 8f 3c a0 00 de fa ba fb 00 00 00 01 +00 0c 61 62 63 31 32 33 64 65 66 34 35 36 00 03 +77 65 62 01 00 00 00 00 00 06 45 cd c8 d0 c0 00 +00 00 00 00 00 00 04 d2 00 00 00 00 10 00 00 00 +00 00 00 00 20 00 00 00 00 00 00 00 00 00 04 00 +00 00 00 00 00 00 08 00 00 0c 6e 67 69 6e 78 3a +61 6c 70 69 6e 65 24 dc 4f 7d diff --git a/coolify-agent/tests/hex_fixtures/error_frame.hex b/coolify-agent/tests/hex_fixtures/error_frame.hex new file mode 100644 index 0000000000..23766e010b --- /dev/null +++ b/coolify-agent/tests/hex_fixtures/error_frame.hex @@ -0,0 +1,4 @@ +c0 01 00 07 00 00 00 01 00 00 00 17 00 00 00 08 +00 06 2b 5e 8f 3c a0 00 c4 22 dc 53 00 00 00 02 +00 09 62 61 64 20 6d 61 67 69 63 00 00 00 01 00 +00 00 07 39 2a aa ce diff --git a/coolify-agent/tests/hex_fixtures/handshake_init.hex b/coolify-agent/tests/hex_fixtures/handshake_init.hex new file mode 100644 index 0000000000..217585fc98 --- /dev/null +++ b/coolify-agent/tests/hex_fixtures/handshake_init.hex @@ -0,0 +1,6 @@ +c0 01 00 01 00 00 00 00 00 00 00 31 00 00 00 01 +00 06 2b 5e 8f 3c a0 00 5d 74 83 65 00 01 63 6c +78 78 78 78 78 78 78 78 78 78 30 30 30 31 63 6c +78 78 78 78 78 78 78 78 78 78 30 30 30 32 de ad +be ef ca fe 00 01 00 05 31 2e 30 2e 30 7f 4b 74 +2f diff --git a/coolify-agent/tests/hex_fixtures/server_stats.hex b/coolify-agent/tests/hex_fixtures/server_stats.hex new file mode 100644 index 0000000000..ec40671491 --- /dev/null +++ b/coolify-agent/tests/hex_fixtures/server_stats.hex @@ -0,0 +1,12 @@ +c0 01 05 00 00 00 00 00 00 00 00 93 00 00 00 02 +00 06 2b 5e 8f 3c a0 00 1f 4d 70 60 00 06 45 cd +c8 d0 c0 01 00 08 00 00 00 00 00 00 09 e6 00 00 +00 03 b9 ac a0 00 00 00 00 01 65 a0 bc 00 00 00 +00 02 54 0b e4 00 00 00 00 00 77 35 94 00 00 00 +00 00 05 f5 e1 00 00 00 00 01 00 01 2f 00 00 00 +77 35 94 00 00 00 00 00 2e 90 ed d0 00 00 00 00 +48 a4 a6 30 00 00 00 00 01 00 04 65 74 68 30 00 +00 00 00 00 00 03 e8 00 00 00 00 00 00 07 d0 00 +00 00 00 00 00 00 0a 00 00 00 00 00 00 00 14 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 24 +42 d0 f5 diff --git a/coolify-agent/tests/test_crc32c.c b/coolify-agent/tests/test_crc32c.c new file mode 100644 index 0000000000..4c548c8bb2 --- /dev/null +++ b/coolify-agent/tests/test_crc32c.c @@ -0,0 +1,19 @@ +#include +#include + +#include "../src/protocol/crc32c.h" + +int main(void) +{ + static const uint8_t empty[] = ""; + static const uint8_t digits[] = "123456789"; + static const uint8_t sentence[] = "Coolify Wire Protocol"; + + assert(cwp_crc32c_sw(0u, empty, 0u) == 0x00000000u); + assert(cwp_crc32c_sw(0u, digits, 9u) == 0xE3069283u); + assert(cwp_crc32c(0u, digits, 9u) == 0xE3069283u); + assert(cwp_crc32c_sw(0u, sentence, 21u) == 0x83373DC5u); + + printf("test_crc32c passed\n"); + return 0; +} diff --git a/coolify-agent/tests/test_frame.c b/coolify-agent/tests/test_frame.c new file mode 100644 index 0000000000..ee9bbd18a1 --- /dev/null +++ b/coolify-agent/tests/test_frame.c @@ -0,0 +1,206 @@ +#include +#include + +#include "../src/protocol/byte_order.h" +#include "../src/protocol/frame.h" +#include "../src/protocol/message.h" + +static void test_ping_frame(void) +{ + uint8_t wire[64]; + size_t written = 0u; + size_t consumed = 0u; + struct cwp_frame parsed; + struct cwp_frame frame = { + .frame_type = CWP_PING, + .stream_id = CWP_CONTROL_STREAM_ID, + .payload_length = 0u, + .sequence_number = 1u, + .timestamp_us = 1u, + .payload = CWP_NULL, + }; + + assert(cwp_frame_serialize(&frame, wire, sizeof(wire), &written) == CWP_OK); + assert(written == CWP_HEADER_LEN); + assert(wire[0] == CWP_MAGIC); + assert(wire[1] == CWP_VERSION); + assert(cwp_read_u16(wire + 2) == CWP_PING); + + assert(cwp_frame_parse(wire, written, &parsed, &consumed) == CWP_OK); + assert(consumed == written); + assert(parsed.frame_type == CWP_PING); + assert(parsed.stream_id == CWP_CONTROL_STREAM_ID); + assert(parsed.payload_length == 0u); + assert(parsed.sequence_number == 1u); + assert(parsed.timestamp_us == 1u); +} + +static void test_handshake_frame_roundtrip(void) +{ + uint8_t payload[128]; + uint8_t wire[256]; + size_t payload_len = 0u; + size_t written = 0u; + size_t consumed = 0u; + struct cwp_frame parsed; + static const uint8_t server_id[16] = { + 0x63, 0x6c, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, + 0x78, 0x78, 0x78, 0x78, 0x30, 0x30, 0x30, 0x31, + }; + static const uint8_t team_id[16] = { + 0x63, 0x6c, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, + 0x78, 0x78, 0x78, 0x78, 0x30, 0x30, 0x30, 0x32, + }; + + assert(cwp_build_handshake_init( + payload, + sizeof(payload), + &payload_len, + server_id, + team_id, + 0xDEADBEEFCAFE0001ULL, + "1.0.0", + 5u) == CWP_OK); + + struct cwp_frame frame = { + .frame_type = CWP_HANDSHAKE_INIT, + .stream_id = CWP_CONTROL_STREAM_ID, + .payload_length = (uint32_t)payload_len, + .sequence_number = 1u, + .timestamp_us = 0x00062B5E8F3CA000ULL, + .payload = payload, + }; + + assert(cwp_frame_serialize(&frame, wire, sizeof(wire), &written) == CWP_OK); + assert(written == CWP_HEADER_LEN + payload_len + CWP_PAYLOAD_CRC_LEN); + assert(cwp_frame_parse(wire, written, &parsed, &consumed) == CWP_OK); + assert(consumed == written); + assert(parsed.frame_type == CWP_HANDSHAKE_INIT); + assert(parsed.payload_length == payload_len); + assert(cwp_read_u16(parsed.payload) == CWP_VERSION); +} + +static void test_crc_rejection(void) +{ + uint8_t wire[64]; + size_t written = 0u; + size_t consumed = 0u; + struct cwp_frame parsed; + struct cwp_frame frame = { + .frame_type = CWP_PONG, + .stream_id = CWP_CONTROL_STREAM_ID, + .payload_length = 0u, + .sequence_number = 2u, + .timestamp_us = 2u, + .payload = CWP_NULL, + }; + + assert(cwp_frame_serialize(&frame, wire, sizeof(wire), &written) == CWP_OK); + wire[3] ^= 0x01u; + assert(cwp_frame_parse(wire, written, &parsed, &consumed) == CWP_ERR_CRC_MISMATCH); +} + +static void test_payload_crc_rejection(void) +{ + uint8_t payload[8] = {0xdeu, 0xadu, 0xbeu, 0xefu, 0xcau, 0xfeu, 0x00u, 0x01u}; + uint8_t wire[64]; + size_t written = 0u; + size_t consumed = 0u; + struct cwp_frame parsed; + struct cwp_frame frame = { + .frame_type = CWP_ERROR, + .stream_id = 1u, + .payload_length = sizeof(payload), + .sequence_number = 3u, + .timestamp_us = 3u, + .payload = payload, + }; + + assert(cwp_frame_serialize(&frame, wire, sizeof(wire), &written) == CWP_OK); + wire[written - 1u] ^= 0x01u; + assert(cwp_frame_parse(wire, written, &parsed, &consumed) == CWP_ERR_CRC_MISMATCH); +} + +static void test_all_known_frame_types_roundtrip(void) +{ + static const uint16_t frame_types[] = { + CWP_HANDSHAKE_INIT, + CWP_HANDSHAKE_RESPONSE, + CWP_HANDSHAKE_COMPLETE, + CWP_PING, + CWP_PONG, + CWP_ACK, + CWP_ERROR, + CWP_STREAM_OPEN, + CWP_STREAM_CLOSE, + CWP_STREAM_RESET, + CWP_CMD_EXEC_REQUEST, + CWP_CMD_EXEC_STDOUT, + CWP_CMD_EXEC_STDERR, + CWP_CMD_EXEC_EXIT, + CWP_FILE_UPLOAD_START, + CWP_FILE_UPLOAD_CHUNK, + CWP_FILE_UPLOAD_COMPLETE, + CWP_FILE_DOWNLOAD_REQUEST, + CWP_FILE_DOWNLOAD_CHUNK, + CWP_FILE_DOWNLOAD_COMPLETE, + CWP_CONTAINER_LIST, + CWP_CONTAINER_LIST_RESPONSE, + CWP_CONTAINER_INSPECT, + CWP_CONTAINER_INSPECT_RESPONSE, + CWP_CONTAINER_START, + CWP_CONTAINER_STOP, + CWP_CONTAINER_REMOVE, + CWP_CONTAINER_LOGS_START, + CWP_CONTAINER_LOGS_DATA, + CWP_CONTAINER_LOGS_STOP, + CWP_CONTAINER_EVENT, + CWP_DEPLOY_START, + CWP_DEPLOY_PROGRESS, + CWP_DEPLOY_LOG, + CWP_DEPLOY_COMPLETE, + CWP_SERVER_STATS, + CWP_SERVER_HEALTH, + CWP_SERVER_ALERT, + CWP_PROXY_CONFIG_PUSH, + CWP_PROXY_CONFIG_ACK, + CWP_PROXY_RELOAD, + CWP_EXTENSION, + }; + + for (size_t i = 0u; i < sizeof(frame_types) / sizeof(frame_types[0]); i++) { + uint8_t wire[64]; + size_t written = 0u; + size_t consumed = 0u; + struct cwp_frame parsed; + struct cwp_frame frame = { + .frame_type = frame_types[i], + .stream_id = CWP_CONTROL_STREAM_ID, + .payload_length = 0u, + .sequence_number = (uint32_t)(i + 1u), + .timestamp_us = (uint64_t)(i + 1u), + .payload = CWP_NULL, + }; + + assert(cwp_frame_type_known(frame_types[i])); + assert(cwp_frame_serialize(&frame, wire, sizeof(wire), &written) == CWP_OK); + assert(cwp_frame_parse(wire, written, &parsed, &consumed) == CWP_OK); + assert(consumed == written); + assert(parsed.frame_type == frame_types[i]); + assert(parsed.sequence_number == (uint32_t)(i + 1u)); + } + + assert(!cwp_frame_type_known(CWP_RESERVED)); +} + +int main(void) +{ + test_ping_frame(); + test_handshake_frame_roundtrip(); + test_crc_rejection(); + test_payload_crc_rejection(); + test_all_known_frame_types_roundtrip(); + + printf("test_frame passed\n"); + return 0; +} diff --git a/coolify-agent/tests/test_protocol.c b/coolify-agent/tests/test_protocol.c new file mode 100644 index 0000000000..ca4853ea52 --- /dev/null +++ b/coolify-agent/tests/test_protocol.c @@ -0,0 +1,28 @@ +#include +#include + +#include "../src/protocol/handshake.h" +#include "../src/protocol/stream.h" + +int main(void) +{ + struct cwp_handshake handshake; + struct cwp_stream_table streams; + uint32_t sequence = 0u; + + cwp_handshake_init(&handshake, 0xDEADBEEFCAFE0001ULL); + assert(handshake.state == CWP_HANDSHAKE_STATE_INIT_SENT); + assert(cwp_handshake_mark_response(&handshake) == CWP_OK); + assert(cwp_handshake_complete(&handshake, 0x0102030405060708ULL) == CWP_OK); + assert(handshake.state == CWP_HANDSHAKE_STATE_ESTABLISHED); + + cwp_stream_table_init(&streams); + assert(cwp_stream_find(&streams, CWP_CONTROL_STREAM_ID) != CWP_NULL); + assert(cwp_stream_open(&streams, 1u) == CWP_OK); + assert(cwp_stream_next_sequence(&streams, 1u, &sequence) == CWP_OK); + assert(sequence == 1u); + assert(cwp_stream_close(&streams, 1u) == CWP_OK); + + printf("test_protocol passed\n"); + return 0; +} diff --git a/coolify-agent/tools/cwp_dump.py b/coolify-agent/tools/cwp_dump.py new file mode 100644 index 0000000000..c600983718 --- /dev/null +++ b/coolify-agent/tools/cwp_dump.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Decode Coolify Wire Protocol frames from hex or binary input.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import sys + +CWP_MAGIC = 0xC0 +CWP_VERSION = 0x01 +CWP_HEADER_LEN = 28 +CWP_PAYLOAD_CRC_LEN = 4 +CWP_MAX_PAYLOAD_LEN = 16_777_215 +CWP_CRC32C_POLY_REFLECTED = 0x82F63B78 + +FRAME_TYPES = { + 0x0001: "HANDSHAKE_INIT", + 0x0002: "HANDSHAKE_RESPONSE", + 0x0003: "HANDSHAKE_COMPLETE", + 0x0004: "PING", + 0x0005: "PONG", + 0x0006: "ACK", + 0x0007: "ERROR", + 0x0008: "STREAM_OPEN", + 0x0009: "STREAM_CLOSE", + 0x000A: "STREAM_RESET", + 0x0100: "CMD_EXEC_REQUEST", + 0x0101: "CMD_EXEC_STDOUT", + 0x0102: "CMD_EXEC_STDERR", + 0x0103: "CMD_EXEC_EXIT", + 0x0301: "CONTAINER_LIST_RESPONSE", + 0x030A: "CONTAINER_EVENT", + 0x0500: "SERVER_STATS", +} + + +@dataclass(frozen=True) +class Frame: + frame_type: int + stream_id: int + payload_length: int + sequence_number: int + timestamp_us: int + payload: bytes + + @property + def frame_name(self) -> str: + return FRAME_TYPES.get(self.frame_type, f"UNKNOWN_0x{self.frame_type:04X}") + + +def crc32c(data: bytes, crc: int = 0) -> int: + crc ^= 0xFFFFFFFF + for byte in data: + crc ^= byte + for _ in range(8): + mask = -(crc & 1) & 0xFFFFFFFF + crc = ((crc >> 1) ^ (CWP_CRC32C_POLY_REFLECTED & mask)) & 0xFFFFFFFF + return crc ^ 0xFFFFFFFF + + +def frame_wire( + frame_type: int, + stream_id: int, + payload: bytes, + sequence_number: int, + timestamp_us: int, +) -> bytes: + if len(payload) > CWP_MAX_PAYLOAD_LEN: + raise ValueError("payload exceeds CWP maximum length") + + header = bytearray(CWP_HEADER_LEN) + header[0] = CWP_MAGIC + header[1] = CWP_VERSION + header[2:4] = frame_type.to_bytes(2, "big") + header[4:8] = stream_id.to_bytes(4, "big") + header[8:12] = len(payload).to_bytes(4, "big") + header[12:16] = sequence_number.to_bytes(4, "big") + header[16:24] = timestamp_us.to_bytes(8, "big") + header[24:28] = crc32c(bytes(header[:24])).to_bytes(4, "big") + + if payload: + return bytes(header) + payload + crc32c(payload).to_bytes(4, "big") + + return bytes(header) + + +def parse_frame(data: bytes) -> Frame: + if len(data) < CWP_HEADER_LEN: + raise ValueError("input shorter than CWP header") + if data[0] != CWP_MAGIC: + raise ValueError(f"bad magic 0x{data[0]:02X}") + if data[1] != CWP_VERSION: + raise ValueError(f"unsupported version 0x{data[1]:02X}") + + payload_length = int.from_bytes(data[8:12], "big") + if payload_length > CWP_MAX_PAYLOAD_LEN: + raise ValueError("payload exceeds CWP maximum length") + + expected_header_crc = int.from_bytes(data[24:28], "big") + actual_header_crc = crc32c(data[:24]) + if expected_header_crc != actual_header_crc: + raise ValueError( + f"header crc mismatch expected=0x{expected_header_crc:08X} actual=0x{actual_header_crc:08X}" + ) + + total_len = CWP_HEADER_LEN + payload_length + (CWP_PAYLOAD_CRC_LEN if payload_length else 0) + if len(data) < total_len: + raise ValueError("input shorter than declared frame length") + + payload = data[CWP_HEADER_LEN : CWP_HEADER_LEN + payload_length] + if payload: + expected_payload_crc = int.from_bytes(data[CWP_HEADER_LEN + payload_length : total_len], "big") + actual_payload_crc = crc32c(payload) + if expected_payload_crc != actual_payload_crc: + raise ValueError( + f"payload crc mismatch expected=0x{expected_payload_crc:08X} actual=0x{actual_payload_crc:08X}" + ) + + return Frame( + frame_type=int.from_bytes(data[2:4], "big"), + stream_id=int.from_bytes(data[4:8], "big"), + payload_length=payload_length, + sequence_number=int.from_bytes(data[12:16], "big"), + timestamp_us=int.from_bytes(data[16:24], "big"), + payload=payload, + ) + + +def read_input(path: str, input_format: str) -> bytes: + raw = sys.stdin.buffer.read() if path == "-" else Path(path).read_bytes() + if input_format == "bin": + return raw + + text = raw.decode() + hex_tokens = [] + for line in text.splitlines(): + line = line.split("#", 1)[0] + hex_tokens.extend(part for part in line.split() if part) + return bytes.fromhex("".join(hex_tokens)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("path", help="Frame file path or - for stdin") + parser.add_argument("--format", choices=["hex", "bin"], default="hex") + args = parser.parse_args() + + frame = parse_frame(read_input(args.path, args.format)) + print(f"type=0x{frame.frame_type:04X} {frame.frame_name}") + print(f"stream_id={frame.stream_id}") + print(f"payload_length={frame.payload_length}") + print(f"sequence_number={frame.sequence_number}") + print(f"timestamp_us={frame.timestamp_us}") + if frame.payload: + print(f"payload_hex={frame.payload.hex()}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/coolify-agent/tools/cwp_send.py b/coolify-agent/tools/cwp_send.py new file mode 100644 index 0000000000..529a81e3ac --- /dev/null +++ b/coolify-agent/tools/cwp_send.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Send one binary CWP frame to a TCP endpoint for agent smoke testing.""" + +from __future__ import annotations + +import argparse +import socket +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--host", required=True) + parser.add_argument("--port", type=int, default=4200) + parser.add_argument("--frame", required=True, help="Binary frame path") + args = parser.parse_args() + + payload = Path(args.frame).read_bytes() + with socket.create_connection((args.host, args.port), timeout=5) as sock: + sock.sendall(payload) + sock.shutdown(socket.SHUT_WR) + response = sock.recv(65536) + + if response: + print(response.hex()) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/coolify-agent/tools/generate_fuzz_corpus.py b/coolify-agent/tools/generate_fuzz_corpus.py new file mode 100644 index 0000000000..6e34eb1884 --- /dev/null +++ b/coolify-agent/tools/generate_fuzz_corpus.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Generate binary libFuzzer corpus entries from canonical CWP fixtures.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE_DIR = ROOT / "tests" / "hex_fixtures" +CORPUS_DIR = ROOT / "tests" / "fuzz" / "corpus" + + +def read_hex_fixture(path: Path) -> bytes: + tokens = [] + for line in path.read_text().splitlines(): + tokens.extend(part for part in line.split() if len(part) == 2) + return bytes(int(token, 16) for token in tokens) + + +def write_case(name: str, data: bytes) -> None: + (CORPUS_DIR / name).write_bytes(data) + + +def with_bad_header_crc(data: bytes) -> bytes: + mutated = bytearray(data) + if len(mutated) > 24: + mutated[24] ^= 0x01 + return bytes(mutated) + + +def with_bad_payload_crc(data: bytes) -> bytes: + mutated = bytearray(data) + if len(mutated) > 32: + mutated[-1] ^= 0x01 + return bytes(mutated) + + +def with_bad_magic(data: bytes) -> bytes: + mutated = bytearray(data) + if mutated: + mutated[0] = 0x00 + return bytes(mutated) + + +def with_oversized_payload_len(data: bytes) -> bytes: + mutated = bytearray(data) + if len(mutated) >= 12: + mutated[8:12] = (0x01000000).to_bytes(4, "big") + return bytes(mutated) + + +def main() -> int: + if CORPUS_DIR.exists(): + shutil.rmtree(CORPUS_DIR) + CORPUS_DIR.mkdir(parents=True, exist_ok=True) + + for fixture_path in sorted(FIXTURE_DIR.glob("*.hex")): + stem = fixture_path.stem + data = read_hex_fixture(fixture_path) + write_case(f"{stem}.bin", data) + write_case(f"{stem}.truncated.bin", data[: max(0, len(data) // 2)]) + write_case(f"{stem}.bad-magic.bin", with_bad_magic(data)) + write_case(f"{stem}.bad-header-crc.bin", with_bad_header_crc(data)) + write_case(f"{stem}.bad-payload-crc.bin", with_bad_payload_crc(data)) + write_case(f"{stem}.oversized-payload.bin", with_oversized_payload_len(data)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/coolify-agent/tools/generate_hex_fixtures.py b/coolify-agent/tools/generate_hex_fixtures.py new file mode 100644 index 0000000000..b5c1d3c299 --- /dev/null +++ b/coolify-agent/tools/generate_hex_fixtures.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Generate canonical CWP hex fixtures used by C and Python tests.""" + +from __future__ import annotations + +from pathlib import Path + +from cwp_dump import frame_wire + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE_DIR = ROOT / "tests" / "hex_fixtures" +TIMESTAMP_US = 0x00062B5E8F3CA000 + + +def lp_string(value: str) -> bytes: + raw = value.encode() + return len(raw).to_bytes(2, "big") + raw + + +def handshake_init_payload() -> bytes: + return b"".join( + [ + (1).to_bytes(2, "big"), + b"clxxxxxxxxxx0001", + b"clxxxxxxxxxx0002", + (0xDEADBEEFCAFE0001).to_bytes(8, "big"), + lp_string("1.0.0"), + ] + ) + + +def cmd_exec_request_payload() -> bytes: + return b"".join( + [ + lp_string("docker ps"), + lp_string("/srv/app"), + (30).to_bytes(4, "big"), + (0).to_bytes(2, "big"), + ] + ) + + +def container_list_response_payload() -> bytes: + return b"".join( + [ + (1).to_bytes(4, "big"), + lp_string("abc123def456"), + lp_string("web"), + (1).to_bytes(1, "big"), + (0).to_bytes(4, "big", signed=True), + (1_765_600_000_000_000).to_bytes(8, "big"), + (1234).to_bytes(8, "big"), + (268_435_456).to_bytes(8, "big"), + (536_870_912).to_bytes(8, "big"), + (1024).to_bytes(8, "big"), + (2048).to_bytes(8, "big"), + lp_string("nginx:alpine"), + ] + ) + + +def server_stats_payload() -> bytes: + return b"".join( + [ + (1_765_600_000_000_001).to_bytes(8, "big"), + (8).to_bytes(2, "big"), + (2534).to_bytes(8, "big"), + (16_000_000_000).to_bytes(8, "big"), + (6_000_000_000).to_bytes(8, "big"), + (10_000_000_000).to_bytes(8, "big"), + (2_000_000_000).to_bytes(8, "big"), + (100_000_000).to_bytes(8, "big"), + (1).to_bytes(4, "big"), + lp_string("/"), + (512_000_000_000).to_bytes(8, "big"), + (200_000_000_000).to_bytes(8, "big"), + (312_000_000_000).to_bytes(8, "big"), + (1).to_bytes(4, "big"), + lp_string("eth0"), + (1000).to_bytes(8, "big"), + (2000).to_bytes(8, "big"), + (10).to_bytes(8, "big"), + (20).to_bytes(8, "big"), + (0).to_bytes(8, "big"), + (0).to_bytes(8, "big"), + ] + ) + + +def error_payload() -> bytes: + return b"".join( + [ + (0x00000002).to_bytes(4, "big"), + lp_string("bad magic"), + (1).to_bytes(4, "big"), + (7).to_bytes(4, "big"), + ] + ) + + +FIXTURES = { + "handshake_init.hex": (0x0001, 0, handshake_init_payload(), 1), + "cmd_exec_request.hex": (0x0100, 1, cmd_exec_request_payload(), 1), + "container_list_response.hex": (0x0301, 2, container_list_response_payload(), 1), + "server_stats.hex": (0x0500, 0, server_stats_payload(), 2), + "error_frame.hex": (0x0007, 1, error_payload(), 8), +} + + +def format_hex(data: bytes) -> str: + rows = [] + for index in range(0, len(data), 16): + chunk = data[index : index + 16] + rows.append(" ".join(f"{byte:02x}" for byte in chunk)) + return "\n".join(rows) + "\n" + + +def main() -> int: + FIXTURE_DIR.mkdir(parents=True, exist_ok=True) + + for name, (frame_type, stream_id, payload, sequence) in FIXTURES.items(): + wire = frame_wire(frame_type, stream_id, payload, sequence, TIMESTAMP_US) + (FIXTURE_DIR / name).write_text(format_hex(wire)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())