Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/cwp-agent.yml
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions coolify-agent/Makefile
Original file line number Diff line number Diff line change
@@ -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)
95 changes: 95 additions & 0 deletions coolify-agent/SPEC.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions coolify-agent/include/cwp.h
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions coolify-agent/src/main.c
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions coolify-agent/src/protocol/byte_order.h
Original file line number Diff line number Diff line change
@@ -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
Loading