From 56f54fc1a0a633fa43ff8589de2caf92dd1699d9 Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:12:29 +0200 Subject: [PATCH] Add embedded web server skeleton --- .gitignore | 9 ++ Makefile | 40 +++++ README.md | 31 ++++ scripts/gen_asset_module.py | 47 ++++++ src/main.c | 37 +++++ src/patchdl_assets.c | 41 +++++ src/patchdl_assets.h | 15 ++ src/patchdl_websrv.c | 304 ++++++++++++++++++++++++++++++++++++ src/patchdl_websrv.h | 4 + web/README.md | 2 + 10 files changed, 530 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 scripts/gen_asset_module.py create mode 100644 src/main.c create mode 100644 src/patchdl_assets.c create mode 100644 src/patchdl_assets.h create mode 100644 src/patchdl_websrv.c create mode 100644 src/patchdl_websrv.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22a4d8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +*.log +dist/ +build/ +node_modules/ +gen/ +*.elf +__pycache__/ +*.pyc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..31f44f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +PS5_HOST ?= ps5 +PS5_PORT ?= 9021 +PATCHDL_HTTP_PORT ?= 12880 + +ifdef PS5_PAYLOAD_SDK + include $(PS5_PAYLOAD_SDK)/toolchain/prospero.mk +else + $(error PS5_PAYLOAD_SDK is undefined) +endif + +PYTHON ?= python3 +BIN := patchdl-ps5.elf + +SRCS := src/main.c src/patchdl_assets.c src/patchdl_websrv.c + +WEB_ASSETS := web/index.html web/styles.css web/app.js +GEN_SRCS := $(patsubst web/%,gen/web/%.c,$(WEB_ASSETS)) + +CFLAGS := -g -O2 -Wall -Werror -Isrc -DPATCHDL_HTTP_PORT=$(PATCHDL_HTTP_PORT) +LDADD := -lkernel_sys +LDADD += `$(PS5_PAYLOAD_SDK)/bin/prospero-pkg-config libmicrohttpd --libs` + +all: $(BIN) + +gen/web: + mkdir -p gen/web + +gen/web/%.c: web/% scripts/gen_asset_module.py | gen/web + $(PYTHON) scripts/gen_asset_module.py --path $* $< > $@ + +$(BIN): $(SRCS) $(GEN_SRCS) + $(CC) $(CFLAGS) -o $@ $^ $(LDADD) + +test: $(BIN) + $(PS5_DEPLOY) -h $(PS5_HOST) -p $(PS5_PORT) $^ + +clean: + rm -rf $(BIN) gen + +.PHONY: all clean test diff --git a/README.md b/README.md index 88e5ef7..721708b 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,40 @@ and `nullfs` mount origins instead of trusting title IDs alone. ## Current Contents ```text +Makefile + PS5 payload build using ps5-payload-sdk and libmicrohttpd. + +src/ + Embedded web server entrypoint and API stubs. + web/ Static web UI prototype for the future embedded PatchDL web server. ``` Open `web/index.html` directly for the mock UI, or serve `web/` from a local HTTP server. + +## Embedded Web Server + +PatchDL is intended to run one self-contained ELF. It does not need a second +`websrv.elf` process. The payload starts its own libmicrohttpd server and serves +the UI from compiled-in assets. + +Default port: + +```text +http://PS5_IP:12880/ +``` + +The port can be changed at build time: + +```sh +make PATCHDL_HTTP_PORT=12881 +``` + +or at runtime by passing a port as the first argument, if the loader supports +argv: + +```sh +patchdl-ps5.elf 12881 +``` diff --git a/scripts/gen_asset_module.py b/scripts/gen_asset_module.py new file mode 100644 index 0000000..1dab649 --- /dev/null +++ b/scripts/gen_asset_module.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import argparse +import mimetypes +from pathlib import Path + + +def c_string(value): + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def bytes_as_c_array(path): + data = Path(path).read_bytes() + lines = [] + for offset in range(0, len(data), 16): + chunk = data[offset:offset + 16] + lines.append(" " + ", ".join(f"0x{byte:02x}" for byte in chunk)) + return "{\n" + ",\n".join(lines) + "\n}" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--path", required=True) + parser.add_argument("file") + args = parser.parse_args() + + mime = mimetypes.guess_type(args.path)[0] or "application/octet-stream" + symbol = ( + "patchdl_asset_" + + "".join(ch if ch.isalnum() else "_" for ch in args.path) + ) + + print('#include "patchdl_assets.h"') + print() + print(f"static const unsigned char {symbol}[] = {bytes_as_c_array(args.file)};") + print() + print("__attribute__((constructor)) static void") + print(f"register_{symbol}(void) {{") + print( + f' patchdl_asset_register("/{c_string(args.path)}", ' + f"(const void*){symbol}, sizeof({symbol}), " + f'"{c_string(mime)}");' + ) + print("}") + + +if __name__ == "__main__": + main() diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..a090896 --- /dev/null +++ b/src/main.c @@ -0,0 +1,37 @@ +#include +#include +#include +#include + +#include "patchdl_websrv.h" + +#ifndef PATCHDL_HTTP_PORT +#define PATCHDL_HTTP_PORT 12880 +#endif + +int +main(int argc, char** argv) { + unsigned short port = PATCHDL_HTTP_PORT; + + if(argc > 1) { + int requested_port = atoi(argv[1]); + if(requested_port > 0 && requested_port <= 65535) { + port = (unsigned short)requested_port; + } + } + + signal(SIGPIPE, SIG_IGN); + + printf("PatchDL starting web UI on port %u\n", port); + + if(patchdl_websrv_start(port)) { + puts("PatchDL web server failed to start"); + return 1; + } + + while(1) { + sleep(60); + } + + return 0; +} diff --git a/src/patchdl_assets.c b/src/patchdl_assets.c new file mode 100644 index 0000000..188966a --- /dev/null +++ b/src/patchdl_assets.c @@ -0,0 +1,41 @@ +#include "patchdl_assets.h" + +#include +#include + +static patchdl_asset_t* asset_head; + +void +patchdl_asset_register(const char* path, const void* data, size_t size, + const char* mime) { + patchdl_asset_t* asset = calloc(1, sizeof(*asset)); + + if(!asset) { + return; + } + + asset->path = path; + asset->data = data; + asset->size = size; + asset->mime = mime; + asset->next = asset_head; + asset_head = asset; +} + +const patchdl_asset_t* +patchdl_asset_find(const char* path) { + const patchdl_asset_t* asset = asset_head; + + if(!path || !strcmp(path, "/")) { + path = "/index.html"; + } + + while(asset) { + if(!strcmp(asset->path, path)) { + return asset; + } + asset = asset->next; + } + + return 0; +} diff --git a/src/patchdl_assets.h b/src/patchdl_assets.h new file mode 100644 index 0000000..20ecac7 --- /dev/null +++ b/src/patchdl_assets.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +typedef struct patchdl_asset { + const char* path; + const void* data; + size_t size; + const char* mime; + struct patchdl_asset* next; +} patchdl_asset_t; + +void patchdl_asset_register(const char* path, const void* data, size_t size, + const char* mime); +const patchdl_asset_t* patchdl_asset_find(const char* path); diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c new file mode 100644 index 0000000..af9e2ab --- /dev/null +++ b/src/patchdl_websrv.c @@ -0,0 +1,304 @@ +#include "patchdl_websrv.h" + +#include "patchdl_assets.h" + +#include +#include +#include +#include + +static struct MHD_Daemon* web_daemon; + +static const char status_json[] = + "{" + "\"firmware\":\"11.60\"," + "\"firmware_build\":\"0x11600000\"," + "\"dns_guard\":\"Aktiv\"," + "\"resolver\":\"Internal allowlist\"," + "\"free_space_mb\":64218," + "\"download_dir\":\"/mnt/usb0/patches\"" + "}"; + +static const char config_json[] = + "{" + "\"default_policy\":\"deny\"," + "\"download_dir\":\"/mnt/usb0/patches\"," + "\"install_after_download\":false," + "\"delete_pkg_after_install\":false," + "\"source_policy\":{" + "\"official\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true}," + "\"external\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":true}," + "\"shadowmount\":{\"allow_check\":true,\"allow_download\":true,\"allow_install\":false}," + "\"unknown\":{\"allow_check\":true,\"allow_download\":false,\"allow_install\":false}" + "}," + "\"cdn_allowlist\":[" + "\"sgst.prod.dl.playstation.net\"," + "\"gst.prod.dl.playstation.net\"," + "\"gs2.ww.prod.dl.playstation.net\"" + "]" + "}"; + +static const char titles_json[] = + "[" + "{" + "\"title_id\":\"PPSA01628_00\"," + "\"name\":\"Call of Duty Black Ops Cold War\"," + "\"content_id\":\"UP0002-PPSA01628_00-CODCWTHEGAME0001\"," + "\"installed_version\":\"01.032.000\"," + "\"compatible_version\":\"01.041.000\"," + "\"latest_version\":\"01.041.000\"," + "\"latest_required_fw\":\"11.60\"," + "\"source_type\":\"official\"," + "\"source_path\":\"/system_ex/app/PPSA01628_00\"," + "\"mount_from\":\"/dev/ssd0.system_ex\"," + "\"enabled\":true," + "\"mode\":\"latest_compatible\"," + "\"queued\":false," + "\"status\":\"available\"" + "}," + "{" + "\"title_id\":\"PPSA90001_00\"," + "\"name\":\"Shadowmounted Test Title\"," + "\"content_id\":\"UP0000-PPSA90001_00-SHADOWMOUNT0001\"," + "\"installed_version\":\"01.000.000\"," + "\"compatible_version\":\"01.006.000\"," + "\"latest_version\":\"01.009.000\"," + "\"latest_required_fw\":\"12.00\"," + "\"source_type\":\"shadowmount\"," + "\"source_path\":\"/system_ex/app/PPSA90001_00\"," + "\"mount_from\":\"/mnt/usb0/itemzflow/Shadowmounted Test Title\"," + "\"enabled\":true," + "\"mode\":\"download_only\"," + "\"queued\":false," + "\"status\":\"available\"" + "}," + "{" + "\"title_id\":\"PPSA08329_00\"," + "\"name\":\"Example Future Game\"," + "\"content_id\":\"EP0000-PPSA08329_00-FUTUREPATCH00001\"," + "\"installed_version\":\"01.000.000\"," + "\"compatible_version\":null," + "\"latest_version\":\"01.012.000\"," + "\"latest_required_fw\":\"12.50\"," + "\"source_type\":\"unknown\"," + "\"source_path\":\"/system_ex/app/PPSA08329_00\"," + "\"mount_from\":\"\"," + "\"enabled\":false," + "\"mode\":\"disabled\"," + "\"queued\":false," + "\"status\":\"blocked\"" + "}" + "]"; + +static const char downloads_json[] = + "[]"; + +static enum MHD_Result +queue_buffer(struct MHD_Connection* conn, unsigned int status, + const char* mime, const void* data, size_t size) { + struct MHD_Response* response; + enum MHD_Result result; + + response = MHD_create_response_from_buffer(size, (void*)data, + MHD_RESPMEM_PERSISTENT); + if(!response) { + return MHD_NO; + } + + MHD_add_response_header(response, MHD_HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, + "*"); + MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL, + "no-store"); + if(mime) { + MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE, mime); + } + + result = MHD_queue_response(conn, status, response); + MHD_destroy_response(response); + return result; +} + +static enum MHD_Result +queue_text(struct MHD_Connection* conn, unsigned int status, const char* text) { + return queue_buffer(conn, status, "text/plain; charset=utf-8", text, + strlen(text)); +} + +static enum MHD_Result +queue_json(struct MHD_Connection* conn, unsigned int status, const char* json) { + return queue_buffer(conn, status, "application/json", json, strlen(json)); +} + +static enum MHD_Result +queue_asset(struct MHD_Connection* conn, const char* url) { + const patchdl_asset_t* asset = patchdl_asset_find(url); + + if(!asset) { + return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found"); + } + + return queue_buffer(conn, MHD_HTTP_OK, asset->mime, asset->data, + asset->size); +} + +static const char* +source_type_for_title(const char* title_id) { + if(!title_id) { + return "unknown"; + } + + if(!strcmp(title_id, "PPSA90001_00")) { + return "shadowmount"; + } + + if(!strcmp(title_id, "PPSA08329_00")) { + return "unknown"; + } + + return "official"; +} + +static int +parse_title_action(const char* url, char* title_id, size_t title_id_size, + char* action, size_t action_size) { + const char* prefix = "/api/titles/"; + const char* start; + const char* slash; + size_t len; + + if(strncmp(url, prefix, strlen(prefix))) { + return -1; + } + + start = url + strlen(prefix); + slash = strchr(start, '/'); + if(!slash || slash == start || !slash[1]) { + return -1; + } + + len = slash - start; + if(len >= title_id_size) { + return -1; + } + memcpy(title_id, start, len); + title_id[len] = 0; + + len = strlen(slash + 1); + if(len >= action_size) { + return -1; + } + memcpy(action, slash + 1, len + 1); + return 0; +} + +static enum MHD_Result +handle_title_action(struct MHD_Connection* conn, const char* url) { + char title_id[32]; + char action[24]; + const char* source_type; + + if(parse_title_action(url, title_id, sizeof(title_id), action, + sizeof(action))) { + return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found"); + } + + source_type = source_type_for_title(title_id); + + if(!strcmp(action, "download")) { + if(!strcmp(source_type, "unknown")) { + return queue_json(conn, MHD_HTTP_FORBIDDEN, + "{\"ok\":false,\"reason\":\"source_unknown\"}"); + } + + if(!strcmp(source_type, "shadowmount")) { + return queue_json(conn, MHD_HTTP_ACCEPTED, + "{\"ok\":true,\"queued\":true," + "\"source_type\":\"shadowmount\"," + "\"install_allowed\":false}"); + } + + return queue_json(conn, MHD_HTTP_ACCEPTED, + "{\"ok\":true,\"queued\":true," + "\"install_allowed\":true}"); + } + + if(!strcmp(action, "check")) { + return queue_json(conn, MHD_HTTP_ACCEPTED, + "{\"ok\":true,\"queued\":true,\"action\":\"check\"}"); + } + + return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found"); +} + +static enum MHD_Result +on_request(void* cls, struct MHD_Connection* conn, const char* url, + const char* method, const char* version, const char* upload_data, + size_t* upload_data_size, void** con_cls) { + (void)cls; + (void)version; + (void)upload_data; + (void)con_cls; + + if(!strcmp(method, MHD_HTTP_METHOD_OPTIONS)) { + return queue_text(conn, MHD_HTTP_NO_CONTENT, ""); + } + + if(!strcmp(method, MHD_HTTP_METHOD_POST)) { + if(*upload_data_size) { + *upload_data_size = 0; + return MHD_YES; + } + + if(!strcmp(url, "/api/config")) { + return queue_json(conn, MHD_HTTP_OK, config_json); + } + + if(!strncmp(url, "/api/titles/", 12)) { + return handle_title_action(conn, url); + } + + return queue_text(conn, MHD_HTTP_NOT_FOUND, "not found"); + } + + if(strcmp(method, MHD_HTTP_METHOD_GET) && + strcmp(method, MHD_HTTP_METHOD_HEAD)) { + return queue_text(conn, MHD_HTTP_METHOD_NOT_ALLOWED, "method not allowed"); + } + + if(!strcmp(url, "/api/status")) { + return queue_json(conn, MHD_HTTP_OK, status_json); + } + if(!strcmp(url, "/api/config")) { + return queue_json(conn, MHD_HTTP_OK, config_json); + } + if(!strcmp(url, "/api/titles")) { + return queue_json(conn, MHD_HTTP_OK, titles_json); + } + if(!strcmp(url, "/api/downloads")) { + return queue_json(conn, MHD_HTTP_OK, downloads_json); + } + + return queue_asset(conn, url); +} + +int +patchdl_websrv_start(unsigned short port) { + if(web_daemon) { + return 0; + } + + web_daemon = MHD_start_daemon(MHD_USE_INTERNAL_POLLING_THREAD | + MHD_USE_THREAD_PER_CONNECTION, + port, NULL, NULL, &on_request, NULL, + MHD_OPTION_END); + + return web_daemon ? 0 : -1; +} + +void +patchdl_websrv_stop(void) { + if(web_daemon) { + MHD_stop_daemon(web_daemon); + web_daemon = 0; + } +} diff --git a/src/patchdl_websrv.h b/src/patchdl_websrv.h new file mode 100644 index 0000000..9162aa2 --- /dev/null +++ b/src/patchdl_websrv.h @@ -0,0 +1,4 @@ +#pragma once + +int patchdl_websrv_start(unsigned short port); +void patchdl_websrv_stop(void); diff --git a/web/README.md b/web/README.md index 7df5375..c958a7a 100644 --- a/web/README.md +++ b/web/README.md @@ -6,6 +6,8 @@ Open `index.html` directly for the mock UI, or serve this directory from the ELF web server. The JavaScript first tries the real API and falls back to demo data when the API is not available. +The embedded ELF server serves this UI on port `12880` by default. + ## Expected API ```text