diff --git a/Makefile b/Makefile index 8f027710..66f1abee 100644 --- a/Makefile +++ b/Makefile @@ -895,6 +895,13 @@ test-payload: payload -o /tmp/ps5upload-webui-auth-selftest \ $(PAYLOAD_DIR)/tests/webui_auth_selftest.c $(PAYLOAD_DIR)/src/webui_auth.c @/tmp/ps5upload-webui-auth-selftest + @echo "Running webui server self-test (host build, ~20 s)..." + @cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include \ + -o /tmp/ps5upload-webui-server-selftest \ + $(PAYLOAD_DIR)/tests/webui_server_selftest.c $(PAYLOAD_DIR)/src/webui_server.c \ + $(PAYLOAD_DIR)/src/webui_http.c $(PAYLOAD_DIR)/src/webui_auth.c + @/tmp/ps5upload-webui-server-selftest + @echo "✓ silent, half-sent and flooding web clients never stall the helper" @echo "Running cheat title list self-test (host build)..." @cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \ -o /tmp/ps5upload-cheats-list-selftest \ diff --git a/engine/crates/ava1-ctest/tests/payload_cutover.rs b/engine/crates/ava1-ctest/tests/payload_cutover.rs index abb30dfb..f2d16889 100644 --- a/engine/crates/ava1-ctest/tests/payload_cutover.rs +++ b/engine/crates/ava1-ctest/tests/payload_cutover.rs @@ -78,12 +78,13 @@ fn c_payload_binds_only_9120() { binders.sort(); assert_eq!( binders, - vec!["ava1/ava1_server.c"], - "only the AVA1 server (9120) opens a listener" + vec!["ava1/ava1_server.c", "src/webui_server.c"], + "only the AVA1 server (9120) and the console web UI server (WEBUI_PORT) open a listener" ); - // The AVA1 server binds AVA1_DEFAULT_PORT and the config carries no other helper port. + // The AVA1 server binds AVA1_DEFAULT_PORT; the only other helper port is the web UI's. let cfg = std::fs::read_to_string(payload().join("include/config.h")).unwrap(); assert!(!cfg.contains("9113") && !cfg.contains("9114")); + assert!(cfg.contains("#define WEBUI_PORT 9180")); let gen = std::fs::read_to_string(payload().join("ava1/gen/ava1_gen.h")).unwrap(); assert!(gen.contains("AVA1_DEFAULT_PORT 9120")); } diff --git a/payload/Makefile b/payload/Makefile index 6d64c219..8a9f7992 100644 --- a/payload/Makefile +++ b/payload/Makefile @@ -166,6 +166,7 @@ SRCS := src/main.c src/runtime.c src/shell_builtin.c src/takeover.c src/takeover src/wake_watchdog.c src/icon_keeper.c src/ffpfsc_unpack.c src/cheats.c src/cheats_list.c src/xml_encoding.c src/activity.c src/sdk_changer.c src/fakelib_overlay.c \ src/tmdb.c src/fw_spoof.c src/focus_probe.c \ src/content_db.c src/mgmt_rpc.c src/fs_jobs.c src/net_probe.c src/net_ports.c src/mgmt_fs.c src/path_policy.c \ + src/webui_http.c src/webui_auth.c src/webui_server.c \ third_party/tiny_aes/aes.c \ $(BLAKE3_SRCS) diff --git a/payload/include/config.h b/payload/include/config.h index e85748a2..67d27055 100644 --- a/payload/include/config.h +++ b/payload/include/config.h @@ -33,4 +33,7 @@ * startup reconciliation to detect orphaned mounts. */ #define PS5UPLOAD2_MOUNTS_DIR "/data/ps5upload/mounts" +/* Console web UI (webui_server.c): loopback-only unless LAN access is on. */ +#define WEBUI_PORT 9180 + #endif diff --git a/payload/include/webui_server.h b/payload/include/webui_server.h new file mode 100644 index 00000000..9c128d31 --- /dev/null +++ b/payload/include/webui_server.h @@ -0,0 +1,51 @@ +/* Bounded HTTP server for the console web UI, running inside the helper. + * + * One accept thread and WEBUI_WORKERS workers. Accepted sockets go through a + * bounded queue (depth 32; a connection that does not fit is closed at once). + * Every connection carries a deadline of accept time + 5 s: reads and writes + * stop there, and a worker that dequeues an already-expired connection closes + * it without reading. A slow, silent or hostile client therefore holds one + * worker for at most 5 s and can never stall the accept thread. */ +#ifndef PS5UPLOAD_WEBUI_SERVER_H +#define PS5UPLOAD_WEBUI_SERVER_H + +#include +#include +#include "webui_http.h" + +/* Writes a complete response to fd (use webui_send / webui_send_json). + * Runs on a worker after the Host/Origin/token checks passed. Unknown paths + * answer 404 and wrong methods 405. The return value is ignored. */ +typedef int (*webui_route_fn)(const webui_req_t *r, const uint8_t *body, size_t body_len, int fd); + +typedef struct { + uint16_t port; + int lan; /* 0: bind 127.0.0.1; 1: bind 0.0.0.0 */ + webui_route_fn route; +} webui_cfg_t; + +/* New token, bind, start the threads. Returns 0, or -1 when already running or + * the threads cannot start. A bind failure is NOT an error: it is logged and + * retried every 5 s by the accept thread (a helper takeover means the old + * instance may still hold the port for a moment). */ +int webui_start(const webui_cfg_t *cfg); + +/* Close the listener and the queued connections, wake the workers and join + * them within 2 s. A worker still inside route() after that is detached and + * finishes on its own; it keeps the server state it needs alive. */ +void webui_stop(void); + +/* Rebind the listener for a LAN setting change. Safe to call from a route + * handler (it never waits on the workers). The token is kept. Returns 0 when + * the new listener is bound, -1 when it is not (yet): the accept thread keeps + * retrying every 5 s. */ +int webui_restart(int lan); + +/* The token for this start ("" when the server is not running). */ +const char *webui_token(void); + +/* Write a complete response (head + body) within the connection deadline. */ +void webui_send(int fd, int status, const char *ctype, const void *body, size_t len); +void webui_send_json(int fd, int status, const char *json); + +#endif diff --git a/payload/src/main.c b/payload/src/main.c index 6d159624..191d1246 100644 --- a/payload/src/main.c +++ b/payload/src/main.c @@ -31,6 +31,7 @@ #include "sony_api_lock.h" #include "proc_identity.h" +#include "webui_server.h" /* Sony "debugger" / system-process authid. Setting our process's * ucred authid to this value grants the credentials Sony's kernel * stubs check before allowing sensor reads / launches / installs @@ -490,6 +491,13 @@ static void redirect_stdio_to_file(void) { /* Starts the AVA1 side; only ever reached through takeover_gate_start (no other helper holds the port). */ static int g_ava1_start_rc = 0; +/* Console web UI router: 404 for everything until the API lands (Task 4 replaces it). */ +static int webui_route_none(const webui_req_t *r, const uint8_t *body, size_t body_len, int fd) { + (void)r; (void)body; (void)body_len; + webui_send_json(fd, 404, "{\"error\":\"not_found\"}"); + return 0; +} + static void start_ava1_cb(void *ctx) { (void)ctx; int ava1_rc = ava1_payload_start(); @@ -791,6 +799,17 @@ int main(void) { } startup_trace("TOAST_DONE"); + /* Console web UI, after AVA1 is up. A bind failure is not fatal: the server's accept thread + * retries every 5 s (a takeover's old instance may still hold the port for a moment). */ + { + webui_cfg_t wc; + wc.port = WEBUI_PORT; + wc.lan = 0; + wc.route = webui_route_none; + if (webui_start(&wc) != 0) fprintf(stderr, "webui: server did not start\n"); + startup_trace("WEBUI_STARTED"); + } + /* The AVA1 server runs on its own threads; this thread has nothing left to serve. It waits for * node.shutdown or the takeover flag (runtime_request_shutdown), then tears down. */ runtime_wait_for_shutdown(&state); @@ -810,6 +829,8 @@ int main(void) { /* The AVA1 half: stop accepting, wait for the sessions (2 s) and for any in-flight Sony call * (this does NOT return while one runs: only the exit watchdog armed above (8 s, or up to 60 s while a Sony call runs) can end the * process then), then stop the data layer so journals are closed and durable jobs resume. */ + webui_stop(); /* bounded: joins its workers within 2 s, detaches a stuck one */ + { int sr = ava1_payload_stop(2000, 3000); if (sr) fprintf(stderr, "ava1: stop was slow (0x%x): sessions or a Sony call outlived the soft wait\n", sr); diff --git a/payload/src/webui_server.c b/payload/src/webui_server.c new file mode 100644 index 00000000..aa8a480b --- /dev/null +++ b/payload/src/webui_server.c @@ -0,0 +1,682 @@ +/* Bounded HTTP server for the console web UI (see webui_server.h). + * + * Shape: one accept thread, WEBUI_WORKERS workers, a fixed queue between + * them. Nothing here can block without a bound: + * - the listener is non-blocking and polled in 200 ms slices, so the accept + * thread notices stop/rebind requests and never sticks in accept(); + * - a full queue closes the new connection instead of waiting; + * - every connection has a deadline of accept time + 5 s; a worker reads in + * poll slices up to that deadline and drops an expired connection unread; + * - writes carry SO_SNDTIMEO and a total budget, so a client that stops + * reading frees the worker. + * + * Lifetime: all state lives in one heap object (srv_t) with a reference per + * thread plus one for the running server. webui_stop() joins what finishes + * within 2 s and detaches the rest; a detached worker still inside route() + * keeps the object alive until it returns. A connection's fd is cleared from + * the worker slot under the lock BEFORE it is closed, so stop() can never + * shutdown() a descriptor number that was reused by another subsystem. */ +#include "webui_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "proc_identity.h" +#include "webui_auth.h" + +#define WEBUI_WORKERS 4 +#define WEBUI_QUEUE_DEPTH 32 +#define WEBUI_CONN_MS 5000u /* read timeout / idle-connection drop */ +#define WEBUI_BIND_RETRY_MS 5000u +#define WEBUI_STOP_MS 2000u +#define WEBUI_SLICE_MS 200 +#define WEBUI_MIN_SEND_MS 1000u /* a route that computed long still gets this to write */ +#define WEBUI_DRAIN_MS 250u +#define WEBUI_STACK (256u * 1024u) +#define WEBUI_MAX_ADDRS 8 +#define WEBUI_THREAD_NAME "ps5upload-web" +#define WEBUI_BUF_CAP (WEBUI_HEADER_CAP + WEBUI_BODY_CAP) + +typedef struct { + int fd; + uint64_t deadline_ms; +} conn_t; + +struct srv; + +typedef struct { + struct srv *s; + int idx; +} worker_arg_t; + +typedef struct srv { + pthread_mutex_t mu; + pthread_cond_t cv; + int refs; + int stop; /* read lock-free with __atomic in loops */ + + conn_t q[WEBUI_QUEUE_DEPTH]; + int qhead, qlen; + + uint16_t port; + webui_route_fn route; + int lan; + int rebind_req; /* accept thread: close and rebind with ->lan */ + int bound; + unsigned bind_gen; /* bumped after every bind attempt */ + int listen_fd; /* owned by the accept thread once started */ + + char token[65]; + char addrs[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]; + size_t n_addrs; + + pthread_t acc; + pthread_t w[WEBUI_WORKERS]; + int w_started[WEBUI_WORKERS]; + int w_done[WEBUI_WORKERS]; + int w_fd[WEBUI_WORKERS]; /* connection being served, -1 if none */ + worker_arg_t wargs[WEBUI_WORKERS]; + char *wbuf[WEBUI_WORKERS]; +} srv_t; + +/* g_life serializes start/stop/restart; g_mu guards g_srv and g_token. */ +static pthread_mutex_t g_life = PTHREAD_MUTEX_INITIALIZER; +static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; +static srv_t *g_srv; +static char g_token[65]; + +static uint64_t mono_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u; +} + +static void nap_ms(int ms) { (void)poll(NULL, 0, ms); } + +static int stopping(srv_t *s) { return __atomic_load_n(&s->stop, __ATOMIC_ACQUIRE); } + +static void srv_release(srv_t *s) { + int last, i; + pthread_mutex_lock(&s->mu); + last = (--s->refs == 0); + pthread_mutex_unlock(&s->mu); + if (!last) return; + for (i = 0; i < s->qlen; i++) close(s->q[(s->qhead + i) % WEBUI_QUEUE_DEPTH].fd); + if (s->listen_fd >= 0) close(s->listen_fd); + for (i = 0; i < WEBUI_WORKERS; i++) free(s->wbuf[i]); + pthread_cond_destroy(&s->cv); + pthread_mutex_destroy(&s->mu); + free(s); +} + +static int thread_start(pthread_t *t, void *(*fn)(void *), void *arg) { + pthread_attr_t attr; + int rc; + if (pthread_attr_init(&attr) != 0) return -1; + if (pthread_attr_setstacksize(&attr, WEBUI_STACK) != 0) { + pthread_attr_destroy(&attr); + return -1; + } + rc = pthread_create(t, &attr, fn, arg); + pthread_attr_destroy(&attr); + return rc == 0 ? 0 : -1; +} + +/* The console's IPv4 addresses, for the LAN Host check. */ +static size_t own_addrs(char out[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]) { + struct ifaddrs *ifa = NULL, *p; + size_t n = 0; + if (getifaddrs(&ifa) != 0) return 0; + for (p = ifa; p && n < WEBUI_MAX_ADDRS; p = p->ifa_next) { + if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET) continue; + if (!inet_ntop(AF_INET, &((struct sockaddr_in *)(void *)p->ifa_addr)->sin_addr, + out[n], INET_ADDRSTRLEN)) + continue; + n++; + } + freeifaddrs(ifa); + return n; +} + +/* Non-blocking listener on 127.0.0.1 or 0.0.0.0. -1 with errno on failure. */ +static int bind_listener(uint16_t port, int lan) { + struct sockaddr_in a; + int one = 1, fd, fl, e; + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + (void)setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_port = htons(port); + a.sin_addr.s_addr = htonl(lan ? INADDR_ANY : INADDR_LOOPBACK); + fl = fcntl(fd, F_GETFL, 0); + if (fl < 0 || fcntl(fd, F_SETFL, fl | O_NONBLOCK) != 0 || + bind(fd, (struct sockaddr *)&a, sizeof a) != 0 || listen(fd, 64) != 0) { + e = errno; + close(fd); + errno = e; + return -1; + } + return fd; +} + +static void set_timeo(int fd, int opt, uint64_t ms) { + struct timeval tv; + if (ms == 0) ms = 1; /* 0 would mean "block forever" */ + tv.tv_sec = (time_t)(ms / 1000u); + tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u); + (void)setsockopt(fd, SOL_SOCKET, opt, &tv, sizeof tv); +} + +/* ---- responses ---------------------------------------------------------- */ + +static uint64_t send_budget_ms(int fd) { + struct timeval tv; + socklen_t l = sizeof tv; + uint64_t ms; + if (getsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, &l) != 0) return WEBUI_CONN_MS; + ms = (uint64_t)tv.tv_sec * 1000u + (uint64_t)tv.tv_usec / 1000u; + if (ms == 0 || ms > WEBUI_CONN_MS) ms = WEBUI_CONN_MS; + return ms; +} + +/* Write all of buf before `end` (monotonic ms). 0 on success. */ +static int send_all(int fd, const void *buf, size_t len, uint64_t end) { + const char *p = (const char *)buf; + while (len > 0) { + struct pollfd pf; + uint64_t now = mono_ms(); + ssize_t n; + int r; + if (now >= end) return -1; + pf.fd = fd; + pf.events = POLLOUT; + pf.revents = 0; + r = poll(&pf, 1, (int)(end - now)); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) return -1; + n = send(fd, p, len, 0); + if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue; + if (n <= 0) return -1; + p += n; + len -= (size_t)n; + } + return 0; +} + +void webui_send(int fd, int status, const char *ctype, const void *body, size_t len) { + char head[512]; + uint64_t end = mono_ms() + send_budget_ms(fd); + int hl = webui_http_head(head, sizeof head, status, ctype, len, NULL); + if (hl < 0) return; + if (send_all(fd, head, (size_t)hl, end) != 0) return; + if (len > 0 && body) (void)send_all(fd, body, len, end); +} + +void webui_send_json(int fd, int status, const char *json) { + webui_send(fd, status, "application/json", json, json ? strlen(json) : 0); +} + +/* ---- one connection ----------------------------------------------------- */ + +enum { RD_OK = 0, RD_DROP = -1 }; + +/* Read one request within the deadline. Returns RD_OK, RD_DROP (silent close), + * or an HTTP status (400/413) to answer with. */ +static int read_request(srv_t *s, int fd, uint64_t deadline, char *buf, webui_req_t *req, + size_t *got_out) { + size_t got = 0, need = 0; + int parsed = 0; + for (;;) { + struct pollfd pf; + uint64_t now; + ssize_t n; + int r, wait; + if (got > 0 && !parsed) { + r = webui_http_parse(buf, got, req); + if (r == WEBUI_PARSE_BAD) return 400; + if (r == WEBUI_PARSE_TOO_BIG) return 413; + if (r == WEBUI_PARSE_OK) { + if (req->content_length > WEBUI_BODY_CAP) return 413; + parsed = 1; + need = req->header_len + req->content_length; + } + } + if (parsed && got >= need) { + *got_out = got; + return RD_OK; + } + if (got >= WEBUI_BUF_CAP) return 413; + if (stopping(s)) return RD_DROP; + now = mono_ms(); + if (now >= deadline) return RD_DROP; + wait = (int)(deadline - now); + if (wait > WEBUI_SLICE_MS) wait = WEBUI_SLICE_MS; + pf.fd = fd; + pf.events = POLLIN; + pf.revents = 0; + r = poll(&pf, 1, wait); + if (r < 0 && errno == EINTR) continue; + if (r < 0) return RD_DROP; + if (r == 0) continue; + n = recv(fd, buf + got, WEBUI_BUF_CAP - got, 0); + if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue; + if (n <= 0) return RD_DROP; + got += (size_t)n; + } +} + +/* Half-close, then read off whatever the client still sends for a moment, so + * the close does not turn into a reset that discards the response. */ +static void finish_conn(int fd, char *scratch, size_t cap) { + uint64_t end = mono_ms() + WEBUI_DRAIN_MS; + size_t total = 0; + (void)shutdown(fd, SHUT_WR); + while (total < 2 * WEBUI_BUF_CAP) { + struct pollfd pf; + uint64_t now = mono_ms(); + ssize_t n; + if (now >= end) break; + pf.fd = fd; + pf.events = POLLIN; + pf.revents = 0; + if (poll(&pf, 1, (int)(end - now)) <= 0) break; + n = recv(fd, scratch, cap, 0); + if (n <= 0) break; + total += (size_t)n; + } +} + +static void serve(srv_t *s, int idx, conn_t c) { + char *buf = s->wbuf[idx]; + webui_req_t req; + webui_auth_cfg_t ac; + char token[65]; + char addrs[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]; + const char *addr_ptrs[WEBUI_MAX_ADDRS]; + size_t got = 0, i, n_addrs; + uint64_t now; + int rd, is_api, lan; + + set_timeo(c.fd, SO_RCVTIMEO, WEBUI_CONN_MS); + set_timeo(c.fd, SO_SNDTIMEO, WEBUI_CONN_MS); + memset(&req, 0, sizeof req); + rd = read_request(s, c.fd, c.deadline_ms, buf, &req, &got); + if (rd == RD_DROP) return; + + /* Writes get what is left of the deadline, but at least a short window. */ + now = mono_ms(); + set_timeo(c.fd, SO_SNDTIMEO, + c.deadline_ms > now + WEBUI_MIN_SEND_MS ? c.deadline_ms - now : WEBUI_MIN_SEND_MS); + + if (rd == 400) { + webui_send_json(c.fd, 400, "{\"error\":\"bad_request\"}"); + return; + } + if (rd == 413) { + webui_send_json(c.fd, 413, "{\"error\":\"too_big\"}"); + return; + } + + pthread_mutex_lock(&s->mu); + memcpy(token, s->token, sizeof token); + lan = s->lan; + n_addrs = s->n_addrs; + memcpy(addrs, s->addrs, sizeof addrs); + pthread_mutex_unlock(&s->mu); + for (i = 0; i < n_addrs; i++) addr_ptrs[i] = addrs[i]; + ac.token = token; + ac.port = s->port; + ac.lan = lan; + ac.own_addrs = addr_ptrs; + ac.n_own = n_addrs; + + is_api = strncmp(req.path, "/api/", 5) == 0 && strcmp(req.path, "/api/token") != 0; + switch (webui_check(&req, &ac, is_api)) { + case WEBUI_ALLOW: + break; + case WEBUI_DENY_TOKEN: + webui_send_json(c.fd, 401, "{\"error\":\"token\"}"); + return; + default: + webui_send_json(c.fd, 403, "{\"error\":\"forbidden\"}"); + return; + } + if (s->route) + (void)s->route(&req, (const uint8_t *)buf + req.header_len, req.content_length, c.fd); + else + webui_send_json(c.fd, 404, "{\"error\":\"not_found\"}"); +} + +static void *worker_main(void *arg) { + worker_arg_t *wa = (worker_arg_t *)arg; + srv_t *s = wa->s; + int idx = wa->idx; + proc_name_set_self(WEBUI_THREAD_NAME); + for (;;) { + conn_t c; + pthread_mutex_lock(&s->mu); + while (!s->stop && s->qlen == 0) pthread_cond_wait(&s->cv, &s->mu); + if (s->stop) { + pthread_mutex_unlock(&s->mu); + break; + } + c = s->q[s->qhead]; + s->qhead = (s->qhead + 1) % WEBUI_QUEUE_DEPTH; + s->qlen--; + s->w_fd[idx] = c.fd; + pthread_mutex_unlock(&s->mu); + + if (mono_ms() < c.deadline_ms) { + serve(s, idx, c); + finish_conn(c.fd, s->wbuf[idx], WEBUI_BUF_CAP); + } + pthread_mutex_lock(&s->mu); + s->w_fd[idx] = -1; + pthread_mutex_unlock(&s->mu); + close(c.fd); + } + pthread_mutex_lock(&s->mu); + s->w_done[idx] = 1; + pthread_mutex_unlock(&s->mu); + srv_release(s); + return NULL; +} + +/* ---- accept thread ------------------------------------------------------ */ + +static void *accept_main(void *arg) { + srv_t *s = (srv_t *)arg; + unsigned fails = 0; + proc_name_set_self(WEBUI_THREAD_NAME); + while (!stopping(s)) { + struct pollfd pf; + int fd, r, lan, fl; + + pthread_mutex_lock(&s->mu); + if (s->rebind_req) { + s->rebind_req = 0; + if (s->listen_fd >= 0) close(s->listen_fd); + s->listen_fd = -1; + s->bound = 0; + fails = 0; + } + lan = s->lan; + fd = s->listen_fd; + pthread_mutex_unlock(&s->mu); + + if (fd < 0) { + uint64_t until; + fd = bind_listener(s->port, lan); + int e = errno; + pthread_mutex_lock(&s->mu); + s->listen_fd = fd; + s->bound = fd >= 0; + s->bind_gen++; + pthread_cond_broadcast(&s->cv); + pthread_mutex_unlock(&s->mu); + if (fd >= 0) { + if (fails) fprintf(stderr, "webui: bound port %u after %u retries\n", (unsigned)s->port, fails); + fails = 0; + continue; + } + /* First failure and then once a minute: the log must not grow every 5 s forever. */ + if (fails % 12 == 0) + fprintf(stderr, "webui: bind port %u (%s) failed: errno %d; retrying every 5 s\n", + (unsigned)s->port, lan ? "lan" : "loopback", e); + fails++; + until = mono_ms() + WEBUI_BIND_RETRY_MS; + while (!stopping(s) && mono_ms() < until) { + int req; + pthread_mutex_lock(&s->mu); + req = s->rebind_req; + pthread_mutex_unlock(&s->mu); + if (req) break; + nap_ms(WEBUI_SLICE_MS); + } + continue; + } + + pf.fd = fd; + pf.events = POLLIN; + pf.revents = 0; + r = poll(&pf, 1, WEBUI_SLICE_MS); + if (r <= 0) { + if (r < 0 && errno != EINTR) nap_ms(50); + continue; + } + r = accept(fd, NULL, NULL); + if (r < 0) { + /* Never leave the loop on an accept error (a stray errno once killed a helper's + * server); back off briefly on the ones that would otherwise spin. */ + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) nap_ms(50); + continue; + } + /* BSD hands the listener's O_NONBLOCK to the accepted socket; workers expect blocking + * sockets bounded by timeouts and poll. */ + fl = fcntl(r, F_GETFL, 0); + if (fl >= 0) (void)fcntl(r, F_SETFL, fl & ~O_NONBLOCK); +#ifdef SO_NOSIGPIPE + { + int one = 1; + (void)setsockopt(r, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof one); + } +#endif + pthread_mutex_lock(&s->mu); + if (!s->stop && s->qlen < WEBUI_QUEUE_DEPTH) { + int tail = (s->qhead + s->qlen) % WEBUI_QUEUE_DEPTH; + s->q[tail].fd = r; + s->q[tail].deadline_ms = mono_ms() + WEBUI_CONN_MS; + s->qlen++; + pthread_cond_signal(&s->cv); + r = -1; + } + pthread_mutex_unlock(&s->mu); + if (r >= 0) close(r); /* queue full: drop now, never wait */ + } + pthread_mutex_lock(&s->mu); + if (s->listen_fd >= 0) close(s->listen_fd); + s->listen_fd = -1; + s->bound = 0; + pthread_mutex_unlock(&s->mu); + srv_release(s); + return NULL; +} + +/* ---- lifecycle ---------------------------------------------------------- */ + +static void stop_locked(srv_t *s) { + uint64_t end; + int i, all; + pthread_mutex_lock(&s->mu); + __atomic_store_n(&s->stop, 1, __ATOMIC_RELEASE); + pthread_cond_broadcast(&s->cv); + /* Wake a worker blocked writing to a client that stopped reading. */ + for (i = 0; i < WEBUI_WORKERS; i++) + if (s->w_fd[i] >= 0) (void)shutdown(s->w_fd[i], SHUT_RDWR); + /* Queued connections were never served: close them now. */ + while (s->qlen > 0) { + close(s->q[s->qhead].fd); + s->qhead = (s->qhead + 1) % WEBUI_QUEUE_DEPTH; + s->qlen--; + } + pthread_mutex_unlock(&s->mu); + + /* The accept thread polls in 200 ms slices; joining it frees the port. */ + pthread_join(s->acc, NULL); + + end = mono_ms() + WEBUI_STOP_MS; + for (;;) { + all = 1; + pthread_mutex_lock(&s->mu); + for (i = 0; i < WEBUI_WORKERS; i++) + if (s->w_started[i] && !s->w_done[i]) all = 0; + pthread_mutex_unlock(&s->mu); + if (all || mono_ms() >= end) break; + nap_ms(20); + } + for (i = 0; i < WEBUI_WORKERS; i++) { + int done; + if (!s->w_started[i]) continue; + pthread_mutex_lock(&s->mu); + done = s->w_done[i]; + pthread_mutex_unlock(&s->mu); + if (done) { + pthread_join(s->w[i], NULL); + } else { + fprintf(stderr, "webui: worker %d still inside a request after 2 s; detached\n", i); + pthread_detach(s->w[i]); + } + } + srv_release(s); /* the running server's own reference */ +} + +int webui_start(const webui_cfg_t *cfg) { + srv_t *s; + int i, e; + if (!cfg) return -1; + pthread_mutex_lock(&g_life); + if (g_srv) { + pthread_mutex_unlock(&g_life); + return -1; + } + s = (srv_t *)calloc(1, sizeof *s); + if (!s) goto fail_nomem; + for (i = 0; i < WEBUI_WORKERS; i++) { + s->wbuf[i] = (char *)malloc(WEBUI_BUF_CAP); + s->w_fd[i] = -1; + if (!s->wbuf[i]) { + while (i >= 0) free(s->wbuf[i--]); + free(s); + goto fail_nomem; + } + } + pthread_mutex_init(&s->mu, NULL); + pthread_cond_init(&s->cv, NULL); + s->refs = 1; + s->port = cfg->port; + s->route = cfg->route; + s->lan = cfg->lan ? 1 : 0; + webui_token_new(s->token); + if (s->lan) s->n_addrs = own_addrs(s->addrs); + + /* First bind here so the server answers as soon as start returns; on failure the accept + * thread retries (a takeover's old instance may still hold the port for a moment). */ + s->listen_fd = bind_listener(s->port, s->lan); + e = errno; + s->bound = s->listen_fd >= 0; + if (s->listen_fd < 0) + fprintf(stderr, "webui: bind port %u failed: errno %d; retrying every 5 s\n", (unsigned)s->port, e); + + s->refs++; + if (thread_start(&s->acc, accept_main, s) != 0) { + s->refs--; + srv_release(s); + fprintf(stderr, "webui: accept thread did not start\n"); + pthread_mutex_unlock(&g_life); + return -1; + } + for (i = 0; i < WEBUI_WORKERS; i++) { + s->wargs[i].s = s; + s->wargs[i].idx = i; + pthread_mutex_lock(&s->mu); + s->refs++; + pthread_mutex_unlock(&s->mu); + if (thread_start(&s->w[i], worker_main, &s->wargs[i]) != 0) { + pthread_mutex_lock(&s->mu); + s->refs--; + pthread_mutex_unlock(&s->mu); + fprintf(stderr, "webui: worker %d did not start\n", i); + continue; /* fewer workers still serve; zero is handled below */ + } + s->w_started[i] = 1; + } + for (i = 0; i < WEBUI_WORKERS && !s->w_started[i]; i++) { + } + if (i == WEBUI_WORKERS) { + stop_locked(s); + pthread_mutex_unlock(&g_life); + return -1; + } + + pthread_mutex_lock(&g_mu); + g_srv = s; + memcpy(g_token, s->token, sizeof g_token); + pthread_mutex_unlock(&g_mu); + if (s->bound) + fprintf(stderr, "webui: serving on %s:%u\n", s->lan ? "0.0.0.0" : "127.0.0.1", (unsigned)s->port); + pthread_mutex_unlock(&g_life); + return 0; + +fail_nomem: + fprintf(stderr, "webui: out of memory\n"); + pthread_mutex_unlock(&g_life); + return -1; +} + +void webui_stop(void) { + srv_t *s; + pthread_mutex_lock(&g_life); + pthread_mutex_lock(&g_mu); + s = g_srv; + g_srv = NULL; + memset(g_token, 0, sizeof g_token); + pthread_mutex_unlock(&g_mu); + if (s) stop_locked(s); + pthread_mutex_unlock(&g_life); +} + +int webui_restart(int lan) { + srv_t *s; + char addrs[WEBUI_MAX_ADDRS][INET_ADDRSTRLEN]; + size_t n = 0; + unsigned gen; + uint64_t end; + int bound = 0; + + if (lan) n = own_addrs(addrs); + pthread_mutex_lock(&g_life); + s = g_srv; + if (!s) { + pthread_mutex_unlock(&g_life); + return -1; + } + pthread_mutex_lock(&s->mu); + s->lan = lan ? 1 : 0; + s->n_addrs = n; + if (n) memcpy(s->addrs, addrs, sizeof addrs); + s->rebind_req = 1; + gen = s->bind_gen; + s->refs++; + pthread_mutex_unlock(&s->mu); + pthread_mutex_unlock(&g_life); + + /* Wait briefly for the accept thread's attempt; it keeps retrying on its own after. */ + end = mono_ms() + 1500u; + for (;;) { + int done; + pthread_mutex_lock(&s->mu); + done = s->bind_gen != gen && !s->rebind_req; + bound = s->bound; + pthread_mutex_unlock(&s->mu); + if (done || stopping(s) || mono_ms() >= end) break; + nap_ms(20); + } + srv_release(s); + return bound ? 0 : -1; +} + +const char *webui_token(void) { return g_token; } diff --git a/payload/tests/webui_server_selftest.c b/payload/tests/webui_server_selftest.c new file mode 100644 index 00000000..5a6b727c --- /dev/null +++ b/payload/tests/webui_server_selftest.c @@ -0,0 +1,304 @@ +/* Host-side tests for the console web UI server: real sockets on an + * ephemeral loopback port and a stub router answering {"ok":true}. + * Covers the properties that keep the helper alive: silent, half-sent and + * flooding clients never stall other requests, and a stop/start rebinds the + * port with a new token. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "webui_server.h" + +static int failures = 0; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); \ + failures++; \ + } \ + } while (0) + +static uint16_t g_port; + +static int stub_route(const webui_req_t *r, const uint8_t *body, size_t body_len, int fd) { + (void)r; (void)body; (void)body_len; + webui_send_json(fd, 200, "{\"ok\":true}"); + return 0; +} + +static double now_s(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +static void sleep_ms(int ms) { + struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L }; + nanosleep(&ts, NULL); +} + +/* An ephemeral port nobody holds right now. */ +static uint16_t free_port(void) { + struct sockaddr_in a; + socklen_t al = sizeof a; + int s = socket(AF_INET, SOCK_STREAM, 0); + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (s < 0 || bind(s, (struct sockaddr *)&a, sizeof a) != 0 || + getsockname(s, (struct sockaddr *)&a, &al) != 0) { + if (s >= 0) close(s); + return 0; + } + close(s); + return ntohs(a.sin_port); +} + +static int dial(void) { + struct sockaddr_in a; + int s = socket(AF_INET, SOCK_STREAM, 0); + if (s < 0) return -1; + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; + a.sin_port = htons(g_port); + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(s, (struct sockaddr *)&a, sizeof a) != 0) { + close(s); + return -1; + } + return s; +} + +/* Read until EOF or timeout. Returns bytes read, -1 on timeout. */ +static int read_all(int s, char *out, size_t cap, int timeout_ms) { + size_t got = 0; + double end = now_s() + timeout_ms / 1000.0; + for (;;) { + struct pollfd p = { s, POLLIN, 0 }; + int left = (int)((end - now_s()) * 1000.0); + ssize_t n; + if (left <= 0) return -1; + if (poll(&p, 1, left) <= 0) continue; + n = recv(s, out + got, cap - 1 - got, 0); + if (n <= 0) break; + got += (size_t)n; + if (got >= cap - 1) break; + } + out[got] = '\0'; + return (int)got; +} + +/* Send one request on a fresh connection; return the HTTP status or -1. */ +static int request(const char *path, const char *token, char *resp, size_t cap) { + char req[512]; + int s = dial(), n, status = -1; + if (s < 0) return -1; + n = snprintf(req, sizeof req, + "GET %s HTTP/1.1\r\nHost: 127.0.0.1:%u\r\n%s%s%s\r\n", + path, (unsigned)g_port, token ? "X-PS5U-Token: " : "", + token ? token : "", token ? "\r\n" : ""); + if (send(s, req, (size_t)n, 0) == n && read_all(s, resp, cap, 7000) > 0) + (void)sscanf(resp, "HTTP/1.1 %d", &status); + close(s); + return status; +} + +static int start_server(void) { + webui_cfg_t c; + c.port = g_port; + c.lan = 0; + c.route = stub_route; + return webui_start(&c); +} + +static void serves_api_with_token(void) { + char resp[2048]; + CHECK(request("/api/x", webui_token(), resp, sizeof resp) == 200); + CHECK(strstr(resp, "{\"ok\":true}") != NULL); + CHECK(strstr(resp, "Connection: close") != NULL); +} + +static void api_without_token_401(void) { + char resp[2048]; + CHECK(request("/api/x", NULL, resp, sizeof resp) == 401); + CHECK(strstr(resp, "{\"error\":\"token\"}") != NULL); + /* The page itself and the token endpoint need no token. */ + CHECK(request("/", NULL, resp, sizeof resp) == 200); + CHECK(request("/api/token", NULL, resp, sizeof resp) == 200); +} + +static void bad_host_403(void) { + char req[256], resp[2048]; + int s = dial(), n, status = -1; + CHECK(s >= 0); + if (s < 0) return; + n = snprintf(req, sizeof req, "GET /api/x HTTP/1.1\r\nHost: evil.example:%u\r\nX-PS5U-Token: %s\r\n\r\n", + (unsigned)g_port, webui_token()); + CHECK(send(s, req, (size_t)n, 0) == n); + if (read_all(s, resp, sizeof resp, 7000) > 0) (void)sscanf(resp, "HTTP/1.1 %d", &status); + close(s); + CHECK(status == 403); + CHECK(strstr(resp, "{\"error\":\"forbidden\"}") != NULL); +} + +static void garbage_400(void) { + char resp[2048]; + int s = dial(), status = -1; + CHECK(s >= 0); + if (s < 0) return; + CHECK(send(s, "NOT HTTP AT ALL\r\n\r\n", 19, 0) == 19); + if (read_all(s, resp, sizeof resp, 7000) > 0) (void)sscanf(resp, "HTTP/1.1 %d", &status); + close(s); + CHECK(status == 400); +} + +static void too_big_413(void) { + char req[256], resp[2048]; + int s = dial(), n, status = -1; + CHECK(s >= 0); + if (s < 0) return; + n = snprintf(req, sizeof req, + "POST /api/x HTTP/1.1\r\nHost: 127.0.0.1:%u\r\nContent-Length: 999999\r\n\r\n", + (unsigned)g_port); + CHECK(send(s, req, (size_t)n, 0) == n); + if (read_all(s, resp, sizeof resp, 7000) > 0) (void)sscanf(resp, "HTTP/1.1 %d", &status); + close(s); + CHECK(status == 413); +} + +/* 4 idle connections occupy every worker; a 5th request is still served + * once their 5 s deadline drops them. */ +static void silent_client_dropped_and_next_served(void) { + int idle[4], i, st; + char resp[2048]; + double t0 = now_s(); + for (i = 0; i < 4; i++) idle[i] = dial(); + for (i = 0; i < 4; i++) CHECK(idle[i] >= 0); + /* Let the workers take the idle ones first, so the request below is + * queued behind them with its own (later) deadline. */ + sleep_ms(1000); + st = request("/api/x", webui_token(), resp, sizeof resp); + CHECK(st == 200); + CHECK(now_s() - t0 < 7.5); + for (i = 0; i < 4; i++) + if (idle[i] >= 0) close(idle[i]); +} + +/* A request that never finishes its headers is dropped within the timeout. */ +static void half_request_dropped_within_timeout(void) { + char buf[256]; + int s = dial(), n; + double t0 = now_s(); + CHECK(s >= 0); + if (s < 0) return; + CHECK(send(s, "GET /api/x HTTP/1.1\r\nHost: 127.0.0.1", 36, 0) == 36); + n = read_all(s, buf, sizeof buf, 7000); + CHECK(n >= 0); /* closed (EOF), not still open at 7 s */ + CHECK(now_s() - t0 < 7.0); + close(s); +} + +/* 40 idle connections: more than queue depth + workers. The overflow is + * closed at once and a fresh request retried once per second is answered + * within 7 s; the accept thread never blocks on the full queue. */ +static void queue_overflow_closes_not_blocks(void) { + int idle[40], i, ok = 0; + char resp[2048]; + double t0 = now_s(); + for (i = 0; i < 40; i++) idle[i] = dial(); + for (i = 0; i < 40; i++) CHECK(idle[i] >= 0); + while (now_s() - t0 < 7.0) { + double t1 = now_s(); + int s = dial(); + if (s >= 0) { + char req[512]; + int n = snprintf(req, sizeof req, + "GET /api/x HTTP/1.1\r\nHost: 127.0.0.1:%u\r\nX-PS5U-Token: %s\r\n\r\n", + (unsigned)g_port, webui_token()); + int st = -1; + if (send(s, req, (size_t)n, 0) == n && read_all(s, resp, sizeof resp, 1000) > 0) + (void)sscanf(resp, "HTTP/1.1 %d", &st); + close(s); + if (st == 200) { ok = 1; break; } + } + if (now_s() - t1 < 1.0) sleep_ms((int)((1.0 - (now_s() - t1)) * 1000.0)); + } + CHECK(ok); + CHECK(now_s() - t0 < 7.5); + for (i = 0; i < 40; i++) + if (idle[i] >= 0) close(idle[i]); +} + +static void stop_then_start_rebinds_with_new_token(void) { + char t1[65], resp[2048]; + double t0; + snprintf(t1, sizeof t1, "%s", webui_token()); + CHECK(strlen(t1) == 64); + t0 = now_s(); + webui_stop(); + CHECK(now_s() - t0 < 2.5); + CHECK(webui_token()[0] == '\0'); + CHECK(request("/api/x", t1, resp, sizeof resp) == -1); /* nothing listening */ + CHECK(start_server() == 0); + CHECK(strlen(webui_token()) == 64); + CHECK(strcmp(t1, webui_token()) != 0); + CHECK(request("/api/x", webui_token(), resp, sizeof resp) == 200); + CHECK(request("/api/x", t1, resp, sizeof resp) == 401); +} + +/* Stop while a worker is blocked on a silent client: still within 2 s. */ +static void stop_while_workers_busy(void) { + int idle[6], i; + double t0; + for (i = 0; i < 6; i++) idle[i] = dial(); + sleep_ms(200); + t0 = now_s(); + webui_stop(); + CHECK(now_s() - t0 < 2.5); + for (i = 0; i < 6; i++) + if (idle[i] >= 0) close(idle[i]); + CHECK(webui_start(NULL) == -1); + CHECK(start_server() == 0); + CHECK(start_server() == -1); /* already running */ +} + +static void restart_keeps_token_and_serves(void) { + char t1[65], resp[2048]; + snprintf(t1, sizeof t1, "%s", webui_token()); + CHECK(webui_restart(0) == 0); + CHECK(strcmp(t1, webui_token()) == 0); + CHECK(request("/api/x", webui_token(), resp, sizeof resp) == 200); +} + +int main(void) { + signal(SIGPIPE, SIG_IGN); + g_port = free_port(); + CHECK(g_port != 0); + CHECK(start_server() == 0); + serves_api_with_token(); + api_without_token_401(); + bad_host_403(); + garbage_400(); + too_big_413(); + silent_client_dropped_and_next_served(); + half_request_dropped_within_timeout(); + queue_overflow_closes_not_blocks(); + stop_then_start_rebinds_with_new_token(); + stop_while_workers_busy(); + restart_keeps_token_and_serves(); + webui_stop(); + if (failures) { + fprintf(stderr, "webui_server_selftest: %d failure(s)\n", failures); + return 1; + } + printf("webui_server_selftest: ok\n"); + return 0; +}