/* signal.c — single-threaded WebRTC room-code signaling server. * * Endpoints (all bodies opaque to the server): * POST /room//offer store offer, wake any waiters * GET /room//offer return offer (long-poll up to 10 s, 204 on timeout) * POST /room//answer store answer, wake any waiters * GET /room//answer return answer (long-poll up to 10 s, 204 on timeout) * OPTIONS * CORS preflight * * Build: cc -O2 signal.c -o signal * Run: ./signal [port] (defaults to 8080) * * The whole server runs on one thread with poll(). Connections are state * machines (READ → WAIT → WRITE → done). A waiting GET is parked with no * IO interest until a matching POST flips it to WRITE. The long-poll * timeout returns 204 No Content * * Portability: strict POSIX.1-2008 */ #ifndef _POSIX_C_SOURCE #define _POSIX_C_SOURCE 200809L #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #define MAX_ROOMS 1024 #define MAX_CONNS (MAX_ROOMS * 2) /* worst case: both peers parked on the same room */ #define MAX_BLOB (64 * 1024) #define MAX_HEADER 4096 #define RBUF_SIZE (MAX_HEADER + MAX_BLOB) #define ROOM_TTL_S 300 #define GC_INTERVAL_S 60 #define LONGPOLL_S 10 #define IDLE_TIMEOUT_S 15 #define CODE_MAX 15 #define METHOD_MAX 7 #define PATH_MAX_LEN 79 enum cstate { C_FREE = 0, C_READ, /* reading request headers + body */ C_WAIT, /* long-polling for a slot to fill */ C_WRITE, /* sending response */ }; struct conn { int fd; enum cstate state; time_t deadline; /* absolute time after which this conn is cleaned up */ char *rbuf; /* allocated lazily on first read, freed on release */ size_t rlen; size_t body_off; /* offset of body start in rbuf (0 = headers not parsed yet) */ size_t body_want; /* Content-Length */ char method[METHOD_MAX + 1]; char path[PATH_MAX_LEN + 1]; char *wbuf; size_t wlen, woff; int wait_room; /* index into rooms[] when C_WAIT, else -1 */ char wait_slot; /* 'o' or 'a' when C_WAIT */ }; struct room { char code[CODE_MAX + 1]; char *offer; size_t offer_n; char *answer; size_t answer_n; time_t touched; }; static struct conn conns[MAX_CONNS]; static struct room rooms[MAX_ROOMS]; static int server_fd = -1; static volatile sig_atomic_t stop_flag = 0; /* ------------------------------------------------------------------------ */ static void on_stop(int sig) { (void)sig; stop_flag = 1; } /* Monotonic seconds — immune to NTP steps and manual wall-clock changes. */ static time_t monotonic_now(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec; } /* Case-insensitive substring search. Replaces the GNU strcasestr() so the * file compiles under strict POSIX (-D_POSIX_C_SOURCE=200809L, no GNU). */ static char *ci_strstr(const char *hay, const char *needle) { size_t nlen = strlen(needle); if (nlen == 0) return (char *)hay; for (; *hay; hay++) { if (strncasecmp(hay, needle, nlen) == 0) return (char *)hay; } return NULL; } static const char *status_text(int s) { switch (s) { case 200: return "OK"; case 204: return "No Content"; case 400: return "Bad Request"; case 404: return "Not Found"; case 405: return "Method Not Allowed"; case 408: return "Request Timeout"; case 413: return "Payload Too Large"; case 414: return "URI Too Long"; case 503: return "Service Unavailable"; default: return "Error"; } } /* Returns 0 on success, -1 on failure. We bail rather than continue with a * blocking socket: in the accept loop a blocking fd would freeze the server * on the second accept after the kernel's queue is drained. */ static int set_nonblock(int fd) { int f = fcntl(fd, F_GETFL, 0); if (f < 0) return -1; return fcntl(fd, F_SETFL, f | O_NONBLOCK); } static void set_cloexec(int fd) { int f = fcntl(fd, F_GETFD, 0); if (f >= 0) fcntl(fd, F_SETFD, f | FD_CLOEXEC); } /* ------------------------------------------------------------------ rooms */ /* Free both SDP blobs and clear the slot so room_get can reuse it. */ static void room_release(struct room *r) { free(r->offer); r->offer = NULL; r->offer_n = 0; free(r->answer); r->answer = NULL; r->answer_n = 0; r->code[0] = 0; } /* Find a room by code, optionally creating it in an empty slot. Touched is * bumped on every hit; expiry is handled separately by gc_rooms(). */ static struct room *room_get(const char *code, int create) { time_t now = monotonic_now(); struct room *empty = NULL, *found = NULL; for (int i = 0; i < MAX_ROOMS; i++) { struct room *r = &rooms[i]; if (r->code[0] == 0) { if (!empty) empty = r; continue; } if (strcmp(r->code, code) == 0) { found = r; break; } } if (!found && create && empty) { memset(empty, 0, sizeof(*empty)); strncpy(empty->code, code, CODE_MAX); found = empty; } if (found) found->touched = now; return found; } /* Reclaim rooms that haven't been touched for ROOM_TTL_S. Called once per * GC_INTERVAL_S from the main loop, independent of request traffic. */ static void gc_rooms(void) { time_t now = monotonic_now(); for (int i = 0; i < MAX_ROOMS; i++) { struct room *r = &rooms[i]; if (r->code[0] && now - r->touched > ROOM_TTL_S) room_release(r); } } static int room_index(struct room *r) { return r ? (int)(r - rooms) : -1; } /* ------------------------------------------------------------ connections */ static void conn_release(struct conn *c) { if (c->fd >= 0) close(c->fd); free(c->rbuf); free(c->wbuf); memset(c, 0, sizeof(*c)); c->fd = -1; c->wait_room = -1; } static struct conn *conn_alloc(int fd) { for (int i = 0; i < MAX_CONNS; i++) { struct conn *c = &conns[i]; if (c->state == C_FREE && c->fd <= 0) { memset(c, 0, sizeof(*c)); c->fd = fd; c->state = C_READ; c->deadline = monotonic_now() + IDLE_TIMEOUT_S; c->wait_room = -1; return c; } } return NULL; } /* Stage a response on the connection (the body is copied into a freshly * allocated send buffer). Moves the conn to C_WRITE. */ static void respond(struct conn *c, int status, const char *ctype, const void *body, size_t body_n) { char hdr[320]; int hn = snprintf(hdr, sizeof(hdr), "HTTP/1.1 %d %s\r\n" "Content-Type: %s\r\n" "Content-Length: %zu\r\n" "Access-Control-Allow-Origin: *\r\n" "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" "Access-Control-Allow-Headers: Content-Type\r\n" "Cache-Control: no-store\r\n" "Connection: close\r\n\r\n", status, status_text(status), ctype, body_n); char *buf = malloc(hn + body_n); if (!buf) { conn_release(c); return; } memcpy(buf, hdr, hn); if (body_n) memcpy(buf + hn, body, body_n); free(c->wbuf); c->wbuf = buf; c->wlen = hn + body_n; c->woff = 0; c->state = C_WRITE; c->deadline = monotonic_now() + IDLE_TIMEOUT_S; c->wait_room = -1; } /* Match-and-wake all C_WAIT connections that are parked on this room/slot. * If the answer was just delivered to at least one waiter, the handshake is * complete — drop the room. respond() has already copied `blob` into each * waker's write buffer by then, so freeing the room's answer is safe. If * nobody was parked, the blob stays so a later GET can still pick it up * (and that GET path releases the room itself). */ static void wake_waiters(int ri, char slot, const void *blob, size_t n) { int woke = 0; for (int i = 0; i < MAX_CONNS; i++) { struct conn *w = &conns[i]; if (w->state == C_WAIT && w->wait_room == ri && w->wait_slot == slot) { respond(w, 200, "application/sdp", blob, n); woke = 1; } } if (slot == 'a' && woke) room_release(&rooms[ri]); } /* ------------------------------------------------------------- dispatch */ /* Called once headers + body are fully buffered. */ static void dispatch(struct conn *c) { if (strcmp(c->method, "OPTIONS") == 0) { respond(c, 204, "text/plain", "", 0); return; } /* Liveness probe — clients hit this to confirm the relay is reachable * before committing to the full handshake. Cheap: no allocation, no * room access. */ if (strcmp(c->path, "/health") == 0) { if (strcmp(c->method, "GET") != 0 && strcmp(c->method, "HEAD") != 0) { respond(c, 405, "text/plain", "no", 2); return; } respond(c, 200, "text/plain", "ok\n", 3); return; } char code[CODE_MAX + 1] = {0}; char slot[8] = {0}; if (sscanf(c->path, "/room/%15[A-Za-z0-9_-]/%7[a-z]", code, slot) != 2) { respond(c, 404, "text/plain", "no", 2); return; } int is_offer = !strcmp(slot, "offer"); int is_answer = !strcmp(slot, "answer"); if (!is_offer && !is_answer) { respond(c, 404, "text/plain", "no", 2); return; } char slot_ch = is_offer ? 'o' : 'a'; if (!strcmp(c->method, "POST")) { struct room *r = room_get(code, 1); if (!r) { respond(c, 503, "text/plain", "full", 4); return; } char **blob = is_offer ? &r->offer : &r->answer; size_t *blen = is_offer ? &r->offer_n : &r->answer_n; /* malloc(0) is implementation-defined; round up to 1 byte so an * empty body POST doesn't masquerade as OOM. */ size_t alloc_n = c->body_want ? c->body_want : 1; char *copy = malloc(alloc_n); if (!copy) { respond(c, 503, "text/plain", "oom", 3); return; } if (c->body_want) memcpy(copy, c->rbuf + c->body_off, c->body_want); free(*blob); *blob = copy; *blen = c->body_want; wake_waiters(room_index(r), slot_ch, *blob, *blen); respond(c, 204, "text/plain", "", 0); return; } if (!strcmp(c->method, "GET")) { struct room *r = room_get(code, 1); if (!r) { respond(c, 503, "text/plain", "full", 4); return; } char *blob = is_offer ? r->offer : r->answer; size_t blen = is_offer ? r->offer_n : r->answer_n; if (blob) { respond(c, 200, "application/sdp", blob, blen); /* Answer delivery completes the SDP exchange — peers go P2P now, * so the relay can drop the room. The offer slot stays in case * the joiner needs to refetch after a crash/retry. */ if (is_answer) room_release(r); return; } /* Park the connection: no IO interest, just sit on the deadline. */ c->state = C_WAIT; c->wait_room = room_index(r); c->wait_slot = slot_ch; c->deadline = monotonic_now() + LONGPOLL_S; return; } respond(c, 405, "text/plain", "no", 2); } /* ------------------------------------------------------------- IO drivers */ /* Parse "METHOD SP PATH SP HTTP/x.y" without mutating rbuf — the subsequent * Content-Length scan needs the header block intact. Returns 0 on success, * 400 on malformed, 414 on field overflow. */ static int parse_request_line(struct conn *c, const char *hdr_end) { const char *p = c->rbuf; const char *sp1 = memchr(p, ' ', (size_t)(hdr_end - p)); if (!sp1) return 400; size_t mlen = (size_t)(sp1 - p); if (mlen > METHOD_MAX) return 414; memcpy(c->method, p, mlen); c->method[mlen] = 0; p = sp1 + 1; const char *sp2 = memchr(p, ' ', (size_t)(hdr_end - p)); if (!sp2) return 400; size_t plen = (size_t)(sp2 - p); if (plen > PATH_MAX_LEN) return 414; memcpy(c->path, p, plen); c->path[plen] = 0; return 0; } static void on_readable(struct conn *c) { if (!c->rbuf) { c->rbuf = malloc(RBUF_SIZE); if (!c->rbuf) { conn_release(c); return; } } for (;;) { size_t cap = RBUF_SIZE - c->rlen; if (cap == 0) { conn_release(c); return; } ssize_t n = recv(c->fd, c->rbuf + c->rlen, cap, 0); if (n == 0) { conn_release(c); return; } if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) return; conn_release(c); return; } c->rlen += n; c->deadline = monotonic_now() + IDLE_TIMEOUT_S; /* Parse headers once \r\n\r\n appears within the first MAX_HEADER bytes. */ if (c->body_off == 0) { size_t scan = c->rlen < MAX_HEADER ? c->rlen : MAX_HEADER; char *eoh = NULL; for (size_t i = 0; i + 3 < scan; i++) { if (c->rbuf[i] == '\r' && c->rbuf[i+1] == '\n' && c->rbuf[i+2] == '\r' && c->rbuf[i+3] == '\n') { eoh = c->rbuf + i; break; } } if (!eoh) { if (c->rlen >= MAX_HEADER) respond(c, 413, "text/plain", "header too large", 16); return; } *eoh = 0; /* terminate header block for ci_strstr */ c->body_off = (size_t)(eoh - c->rbuf) + 4; int err = parse_request_line(c, eoh); if (err == 400) { respond(c, 400, "text/plain", "bad request", 11); return; } if (err == 414) { respond(c, 414, "text/plain", "uri too long", 12); return; } /* Parse Content-Length line-anchored to avoid matching header * names that merely *contain* "Content-Length:" as a substring * (e.g. X-Original-Content-Length). RFC 7230 §3.3.2 requires * rejecting duplicate Content-Length headers — important for * direct-bind deployments not fronted by a normalizing proxy. */ int cl_seen = 0; const char *needle = "\nContent-Length:"; const size_t needle_n = 16; for (char *p = c->rbuf; (p = ci_strstr(p, needle)) != NULL; p += needle_n) { if (cl_seen++) { respond(c, 400, "text/plain", "duplicate content-length", 24); return; } const char *v = p + needle_n; while (*v == ' ' || *v == '\t') v++; if (*v == '-' || *v == '+') { respond(c, 400, "text/plain", "bad content-length", 18); return; } char *end; errno = 0; unsigned long len = strtoul(v, &end, 10); if (end == v || errno == ERANGE) { respond(c, 400, "text/plain", "bad content-length", 18); return; } c->body_want = len; } if (c->body_want > MAX_BLOB) { respond(c, 413, "text/plain", "body too large", 14); return; } } if (c->rlen - c->body_off >= c->body_want) { dispatch(c); return; } /* else loop and read more */ } } static void on_writable(struct conn *c) { while (c->woff < c->wlen) { /* SIGPIPE is suppressed via sigaction(SIGPIPE, SIG_IGN), so plain * send() is enough — no need for the non-portable MSG_NOSIGNAL. */ ssize_t n = send(c->fd, c->wbuf + c->woff, c->wlen - c->woff, 0); if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) return; conn_release(c); return; } c->woff += n; } conn_release(c); } static void accept_new(void) { for (;;) { int cfd = accept(server_fd, NULL, NULL); if (cfd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) return; return; } set_cloexec(cfd); if (set_nonblock(cfd) < 0 || !conn_alloc(cfd)) close(cfd); /* full or fcntl failed */ } } /* ------------------------------------------------------------------ main */ int main(int argc, char **argv) { int port = 8080; if (argc > 1) { char *end; errno = 0; long lport = strtol(argv[1], &end, 10); if (errno || *end || lport < 1 || lport > 65535) { fprintf(stderr, "bad port: %s\n", argv[1]); return 1; } port = (int)lport; } /* sigaction() has portable, well-defined semantics across POSIX systems; * signal()'s behavior is historically split SysV/BSD. */ struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = SIG_IGN; sigaction(SIGPIPE, &sa, NULL); sa.sa_handler = on_stop; sigaction(SIGINT, &sa, NULL); sigaction(SIGTERM, &sa, NULL); /* Raise RLIMIT_NOFILE up to what MAX_CONNS needs (plus a few for stdio / * the listen socket). If the hard cap is below that, warn — accepts will * silently fail at the kernel layer otherwise. */ struct rlimit rl; if (getrlimit(RLIMIT_NOFILE, &rl) == 0) { rlim_t need = (rlim_t)MAX_CONNS + 16; if (rl.rlim_cur < need) { rl.rlim_cur = rl.rlim_max < need ? rl.rlim_max : need; setrlimit(RLIMIT_NOFILE, &rl); if (rl.rlim_cur < need) { fprintf(stderr, "warning: nofile soft limit %lu < %lu — capacity reduced\n", (unsigned long)rl.rlim_cur, (unsigned long)need); } } } server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket"); return 1; } set_cloexec(server_fd); int one = 1; if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) { perror("setsockopt SO_REUSEADDR"); /* non-fatal */ } struct sockaddr_in addr = { .sin_family = AF_INET, .sin_addr.s_addr = INADDR_ANY, .sin_port = htons((uint16_t)port) }; if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) || listen(server_fd, 64)) { perror("bind/listen"); return 1; } /* Flip the listener non-blocking only just before entering the accept * loop — bind/listen don't care, and this keeps the setup order * conventional. */ if (set_nonblock(server_fd) < 0) { perror("fcntl"); return 1; } fprintf(stderr, "signaling server listening on :%d\n", port); for (int i = 0; i < MAX_CONNS; i++) { conns[i].fd = -1; conns[i].wait_room = -1; } time_t last_gc = monotonic_now(); struct pollfd pfds[MAX_CONNS + 1]; int pidx[MAX_CONNS + 1]; /* maps pfd index → conns[] index */ while (!stop_flag) { time_t now = monotonic_now(); int nfd = 0; int timeout_ms = 1000; pfds[nfd++] = (struct pollfd){ .fd = server_fd, .events = POLLIN }; /* Single sweep over the conn table: fire expired deadlines, build * the pfds array, and compute the poll timeout in one pass. */ for (int i = 0; i < MAX_CONNS; i++) { struct conn *c = &conns[i]; if (c->state == C_FREE || c->fd < 0) continue; if (c->deadline <= now) { /* C_WAIT → 204 No Content (not 408: Firefox auto-retries 408 * internally per RFC 7231 §6.5.7 and only surfaces a single * fetch attempt to JS, eventually giving up with a * CORS-adjacent NetworkError. 204 carries no retry semantics). */ if (c->state == C_WAIT) { respond(c, 204, "text/plain", "", 0); /* respond() moved this conn to C_WRITE with a fresh * deadline; fall through to register POLLOUT. */ } else { conn_release(c); continue; } } short ev = 0; switch (c->state) { case C_READ: ev = POLLIN; break; case C_WRITE: ev = POLLOUT; break; /* WAIT: register POLLIN so a client disconnect (read-side EOF) * surfaces as a poll event; we never actually read while parked. */ case C_WAIT: ev = POLLIN; break; default: continue; } pfds[nfd] = (struct pollfd){ .fd = c->fd, .events = ev }; pidx[nfd] = i; nfd++; long dt = (long)(c->deadline - now) * 1000; if (dt < 0) dt = 0; if (dt < timeout_ms) timeout_ms = (int)dt; } int rc = poll(pfds, nfd, timeout_ms); if (rc < 0) { if (errno == EINTR) continue; perror("poll"); break; } if (pfds[0].revents & POLLIN) accept_new(); for (int k = 1; k < nfd; k++) { short rev = pfds[k].revents; if (!rev) continue; struct conn *c = &conns[pidx[k]]; /* The conn may have been released this iteration (e.g. via wake); * stale entries are no-ops since fd would be -1. */ if (c->fd != pfds[k].fd) continue; if (c->state == C_WAIT) { /* We set Connection: close on every response, so a parked * GET should send nothing further until the next request. * Any POLLIN means either the client closed (recv == 0) or * it pipelined / sent garbage — drop the conn either way. */ if (rev & (POLLIN | POLLHUP | POLLERR | POLLNVAL)) conn_release(c); continue; } if (rev & (POLLERR | POLLNVAL)) { conn_release(c); continue; } if (c->state == C_READ && (rev & POLLIN)) on_readable(c); else if (c->state == C_WRITE && (rev & POLLOUT)) on_writable(c); else if (rev & POLLHUP) conn_release(c); } /* Periodic room GC. The poll timeout is clamped to 1 s so this fires * on a wall-clock cadence even when the server is otherwise idle. */ time_t tick = monotonic_now(); if (tick - last_gc >= GC_INTERVAL_S) { gc_rooms(); last_gc = tick; } } fprintf(stderr, "signaling server shutting down\n"); if (server_fd >= 0) close(server_fd); for (int i = 0; i < MAX_CONNS; i++) { if (conns[i].fd >= 0) conn_release(&conns[i]); } for (int i = 0; i < MAX_ROOMS; i++) { if (rooms[i].code[0]) room_release(&rooms[i]); } return 0; }